Compare commits
42 Commits
7dc8490d96
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d90b5ded0d | |||
| 8292663791 | |||
| dc29adf9bb | |||
| 18abe4210f | |||
| 9f9ed34627 | |||
| ea250c02a6 | |||
| ac60a26d82 | |||
| e72ba71165 | |||
| 58c0f8e8c4 | |||
| ba427e85e1 | |||
| e494dce311 | |||
| b094755e08 | |||
| 7c456042a6 | |||
| 9873eeda7d | |||
| db5fc06174 | |||
| 8868f98392 | |||
| 5437c776fe | |||
| 5e8b8e5835 | |||
| e1d9e1388d | |||
| 3f7f1880c5 | |||
| 60259c08c2 | |||
| cc3b1c9234 | |||
| 8c7938aca0 | |||
| 18a21010bb | |||
| b00a4cbd5e | |||
| 4b3fb04322 | |||
| 4d10b23714 | |||
| 3c32c8c105 | |||
| d5f50edf23 | |||
| ad9b3e1e48 | |||
| e037ba027b | |||
| f31ce9fdb7 | |||
| 7bc70872d9 | |||
| b64830ed11 | |||
| 98cdc4b3f4 | |||
| d97b5d375c | |||
| 4afe11e718 | |||
| 8f0ecd3796 | |||
| 4bf4a13299 | |||
| 4ef11ef5ac | |||
| 3a2c87f518 | |||
| a74162e34e |
@@ -0,0 +1,79 @@
|
|||||||
|
# AGENTS — how to work in this repo
|
||||||
|
|
||||||
|
This file is auto-read at the start of every session. The project keeps its
|
||||||
|
memory as a linked map of markdown files — read it before doing anything.
|
||||||
|
|
||||||
|
## ⚠️ CRITICAL: The Good Dog Rule
|
||||||
|
|
||||||
|
BEFORE any planning or coding, follow this rule:
|
||||||
|
|
||||||
|
> ONE integration test per branch, ONE test per PR.
|
||||||
|
|
||||||
|
When the user asks for changes that would involve multiple integration tests:
|
||||||
|
1. Identify the ONE specific use case/test they want to work on
|
||||||
|
2. Do NOT create branches or PRs for multiple tests
|
||||||
|
3. Focus ONLY on the single use case until it's complete and tests pass
|
||||||
|
|
||||||
|
> *A good dog learns one trick perfectly before learning the next.*
|
||||||
|
>
|
||||||
|
> PRs that address multiple integration tests will be REJECTED.
|
||||||
|
|
||||||
|
MUST read the architecture guide before planning or modifying the codebase:
|
||||||
|
[`ai.md`](ai.md) (+ [`schema.md`](schema.md) for the memory-map conventions).
|
||||||
|
|
||||||
|
MUST read the coding guide before writing tests or code: the [Working
|
||||||
|
rules](#working-rules) in this file — this repo has no `CODING.md`;
|
||||||
|
conventions live here and in `ai.md`.
|
||||||
|
|
||||||
|
## Onboarding (in order)
|
||||||
|
|
||||||
|
1. [`schema.md`](schema.md) — the memory-map conventions (what lives where, how to keep it true).
|
||||||
|
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
|
||||||
|
3. [`TASKS.md`](TASKS.md) — the task queue + authoritative YouTube API research.
|
||||||
|
4. [`HANDOFF.md`](HANDOFF.md) — current operational state: what's in flight, landmines, next step.
|
||||||
|
5. `<dir>/index.md` — the index for any directory you're about to touch.
|
||||||
|
|
||||||
|
**If `HANDOFF.md` exists, trust it as current state** — no `fsck`, no branch
|
||||||
|
hunting, no file-scanning to re-derive what it already states, unless it points
|
||||||
|
at a problem.
|
||||||
|
|
||||||
|
## Working rules
|
||||||
|
|
||||||
|
- **Never work without the map.** If the map contradicts the code, the code wins
|
||||||
|
and the map gets fixed in the same change (stale facts are corrected, not appended).
|
||||||
|
- **Every feature change ships with its memory update in the SAME commit:**
|
||||||
|
`ai.md` for architecture/patterns, `TASKS.md` for status, index files when
|
||||||
|
layout changes. No code commit without its docs — a follow-up "docs backfill"
|
||||||
|
commit is a broken rule, not a style.
|
||||||
|
- **Rewrite `HANDOFF.md` at session end, compaction, or any interruption.**
|
||||||
|
Never end a session with uncommitted work unrecorded — the handoff names the
|
||||||
|
branch, the dirty files, and why it stopped.
|
||||||
|
- **The first time a fact costs a hunt (secrets path, DB path, port, recovery
|
||||||
|
source), record it** in `ai.md`/indexes/`HANDOFF.md` so the next session never
|
||||||
|
re-hunts it.
|
||||||
|
- **Follow existing conventions** — MVVM, `RelayCommand` for actions,
|
||||||
|
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
|
||||||
|
(merged once in `App.xaml`; never duplicate per-window).
|
||||||
|
- **Do not add comments unless the code needs them; do not expand the task queue
|
||||||
|
on your own** — work only what the user queues.
|
||||||
|
- **Response style: no default planning template.** Do the work, then report
|
||||||
|
what changed and what's next. No "Plans & Pitfalls", pros/cons tables, or
|
||||||
|
step-by-step plans unless the user asks for a plan first (see `ai.md`).
|
||||||
|
- **No-Fluff Mode is available on request** — unpadded, ruthless review that
|
||||||
|
argues rather than reassures (see `ai.md`). Optional, never the default.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
From WSL, ALWAYS use the Windows dotnet host — Linux `dotnet` re-downloads the
|
||||||
|
`windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF
|
||||||
|
`_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus
|
||||||
|
`NETSDK1064` errors. See `ai.md` → Run for the exact commands and why.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
|
||||||
|
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep it at **0 warnings**. Running requires Windows. On a silent startup crash,
|
||||||
|
read `%APPDATA%\ytLlive\startup.log` (`Helpers/AppLog.cs` writes checkpoints and
|
||||||
|
unhandled exceptions there).
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
xmlns:local="clr-namespace:ytLive"
|
xmlns:local="clr-namespace:ytLive"
|
||||||
StartupUri="MainWindow.xaml">
|
StartupUri="MainWindow.xaml">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="Themes/Controls.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
+34
-13
@@ -1,13 +1,34 @@
|
|||||||
using System.Configuration;
|
using System.Windows;
|
||||||
using System.Data;
|
using System.Windows.Threading;
|
||||||
using System.Windows;
|
using ytLive.Helpers;
|
||||||
|
|
||||||
namespace ytLive;
|
namespace ytLive;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interaction logic for App.xaml
|
/// Interaction logic for App.xaml
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<Window x:Class="ytLive.CameraPickerDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Add Webcam" Height="340" Width="460"
|
||||||
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
|
||||||
|
<Style x:Key="CandidateItem" TargetType="ListBoxItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="6,5" Margin="0,2">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="Add Webcam" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="#e0e0e0" Margin="0,0,0,14"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Text="Pick the camera to add to your stream."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2">
|
||||||
|
<ListBox Background="Transparent" BorderThickness="0"
|
||||||
|
ItemContainerStyle="{StaticResource CandidateItem}"
|
||||||
|
ItemsSource="{Binding Cameras}"
|
||||||
|
SelectedItem="{Binding SelectedCamera, Mode=TwoWay}"
|
||||||
|
Visibility="{Binding HasCameras, Converter={StaticResource BoolToVis}}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="📷" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
|
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Searching for cameras…" Foreground="#a0a0b0" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding NoCameras, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="No cameras found" Foreground="#e0e0e0" FontSize="15"
|
||||||
|
FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Check that a webcam is connected and that Windows camera access is enabled."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
|
||||||
|
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
|
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
|
||||||
|
Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive;
|
||||||
|
|
||||||
|
public partial class CameraPickerDialog : Window
|
||||||
|
{
|
||||||
|
public CameraDeviceInfo? PickedDevice { get; private set; }
|
||||||
|
|
||||||
|
public CameraPickerDialog(CameraPickerViewModel viewModel)
|
||||||
|
{
|
||||||
|
AppLog.Write("CameraPickerDialog ctor: before InitializeComponent");
|
||||||
|
InitializeComponent();
|
||||||
|
AppLog.Write("CameraPickerDialog ctor: after InitializeComponent");
|
||||||
|
DataContext = viewModel;
|
||||||
|
viewModel.UseRequested += device =>
|
||||||
|
{
|
||||||
|
PickedDevice = device;
|
||||||
|
DialogResult = true;
|
||||||
|
};
|
||||||
|
viewModel.CancelRequested += () => DialogResult = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
-63
@@ -1,68 +1,20 @@
|
|||||||
<Window x:Class="ytLive.GoLiveWindow"
|
<Window x:Class="ytLive.GoLiveWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Start Stream" Height="430" Width="520"
|
Title="Start Stream" Height="480" Width="520"
|
||||||
Icon="/Assets/llama-logo-icon.png"
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
ShowInTaskbar="False" Background="#1a1a2e">
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
<ResourceDictionary>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
<Style x:Key="GoLiveButton" TargetType="Button">
|
</ResourceDictionary>
|
||||||
<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>
|
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
<Grid Margin="24">
|
<Grid Margin="24">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
@@ -72,32 +24,92 @@
|
|||||||
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
|
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
|
||||||
Foreground="#e0e0e0" Margin="0,0,0,16"/>
|
Foreground="#e0e0e0" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<!-- ACCOUNT -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="ACCOUNT" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
|
<!-- signed in -->
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<StackPanel.Style>
|
||||||
|
<Style TargetType="StackPanel">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSignedIn}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</StackPanel.Style>
|
||||||
|
<Border Width="34" Height="34" CornerRadius="17" Background="#16213e" ClipToBounds="True"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0">
|
||||||
|
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{Binding AccountDisplayName}" Foreground="#e0e0e0" FontSize="14"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
<Button Content="Change Account" Command="{Binding SignInCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- signed out -->
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<StackPanel.Style>
|
||||||
|
<Style TargetType="StackPanel">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSignedIn}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</StackPanel.Style>
|
||||||
|
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,10,0"/>
|
||||||
|
<TextBlock Text="Going live requires a YouTube account." Foreground="#a0a0b0" FontSize="12"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="Signing in… open the browser to authorize, then come back here."
|
||||||
|
Foreground="#e94560" FontSize="12" Margin="0,8,0,0">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsBusy}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- STREAM METADATA -->
|
<!-- STREAM METADATA -->
|
||||||
<StackPanel Grid.Row="1">
|
<StackPanel Grid.Row="2">
|
||||||
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
||||||
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
<TextBlock Text="Title" Style="{StaticResource GoLiveLabel}"/>
|
<TextBlock Text="Title" Style="{StaticResource YtLabel}"/>
|
||||||
<TextBox Style="{StaticResource GoLiveTextBox}"
|
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||||
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
|
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
|
||||||
<TextBlock Text="Description" Style="{StaticResource GoLiveLabel}"/>
|
<TextBlock Text="Description" Style="{StaticResource YtLabel}"/>
|
||||||
<TextBox Style="{StaticResource GoLiveTextBox}" Height="60" AcceptsReturn="True"
|
<TextBox Style="{StaticResource YtTextBox}" Height="60" AcceptsReturn="True" Margin="0,0,0,12"
|
||||||
Text="{Binding StreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
Text="{Binding StreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||||
VerticalScrollBarVisibility="Auto"/>
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
<TextBlock Text="Visibility" Style="{StaticResource GoLiveLabel}"/>
|
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
|
||||||
<ComboBox Style="{StaticResource GoLiveCombo}"
|
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,12"
|
||||||
ItemsSource="{Binding Visibilities}"
|
ItemsSource="{Binding Visibilities}"
|
||||||
SelectedItem="{Binding Visibility}"/>
|
SelectedItem="{Binding Visibility}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ACTIONS -->
|
<!-- ACTIONS -->
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
<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}"
|
<Button Content="Start Stream" Command="{Binding StartCommand}"
|
||||||
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
|
Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using ytLive.Helpers;
|
||||||
using ytLive.ViewModels;
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
namespace ytLive;
|
namespace ytLive;
|
||||||
@@ -7,7 +8,9 @@ public partial class GoLiveWindow : Window
|
|||||||
{
|
{
|
||||||
public GoLiveWindow(GoLiveViewModel viewModel)
|
public GoLiveWindow(GoLiveViewModel viewModel)
|
||||||
{
|
{
|
||||||
|
AppLog.Write("GoLiveWindow ctor: before InitializeComponent");
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
AppLog.Write("GoLiveWindow ctor: after InitializeComponent");
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
viewModel.StartRequested += () => DialogResult = true;
|
viewModel.StartRequested += () => DialogResult = true;
|
||||||
viewModel.CancelRequested += () => DialogResult = false;
|
viewModel.CancelRequested += () => DialogResult = false;
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
# HANDOFF — session state
|
||||||
|
|
||||||
|
> Current operational state, read right after `TASKS.md`. Trust this file as the
|
||||||
|
> truth of what is in flight — do not re-derive from git/fs unless it points at
|
||||||
|
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
|
||||||
|
> end, compaction, or any interruption.
|
||||||
|
|
||||||
|
## Session state (last updated: 2026-08-13)
|
||||||
|
|
||||||
|
- **Branch:** `main`, in sync with `origin/main`.
|
||||||
|
- **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
|
||||||
|
1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
|
||||||
|
rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
|
||||||
|
persisted and still dims a hidden row to 45%. Removed dead surface:
|
||||||
|
`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` +
|
||||||
|
`BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`.
|
||||||
|
2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New
|
||||||
|
`EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/
|
||||||
|
lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`);
|
||||||
|
the eye style now binds `IsVisible` and hidden rows dim to 45%.
|
||||||
|
3. **Duplicate naming:** shared `NextSourceName(scene, baseName)` → `Image`, `Image2`,
|
||||||
|
`Image3`… (no space), next free number derived from actual names so deletions never
|
||||||
|
collide. Used by both `AddSource` and `AddReusedImage`.
|
||||||
|
4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH
|
||||||
|
`SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders.
|
||||||
|
5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout
|
||||||
|
on resize; the preview absorbs it. No change.
|
||||||
|
6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC
|
||||||
|
throttling when unfocused + GPU readback contention + the `_framePending` /
|
||||||
|
`DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision.
|
||||||
|
- **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now
|
||||||
|
share `RealAppHost` — a dedicated STA thread owning the single `App` — via the `RealApp`
|
||||||
|
serial collection. WPF allows exactly one `Application` per AppDomain; never add a test
|
||||||
|
that calls `new App()` directly again — marshal onto `RealAppHost` instead.
|
||||||
|
- **Uncommitted:** `MainWindow.xaml` + `MainWindow.xaml.cs` (rows), `ViewModels/MainViewModel.cs`
|
||||||
|
(commands + naming), `Models/SceneElement.cs` (+`IsEditing`), `Models/Scene.cs` (−`IsEditing`),
|
||||||
|
`Services/Compositor/SocialBarRenderer.cs` (no truncation), `ytLive.Tests/SourceNamingTests.cs`
|
||||||
|
(new), `ytLive.Tests/RealAppCollection.cs` (new), `ytLive.Tests/RoundClipInteractionTests.cs`
|
||||||
|
(now uses the shared host), `TASKS.md` (TASK 7 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
|
||||||
|
- **Verified:** build 0 warnings / 0 errors; **170/170 tests pass** (169 + the naming test).
|
||||||
|
- **Landmines:**
|
||||||
|
- Never add another test that constructs `new App()` — use `RealAppHost.Run(...)`.
|
||||||
|
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
|
||||||
|
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
|
||||||
|
- `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
|
||||||
|
`OnLoopbackSample` compute the level first, then raise the event — a
|
||||||
|
`?.Invoke(meter.Push(...))` short-circuit skipped the meter update when
|
||||||
|
nothing was subscribed (found by `RestartMic_ResetsLevel`, fixed).
|
||||||
|
- `MicConnected` comes from the source `Started` event, raised right after
|
||||||
|
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
|
||||||
|
before asserting connection state.
|
||||||
|
- The mic dot is red until a resource connects (see contract below) — green
|
||||||
|
only after `Started`, yellow on `Failed`.
|
||||||
|
- Zero mic devices at startup = red dot AND the mixer is never started, so
|
||||||
|
loopback + the game bar can't run either (no capture at all) — acceptable.
|
||||||
|
- The game detector is polled on the UI thread via a 250ms `DispatcherTimer`;
|
||||||
|
`OnGameAudioPollTick` wraps `Poll()` in try/catch + `AppLog`.
|
||||||
|
- The pump reads the active scene on a background thread while the UI can still
|
||||||
|
edit it — a concurrent-mutation exception is contained (logged + `Failed` +
|
||||||
|
the pump stops), not a crash.
|
||||||
|
- `StopAsync` must stop the encoder (closes stdin) **before** awaiting the pump
|
||||||
|
loop — closing stdin unblocks a write stuck on pipe backpressure; the reverse
|
||||||
|
order deadlocks.
|
||||||
|
- Tests never instantiate `MainViewModel` directly except via a real `MainWindow`
|
||||||
|
on the `RealAppHost` STA thread (round-clip + naming), which never go live.
|
||||||
|
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
|
||||||
|
only, never the real instance.
|
||||||
|
- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
|
||||||
|
**red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
|
||||||
|
(red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
|
||||||
|
from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero
|
||||||
|
devices at startup → stays red and the mixer is never started; capture failure → yellow.
|
||||||
|
- **Next step:** commit + push this TASK 7 batch (one commit). Then the remaining TASK 4
|
||||||
|
requirement is **ship step 7 — one-click go live + private-only enforcement** (honor the
|
||||||
|
dialog's chosen visibility / enforce `privacyStatus = "private"`; also fixes the REC sign's
|
||||||
|
private state — see round-3 note). Also queued: task 21 (logo + About hub), task 22 (voice
|
||||||
|
filters). Nothing else queued — do not expand the task queue on your own. Optional, not
|
||||||
|
queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`
|
||||||
|
(user must say the word).
|
||||||
|
|
||||||
|
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`;
|
||||||
|
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
|
||||||
|
layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v8; `SocialEntry.Software`
|
||||||
|
column is a column-presence migration like the others, no version bump); OAuth callback
|
||||||
|
`http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort diagnostic: when a camera won't start, lists other running
|
||||||
|
/// processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, etc.).
|
||||||
|
/// Windows doesn't expose "which process has this device" via any public API,
|
||||||
|
/// so this is a suspect list, not a verdict — but it's far better than
|
||||||
|
/// "maybe in use by another app" with no idea which one.
|
||||||
|
/// </summary>
|
||||||
|
public static class CameraConflictProbe
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> KnownCameraApps = new()
|
||||||
|
{
|
||||||
|
"obs64", "obs32", "zoom", "teams", "ms-teams", "discord",
|
||||||
|
"nvidia broadcast", "skype", "webex", "slack",
|
||||||
|
"streamlabs obs", "restream studio", "manycam", "snap camera",
|
||||||
|
"logitech capture", "logitune", "facerig", "animaze",
|
||||||
|
"camera", "vmix", "xsplit broadcaster", "xsplit gamecaster",
|
||||||
|
"droidcam", "ivcam", "epoccam", "camo",
|
||||||
|
"chrome", "msedge", "firefox", "brave",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the friendly names of known camera apps currently running, or an
|
||||||
|
/// empty list if none are found (or the probe itself fails).
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> GetRunningCameraApps()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Process.GetProcesses()
|
||||||
|
.Where(p => KnownCameraApps.Contains(p.ProcessName.ToLowerInvariant()))
|
||||||
|
.Select(p => p.ProcessName)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(n => n)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>True when the bound value's ToString() equals the ConverterParameter string.</summary>
|
||||||
|
public class EnumToBoolConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value?.ToString() == parameter?.ToString();
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> Binding.DoNothing;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the OAuth session (channel + tokens) to a DPAPI-protected file in
|
||||||
|
/// %APPDATA%\ytLlive\ytLlive.auth so sign-in survives restarts. The whole
|
||||||
|
/// payload is protected with DataProtectionScope.CurrentUser — only the
|
||||||
|
/// signed-in Windows user can decrypt it.
|
||||||
|
/// </summary>
|
||||||
|
public static class TokenStore
|
||||||
|
{
|
||||||
|
public static string DefaultPath => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"ytLlive",
|
||||||
|
"ytLlive.auth");
|
||||||
|
|
||||||
|
public static void Save(YouTubeChannel channel, string? path = null)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(channel);
|
||||||
|
var plain = System.Text.Encoding.UTF8.GetBytes(json);
|
||||||
|
var protectedBytes = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
||||||
|
|
||||||
|
var file = path ?? DefaultPath;
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
|
||||||
|
File.WriteAllBytes(file, protectedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static YouTubeChannel? Load(string? path = null)
|
||||||
|
{
|
||||||
|
var file = path ?? DefaultPath;
|
||||||
|
if (!File.Exists(file)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var protectedBytes = File.ReadAllBytes(file);
|
||||||
|
var plain = ProtectedData.Unprotect(protectedBytes, null, DataProtectionScope.CurrentUser);
|
||||||
|
var json = System.Text.Encoding.UTF8.GetString(plain);
|
||||||
|
return JsonSerializer.Deserialize<YouTubeChannel>(json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Clear(string? path = null)
|
||||||
|
{
|
||||||
|
var file = path ?? DefaultPath;
|
||||||
|
if (File.Exists(file)) File.Delete(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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) |
|
||||||
|
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
|
||||||
|
| `ImageCache.cs` | Image byte caching (assets live in the DB) |
|
||||||
|
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
|
||||||
|
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
|
||||||
|
|
||||||
|
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.
|
||||||
+690
-267
File diff suppressed because it is too large
Load Diff
+189
-32
@@ -4,6 +4,7 @@ using System.Windows;
|
|||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
|
using ytLive.Helpers;
|
||||||
using ytLive.Models;
|
using ytLive.Models;
|
||||||
using ytLive.ViewModels;
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
@@ -15,18 +16,28 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
|
AppLog.Write("MainWindow ctor: before InitializeComponent");
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
||||||
_viewModel = (MainViewModel)DataContext;
|
_viewModel = (MainViewModel)DataContext;
|
||||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
_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();
|
UpdateTaskbarOverlay();
|
||||||
UpdateSelectionOverlay();
|
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)
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(MainViewModel.IsLive))
|
if (e.PropertyName == nameof(MainViewModel.IsLive))
|
||||||
UpdateTaskbarOverlay();
|
UpdateTaskbarOverlay();
|
||||||
else if (e.PropertyName == nameof(MainViewModel.SelectedSource))
|
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
|
||||||
UpdateSelectionOverlay();
|
UpdateSelectionOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +46,16 @@ public partial class MainWindow : Window
|
|||||||
_viewModel.Shutdown();
|
_viewModel.Shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void MainWindow_Activated(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_viewModel.RefreshBackdropAutoCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_Deactivated(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_viewModel.NoteBackgroundWindow();
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateTaskbarOverlay()
|
private void UpdateTaskbarOverlay()
|
||||||
{
|
{
|
||||||
TaskbarInfo.Overlay = _viewModel.IsLive
|
TaskbarInfo.Overlay = _viewModel.IsLive
|
||||||
@@ -42,7 +63,7 @@ public partial class MainWindow : Window
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SceneNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is TextBox { IsVisible: true } box)
|
if (sender is TextBox { IsVisible: true } box)
|
||||||
{
|
{
|
||||||
@@ -60,6 +81,58 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetVolumeAdjusting(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetVolumeAdjusting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetVolumeAdjusting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetGameVolumeAdjusting(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetGameVolumeAdjusting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetGameVolumeAdjusting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
{
|
||||||
|
vm.ToggleMicMuteCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
{
|
||||||
|
vm.ToggleGameMuteCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is Button { ContextMenu: { } menu } button)
|
if (sender is Button { ContextMenu: { } menu } button)
|
||||||
@@ -80,6 +153,16 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddSceneButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Button { ContextMenu: { } menu } button)
|
||||||
|
{
|
||||||
|
menu.PlacementTarget = button;
|
||||||
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||||
|
menu.IsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Preview image selection / move / resize ───
|
// ─── Preview image selection / move / resize ───
|
||||||
private bool _isDraggingOverlay;
|
private bool _isDraggingOverlay;
|
||||||
private bool _isResizing;
|
private bool _isResizing;
|
||||||
@@ -92,7 +175,7 @@ public partial class MainWindow : Window
|
|||||||
if (IsDescendantOf(original, PreviewGrid)) return;
|
if (IsDescendantOf(original, PreviewGrid)) return;
|
||||||
if (IsDescendantOf(original, SourceList)) return;
|
if (IsDescendantOf(original, SourceList)) return;
|
||||||
if (IsDescendantOf(original, OpacityChip)) return;
|
if (IsDescendantOf(original, OpacityChip)) return;
|
||||||
_viewModel.SelectedSource = null;
|
_viewModel.SelectedElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||||
@@ -104,16 +187,85 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void UpdateSelectionOverlay()
|
private void UpdateSelectionOverlay()
|
||||||
{
|
{
|
||||||
var selected = _viewModel.SelectedSource is { Type: SourceType.Image };
|
var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
|
||||||
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||||
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||||
if (selected)
|
if (selected)
|
||||||
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedSource!.Opacity * 100)}%";
|
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static images and the webcam share the move/resize/selection behavior.
|
||||||
|
private static bool IsDraggableElement(SceneElement element)
|
||||||
|
=> element is Source { Type: SourceType.Image } or WebcamSceneConfig;
|
||||||
|
|
||||||
|
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||||
|
webcam.IsMirrored = !webcam.IsMirrored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||||
|
webcam.ToggleClipShape();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
||||||
|
=> _viewModel.ChangeWebcamCommand.Execute(null);
|
||||||
|
|
||||||
|
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string tag }
|
||||||
|
&& Enum.TryParse<BorderAnimation>(tag, out var animation))
|
||||||
|
webcam.BorderAnimation = animation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string color })
|
||||||
|
webcam.BorderColor = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam })
|
||||||
|
webcam.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuItem { DataContext: SceneElement element })
|
||||||
|
_viewModel.RemoveSourceCommand.Execute(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackdropMenu_ChangeCapture(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = _viewModel.ChangeBackdropCaptureAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackdropMenu_RefreshCapture(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_viewModel.RefreshBackdropAutoCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuItem { DataContext: SceneElement element })
|
||||||
|
_viewModel.RemoveSourceCommand.Execute(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||||
|
|
||||||
|
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
||||||
|
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
||||||
|
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
||||||
|
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
_viewModel.ToggleSocialBarPosition();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
if (OpacityChip.IsMouseOver) return;
|
if (OpacityChip.IsMouseOver) return;
|
||||||
@@ -121,21 +273,21 @@ public partial class MainWindow : Window
|
|||||||
var grid = (Grid)sender;
|
var grid = (Grid)sender;
|
||||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||||
var selected = _viewModel.SelectedSource;
|
var selected = _viewModel.SelectedElement;
|
||||||
|
|
||||||
if (selected is { Type: SourceType.Image } && HitHandle(e.GetPosition(grid), selected))
|
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||||
{
|
{
|
||||||
_isResizing = true;
|
_isResizing = true;
|
||||||
_resizeAspect = selected.Width / Math.Max(1, selected.Height);
|
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||||
grid.CaptureMouse();
|
grid.CaptureMouse();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hit = HitImage(p);
|
var hit = HitElement(p);
|
||||||
if (hit != null)
|
if (hit != null)
|
||||||
{
|
{
|
||||||
_viewModel.SelectedSource = hit;
|
_viewModel.SelectedElement = hit;
|
||||||
_isDraggingOverlay = true;
|
_isDraggingOverlay = true;
|
||||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||||
grid.CaptureMouse();
|
grid.CaptureMouse();
|
||||||
@@ -143,7 +295,7 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_viewModel.SelectedSource = null;
|
_viewModel.SelectedElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||||
@@ -153,7 +305,7 @@ public partial class MainWindow : Window
|
|||||||
var grid = (Grid)sender;
|
var grid = (Grid)sender;
|
||||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||||
var selected = _viewModel.SelectedSource;
|
var selected = _viewModel.SelectedElement;
|
||||||
if (selected == null)
|
if (selected == null)
|
||||||
{
|
{
|
||||||
EndPreviewDrag(grid);
|
EndPreviewDrag(grid);
|
||||||
@@ -171,11 +323,14 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
else if (_isResizing)
|
else if (_isResizing)
|
||||||
{
|
{
|
||||||
var newW = Math.Clamp(p.X - selected.X, 32, 1920);
|
var isWebcam = selected is WebcamSceneConfig;
|
||||||
|
var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.ActiveScene?.Name) : 1920;
|
||||||
|
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.ActiveScene?.Name) : 1080;
|
||||||
|
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
|
||||||
var newH = newW / _resizeAspect;
|
var newH = newW / _resizeAspect;
|
||||||
if (newH > 1080)
|
if (newH > maxH)
|
||||||
{
|
{
|
||||||
newH = 1080;
|
newH = maxH;
|
||||||
newW = newH * _resizeAspect;
|
newW = newH * _resizeAspect;
|
||||||
}
|
}
|
||||||
selected.Width = newW;
|
selected.Width = newW;
|
||||||
@@ -194,45 +349,47 @@ public partial class MainWindow : Window
|
|||||||
_isResizing = false;
|
_isResizing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HitHandle(Point mouseScreen, Source source)
|
private bool HitHandle(Point mouseScreen, SceneElement element)
|
||||||
{
|
{
|
||||||
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(source.X + source.Width, source.Y + source.Height));
|
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
|
||||||
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Source? HitImage(Point p)
|
private SceneElement? HitElement(Point p)
|
||||||
{
|
{
|
||||||
var scene = _viewModel.ActiveScene;
|
var scene = _viewModel.ActiveScene;
|
||||||
if (scene == null) return null;
|
if (scene == null) return null;
|
||||||
for (var i = scene.Sources.Count - 1; i >= 0; i--)
|
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var source = scene.Sources[i];
|
var element = scene.Elements[i];
|
||||||
if (source.Type != SourceType.Image || !source.IsEnabled) continue;
|
if (!IsDraggableElement(element)) continue;
|
||||||
if (p.X >= source.X && p.X <= source.X + source.Width &&
|
if (element is Source { IsEnabled: false }) continue;
|
||||||
p.Y >= source.Y && p.Y <= source.Y + source.Height)
|
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||||
return source;
|
if (p.X >= element.X && p.X <= element.X + element.Width &&
|
||||||
|
p.Y >= element.Y && p.Y <= element.Y + element.Height)
|
||||||
|
return element;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SceneNameBox_KeyDown(object sender, KeyEventArgs e)
|
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key is Key.Enter or Key.Escape)
|
if (e.Key is Key.Enter or Key.Escape)
|
||||||
{
|
{
|
||||||
CommitSceneEdit(sender);
|
CommitSourceEdit(sender);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SceneNameBox_LostFocus(object sender, RoutedEventArgs e)
|
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
CommitSceneEdit(sender);
|
CommitSourceEdit(sender);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CommitSceneEdit(object sender)
|
private static void CommitSourceEdit(object sender)
|
||||||
{
|
{
|
||||||
if (sender is FrameworkElement { DataContext: Scene scene })
|
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||||
scene.IsEditing = false;
|
element.IsEditing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||||
@@ -248,7 +405,7 @@ public partial class MainWindow : Window
|
|||||||
if (item == null)
|
if (item == null)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(listBox, SourceList))
|
if (ReferenceEquals(listBox, SourceList))
|
||||||
_viewModel.SelectedSource = null;
|
_viewModel.SelectedElement = null;
|
||||||
_dragIndex = -1;
|
_dragIndex = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<Window x:Class="ytLive.MicPickerDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Select Microphone" Height="340" Width="460"
|
||||||
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
|
||||||
|
<Style x:Key="CandidateItem" TargetType="ListBoxItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="6,5" Margin="0,2">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="Select Microphone" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="#e0e0e0" Margin="0,0,0,14"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Text="Pick the microphone to use as your voice source."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2">
|
||||||
|
<ListBox Background="Transparent" BorderThickness="0"
|
||||||
|
ItemContainerStyle="{StaticResource CandidateItem}"
|
||||||
|
ItemsSource="{Binding Microphones}"
|
||||||
|
SelectedItem="{Binding SelectedMic, Mode=TwoWay}"
|
||||||
|
Visibility="{Binding HasMicrophones, Converter={StaticResource BoolToVis}}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="🎙️" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
|
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Searching for microphones…" Foreground="#a0a0b0" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding NoMicrophones, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="No microphones found" Foreground="#e0e0e0" FontSize="15"
|
||||||
|
FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Check that a microphone is connected and that Windows microphone access is enabled."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
|
||||||
|
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
|
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
|
||||||
|
Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive;
|
||||||
|
|
||||||
|
public partial class MicPickerDialog : Window
|
||||||
|
{
|
||||||
|
public MicrophoneDeviceInfo? PickedDevice { get; private set; }
|
||||||
|
|
||||||
|
public MicPickerDialog(MicPickerViewModel viewModel)
|
||||||
|
{
|
||||||
|
AppLog.Write("MicPickerDialog ctor: before InitializeComponent");
|
||||||
|
InitializeComponent();
|
||||||
|
AppLog.Write("MicPickerDialog ctor: after InitializeComponent");
|
||||||
|
DataContext = viewModel;
|
||||||
|
viewModel.UseRequested += device =>
|
||||||
|
{
|
||||||
|
PickedDevice = device;
|
||||||
|
DialogResult = true;
|
||||||
|
};
|
||||||
|
viewModel.CancelRequested += () => DialogResult = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>Footer mic indicator state (the dot beside the MIC button):
|
||||||
|
/// <see cref="Connected"/> = mic capture is running; <see cref="Problem"/> = a
|
||||||
|
/// mic was requested but the connection failed (device in use, unplugged, or
|
||||||
|
/// unavailable); <see cref="NotConnected"/> = no capture is active (e.g. no
|
||||||
|
/// mic device present).</summary>
|
||||||
|
public enum MicStatus
|
||||||
|
{
|
||||||
|
NotConnected,
|
||||||
|
Connected,
|
||||||
|
Problem
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A resolution tier offered by the bottom-bar dropdown. Bitrate is Mbps.
|
||||||
|
/// Width/Height is the output frame; the composition master is always
|
||||||
|
/// 1920x1080 and a non-16:9 tier renders as a centered crop of it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record QualityOption(string Display, int Fps, double Bitrate, int Width, int Height)
|
||||||
|
{
|
||||||
|
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
|
||||||
|
public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
|
||||||
|
}
|
||||||
+31
-8
@@ -9,8 +9,9 @@ public class Scene : INotifyPropertyChanged
|
|||||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
private string _name = string.Empty;
|
private string _name = string.Empty;
|
||||||
private bool _isEditing;
|
|
||||||
private bool _isHidden;
|
private bool _isHidden;
|
||||||
|
private bool _hasBackdrop;
|
||||||
|
private bool _hasSocialBar;
|
||||||
|
|
||||||
public string Name
|
public string Name
|
||||||
{
|
{
|
||||||
@@ -18,19 +19,41 @@ public class Scene : INotifyPropertyChanged
|
|||||||
set => Set(ref _name, value);
|
set => Set(ref _name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsEditing
|
|
||||||
{
|
|
||||||
get => _isEditing;
|
|
||||||
set => Set(ref _isEditing, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsHidden
|
public bool IsHidden
|
||||||
{
|
{
|
||||||
get => _isHidden;
|
get => _isHidden;
|
||||||
set => Set(ref _isHidden, value);
|
set => Set(ref _isHidden, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<Source> Sources { get; } = new();
|
/// <summary>
|
||||||
|
/// Whether the scene has a live desktop/game backdrop layer. Enforced by
|
||||||
|
/// policy: only the canonical Live scene ever has one (see SceneCatalog).
|
||||||
|
/// </summary>
|
||||||
|
public bool HasBackdrop
|
||||||
|
{
|
||||||
|
get => _hasBackdrop;
|
||||||
|
set => Set(ref _hasBackdrop, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the social bar plays on this scene. Toggled by the footer [+]/[-]
|
||||||
|
/// control — the socials themselves are a global resource (see SocialsConfig).
|
||||||
|
/// </summary>
|
||||||
|
public bool HasSocialBar
|
||||||
|
{
|
||||||
|
get => _hasSocialBar;
|
||||||
|
set => Set(ref _hasSocialBar, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scene's rendered elements in z-order (back to front): multi-instance
|
||||||
|
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<SceneElement> Elements { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>This scene's webcam usage, or null if the creator hasn't added the webcam here.</summary>
|
||||||
|
public WebcamSceneConfig? WebcamConfig => Elements.OfType<WebcamSceneConfig>().FirstOrDefault();
|
||||||
|
|
||||||
public bool IsChatScene { get; init; }
|
public bool IsChatScene { get; init; }
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The app's five canonical scenes. Scenes are worked on by name; you can delete
|
||||||
|
/// them ("work with less"), but the app only ever adds back one of these five —
|
||||||
|
/// anything beyond them is OBS territory. The live desktop/game backdrop exists
|
||||||
|
/// only in the Live scene.
|
||||||
|
/// </summary>
|
||||||
|
public static class SceneCatalog
|
||||||
|
{
|
||||||
|
public const string Starting = "Starting";
|
||||||
|
public const string Live = "Live";
|
||||||
|
public const string Brb = "BRB";
|
||||||
|
public const string Chat = "Chat";
|
||||||
|
public const string Ending = "Ending";
|
||||||
|
|
||||||
|
public static readonly string[] All = { Starting, Live, Brb, Chat, Ending };
|
||||||
|
|
||||||
|
public static bool IsCanonical(string? name)
|
||||||
|
=> name != null && All.Contains(name.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static bool Is(string? name, string canonical)
|
||||||
|
=> name != null && string.Equals(name.Trim(), canonical, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static bool IsChat(string? name) => Is(name, Chat);
|
||||||
|
|
||||||
|
/// <summary>Only the Live scene carries the live desktop/game backdrop.</summary>
|
||||||
|
public static bool HasBackdrop(string? name) => Is(name, Live);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
public enum BorderAnimation
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Pulse,
|
||||||
|
Chase,
|
||||||
|
Rainbow,
|
||||||
|
Shimmer,
|
||||||
|
MarchingAnts,
|
||||||
|
Glow,
|
||||||
|
Electricity,
|
||||||
|
Sparkles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
|
||||||
|
/// or a singleton resource's per-scene config (webcam). Carries the shared
|
||||||
|
/// layout + clip/mirror + border surface the preview pipeline renders from.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class SceneElement : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
protected void Raise([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
|
||||||
|
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||||
|
field = value;
|
||||||
|
Raise(name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
private string _name = string.Empty;
|
||||||
|
public string Name { get => _name; set => Set(ref _name, value); }
|
||||||
|
|
||||||
|
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
||||||
|
public virtual bool IsWebcam => false;
|
||||||
|
|
||||||
|
/// <summary>True only for image sources — the raw preview renderer's discriminator.</summary>
|
||||||
|
public virtual bool IsImageSource => false;
|
||||||
|
|
||||||
|
private bool _isVisible = true;
|
||||||
|
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
|
||||||
|
|
||||||
|
private bool _isEditing;
|
||||||
|
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
|
||||||
|
|
||||||
|
private ClipShape _clipShape = ClipShape.Traditional;
|
||||||
|
public ClipShape ClipShape
|
||||||
|
{
|
||||||
|
get => _clipShape;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _clipShape, value))
|
||||||
|
Raise(nameof(ShapeButtonText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rectangular dimensions before a Round toggle, so switching back restores
|
||||||
|
// the aspect instead of staying locked to the square Round used. Persisted so
|
||||||
|
// the restore survives a reload of a Round element.
|
||||||
|
private double? _rectWidth;
|
||||||
|
private double? _rectHeight;
|
||||||
|
|
||||||
|
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
|
||||||
|
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
|
||||||
|
|
||||||
|
public void ToggleClipShape()
|
||||||
|
{
|
||||||
|
if (ClipShape == ClipShape.Traditional)
|
||||||
|
{
|
||||||
|
RectWidth = Width;
|
||||||
|
RectHeight = Height;
|
||||||
|
ClipShape = ClipShape.Round;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ClipShape = ClipShape.Traditional;
|
||||||
|
if (RectWidth is { } rw && RectHeight is { } rh)
|
||||||
|
{
|
||||||
|
Width = rw;
|
||||||
|
Height = rh;
|
||||||
|
RectWidth = null;
|
||||||
|
RectHeight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isMirrored;
|
||||||
|
public bool IsMirrored
|
||||||
|
{
|
||||||
|
get => _isMirrored;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _isMirrored, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(MirrorScale));
|
||||||
|
Raise(nameof(MirrorButtonText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double MirrorScale => IsMirrored ? -1 : 1;
|
||||||
|
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
||||||
|
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
||||||
|
|
||||||
|
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
||||||
|
private WriteableBitmap? _videoImageSource;
|
||||||
|
public WriteableBitmap? VideoImageSource
|
||||||
|
{
|
||||||
|
get => _videoImageSource;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _videoImageSource, value))
|
||||||
|
Raise(nameof(DisplaySource));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
|
||||||
|
public abstract ImageSource? DisplaySource { get; }
|
||||||
|
|
||||||
|
// Position/transform (per-scene usage)
|
||||||
|
private double _x;
|
||||||
|
public double X { get => _x; set => Set(ref _x, value); }
|
||||||
|
|
||||||
|
private double _y;
|
||||||
|
public double Y { get => _y; set => Set(ref _y, value); }
|
||||||
|
|
||||||
|
private double _width;
|
||||||
|
public double Width
|
||||||
|
{
|
||||||
|
get => _width;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _width, value))
|
||||||
|
Raise(nameof(RoundBorderSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _height;
|
||||||
|
public double Height
|
||||||
|
{
|
||||||
|
get => _height;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _height, value))
|
||||||
|
Raise(nameof(RoundBorderSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _opacity = 1.0;
|
||||||
|
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
|
||||||
|
|
||||||
|
/// <summary>Round clip/border diameter = the shorter dimension (true circle).</summary>
|
||||||
|
public double RoundBorderSize => Math.Min(Width, Height);
|
||||||
|
|
||||||
|
// Border (OBS-style): #RRGGBB color, alpha opacity, pixel width. Off by default.
|
||||||
|
private string _borderColor = string.Empty;
|
||||||
|
public string BorderColor
|
||||||
|
{
|
||||||
|
get => _borderColor;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _borderColor, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(HasBorder));
|
||||||
|
Raise(nameof(BorderBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _borderOpacity = 1.0;
|
||||||
|
public double BorderOpacity
|
||||||
|
{
|
||||||
|
get => _borderOpacity;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _borderOpacity, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(HasBorder));
|
||||||
|
Raise(nameof(BorderBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _borderWidth;
|
||||||
|
public int BorderWidth
|
||||||
|
{
|
||||||
|
get => _borderWidth;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _borderWidth, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(HasBorder));
|
||||||
|
Raise(nameof(BorderBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BorderAnimation _borderAnimation = BorderAnimation.None;
|
||||||
|
public BorderAnimation BorderAnimation { get => _borderAnimation; set => Set(ref _borderAnimation, value); }
|
||||||
|
|
||||||
|
public bool HasBorder
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (BorderWidth <= 0) return false;
|
||||||
|
return TryGetBorderColor(out _, out _, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Brush? BorderBrush
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!HasBorder || !TryGetBorderColor(out var r, out var g, out var b)) return null;
|
||||||
|
var alpha = (byte)Math.Round(Math.Clamp(BorderOpacity, 0, 1) * 255);
|
||||||
|
return new SolidColorBrush(Color.FromArgb(alpha, r, g, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetBorderColor(out byte r, out byte g, out byte b)
|
||||||
|
{
|
||||||
|
r = g = b = 0;
|
||||||
|
var hex = BorderColor.Trim().TrimStart('#');
|
||||||
|
if (hex.Length != 6) return false;
|
||||||
|
return byte.TryParse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r)
|
||||||
|
&& byte.TryParse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g)
|
||||||
|
&& byte.TryParse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
public enum SocialService
|
||||||
|
{
|
||||||
|
YouTube, Twitch, X, Instagram, TikTok, Facebook, Discord, Kick,
|
||||||
|
Threads, Bluesky, GitHub, LinkedIn, Pinterest, Snapchat, Reddit,
|
||||||
|
WhatsApp, Telegram, Link, Website, Fediverse
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SocialBarPosition { Top, Bottom }
|
||||||
|
|
||||||
|
public sealed class SocialEntry : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public SocialService Service { get; init; }
|
||||||
|
public string Handle { get; init; } = string.Empty;
|
||||||
|
public string ProfileUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
private string? _fediverseSoftware;
|
||||||
|
|
||||||
|
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.
|
||||||
|
/// Settable so the load-time heal can fill a missing name and the bar icon
|
||||||
|
/// updates in place.</summary>
|
||||||
|
public string? FediverseSoftware
|
||||||
|
{
|
||||||
|
get => _fediverseSoftware;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_fediverseSoftware == value) return;
|
||||||
|
_fediverseSoftware = value;
|
||||||
|
Raise(nameof(FediverseSoftware));
|
||||||
|
Raise(nameof(LogoData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ServiceName => Service.ToString();
|
||||||
|
public string LogoData => Service == SocialService.Fediverse
|
||||||
|
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
|
||||||
|
: SocialServiceIcons.LogoDataFor(Service);
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void Raise([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The app-wide social bar config — a global resource (like the webcam) that
|
||||||
|
/// every scene shows when <see cref="BarEnabled"/> is on and entries exist. The
|
||||||
|
/// dialog provides the handles; the app validates each by looking up the social
|
||||||
|
/// page before committing. Freemium: YT + 1 other. Premium: all six slots.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SocialsConfig : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public ObservableCollection<SocialEntry> Entries { get; } = new();
|
||||||
|
|
||||||
|
private bool _barEnabled = true;
|
||||||
|
public bool BarEnabled
|
||||||
|
{
|
||||||
|
get => _barEnabled;
|
||||||
|
set => Set(ref _barEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SocialBarPosition _barPosition = SocialBarPosition.Bottom;
|
||||||
|
public SocialBarPosition BarPosition
|
||||||
|
{
|
||||||
|
get => _barPosition;
|
||||||
|
set => Set(ref _barPosition, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||||
|
field = value;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brand logo path data (Simple Icons, CC0) plus the small utility glyphs the
|
||||||
|
/// social dialog uses — lock (Premium gate), do-not (empty/invalid slot) and a
|
||||||
|
/// generic link (bare Link/Website entries). All strings are 24x24 path data
|
||||||
|
/// consumed by a Path.Data binding (the Geometry type converter handles the string).
|
||||||
|
/// </summary>
|
||||||
|
public static class SocialServiceIcons
|
||||||
|
{
|
||||||
|
public const string LockedIconData = "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z";
|
||||||
|
public const string DoNotIconData = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z";
|
||||||
|
public const string LinkIconData = "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z";
|
||||||
|
|
||||||
|
private const string YouTubePath = "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z";
|
||||||
|
private const string TwitchPath = "M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z";
|
||||||
|
private const string XPath = "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z";
|
||||||
|
private const string InstagramPath = "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077";
|
||||||
|
private const string TikTokPath = "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z";
|
||||||
|
private const string FacebookPath = "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z";
|
||||||
|
private const string DiscordPath = "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z";
|
||||||
|
private const string KickPath = "M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z";
|
||||||
|
private const string ThreadsPath = "M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z";
|
||||||
|
private const string BlueskyPath = "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026";
|
||||||
|
private const string GitHubPath = "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
|
||||||
|
private const string LinkedInPath = "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z";
|
||||||
|
private const string PinterestPath = "M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z";
|
||||||
|
private const string SnapchatPath = "M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z";
|
||||||
|
private const string RedditPath = "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z";
|
||||||
|
private const string WhatsAppPath = "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z";
|
||||||
|
private const string TelegramPath = "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z";
|
||||||
|
|
||||||
|
// Fediverse instance software logos (Simple Icons, CC0). Resolved at
|
||||||
|
// validate-time from each instance's nodeinfo `software.name`; unknown
|
||||||
|
// software falls back to the generic fediverse glyph below.
|
||||||
|
private const string MastodonPath = "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z";
|
||||||
|
private const string PeerTubePath = "M12 6.545v10.91L20.727 12M3.273 12v12L12 17.455M3.273 0v12L12 6.545";
|
||||||
|
private const string PixelfedPath = "M12 24C5.3726 24 0 18.6274 0 12S5.3726 0 12 0s12 5.3726 12 12-5.3726 12-12 12m-.9526-9.3802h2.2014c2.0738 0 3.7549-1.6366 3.7549-3.6554S15.3226 7.309 13.2488 7.309h-3.1772c-1.1964 0-2.1663.9442-2.1663 2.1089v8.208z";
|
||||||
|
private const string MisskeyPath = "M8.91076 16.8915c-1.03957.0038-1.93213-.6294-2.35267-1.366-.22516-.3217-.66989-.4364-.6761 0v2.0148c0 .8094-.29152 1.5097-.87581 2.1002-.56755.573-1.25977.8595-2.0779.8595-.80014 0-1.49298-.2865-2.07727-.8601C.28408 19.05 0 18.3497 0 17.5403V6.45968c0-.62378.17553-1.18863.52599-1.69455.36657-.52284.83426-.88582 1.4018-1.08769a2.84574 2.84574 0 0 1 1.00049-.17742c.90125 0 1.65239.35421 2.25281 1.06262l2.99713 3.51572c.06699.05016.263.43696.73192.43696.47016 0 .6916-.3868.75796-.43758l2.9717-3.5151c.6178-.70841 1.377-1.06262 2.2782-1.06262.3337 0 .6675.05893 1.0012.17742.5669.20187 1.0259.56422 1.377 1.08769.3665.50592.5501 1.07077.5501 1.69455V17.5403c0 .8094-.2915 1.5097-.8758 2.1002-.5675.573-1.2604.8595-2.0779.8595-.8008 0-1.493-.2865-2.0779-.8601-.5669-.5899-.8504-1.2902-.8504-2.0996v-2.0148c-.0496-.5499-.5303-.2032-.7009 0-.4503.8431-1.31369 1.3616-2.35264 1.366ZM21.447 8.60998c-.7009 0-1.3015-.24449-1.8019-.73348-.4838-.50571-.7257-1.11277-.7257-1.82118s.2419-1.30711.7257-1.79611c.5004-.50571 1.101-.75856 1.8019-.75856.7009 0 1.3017.25285 1.8025.75856.5003.489.7505 1.0877.7505 1.79611 0 .70841-.2502 1.31547-.7505 1.82118-.5008.48899-1.1016.73348-1.8025.73348Zm.0248.50655c.7009 0 1.2935.25285 1.7777.75856.5003.50571.7505 1.11301.7505 1.82181v6.2484c0 .7084-.2502 1.3155-.7505 1.8212-.4838.489-1.0764.7335-1.7777.7335-.7005 0-1.3011-.2445-1.8019-.7335-.5003-.5057-.7505-1.1128-.7505-1.8212v-6.2484c0-.7084.2502-1.3157.7505-1.82181.5004-.50571 1.101-.75856 1.8019-.75856Z";
|
||||||
|
private const string LemmyPath = "M2.9595 4.2228a3.9132 3.9132 0 0 0-.332.019c-.8781.1012-1.67.5699-2.155 1.3862-.475.8-.5922 1.6809-.35 2.4971.2421.8162.8297 1.5575 1.6982 2.1449.0053.0035.0106.0076.0163.0114.746.4498 1.492.7431 2.2877.8994-.02.3318-.0272.6689-.006 1.0181.0634 1.0432.4368 2.0006.996 2.8492l-2.0061.8189a.4163.4163 0 0 0-.2276.2239.416.416 0 0 0 .0879.455.415.415 0 0 0 .2941.1231.4156.4156 0 0 0 .1595-.0312l2.2093-.9035c.408.4859.8695.9315 1.3723 1.318.0196.0151.0407.0264.0603.0423l-1.2918 1.7103a.416.416 0 0 0 .664.501l1.314-1.7385c.7185.4548 1.4782.7927 2.2294 1.0242.3833.7209 1.1379 1.1871 2.0202 1.1871.8907 0 1.6442-.501 2.0242-1.2072.744-.2347 1.4959-.5729 2.2073-1.0262l1.332 1.7606a.4157.4157 0 0 0 .7439-.1936.4165.4165 0 0 0-.0799-.3074l-1.3099-1.7345c.0083-.0075.0178-.0113.0261-.0188.4968-.3803.9549-.8175 1.3622-1.2939l2.155.8794a.4156.4156 0 0 0 .5412-.2276.4151.4151 0 0 0-.2273-.5432l-1.9438-.7928c.577-.8538.9697-1.8183 1.0504-2.8693.0268-.3507.0242-.6914.0079-1.0262.7905-.1572 1.5321-.4502 2.2737-.8974.0053-.0033.011-.0076.0163-.0113.8684-.5874 1.456-1.3287 1.6982-2.145.2421-.8161.125-1.697-.3501-2.497-.4849-.8163-1.2768-1.2852-2.155-1.3863a3.2175 3.2175 0 0 0-.332-.0189c-.7852-.0151-1.6231.229-2.4286.6942-.5926.342-1.1252.867-1.5433 1.4387-1.1699-.6703-2.6923-1.0476-4.5635-1.0785a15.5768 15.5768 0 0 0-.5111 0c-2.085.034-3.7537.43-5.0142 1.1449-.0033-.0038-.0045-.0114-.008-.0152-.4233-.5916-.973-1.1365-1.5835-1.489-.8055-.465-1.6434-.7083-2.4286-.6941Zm.2858.7365c.5568.042 1.1696.2358 1.7787.5875.485.28.9757.7554 1.346 1.2696a5.6875 5.6875 0 0 0-.4969.4085c-.9201.8516-1.4615 1.9597-1.668 3.2335-.6809-.1402-1.3183-.3945-1.984-.7948-.7553-.5128-1.2159-1.1225-1.4004-1.7445-.1851-.624-.1074-1.2712.2776-1.9196.3743-.63.9275-.9534 1.6118-1.0322a2.796 2.796 0 0 1 .5352-.0076Zm17.5094 0a2.797 2.797 0 0 1 .5353.0075c.6842.0786 1.2374.4021 1.6117 1.0322.385.6484.4627 1.2957.2776 1.9196-.1845.622-.645 1.2317-1.4004 1.7445-.6578.3955-1.2881.6472-1.9598.7888-.1942-1.2968-.7375-2.4338-1.666-3.302a5.5639 5.5639 0 0 0-.4709-.3923c.3645-.49.8287-.9428 1.2938-1.2113.6091-.3515 1.2219-.5454 1.7787-.5875ZM12.006 6.0036a14.832 14.832 0 0 1 .487 0c2.3901.0393 4.0848.67 5.1631 1.678 1.1501 1.0754 1.6423 2.6006 1.499 4.467-.1311 1.7079-1.2203 3.2281-2.652 4.324-.694.5313-1.4626.9354-2.2254 1.2294.0031-.0453.014-.0888.014-.1349.0029-1.1964-.9313-2.2133-2.2918-2.2133-1.3606 0-2.3222 1.0154-2.2918 2.2213.0013.0507.014.0972.0181.1471-.781-.2933-1.5696-.7013-2.2777-1.2456-1.4239-1.0945-2.4997-2.6129-2.6037-4.322-.1129-1.8567.3778-3.3382 1.5212-4.3965C7.5094 6.7 9.352 6.047 12.006 6.0036Zm-3.6419 6.8291c-.6053 0-1.0966.4903-1.0966 1.0966 0 .6063.4913 1.0986 1.0966 1.0986s1.0966-.4923 1.0966-1.0986c0-.6063-.4913-1.0966-1.0966-1.0966zm7.2819.0113c-.5998 0-1.0866.4859-1.0866 1.0866s.4868 1.0885 1.0866 1.0885c.5997 0 1.0865-.4878 1.0865-1.0885s-.4868-1.0866-1.0865-1.0866zM12 16.0835c1.0237 0 1.5654.638 1.5634 1.4829-.0018.7849-.6723 1.485-1.5634 1.485-.9167 0-1.54-.5629-1.5634-1.493-.0212-.8347.5397-1.4749 1.5634-1.4749Z";
|
||||||
|
private const string PleromaPath = "M6.36 0A1.868 1.868 0 004.49 1.868V24h5.964V0zm7.113 0v12h4.168a1.868 1.868 0 001.868-1.868V0zm0 18.036V24h4.168a1.868 1.868 0 001.868-1.868v-4.096Z";
|
||||||
|
private const string FirefishPath = "M16.771 0c-.68-.016-1.342.507-1.342 1.304V7.27c0 .719.582 1.301 1.3 1.301h5.967c1.16 0 1.74-1.401.92-2.22L17.65.383a1.275 1.275 0 0 0-.879-.383ZM6.573.106c-.672-.017-1.326.5-1.326 1.287v5.892c0 .71.575 1.285 1.285 1.285h5.892c1.145 0 1.718-1.384.908-2.194L7.44.484a1.259 1.259 0 0 0-.867-.379ZM1.286 10.287c-.71 0-1.286.576-1.286 1.286v11.142C0 23.425.576 24 1.286 24h11.143c.71 0 1.285-.575 1.285-1.285V11.573c0-.71-.575-1.286-1.285-1.286zm15.485 0c-.68-.017-1.342.507-1.342 1.304v5.966c0 .718.582 1.3 1.3 1.3h5.967c1.16 0 1.74-1.4.92-2.22L17.65 10.67a1.275 1.275 0 0 0-.879-.384zM3.43 17.144a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428zm4.285 0a1.714 1.714 0 1 1 0 3.428 1.714 1.714 0 0 1 0-3.428z";
|
||||||
|
|
||||||
|
/// <summary>Generic fediverse glyph (honeycomb) for instances whose software isn't in our icon set.</summary>
|
||||||
|
public const string FediverseIconData = "M12 4A1.3 1.3 0 1 0 12 6.6 1.3 1.3 0 1 0 12 4ZM12 10.7A1.3 1.3 0 1 0 12 13.3 1.3 1.3 0 1 0 12 10.7ZM12 17.4A1.3 1.3 0 1 0 12 20 1.3 1.3 0 1 0 12 17.4ZM5.4 6.05A1.3 1.3 0 1 0 5.4 8.65 1.3 1.3 0 1 0 5.4 6.05ZM5.4 15.35A1.3 1.3 0 1 0 5.4 17.95 1.3 1.3 0 1 0 5.4 15.35ZM18.6 6.05A1.3 1.3 0 1 0 18.6 8.65 1.3 1.3 0 1 0 18.6 6.05ZM18.6 15.35A1.3 1.3 0 1 0 18.6 17.95 1.3 1.3 0 1 0 18.6 15.35Z";
|
||||||
|
|
||||||
|
public static string LogoDataFor(SocialService service) => service switch
|
||||||
|
{
|
||||||
|
SocialService.YouTube => YouTubePath,
|
||||||
|
SocialService.Twitch => TwitchPath,
|
||||||
|
SocialService.X => XPath,
|
||||||
|
SocialService.Instagram => InstagramPath,
|
||||||
|
SocialService.TikTok => TikTokPath,
|
||||||
|
SocialService.Facebook => FacebookPath,
|
||||||
|
SocialService.Discord => DiscordPath,
|
||||||
|
SocialService.Kick => KickPath,
|
||||||
|
SocialService.Threads => ThreadsPath,
|
||||||
|
SocialService.Bluesky => BlueskyPath,
|
||||||
|
SocialService.GitHub => GitHubPath,
|
||||||
|
SocialService.LinkedIn => LinkedInPath,
|
||||||
|
SocialService.Pinterest => PinterestPath,
|
||||||
|
SocialService.Snapchat => SnapchatPath,
|
||||||
|
SocialService.Reddit => RedditPath,
|
||||||
|
SocialService.WhatsApp => WhatsAppPath,
|
||||||
|
SocialService.Telegram => TelegramPath,
|
||||||
|
SocialService.Fediverse => FediverseIconData,
|
||||||
|
_ => LinkIconData,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The logo for a fediverse instance given its nodeinfo software name
|
||||||
|
/// (e.g. "peertube", "mastodon"). Unknown software gets the generic
|
||||||
|
/// fediverse glyph.
|
||||||
|
/// </summary>
|
||||||
|
public static string LogoDataForFediverse(string? software) => software?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"mastodon" => MastodonPath,
|
||||||
|
"peertube" => PeerTubePath,
|
||||||
|
"pixelfed" => PixelfedPath,
|
||||||
|
"misskey" => MisskeyPath,
|
||||||
|
"lemmy" => LemmyPath,
|
||||||
|
"pleroma" => PleromaPath,
|
||||||
|
"firefish" => FirefishPath,
|
||||||
|
_ => FediverseIconData,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The canonical profile URL for a service handle. Fediverse handles
|
||||||
|
/// (@user@domain) are recognized first — their identity carries its own host.
|
||||||
|
/// </summary>
|
||||||
|
public static string CanonicalUrlFor(SocialService service, string handle)
|
||||||
|
{
|
||||||
|
var input = handle.Trim();
|
||||||
|
if (TryParseFediverse(input, out var fedUser, out var fedDomain))
|
||||||
|
return $"https://{fedDomain}/@{fedUser}";
|
||||||
|
var h = input.TrimStart('@');
|
||||||
|
return service switch
|
||||||
|
{
|
||||||
|
SocialService.YouTube => $"https://www.youtube.com/@{h}",
|
||||||
|
SocialService.Twitch => $"https://www.twitch.tv/{h}",
|
||||||
|
SocialService.X => $"https://x.com/{h}",
|
||||||
|
SocialService.Instagram => $"https://www.instagram.com/{h}",
|
||||||
|
SocialService.TikTok => $"https://www.tiktok.com/@{h}",
|
||||||
|
SocialService.Facebook => $"https://www.facebook.com/{h}",
|
||||||
|
SocialService.Discord => $"https://discord.gg/{h}",
|
||||||
|
SocialService.Kick => $"https://kick.com/{h}",
|
||||||
|
SocialService.Threads => $"https://www.threads.net/@{h}",
|
||||||
|
SocialService.Bluesky => $"https://bsky.app/profile/{h}",
|
||||||
|
SocialService.GitHub => $"https://github.com/{h}",
|
||||||
|
SocialService.LinkedIn => $"https://www.linkedin.com/in/{h}",
|
||||||
|
SocialService.Pinterest => $"https://www.pinterest.com/{h}",
|
||||||
|
SocialService.Snapchat => $"https://www.snapchat.com/add/{h}",
|
||||||
|
SocialService.Reddit => $"https://www.reddit.com/user/{h}",
|
||||||
|
SocialService.WhatsApp => $"https://wa.me/{h}",
|
||||||
|
SocialService.Telegram => $"https://t.me/{h}",
|
||||||
|
SocialService.Link or SocialService.Website => h.StartsWith("http") ? h : $"https://{h}",
|
||||||
|
_ => $"https://{h}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-detects the social service from a URL or fediverse handle. Supports
|
||||||
|
/// full URLs (https://x.com/user), domain-only (x.com/user), and fediverse
|
||||||
|
/// handles (@user@instance.tube). Returns Link/Website for unknown domains
|
||||||
|
/// or bare handles — the caller can then ask which service it is.
|
||||||
|
/// </summary>
|
||||||
|
public static (SocialService service, string handle, string url) DetectService(string input)
|
||||||
|
{
|
||||||
|
var trimmed = input.Trim();
|
||||||
|
|
||||||
|
// Fediverse handle: @user@domain — the full handle IS the identity.
|
||||||
|
if (TryParseFediverse(trimmed, out var fedUser, out var fedDomain))
|
||||||
|
return (SocialService.Fediverse, trimmed, $"https://{fedDomain}/@{fedUser}");
|
||||||
|
|
||||||
|
// Strip protocol for domain parsing
|
||||||
|
var url = trimmed;
|
||||||
|
if (!trimmed.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||||
|
url = $"https://{trimmed}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var domain = uri.Host.ToLowerInvariant();
|
||||||
|
var path = uri.AbsolutePath.Trim('/');
|
||||||
|
|
||||||
|
return domain switch
|
||||||
|
{
|
||||||
|
"youtube.com" or "www.youtube.com" or "youtu.be" => (SocialService.YouTube, path, url),
|
||||||
|
"twitch.tv" or "www.twitch.tv" => (SocialService.Twitch, path, url),
|
||||||
|
"x.com" or "twitter.com" => (SocialService.X, path, url),
|
||||||
|
"instagram.com" or "www.instagram.com" => (SocialService.Instagram, path, url),
|
||||||
|
"tiktok.com" or "www.tiktok.com" => (SocialService.TikTok, path, url),
|
||||||
|
"facebook.com" or "www.facebook.com" or "fb.com" => (SocialService.Facebook, path, url),
|
||||||
|
"discord.gg" or "discord.com" => (SocialService.Discord, path, url),
|
||||||
|
"kick.com" => (SocialService.Kick, path, url),
|
||||||
|
"threads.net" or "www.threads.net" => (SocialService.Threads, path, url),
|
||||||
|
"bsky.app" => (SocialService.Bluesky, path, url),
|
||||||
|
"github.com" => (SocialService.GitHub, path, url),
|
||||||
|
"linkedin.com" or "www.linkedin.com" => (SocialService.LinkedIn, path, url),
|
||||||
|
"pinterest.com" or "www.pinterest.com" => (SocialService.Pinterest, path, url),
|
||||||
|
"snapchat.com" => (SocialService.Snapchat, path, url),
|
||||||
|
"reddit.com" or "www.reddit.com" => (SocialService.Reddit, path, url),
|
||||||
|
"wa.me" or "whatsapp.com" => (SocialService.WhatsApp, path, url),
|
||||||
|
"t.me" => (SocialService.Telegram, path, url),
|
||||||
|
_ => (SocialService.Website, path, url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (SocialService.Link, trimmed.TrimStart('@'), SocialServiceIcons.CanonicalUrlFor(SocialService.Link, trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the input is a fediverse handle (@user@domain); out the parts.</summary>
|
||||||
|
public static bool TryParseFediverse(string input, out string user, out string domain)
|
||||||
|
{
|
||||||
|
user = domain = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false;
|
||||||
|
var secondAt = input.IndexOf('@', 1);
|
||||||
|
if (secondAt <= 1 || secondAt == input.Length - 1) return false;
|
||||||
|
user = input[1..secondAt];
|
||||||
|
domain = input[(secondAt + 1)..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-37
@@ -1,5 +1,3 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
|
|
||||||
@@ -9,34 +7,42 @@ public enum SourceType
|
|||||||
{
|
{
|
||||||
DisplayCapture,
|
DisplayCapture,
|
||||||
WindowCapture,
|
WindowCapture,
|
||||||
Webcam,
|
|
||||||
Background,
|
Background,
|
||||||
Image,
|
Image,
|
||||||
TextOverlay
|
TextOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Source : INotifyPropertyChanged
|
public enum ClipShape
|
||||||
{
|
{
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
Traditional,
|
||||||
|
Round
|
||||||
|
}
|
||||||
|
|
||||||
private void Raise([CallerMemberName] string? name = null)
|
/// <summary>
|
||||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
/// A multi-instance scene object: image/background/text (screen/window later).
|
||||||
|
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
/// is a <see cref="WebcamSceneConfig"/>. See the singleton-vs-multi-instance
|
||||||
|
/// discriminator in ai.md.
|
||||||
|
/// </summary>
|
||||||
|
public class Source : SceneElement
|
||||||
|
{
|
||||||
|
private SourceType _type;
|
||||||
|
public SourceType Type
|
||||||
{
|
{
|
||||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
get => _type;
|
||||||
field = value;
|
set
|
||||||
Raise(name);
|
{
|
||||||
return true;
|
if (Set(ref _type, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(IsLiveCapture));
|
||||||
|
Raise(nameof(DisplaySource));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
/// <summary>The permanent scene backdrop (live desktop/game capture). Never removable/reorderable.</summary>
|
||||||
|
private bool _isBackdrop;
|
||||||
private string _name = string.Empty;
|
public bool IsBackdrop { get => _isBackdrop; set => Set(ref _isBackdrop, value); }
|
||||||
public string Name { get => _name; set => Set(ref _name, value); }
|
|
||||||
|
|
||||||
private SourceType _type;
|
|
||||||
public SourceType Type { get => _type; set => Set(ref _type, value); }
|
|
||||||
|
|
||||||
private bool _isEnabled = true;
|
private bool _isEnabled = true;
|
||||||
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
|
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
|
||||||
@@ -48,9 +54,14 @@ public class Source : INotifyPropertyChanged
|
|||||||
private IntPtr? _windowHandle;
|
private IntPtr? _windowHandle;
|
||||||
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
||||||
|
|
||||||
// Webcam
|
/// <summary>
|
||||||
private string? _deviceId;
|
/// Identifies what this live-capture source points at: "monitor:<index>" or
|
||||||
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); }
|
/// "window:<hwnd>". Persisted so a re-designated backdrop survives a reload.
|
||||||
|
/// </summary>
|
||||||
|
private string? _captureKey;
|
||||||
|
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
|
||||||
|
|
||||||
|
public bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
||||||
|
|
||||||
// Image (asset stored in the layout database)
|
// Image (asset stored in the layout database)
|
||||||
private string? _assetId;
|
private string? _assetId;
|
||||||
@@ -65,25 +76,14 @@ public class Source : INotifyPropertyChanged
|
|||||||
{
|
{
|
||||||
_imageSource = ImageCache.Get(value ?? string.Empty);
|
_imageSource = ImageCache.Get(value ?? string.Empty);
|
||||||
Raise(nameof(ImageSource));
|
Raise(nameof(ImageSource));
|
||||||
|
Raise(nameof(DisplaySource));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ImageSource? ImageSource => _imageSource;
|
public ImageSource? ImageSource => _imageSource;
|
||||||
|
|
||||||
// Position/transform (per-scene usage)
|
public override ImageSource? DisplaySource => IsLiveCapture ? VideoImageSource : _imageSource;
|
||||||
private double _x;
|
|
||||||
public double X { get => _x; set => Set(ref _x, value); }
|
|
||||||
|
|
||||||
private double _y;
|
public override bool IsImageSource => Type == SourceType.Image;
|
||||||
public double Y { get => _y; set => Set(ref _y, value); }
|
|
||||||
|
|
||||||
private double _width;
|
|
||||||
public double Width { get => _width; set => Set(ref _width, value); }
|
|
||||||
|
|
||||||
private double _height;
|
|
||||||
public double Height { get => _height; set => Set(ref _height, value); }
|
|
||||||
|
|
||||||
private double _opacity = 1.0;
|
|
||||||
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The app-wide webcam identity — one camera input (the limit is the hardware,
|
||||||
|
/// not us). Scenes reference it via <see cref="WebcamSceneConfig"/>; the DeviceId
|
||||||
|
/// lives only here, so the camera is a Windows-controlled singleton.
|
||||||
|
/// </summary>
|
||||||
|
public class Webcam
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scene's usage of the app-wide webcam — conceptually `webcam.{scene}.config`.
|
||||||
|
/// The identity lives once on the <see cref="Webcam"/> entity; this object is that
|
||||||
|
/// scene's placement, clip/mirror, border, and visibility for it. Exists only in
|
||||||
|
/// scenes where the creator added the webcam.
|
||||||
|
/// </summary>
|
||||||
|
public class WebcamSceneConfig : SceneElement
|
||||||
|
{
|
||||||
|
public string WebcamId { get; init; } = string.Empty;
|
||||||
|
public override bool IsWebcam => true;
|
||||||
|
public override ImageSource? DisplaySource => VideoImageSource;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 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`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||||
|
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||||
|
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||||
|
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
||||||
|
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||||
|
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||||
|
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||||
|
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||||
|
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
|
||||||
|
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
||||||
|
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
|
||||||
|
|
||||||
|
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
||||||
|
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
A creator-proof live-streaming and recording app for YouTube — native Windows (C# / WPF / .NET 8).
|
A creator-proof live-streaming and recording app for YouTube — native Windows (C# / WPF / .NET 8).
|
||||||
|
|
||||||
|
## Screenshot
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Design Principle
|
## Design Principle
|
||||||
|
|
||||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||||
@@ -41,8 +45,15 @@ dotnet run
|
|||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `Models/` | Scene, Source, StreamConfig, StreamHealth, YouTube channel/chat |
|
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
|
||||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat |
|
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
|
||||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling) |
|
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) |
|
||||||
| `Helpers/` | ViewModelBase, RelayCommand |
|
| `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 |
|
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
|
||||||
|
| `SocialsDialog.xaml` | Social Media Site Promotion dialog — 6-slot social bar editor with sign-in gate and validation |
|
||||||
|
|
||||||
|
## 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
@@ -7,30 +7,7 @@
|
|||||||
ShowInTaskbar="False" Background="#1a1a2e">
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<Style x:Key="GoLiveButton" TargetType="Button">
|
<ResourceDictionary>
|
||||||
<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="CandidateItem" TargetType="ListBoxItem">
|
<Style x:Key="CandidateItem" TargetType="ListBoxItem">
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
@@ -53,6 +30,7 @@
|
|||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
<Grid Margin="24">
|
<Grid Margin="24">
|
||||||
@@ -95,11 +73,11 @@
|
|||||||
|
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
<Button Content="New Image…" Command="{Binding NewImageCommand}"
|
<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}"
|
<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}"
|
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
|
||||||
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
|
Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using ytLive.Helpers;
|
||||||
using ytLive.ViewModels;
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
namespace ytLive;
|
namespace ytLive;
|
||||||
@@ -10,7 +11,9 @@ public partial class ReuseImageDialog : Window
|
|||||||
|
|
||||||
public ReuseImageDialog(ReuseImageViewModel viewModel)
|
public ReuseImageDialog(ReuseImageViewModel viewModel)
|
||||||
{
|
{
|
||||||
|
AppLog.Write("ReuseImageDialog ctor: before InitializeComponent");
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
AppLog.Write("ReuseImageDialog ctor: after InitializeComponent");
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
viewModel.ReuseRequested += () =>
|
viewModel.ReuseRequested += () =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the smoothed mic level (0..1) from captured samples. Pure —
|
||||||
|
/// unit-tested; the <see cref="AudioMixer"/> only feeds it and forwards the
|
||||||
|
/// result. RMS-based so it tracks perceived loudness, smoothed so the meter
|
||||||
|
/// does not flicker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AudioLevelMeter
|
||||||
|
{
|
||||||
|
private const float Smoothing = 0.2f;
|
||||||
|
private float _level;
|
||||||
|
|
||||||
|
public float Level => _level;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
|
||||||
|
/// spread linearly across 0..1. Linear RMS of real speech or game audio is
|
||||||
|
/// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking
|
||||||
|
/// dead; the log scale makes typical levels occupy the bar.
|
||||||
|
/// </summary>
|
||||||
|
public static float ToDisplay(float linear)
|
||||||
|
{
|
||||||
|
if (linear <= 0.001f)
|
||||||
|
return 0f;
|
||||||
|
var db = 20f * MathF.Log10(linear);
|
||||||
|
return Math.Clamp(1f + db / 60f, 0f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public float Push(AudioSample sample)
|
||||||
|
{
|
||||||
|
if (sample.Samples.Length == 0)
|
||||||
|
return _level;
|
||||||
|
|
||||||
|
double sumSquares = 0;
|
||||||
|
var count = 0;
|
||||||
|
foreach (var value in sample.Samples)
|
||||||
|
{
|
||||||
|
sumSquares += value * value;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rms = (float)Math.Sqrt(sumSquares / count);
|
||||||
|
_level = _level * (1 - Smoothing) + rms * Smoothing;
|
||||||
|
return _level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset() => _level = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
|
||||||
|
/// the footer meters. Capture runs for the app's lifetime (started once at
|
||||||
|
/// startup, stopped on shutdown) so both bars stay live in preview: mic samples
|
||||||
|
/// are level-metered and forwarded, loopback samples feed the game bar's meter.
|
||||||
|
/// The mixer surfaces mic connection state (Connected/Failed) for the status
|
||||||
|
/// dot and can restart the mic source mid-session when a device is re-picked.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AudioMixer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IAudioSource _mic;
|
||||||
|
private readonly IAudioSource _loopback;
|
||||||
|
private readonly AudioLevelMeter _meter;
|
||||||
|
private readonly AudioLevelMeter _loopbackMeter;
|
||||||
|
private readonly Action<string>? _log;
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
public AudioMixer(IAudioSource mic, IAudioSource loopback, Action<string>? log = null)
|
||||||
|
{
|
||||||
|
_mic = mic;
|
||||||
|
_loopback = loopback;
|
||||||
|
_meter = new AudioLevelMeter();
|
||||||
|
_loopbackMeter = new AudioLevelMeter();
|
||||||
|
_log = log;
|
||||||
|
|
||||||
|
_mic.Started += OnMicStarted;
|
||||||
|
_mic.SampleReady += OnMicSample;
|
||||||
|
_loopback.SampleReady += OnLoopbackSample;
|
||||||
|
_mic.Failed += OnMicFailed;
|
||||||
|
_loopback.Failed += OnLoopbackFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current smoothed mic level (0..1).</summary>
|
||||||
|
public float MicLevel => _meter.Level;
|
||||||
|
|
||||||
|
/// <summary>Current smoothed desktop/game level (0..1).</summary>
|
||||||
|
public float LoopbackLevel => _loopbackMeter.Level;
|
||||||
|
|
||||||
|
/// <summary>Raised whenever the smoothed mic level changes.</summary>
|
||||||
|
public event Action<float>? MicLevelChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised whenever the smoothed desktop/game level changes.</summary>
|
||||||
|
public event Action<float>? LoopbackLevelChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised when the mic capture comes up (the status dot goes green).</summary>
|
||||||
|
public event Action? MicConnected;
|
||||||
|
|
||||||
|
/// <summary>Raised when the mic capture fails or dies (the status dot goes yellow).</summary>
|
||||||
|
public event Action<Exception>? MicFailed;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_started)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_started = true;
|
||||||
|
_meter.Reset();
|
||||||
|
_loopback.Start();
|
||||||
|
_mic.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Swaps the mic source without touching loopback — used when the
|
||||||
|
/// creator picks a different device mid-session. The level resets and the
|
||||||
|
/// new source raises <see cref="MicConnected"/> or <see cref="MicFailed"/>.</summary>
|
||||||
|
public void RestartMic()
|
||||||
|
{
|
||||||
|
_mic.Stop();
|
||||||
|
_meter.Reset();
|
||||||
|
MicLevelChanged?.Invoke(0);
|
||||||
|
_mic.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!_started)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_started = false;
|
||||||
|
_mic.Stop();
|
||||||
|
_loopback.Stop();
|
||||||
|
_meter.Reset();
|
||||||
|
_loopbackMeter.Reset();
|
||||||
|
MicLevelChanged?.Invoke(0);
|
||||||
|
LoopbackLevelChanged?.Invoke(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
_mic.Started -= OnMicStarted;
|
||||||
|
_mic.SampleReady -= OnMicSample;
|
||||||
|
_loopback.SampleReady -= OnLoopbackSample;
|
||||||
|
_mic.Failed -= OnMicFailed;
|
||||||
|
_loopback.Failed -= OnLoopbackFailed;
|
||||||
|
_mic.Dispose();
|
||||||
|
_loopback.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMicStarted()
|
||||||
|
{
|
||||||
|
MicConnected?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMicSample(AudioSample sample)
|
||||||
|
{
|
||||||
|
// Push unconditionally: the ?. on the event would otherwise skip the
|
||||||
|
// argument (and the meter update) when nothing is subscribed yet.
|
||||||
|
var level = _meter.Push(sample);
|
||||||
|
MicLevelChanged?.Invoke(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoopbackSample(AudioSample sample)
|
||||||
|
{
|
||||||
|
var level = _loopbackMeter.Push(sample);
|
||||||
|
LoopbackLevelChanged?.Invoke(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMicFailed(Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"Mic capture failed: {ex.Message}");
|
||||||
|
MicLevelChanged?.Invoke(0);
|
||||||
|
MicFailed?.Invoke(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoopbackFailed(Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"Desktop audio capture failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A chunk of interleaved PCM float samples (-1..1) with its format. This is
|
||||||
|
/// the unit every <see cref="IAudioSource"/> produces and the
|
||||||
|
/// <see cref="AudioMixer"/> consumes (TASK 4 ship step 4).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AudioSample
|
||||||
|
{
|
||||||
|
public float[] Samples { get; }
|
||||||
|
public int SampleRate { get; }
|
||||||
|
public int Channels { get; }
|
||||||
|
|
||||||
|
public AudioSample(float[] samples, int sampleRate, int channels)
|
||||||
|
{
|
||||||
|
Samples = samples;
|
||||||
|
SampleRate = sampleRate;
|
||||||
|
Channels = channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A live audio capture source (TASK 4 ship step 4 seam): produces PCM float
|
||||||
|
/// chunks. The default implementations wrap NAudio's WASAPI capture (mic) /
|
||||||
|
/// loopback (desktop/game); the mixer and the tests consume this interface,
|
||||||
|
/// never NAudio directly. Capture now runs for the app's lifetime so the
|
||||||
|
/// footer meters stay live in preview — the mixer owns start/stop.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAudioSource : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Starts capturing. Safe to call only once per Stop.</summary>
|
||||||
|
void Start();
|
||||||
|
|
||||||
|
/// <summary>Stops capturing; a later Start begins a fresh session.</summary>
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
/// <summary>Raises once capture is live (right after recording starts).
|
||||||
|
/// Never raised when Start fails — <see cref="Failed"/> fires instead.</summary>
|
||||||
|
event Action? Started;
|
||||||
|
|
||||||
|
/// <summary>Raises each captured chunk (interleaved PCM float, -1..1).</summary>
|
||||||
|
event Action<AudioSample>? SampleReady;
|
||||||
|
|
||||||
|
/// <summary>Raises when capture dies or fails to start (e.g. no device).</summary>
|
||||||
|
event Action<Exception>? Failed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using NAudio.Wave;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures the user's desktop/game audio (TASK 4 ship step 4) via WASAPI
|
||||||
|
/// loopback on the default render device. Runs for the app's lifetime so the
|
||||||
|
/// game audio bar stays live in preview.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WasapiLoopbackAudioSource : IAudioSource
|
||||||
|
{
|
||||||
|
private WasapiLoopbackCapture? _capture;
|
||||||
|
|
||||||
|
public event Action? Started;
|
||||||
|
public event Action<AudioSample>? SampleReady;
|
||||||
|
public event Action<Exception>? Failed;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_capture != null)
|
||||||
|
throw new InvalidOperationException("Already started.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_capture = new WasapiLoopbackCapture();
|
||||||
|
_capture.DataAvailable += OnDataAvailable;
|
||||||
|
_capture.RecordingStopped += OnRecordingStopped;
|
||||||
|
_capture.StartRecording();
|
||||||
|
Started?.Invoke();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
Failed?.Invoke(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (_capture == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_capture.StopRecording();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
_capture.Dispose();
|
||||||
|
_capture = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Stop();
|
||||||
|
|
||||||
|
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.BytesRecorded <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||||
|
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||||
|
if (samples.Length > 0)
|
||||||
|
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Exception != null)
|
||||||
|
Failed?.Invoke(e.Exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using NAudio.CoreAudioApi;
|
||||||
|
using NAudio.Wave;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures the selected microphone (TASK 4 ship step 4) via WASAPI. Resolves
|
||||||
|
/// the NAudio device by FriendlyName matching <c>MicSourceName</c> (the app only
|
||||||
|
/// persists DisplayName), falling back to the default capture endpoint.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WasapiMicAudioSource : IAudioSource
|
||||||
|
{
|
||||||
|
private readonly Func<string?> _micNameProvider;
|
||||||
|
private WasapiCapture? _capture;
|
||||||
|
|
||||||
|
/// <param name="micNameProvider">Returns the current mic DisplayName; read
|
||||||
|
/// at each Start so a device picked mid-session takes effect immediately
|
||||||
|
/// (the mixer restarts the mic on pick).</param>
|
||||||
|
public WasapiMicAudioSource(Func<string?> micNameProvider)
|
||||||
|
{
|
||||||
|
_micNameProvider = micNameProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? Started;
|
||||||
|
public event Action<AudioSample>? SampleReady;
|
||||||
|
public event Action<Exception>? Failed;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_capture != null)
|
||||||
|
throw new InvalidOperationException("Already started.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var device = ResolveDevice();
|
||||||
|
_capture = device != null ? new WasapiCapture(device) : new WasapiCapture();
|
||||||
|
_capture.DataAvailable += OnDataAvailable;
|
||||||
|
_capture.RecordingStopped += OnRecordingStopped;
|
||||||
|
_capture.StartRecording();
|
||||||
|
Started?.Invoke();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
Failed?.Invoke(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (_capture == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_capture.StopRecording();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
_capture.Dispose();
|
||||||
|
_capture = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Stop();
|
||||||
|
|
||||||
|
private MMDevice? ResolveDevice()
|
||||||
|
{
|
||||||
|
var micName = _micNameProvider();
|
||||||
|
if (string.IsNullOrWhiteSpace(micName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var enumerator = new MMDeviceEnumerator();
|
||||||
|
foreach (var endpoint in enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active))
|
||||||
|
{
|
||||||
|
if (string.Equals(endpoint.FriendlyName, micName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.BytesRecorded <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 1);
|
||||||
|
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||||
|
if (samples.Length > 0)
|
||||||
|
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Exception != null)
|
||||||
|
Failed?.Invoke(e.Exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using NAudio.Dmo;
|
||||||
|
using NAudio.Wave;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts NAudio's raw WASAPI capture buffers (byte[], whatever bit depth the
|
||||||
|
/// device's mix format reports) into interleaved PCM float samples. Pure —
|
||||||
|
/// unit-tested; the two WASAPI sources share it.
|
||||||
|
/// </summary>
|
||||||
|
public static class WaveToFloat
|
||||||
|
{
|
||||||
|
public static float[] Convert(byte[] buffer, int bytesRecorded, WaveFormat format)
|
||||||
|
{
|
||||||
|
if (format.Encoding == WaveFormatEncoding.IeeeFloat && format.BitsPerSample == 32)
|
||||||
|
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||||
|
if (format.Encoding == WaveFormatEncoding.Pcm && format.BitsPerSample == 16)
|
||||||
|
return ConvertPcm16(buffer, bytesRecorded);
|
||||||
|
if (format is WaveFormatExtensible extensible
|
||||||
|
&& extensible.SubFormat == AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT)
|
||||||
|
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||||
|
return ConvertPcm16(buffer, bytesRecorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] ConvertIeeeFloat(byte[] buffer, int bytesRecorded)
|
||||||
|
{
|
||||||
|
var count = bytesRecorded / 4;
|
||||||
|
var result = new float[count];
|
||||||
|
Buffer.BlockCopy(buffer, 0, result, 0, count * 4);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] ConvertPcm16(byte[] buffer, int bytesRecorded)
|
||||||
|
{
|
||||||
|
var count = bytesRecorded / 2;
|
||||||
|
var result = new float[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
result[i] = BitConverter.ToInt16(buffer, i * 2) / 32768f;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class CameraDeviceInfo
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public CameraDeviceInfo(string id, string displayName)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
DisplayName = displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns webcam capture sessions app-wide, refcounted by DeviceId. One device =
|
||||||
|
/// at most one capture, one shared WriteableBitmap; the last release stops and
|
||||||
|
/// disposes the source. Frames arrive on a worker thread and are coalesced onto
|
||||||
|
/// the UI dispatcher (at most one pending copy per session, using the latest
|
||||||
|
/// frame) so a 60fps device doesn't drown the render thread.
|
||||||
|
///
|
||||||
|
/// Allocation is proven, never assumed: <see cref="AcquireAsync"/> treats a
|
||||||
|
/// started reader as success only once the first frame actually arrives (within
|
||||||
|
/// <paramref name="firstFrameTimeout"/>), and an async <see cref="SourceFailed"/>
|
||||||
|
/// tears the session down and surfaces <see cref="CameraFailed"/> — no silent
|
||||||
|
/// empty box.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CameraManager : IDisposable
|
||||||
|
{
|
||||||
|
private sealed class CameraSession
|
||||||
|
{
|
||||||
|
public string DeviceId { get; }
|
||||||
|
public ICameraFrameSource Source { get; }
|
||||||
|
public Action<VideoFrame>? FrameHandler;
|
||||||
|
public Action<string>? FailureHandler;
|
||||||
|
public int RefCount;
|
||||||
|
public bool Started;
|
||||||
|
public WriteableBitmap? PreviewBitmap;
|
||||||
|
public VideoFrame? LatestFrame;
|
||||||
|
public bool FramePending;
|
||||||
|
public TaskCompletionSource<bool>? FirstFrame;
|
||||||
|
|
||||||
|
public CameraSession(string deviceId, ICameraFrameSource source)
|
||||||
|
{
|
||||||
|
DeviceId = deviceId;
|
||||||
|
Source = source;
|
||||||
|
RefCount = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ICameraEnumerator _enumerator;
|
||||||
|
private readonly Func<string, ICameraFrameSource> _frameSourceFactory;
|
||||||
|
private readonly Dispatcher? _uiDispatcher;
|
||||||
|
private readonly TimeSpan _firstFrameTimeout;
|
||||||
|
private readonly Dictionary<string, CameraSession> _sessions = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
|
||||||
|
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when a capture fails: device in use, access denied, no preview source,
|
||||||
|
/// or a started reader that never delivers a first frame within the timeout.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? CameraFailed;
|
||||||
|
|
||||||
|
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
|
||||||
|
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null)
|
||||||
|
{
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_frameSourceFactory = frameSourceFactory;
|
||||||
|
_uiDispatcher = uiDispatcher;
|
||||||
|
_firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICameraEnumerator Enumerator => _enumerator;
|
||||||
|
|
||||||
|
/// <summary>Increments the refcount for a device, starting capture the first time.</summary>
|
||||||
|
public async Task<bool> AcquireAsync(string deviceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceId)) return false;
|
||||||
|
|
||||||
|
CameraSession session;
|
||||||
|
bool shouldStart;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var existing))
|
||||||
|
{
|
||||||
|
existing.RefCount++;
|
||||||
|
shouldStart = false;
|
||||||
|
session = existing;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
session = new CameraSession(deviceId, _frameSourceFactory(deviceId));
|
||||||
|
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||||
|
session.Source.FrameAvailable += session.FrameHandler;
|
||||||
|
session.FailureHandler = message => OnSourceFailed(session, message);
|
||||||
|
session.Source.SourceFailed += session.FailureHandler;
|
||||||
|
_sessions[deviceId] = session;
|
||||||
|
shouldStart = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldStart) return session.Started;
|
||||||
|
|
||||||
|
session.FirstFrame = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.Source.StartAsync();
|
||||||
|
|
||||||
|
// First-frame proof: "the reader started" is not "the stream is live".
|
||||||
|
// Success requires an actual frame within the timeout, else the session
|
||||||
|
// is rolled back and reported — never left as a silent empty preview.
|
||||||
|
var proven = _firstFrameTimeout <= TimeSpan.Zero
|
||||||
|
|| await WaitForFirstFrameAsync(session, _firstFrameTimeout);
|
||||||
|
if (!proven)
|
||||||
|
{
|
||||||
|
var reason = $"Camera '{deviceId}' started but produced no frames within {_firstFrameTimeout.TotalSeconds:0.#}s.";
|
||||||
|
RollbackSession(session, reason);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the SourceFailed handler subscribed: an async failure after a
|
||||||
|
// successful start (device lost, stream state Failed) must still surface.
|
||||||
|
session.FirstFrame = null;
|
||||||
|
session.Started = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
RollbackSession(session, ex.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrements the refcount; stops and disposes the source at zero.</summary>
|
||||||
|
public async Task ReleaseAsync(string deviceId)
|
||||||
|
{
|
||||||
|
CameraSession? toStop = null;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(deviceId, out var session)) return;
|
||||||
|
if (--session.RefCount > 0) return;
|
||||||
|
_sessions.Remove(deviceId);
|
||||||
|
toStop = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toStop == null) return;
|
||||||
|
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||||
|
await SafeStopAsync(toStop.Source);
|
||||||
|
toStop.PreviewBitmap = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases a device unconditionally (every ref), regardless of how many scenes
|
||||||
|
/// hold it. Used on identity changes (webcam swap, layout reload) where the old
|
||||||
|
/// device's refcount isn't known after the scenes are replaced.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ReleaseAllAsync(string deviceId)
|
||||||
|
{
|
||||||
|
CameraSession? toStop = null;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(deviceId, out var session)) return;
|
||||||
|
session.RefCount = 0;
|
||||||
|
_sessions.Remove(deviceId);
|
||||||
|
toStop = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toStop == null) return;
|
||||||
|
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||||
|
await SafeStopAsync(toStop.Source);
|
||||||
|
toStop.PreviewBitmap = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VideoFrame? GetLatestFrame(string deviceId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared WriteableBitmap for a running session, or null when the camera
|
||||||
|
/// hasn't produced its first frame yet. <see cref="PreviewBitmapChanged"/> only
|
||||||
|
/// fires once (first frame), so configs created after the session started must
|
||||||
|
/// pick the bitmap up from here.
|
||||||
|
/// </summary>
|
||||||
|
public WriteableBitmap? GetPreviewBitmap(string deviceId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _sessions.TryGetValue(deviceId, out var session) ? session.PreviewBitmap : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
List<CameraSession> sessions;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
sessions = new List<CameraSession>(_sessions.Values);
|
||||||
|
_sessions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
session.Source.FrameAvailable -= session.FrameHandler;
|
||||||
|
if (session.FailureHandler != null)
|
||||||
|
session.Source.SourceFailed -= session.FailureHandler;
|
||||||
|
_ = SafeStopAsync(session.Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> WaitForFirstFrameAsync(CameraSession session, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var first = session.FirstFrame;
|
||||||
|
if (first == null) return true;
|
||||||
|
var completed = await Task.WhenAny(first.Task, Task.Delay(timeout));
|
||||||
|
return ReferenceEquals(completed, first.Task) && first.Task.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RollbackSession(CameraSession session, string message)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (TryGetActiveSession(session))
|
||||||
|
_sessions.Remove(session.DeviceId);
|
||||||
|
}
|
||||||
|
session.Source.FrameAvailable -= session.FrameHandler;
|
||||||
|
if (session.FailureHandler != null)
|
||||||
|
session.Source.SourceFailed -= session.FailureHandler;
|
||||||
|
session.FirstFrame?.TrySetResult(false);
|
||||||
|
|
||||||
|
var enriched = message;
|
||||||
|
var suspects = CameraConflictProbe.GetRunningCameraApps();
|
||||||
|
if (suspects.Count > 0)
|
||||||
|
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
|
||||||
|
|
||||||
|
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
|
||||||
|
CameraFailed?.Invoke(session.DeviceId, enriched);
|
||||||
|
_ = SafeStopAsync(session.Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourceFailed(CameraSession session, string message)
|
||||||
|
{
|
||||||
|
RollbackSession(session, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SafeStopAsync(ICameraFrameSource source)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await source.StopAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetActiveSession(CameraSession session)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _sessions.TryGetValue(session.DeviceId, out var current) && ReferenceEquals(current, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFrameAvailable(CameraSession session, VideoFrame frame)
|
||||||
|
{
|
||||||
|
if (!TryGetActiveSession(session)) return;
|
||||||
|
session.LatestFrame = frame;
|
||||||
|
session.FirstFrame?.TrySetResult(true);
|
||||||
|
|
||||||
|
if (session.PreviewBitmap == null)
|
||||||
|
{
|
||||||
|
if (_uiDispatcher == null) return;
|
||||||
|
if (_uiDispatcher.CheckAccess())
|
||||||
|
EnsurePreviewBitmap(session);
|
||||||
|
else
|
||||||
|
_uiDispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
if (TryGetActiveSession(session))
|
||||||
|
EnsurePreviewBitmap(session);
|
||||||
|
}, DispatcherPriority.Render);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_uiDispatcher == null || _uiDispatcher.CheckAccess())
|
||||||
|
{
|
||||||
|
CopyFrame(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.FramePending) return;
|
||||||
|
session.FramePending = true;
|
||||||
|
_uiDispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
session.FramePending = false;
|
||||||
|
if (TryGetActiveSession(session) && session.PreviewBitmap != null)
|
||||||
|
CopyFrame(session);
|
||||||
|
}, DispatcherPriority.Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePreviewBitmap(CameraSession session)
|
||||||
|
{
|
||||||
|
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
|
||||||
|
var frame = session.LatestFrame;
|
||||||
|
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||||
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||||
|
session.PreviewBitmap = bitmap;
|
||||||
|
PreviewBitmapChanged?.Invoke(session.DeviceId, bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyFrame(CameraSession session)
|
||||||
|
{
|
||||||
|
var bitmap = session.PreviewBitmap;
|
||||||
|
var frame = session.LatestFrame;
|
||||||
|
if (bitmap == null || frame == null) return;
|
||||||
|
if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return;
|
||||||
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Windows.Graphics.Capture;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Desktop-interop bridges for Graphics Capture that CsWinRT can't project:
|
||||||
|
/// creating a <see cref="GraphicsCaptureItem"/> from an HMONITOR/HWND via the
|
||||||
|
/// activation factory's IGraphicsCaptureItemInterop, and wiring the
|
||||||
|
/// GraphicsCapturePicker to an owner window via IInitializeWithWindow.
|
||||||
|
/// GUIDs and signatures come from the official Windows GraphicsCapture samples.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CaptureInterop
|
||||||
|
{
|
||||||
|
private static readonly Guid GraphicsCaptureItemGuid = new("79C3F95B-31F7-4EC2-A464-632EF5D30760");
|
||||||
|
|
||||||
|
public static GraphicsCaptureItem CreateForMonitor(IntPtr hmonitor)
|
||||||
|
{
|
||||||
|
var interop = GraphicsCaptureItem.As<IGraphicsCaptureItemInterop>();
|
||||||
|
return GraphicsCaptureItem.FromAbi(interop.CreateForMonitor(hmonitor, GraphicsCaptureItemGuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GraphicsCaptureItem CreateForWindow(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
var interop = GraphicsCaptureItem.As<IGraphicsCaptureItemInterop>();
|
||||||
|
return GraphicsCaptureItem.FromAbi(interop.CreateForWindow(hwnd, GraphicsCaptureItemGuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetWindowOwner(GraphicsCapturePicker picker, IntPtr hwnd)
|
||||||
|
{
|
||||||
|
var interop = (IInitializeWithWindow)(object)picker;
|
||||||
|
interop.Initialize(hwnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport]
|
||||||
|
[Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IGraphicsCaptureItemInterop
|
||||||
|
{
|
||||||
|
IntPtr CreateForWindow([In] IntPtr window, [In] ref Guid iid);
|
||||||
|
IntPtr CreateForMonitor([In] IntPtr monitor, [In] ref Guid iid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport]
|
||||||
|
[Guid("3E68D4BD-7135-4D10-8018-9FB6D9F33FA1")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IInitializeWithWindow
|
||||||
|
{
|
||||||
|
void Initialize(IntPtr hwnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the compositor draws from (the active quality tier's output rect over the
|
||||||
|
/// 1920×1080 master) and at what size. 16:9 tiers = the full master 1:1; the vertical
|
||||||
|
/// 9:16 tier = the centered 607×1080 crop scaled up to 1080×1920. The source rect is
|
||||||
|
/// integer-aligned — MainViewModel's <c>OutputRectX</c> can be 656.5, so the caller
|
||||||
|
/// rounds before building these options.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CompositorOptions
|
||||||
|
{
|
||||||
|
public int SourceRectX { get; init; }
|
||||||
|
public int SourceRectY { get; init; }
|
||||||
|
public int SourceRectWidth { get; init; }
|
||||||
|
public int SourceRectHeight { get; init; }
|
||||||
|
|
||||||
|
public int OutputWidth { get; init; }
|
||||||
|
public int OutputHeight { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
|
||||||
|
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
|
||||||
|
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
|
||||||
|
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||||
|
///
|
||||||
|
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
|
||||||
|
/// (the caller maps webcam → DeviceId, images → AssetId, backdrop → CaptureKey), keeping
|
||||||
|
/// the compositor pure and free of WPF and of the capture managers.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SceneCompositor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||||
|
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
|
||||||
|
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||||
|
/// branding flash → social bar (a global overlay; spans the master width at
|
||||||
|
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
|
||||||
|
/// </summary>
|
||||||
|
public VideoFrame Render(
|
||||||
|
Scene scene,
|
||||||
|
Func<SceneElement, VideoFrame?> frameFor,
|
||||||
|
VideoFrame? flashFrame,
|
||||||
|
CompositorOptions options,
|
||||||
|
VideoFrame? socialBarFrame = null,
|
||||||
|
int socialBarTop = 0)
|
||||||
|
{
|
||||||
|
if (scene == null) throw new ArgumentNullException(nameof(scene));
|
||||||
|
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
|
||||||
|
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||||
|
if (options.SourceRectWidth <= 0 || options.SourceRectHeight <= 0)
|
||||||
|
throw new ArgumentException("The source rect must be positive.", nameof(options));
|
||||||
|
if (options.OutputWidth <= 0 || options.OutputHeight <= 0)
|
||||||
|
throw new ArgumentException("The output size must be positive.", nameof(options));
|
||||||
|
|
||||||
|
var cropW = options.SourceRectWidth;
|
||||||
|
var cropH = options.SourceRectHeight;
|
||||||
|
var buffer = new byte[cropW * cropH * 4];
|
||||||
|
for (var i = 3; i < buffer.Length; i += 4)
|
||||||
|
buffer[i] = 255; // opaque black base — video frames are never transparent
|
||||||
|
|
||||||
|
var elements = scene.Elements;
|
||||||
|
|
||||||
|
var backdrop = elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
|
||||||
|
var backdropFrame = backdrop != null ? frameFor(backdrop) : null;
|
||||||
|
if (backdropFrame != null)
|
||||||
|
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backdropFrame, 1f, false, false);
|
||||||
|
|
||||||
|
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||||
|
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||||
|
if (backgroundFrame != null)
|
||||||
|
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||||
|
|
||||||
|
foreach (var element in elements)
|
||||||
|
{
|
||||||
|
if (!element.IsVisible) continue;
|
||||||
|
switch (element)
|
||||||
|
{
|
||||||
|
case Source { IsBackdrop: true }:
|
||||||
|
case Source { Type: SourceType.Background }:
|
||||||
|
case Source { Type: SourceType.TextOverlay }:
|
||||||
|
continue; // backdrop/background are their own layers; Text isn't shipped
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame = frameFor(element);
|
||||||
|
if (frame == null) continue;
|
||||||
|
|
||||||
|
var ex = (float)(element.X - options.SourceRectX);
|
||||||
|
var ey = (float)(element.Y - options.SourceRectY);
|
||||||
|
var ew = (float)element.Width;
|
||||||
|
var eh = (float)element.Height;
|
||||||
|
var isRound = element.ClipShape == ClipShape.Round;
|
||||||
|
|
||||||
|
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
|
||||||
|
(float)element.Opacity, isRound, element.IsMirrored);
|
||||||
|
if (element.HasBorder)
|
||||||
|
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flashFrame != null)
|
||||||
|
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
|
||||||
|
|
||||||
|
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||||
|
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
|
||||||
|
|
||||||
|
return StretchMath.BilinearScale(
|
||||||
|
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UniformToFill blit of a source frame into element rect (ex, ey, ew, eh), with
|
||||||
|
/// straight-alpha source-over, optional round clip (true circle, hard edge) and
|
||||||
|
/// horizontal mirror around the element center.
|
||||||
|
/// </summary>
|
||||||
|
private static void BlitContent(
|
||||||
|
byte[] dst, int dstW, int dstH,
|
||||||
|
float ex, float ey, float ew, float eh,
|
||||||
|
VideoFrame src, float opacity, bool isRound, bool isMirror)
|
||||||
|
{
|
||||||
|
if (ew <= 0 || eh <= 0) return;
|
||||||
|
|
||||||
|
var x0 = Math.Max(0, (int)Math.Floor(ex));
|
||||||
|
var y0 = Math.Max(0, (int)Math.Floor(ey));
|
||||||
|
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
|
||||||
|
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh));
|
||||||
|
if (x0 > x1 || y0 > y1) return;
|
||||||
|
|
||||||
|
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||||
|
var drawnW = src.Width * scale;
|
||||||
|
var drawnH = src.Height * scale;
|
||||||
|
var radius = Math.Min(ew, eh) / 2f;
|
||||||
|
var cx = ew / 2f;
|
||||||
|
var cy = eh / 2f;
|
||||||
|
|
||||||
|
for (var y = y0; y <= y1; y++)
|
||||||
|
{
|
||||||
|
for (var x = x0; x <= x1; x++)
|
||||||
|
{
|
||||||
|
var px = x - ex; // element space
|
||||||
|
var py = y - ey;
|
||||||
|
if (px < ox || px > ox + drawnW || py < oy || py > oy + drawnH) continue;
|
||||||
|
if (isRound)
|
||||||
|
{
|
||||||
|
var dx = px - cx;
|
||||||
|
var dy = py - cy;
|
||||||
|
if (dx * dx + dy * dy > radius * radius) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sx = (px - ox) / scale;
|
||||||
|
var sy = (py - oy) / scale;
|
||||||
|
if (isMirror) sx = src.Width - 1 - sx;
|
||||||
|
var sample = StretchMath.SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
|
||||||
|
BlendPixel(dst, (y * dstW + x) * 4, sample, opacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Centered OBS-style border stroke: rect ring (Traditional) or circle ring (Round).</summary>
|
||||||
|
private static void DrawBorder(
|
||||||
|
byte[] dst, int dstW, int dstH,
|
||||||
|
float ex, float ey, float ew, float eh,
|
||||||
|
SceneElement element, bool isRound)
|
||||||
|
{
|
||||||
|
if (!element.TryGetBorderColor(out var r, out var g, out var b)) return;
|
||||||
|
var half = element.BorderWidth / 2f;
|
||||||
|
var alpha = (float)(element.Opacity * element.BorderOpacity);
|
||||||
|
if (half <= 0 || alpha <= 0) return;
|
||||||
|
|
||||||
|
var cx = ex + ew / 2f;
|
||||||
|
var cy = ey + eh / 2f;
|
||||||
|
var radius = Math.Min(ew, eh) / 2f;
|
||||||
|
|
||||||
|
var x0 = Math.Max(0, (int)Math.Floor(ex - half));
|
||||||
|
var y0 = Math.Max(0, (int)Math.Floor(ey - half));
|
||||||
|
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew + half));
|
||||||
|
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh + half));
|
||||||
|
|
||||||
|
for (var y = y0; y <= y1; y++)
|
||||||
|
{
|
||||||
|
for (var x = x0; x <= x1; x++)
|
||||||
|
{
|
||||||
|
float d = isRound
|
||||||
|
? MathF.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) - radius
|
||||||
|
: MathF.Max(MathF.Max(ex - x, x - (ex + ew)), MathF.Max(ey - y, y - (ey + eh)));
|
||||||
|
if (MathF.Abs(d) <= half)
|
||||||
|
BlendPixel(dst, (y * dstW + x) * 4, (b, g, r, (byte)255), alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
|
||||||
|
/// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
|
||||||
|
private static void BlitOverlay(
|
||||||
|
byte[] dst, int dstW, int dstH, CompositorOptions options,
|
||||||
|
VideoFrame overlay, int sx0, int sy0)
|
||||||
|
{
|
||||||
|
for (var y = 0; y < dstH; y++)
|
||||||
|
{
|
||||||
|
for (var x = 0; x < dstW; x++)
|
||||||
|
{
|
||||||
|
var sx = x + options.SourceRectX - sx0;
|
||||||
|
var sy = y + options.SourceRectY - sy0;
|
||||||
|
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
|
||||||
|
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
|
||||||
|
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
|
||||||
|
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
|
||||||
|
{
|
||||||
|
var sa = src.A / 255f * opacity;
|
||||||
|
if (sa <= 0) return;
|
||||||
|
var da = dst[di + 3] / 255f;
|
||||||
|
var outA = sa + da * (1 - sa);
|
||||||
|
if (outA <= 0) return;
|
||||||
|
dst[di] = (byte)Math.Round((src.B * sa + dst[di] * da * (1 - sa)) / outA);
|
||||||
|
dst[di + 1] = (byte)Math.Round((src.G * sa + dst[di + 1] * da * (1 - sa)) / outA);
|
||||||
|
dst[di + 2] = (byte)Math.Round((src.R * sa + dst[di + 2] * da * (1 - sa)) / outA);
|
||||||
|
dst[di + 3] = (byte)Math.Round(outA * 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Effects;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rasterizes the global social bar into a transparent BGRA8 strip the output
|
||||||
|
/// compositor overlays onto the master frame (top or bottom edge). Replicates the
|
||||||
|
/// preview's bar DataTemplate in code — one Path (logo) + one TextBlock (handle)
|
||||||
|
/// per entry, white on transparent, green glow — then software-renders it via
|
||||||
|
/// <c>RenderTargetBitmap</c> and converts Pbgra32 (premultiplied) to straight-alpha
|
||||||
|
/// BGRA for the compositor. WPF glue like <see cref="StaticPixelCache"/>: the
|
||||||
|
/// compositor core itself stays pure byte-math. Renders on the UI thread only;
|
||||||
|
/// the returned frame is immutable afterwards, so the frame pump may read it from
|
||||||
|
/// any thread.
|
||||||
|
/// </summary>
|
||||||
|
public static class SocialBarRenderer
|
||||||
|
{
|
||||||
|
/// <summary>Icon size + the preview's 8px top/bottom margins.</summary>
|
||||||
|
private const int ContentHeight = 40;
|
||||||
|
|
||||||
|
/// <summary>Room for the green DropShadowEffect blur to bleed past the content.</summary>
|
||||||
|
private const int GlowPad = 24;
|
||||||
|
|
||||||
|
/// <summary>Master-frame width — the bar spans the full 1920px output.</summary>
|
||||||
|
public const int Width = 1920;
|
||||||
|
|
||||||
|
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
|
||||||
|
public static VideoFrame? Render(IEnumerable<SocialEntry>? entries)
|
||||||
|
{
|
||||||
|
var list = entries?.Where(e => e != null).ToList();
|
||||||
|
if (list == null || list.Count == 0) return null;
|
||||||
|
|
||||||
|
var height = ContentHeight + GlowPad * 2;
|
||||||
|
|
||||||
|
var row = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
foreach (var entry in list)
|
||||||
|
{
|
||||||
|
var item = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Margin = new Thickness(0, 0, 20, 0),
|
||||||
|
};
|
||||||
|
item.Children.Add(new Path
|
||||||
|
{
|
||||||
|
Data = Geometry.Parse(entry.LogoData),
|
||||||
|
Fill = Brushes.White,
|
||||||
|
Width = 24,
|
||||||
|
Height = 24,
|
||||||
|
Stretch = Stretch.Uniform,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
item.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = entry.Handle,
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
FontSize = 14,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(8, 0, 0, 0),
|
||||||
|
});
|
||||||
|
row.Children.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The glow is baked in so the output bar matches the preview's green halo.
|
||||||
|
var container = new Grid
|
||||||
|
{
|
||||||
|
Width = Width,
|
||||||
|
Height = height,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Effect = new DropShadowEffect { Color = Color.FromRgb(0x2e, 0xcc, 0x71), BlurRadius = 18, ShadowDepth = 0, Opacity = 0.9 },
|
||||||
|
};
|
||||||
|
var centered = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, GlowPad, 0, 0) };
|
||||||
|
centered.Children.Add(row);
|
||||||
|
container.Children.Add(centered);
|
||||||
|
|
||||||
|
container.Measure(new Size(Width, height));
|
||||||
|
container.Arrange(new Rect(0, 0, Width, height));
|
||||||
|
|
||||||
|
var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32);
|
||||||
|
bitmap.Render(container);
|
||||||
|
|
||||||
|
var pixels = new byte[Width * height * 4];
|
||||||
|
bitmap.CopyPixels(pixels, Width * 4, 0);
|
||||||
|
|
||||||
|
// Pbgra32 is premultiplied — unpremultiply so the compositor's straight-alpha
|
||||||
|
// source-over blend doesn't darken the logo edges with a halo.
|
||||||
|
for (var i = 0; i < pixels.Length; i += 4)
|
||||||
|
{
|
||||||
|
var a = pixels[i + 3];
|
||||||
|
if (a == 0 || a == 255) continue;
|
||||||
|
pixels[i] = (byte)(pixels[i] * 255 / a);
|
||||||
|
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
||||||
|
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VideoFrame(Width, height, pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes a layout asset's bytes into a tightly-packed BGRA8 <see cref="VideoFrame"/>
|
||||||
|
/// once per content-hash asset id. The preview uses the WPF <c>BitmapImage</c> in
|
||||||
|
/// ImageCache; the output path needs raw pixels, so assets decode here instead.
|
||||||
|
/// </summary>
|
||||||
|
public static class StaticPixelCache
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, VideoFrame> Cache = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public static VideoFrame? Get(string assetId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||||
|
if (Cache.TryGetValue(assetId, out var frame)) return frame;
|
||||||
|
|
||||||
|
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||||
|
if (bytes == null || bytes.Length == 0) return null;
|
||||||
|
|
||||||
|
var decoded = Decode(bytes);
|
||||||
|
if (decoded != null) Cache[assetId] = decoded;
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static VideoFrame? Decode(byte[] bytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(bytes, writable: false);
|
||||||
|
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||||
|
var source = decoder.Frames[0];
|
||||||
|
var bgra = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0);
|
||||||
|
var width = bgra.PixelWidth;
|
||||||
|
var height = bgra.PixelHeight;
|
||||||
|
var pixels = new byte[width * height * 4];
|
||||||
|
bgra.CopyPixels(pixels, width * 4, 0);
|
||||||
|
return new VideoFrame(width, height, pixels);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
namespace ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure pixel math shared by the compositor: the WPF "UniformToFill" cover-crop
|
||||||
|
/// (what the preview's <c>Stretch="UniformToFill"</c> does) and a clamped bilinear
|
||||||
|
/// sample/scale. Pure and deterministic — the unit-tested half of the compositor.
|
||||||
|
/// </summary>
|
||||||
|
public static class StretchMath
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// UniformToFill: the drawn content covers the destination while preserving the
|
||||||
|
/// source aspect, centered; overflow is cropped. Returns the scale and the
|
||||||
|
/// source-origin offset (in destination pixels) of the drawn content within the
|
||||||
|
/// destination rect.
|
||||||
|
/// </summary>
|
||||||
|
public static (float Scale, float OffsetX, float OffsetY) UniformToFill(float dstW, float dstH, int srcW, int srcH)
|
||||||
|
{
|
||||||
|
var scale = Math.Max(dstW / srcW, dstH / srcH);
|
||||||
|
var drawnW = srcW * scale;
|
||||||
|
var drawnH = srcH * scale;
|
||||||
|
return (scale, (dstW - drawnW) / 2f, (dstH - drawnH) / 2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clamped bilinear scale into a new tightly-packed BGRA8 frame; returns the input unchanged when the sizes already match.</summary>
|
||||||
|
public static VideoFrame BilinearScale(VideoFrame src, int outW, int outH)
|
||||||
|
{
|
||||||
|
if (outW == src.Width && outH == src.Height) return src;
|
||||||
|
|
||||||
|
var outPixels = new byte[outW * outH * 4];
|
||||||
|
for (var y = 0; y < outH; y++)
|
||||||
|
{
|
||||||
|
var sy = (y + 0.5f) / outH * src.Height - 0.5f;
|
||||||
|
for (var x = 0; x < outW; x++)
|
||||||
|
{
|
||||||
|
var sx = (x + 0.5f) / outW * src.Width - 0.5f;
|
||||||
|
var (b, g, r, a) = SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
|
||||||
|
var di = (y * outW + x) * 4;
|
||||||
|
outPixels[di] = b;
|
||||||
|
outPixels[di + 1] = g;
|
||||||
|
outPixels[di + 2] = r;
|
||||||
|
outPixels[di + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new VideoFrame(outW, outH, outPixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clamped bilinear sample of one BGRA8 pixel at fractional (sx, sy). Coordinates
|
||||||
|
/// outside [0, w-1]×[0, h-1] clamp to the edge, so a caller can sample freely
|
||||||
|
/// without bounds checks.
|
||||||
|
/// </summary>
|
||||||
|
public static (byte B, byte G, byte R, byte A) SampleBgra(byte[] src, int w, int h, float sx, float sy)
|
||||||
|
{
|
||||||
|
sx = Math.Clamp(sx, 0, w - 1);
|
||||||
|
sy = Math.Clamp(sy, 0, h - 1);
|
||||||
|
var x0 = (int)sx;
|
||||||
|
var y0 = (int)sy;
|
||||||
|
var x1 = Math.Min(x0 + 1, w - 1);
|
||||||
|
var y1 = Math.Min(y0 + 1, h - 1);
|
||||||
|
var fx = sx - x0;
|
||||||
|
var fy = sy - y0;
|
||||||
|
|
||||||
|
var p00 = (y0 * w + x0) * 4;
|
||||||
|
var p10 = (y0 * w + x1) * 4;
|
||||||
|
var p01 = (y1 * w + x0) * 4;
|
||||||
|
var p11 = (y1 * w + x1) * 4;
|
||||||
|
|
||||||
|
float r = Lerp(Lerp(src[p00 + 2], src[p10 + 2], fx), Lerp(src[p01 + 2], src[p11 + 2], fx), fy);
|
||||||
|
float g = Lerp(Lerp(src[p00 + 1], src[p10 + 1], fx), Lerp(src[p01 + 1], src[p11 + 1], fx), fy);
|
||||||
|
float b = Lerp(Lerp(src[p00], src[p10], fx), Lerp(src[p01], src[p11], fx), fy);
|
||||||
|
float a = Lerp(Lerp(src[p00 + 3], src[p10 + 3], fx), Lerp(src[p01 + 3], src[p11 + 3], fx), fy);
|
||||||
|
return ((byte)Math.Round(b), (byte)Math.Round(g), (byte)Math.Round(r), (byte)Math.Round(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Windows.Graphics.DirectX.Direct3D11;
|
||||||
|
using WinRT;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges a native D3D11 device into the WinRT <see cref="IDirect3DDevice"/>
|
||||||
|
/// the capture API needs. CsWinRT doesn't project the Direct3D11Helper static,
|
||||||
|
/// so this creates the device with D3D11CreateDevice and converts it via the
|
||||||
|
/// WinRT interop export <c>CreateDirect3D11DeviceFromDXGIDevice</c> (d3d11.dll).
|
||||||
|
///
|
||||||
|
/// This deliberately does NOT use the older QI-for-IDirect3DDxgiInterfaceAccess
|
||||||
|
/// trick: the raw D3D11 device stopped exposing that interface on newer Windows
|
||||||
|
/// (verified E_NOINTERFACE on build 26200, hardware and WARP alike), while
|
||||||
|
/// CreateDirect3D11DeviceFromDXGIDevice keeps working. One shared device per
|
||||||
|
/// process.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Direct3D11Helper
|
||||||
|
{
|
||||||
|
private const uint D3D11CreateDeviceBgraSupport = 0x20;
|
||||||
|
private const uint D3D11SdkVersion = 7;
|
||||||
|
private const int DriverTypeHardware = 1;
|
||||||
|
|
||||||
|
// IDirect3DDxgiInterfaceAccess (the legacy bridge) is only exposed on an
|
||||||
|
// 11.1 device AND requires the 11.1 runtime to be in the requested set —
|
||||||
|
// with pFeatureLevels = NULL D3D11CreateDevice never creates 11.1, so the
|
||||||
|
// array must be explicit (11.1 first, then descending).
|
||||||
|
private static readonly int[] FeatureLevels = { 0xB100, 0xB000, 0xA100, 0xA000, 0x9300, 0x9200, 0x9100 };
|
||||||
|
|
||||||
|
private static IDirect3DDevice? _sharedDevice;
|
||||||
|
private static readonly object Gate = new();
|
||||||
|
|
||||||
|
public static IDirect3DDevice CreateDevice()
|
||||||
|
{
|
||||||
|
if (_sharedDevice != null) return _sharedDevice;
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
return _sharedDevice ??= CreateDeviceCore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDirect3DDevice CreateDeviceCore()
|
||||||
|
{
|
||||||
|
var hr = D3D11CreateDevice(IntPtr.Zero, DriverTypeHardware, IntPtr.Zero, D3D11CreateDeviceBgraSupport,
|
||||||
|
FeatureLevels, (uint)FeatureLevels.Length, D3D11SdkVersion, out var devicePtr, out _, out var contextPtr);
|
||||||
|
if (hr != 0)
|
||||||
|
throw Marshal.GetExceptionForHR(hr)!;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dxgiGuid = new Guid("54EC77FA-1377-44E6-8C32-88FD5F44C84C");
|
||||||
|
var qiHr = Marshal.QueryInterface(devicePtr, ref dxgiGuid, out var dxgiDevice);
|
||||||
|
if (qiHr != 0)
|
||||||
|
throw Marshal.GetExceptionForHR(qiHr)!;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice, out var winrtDevice);
|
||||||
|
if (hr != 0)
|
||||||
|
throw Marshal.GetExceptionForHR(hr)!;
|
||||||
|
return MarshalInterface<IDirect3DDevice>.FromAbi(winrtDevice);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.Release(dxgiDevice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.Release(devicePtr);
|
||||||
|
Marshal.Release(contextPtr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("d3d11.dll")]
|
||||||
|
private static extern int D3D11CreateDevice(IntPtr pAdapter, int driverType, IntPtr software,
|
||||||
|
uint flags, int[]? featureLevels, uint featureLevelsCount, uint sdkVersion,
|
||||||
|
out IntPtr device, out int featureLevel, out IntPtr immediateContext);
|
||||||
|
|
||||||
|
[DllImport("d3d11.dll", ExactSpelling = true)]
|
||||||
|
private static extern int CreateDirect3D11DeviceFromDXGIDevice(IntPtr dxgiDevice, out IntPtr graphicsDevice);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship
|
||||||
|
/// step 3). <see cref="RtmpUrl"/> is the FULL ingestion URL — the reusable
|
||||||
|
/// stream's ingest address plus its stream key (<c>rtmp://a.rtmp.youtube.com/live2/<key></c>).
|
||||||
|
/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent
|
||||||
|
/// placeholder track until the WASAPI capture step replaces the input.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EncoderOptions
|
||||||
|
{
|
||||||
|
public string RtmpUrl { get; init; } = string.Empty;
|
||||||
|
public int Width { get; init; } = 1920;
|
||||||
|
public int Height { get; init; } = 1080;
|
||||||
|
public int Fps { get; init; } = 60;
|
||||||
|
public int BitrateKbps { get; init; } = 8000;
|
||||||
|
|
||||||
|
/// <summary>H.264 encoder name for <c>-c:v</c>; the encoder probes and prefers
|
||||||
|
/// nvenc → qsv → amf → libopenh264 when not forced.</summary>
|
||||||
|
public string? VideoEncoder { get; init; }
|
||||||
|
|
||||||
|
public int AudioSampleRate { get; init; } = 48000;
|
||||||
|
public int AudioChannels { get; init; } = 2;
|
||||||
|
|
||||||
|
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
|
||||||
|
public int GopSize => Fps * 4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
|
||||||
|
/// raw BGRA frames via stdin (paced <c>-re</c>), silent placeholder audio via lavfi
|
||||||
|
/// <c>anullsrc</c> (the WASAPI step replaces this input), H.264 + AAC encoding, FLV
|
||||||
|
/// muxing to the ingestion URL. Pure — the encoder just starts
|
||||||
|
/// <c>ffmpeg.exe [Build(...)]</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class FfmpegArgs
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder)
|
||||||
|
{
|
||||||
|
var gop = options.GopSize;
|
||||||
|
return
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "info",
|
||||||
|
"-stats",
|
||||||
|
"-stats_period", "0.5",
|
||||||
|
"-re",
|
||||||
|
"-f", "rawvideo",
|
||||||
|
"-pix_fmt", "bgra",
|
||||||
|
"-video_size", $"{options.Width}x{options.Height}",
|
||||||
|
"-framerate", options.Fps.ToString(),
|
||||||
|
"-i", "pipe:0",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", $"anullsrc=channel_layout=stereo:sample_rate={options.AudioSampleRate}",
|
||||||
|
"-c:v", videoEncoder,
|
||||||
|
"-b:v", $"{options.BitrateKbps}k",
|
||||||
|
"-maxrate", $"{options.BitrateKbps}k",
|
||||||
|
"-bufsize", $"{options.BitrateKbps * 2}k",
|
||||||
|
"-g", gop.ToString(),
|
||||||
|
"-keyint_min", gop.ToString(),
|
||||||
|
"-sc_threshold", "0",
|
||||||
|
"-bf", "0",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "128k",
|
||||||
|
"-ar", options.AudioSampleRate.ToString(),
|
||||||
|
"-ac", options.AudioChannels.ToString(),
|
||||||
|
"-f", "flv",
|
||||||
|
options.RtmpUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default <see cref="IFfmpegEncoder"/>: spawns <c>ffmpeg.exe</c> (resolved via
|
||||||
|
/// <see cref="IFfmpegLocator"/>), feeds raw BGRA frames into stdin, and parses the
|
||||||
|
/// <c>-stats</c> progress lines into <see cref="StreamHealth"/>. Encoder choice is
|
||||||
|
/// probed from the binary's <c>-encoders</c> listing (hardware NVENC/QSV/AMF first,
|
||||||
|
/// OpenH264 software fallback — never libx264, see the license posture) unless
|
||||||
|
/// <see cref="EncoderOptions.VideoEncoder"/> forces one.
|
||||||
|
///
|
||||||
|
/// Graceful stop = close stdin (EOF) → ffmpeg finalizes the FLV and exits by itself;
|
||||||
|
/// a watchdogs kill fires only if it hasn't exited shortly after EOF.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||||
|
{
|
||||||
|
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||||
|
public event EventHandler<string>? ProcessFailed;
|
||||||
|
|
||||||
|
private readonly IFfmpegLocator _locator;
|
||||||
|
private readonly Func<IEncoderProcess> _processFactory;
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private IEncoderProcess? _process;
|
||||||
|
private EncoderOptions? _options;
|
||||||
|
private StreamHealth _health = new() { Status = StreamStatus.Offline };
|
||||||
|
private Task? _stderrLoop;
|
||||||
|
private bool _stopRequested;
|
||||||
|
|
||||||
|
public FfmpegEncoder(
|
||||||
|
IFfmpegLocator locator,
|
||||||
|
Func<IEncoderProcess>? processFactory = null)
|
||||||
|
{
|
||||||
|
_locator = locator;
|
||||||
|
_processFactory = processFactory ?? (() => new FfmpegEncoderProcess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning { get; private set; }
|
||||||
|
|
||||||
|
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||||
|
if (string.IsNullOrWhiteSpace(options.RtmpUrl))
|
||||||
|
throw new ArgumentException("An RTMP ingestion URL is required.", nameof(options));
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (IsRunning) throw new InvalidOperationException("The encoder is already running.");
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
var args = FfmpegArgs.Build(options, encoder);
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = ffmpegPath,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
foreach (var arg in args) startInfo.ArgumentList.Add(arg);
|
||||||
|
|
||||||
|
IEncoderProcess process;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process = _processFactory();
|
||||||
|
process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
|
||||||
|
lock (_gate) _options = null;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_process = process;
|
||||||
|
IsRunning = true;
|
||||||
|
_health = new StreamHealth { Status = StreamStatus.Streaming };
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopRequested = false;
|
||||||
|
_stderrLoop = RunStderrLoopAsync(process);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
|
||||||
|
/// (the compositor pump) may race freely. Frames are written as-is — the caller
|
||||||
|
/// paces to capture rate (the compositor's job, ship step 5).
|
||||||
|
/// </summary>
|
||||||
|
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (frame == null) throw new ArgumentNullException(nameof(frame));
|
||||||
|
IEncoderProcess? process;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
|
||||||
|
process = _process;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = frame.BgraPixels;
|
||||||
|
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
IEncoderProcess? process;
|
||||||
|
Task? loop;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!IsRunning) return;
|
||||||
|
_stopRequested = true;
|
||||||
|
process = _process;
|
||||||
|
loop = _stderrLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||||
|
await process!.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
|
||||||
|
process!.Kill();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
|
||||||
|
process!.Kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (loop != null) await loop.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.Dispose();
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
IsRunning = false;
|
||||||
|
_health.Status = StreamStatus.Offline;
|
||||||
|
_health.LastError = null;
|
||||||
|
_process = null;
|
||||||
|
_options = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLog.Write($"FFmpeg encoder stopped (exit {process.ExitCode})");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!IsRunning) return;
|
||||||
|
_process?.Kill();
|
||||||
|
_process?.Dispose();
|
||||||
|
_process = null;
|
||||||
|
IsRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = ffmpegPath,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-hide_banner");
|
||||||
|
startInfo.ArgumentList.Add("-encoders");
|
||||||
|
|
||||||
|
using var probe = _processFactory();
|
||||||
|
probe.Start(startInfo);
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||||
|
var output = await probe.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
return FfmpegEncoderPicker.Pick(output);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
|
||||||
|
return FfmpegEncoderPicker.Preference[^1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RunStderrLoopAsync(IEncoderProcess process)
|
||||||
|
{
|
||||||
|
return Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false);
|
||||||
|
if (line == null) break;
|
||||||
|
OnStderrLine(process, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = process.ExitCode;
|
||||||
|
var stillRunning = false;
|
||||||
|
lock (_gate) stillRunning = IsRunning;
|
||||||
|
if (stillRunning && !_stopRequested && code != 0)
|
||||||
|
{
|
||||||
|
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
|
||||||
|
_health.LastError = $"FFmpeg exited with code {code}";
|
||||||
|
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStderrLine(IEncoderProcess process, string line)
|
||||||
|
{
|
||||||
|
var progress = FfmpegProgressParser.TryParse(line);
|
||||||
|
if (progress == null) return;
|
||||||
|
|
||||||
|
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_health.CurrentBitrate = progress.Value.BitrateKbps;
|
||||||
|
_health.FPS = progress.Value.Fps;
|
||||||
|
_health.DroppedFrames = (int)dropped;
|
||||||
|
_health.StreamDuration = progress.Value.Duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
HealthUpdated?.Invoke(this, _health);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the best available H.264 encoder from ffmpeg's <c>-encoders</c> listing,
|
||||||
|
/// honoring the license posture (no GPL libx264): hardware NVENC → QSV → AMF, then
|
||||||
|
/// the OpenH264 software fallback. Pure parser — the <c>-encoders</c> probe output is
|
||||||
|
/// fetched by the encoder via an <see cref="IEncoderProcess"/> and fed here.
|
||||||
|
/// </summary>
|
||||||
|
public static class FfmpegEncoderPicker
|
||||||
|
{
|
||||||
|
/// <summary>Preference order, best first. All ship in the pinned BtbN lgpl-shared build.</summary>
|
||||||
|
public static readonly string[] Preference =
|
||||||
|
[
|
||||||
|
"h264_nvenc",
|
||||||
|
"h264_qsv",
|
||||||
|
"h264_amf",
|
||||||
|
"libopenh264",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First <see cref="Preference"/> entry present in the probe output, or the
|
||||||
|
/// software fallback (which the pinned build always contains) if none matched.
|
||||||
|
/// Never returns libx264 — it is GPL and would contaminate the paid product.
|
||||||
|
/// </summary>
|
||||||
|
public static string Pick(string probeOutput)
|
||||||
|
{
|
||||||
|
var available = probeOutput.Split('\n');
|
||||||
|
foreach (var name in Preference)
|
||||||
|
{
|
||||||
|
if (available.Any(line => line.Contains(name, StringComparison.Ordinal)))
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return Preference[^1];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The real <see cref="IEncoderProcess"/>: a <see cref="Process"/> with all three
|
||||||
|
/// std streams redirected. Constructed by <see cref="FfmpegEncoder"/> for both the
|
||||||
|
/// encoder subprocess and the <c>-encoders</c> probe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FfmpegEncoderProcess : IEncoderProcess
|
||||||
|
{
|
||||||
|
private readonly Process _process;
|
||||||
|
|
||||||
|
public FfmpegEncoderProcess() => _process = new Process { EnableRaisingEvents = true };
|
||||||
|
|
||||||
|
public void Start(ProcessStartInfo startInfo)
|
||||||
|
{
|
||||||
|
_process.StartInfo = startInfo;
|
||||||
|
_process.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream StandardInput => _process.StandardInput.BaseStream;
|
||||||
|
public TextReader StandardOutput => _process.StandardOutput;
|
||||||
|
public TextReader StandardError => _process.StandardError;
|
||||||
|
|
||||||
|
public bool HasExited => _process.HasExited;
|
||||||
|
public int ExitCode => _process.ExitCode;
|
||||||
|
public void Kill()
|
||||||
|
{
|
||||||
|
if (!_process.HasExited) _process.Kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> _process.WaitForExitAsync(cancellationToken);
|
||||||
|
|
||||||
|
public void Dispose() => _process.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Net.Http;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default <see cref="IFfmpegLocator"/>: probe PATH first (the user's own
|
||||||
|
/// install wins), then the cache in <c>%APPDATA%\ytLlive\tools</c>, then pull the
|
||||||
|
/// pinned BtbN **lgpl-shared** build (TASK 4 ship step 2). The shared variant is a
|
||||||
|
/// deliberate licensing choice: dynamic linking means LGPL compliance is "license
|
||||||
|
/// text + source offer", with no static-relink (LGPL §6) material required. The
|
||||||
|
/// shared zip puts <c>ffmpeg.exe</c> plus the <c>libav*.dll</c> family in <c>bin/</c>,
|
||||||
|
/// so both are extracted — Windows resolves the DLLs from the exe's own directory.
|
||||||
|
/// Search dirs, tools dir, and the downloader are constructor-injected so tests
|
||||||
|
/// fake the network and stay on a temp directory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FfmpegLocator : IFfmpegLocator
|
||||||
|
{
|
||||||
|
public const string FileName = "ffmpeg.exe";
|
||||||
|
|
||||||
|
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
|
||||||
|
public const string PinnedUrl =
|
||||||
|
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
|
||||||
|
|
||||||
|
private readonly string[] _searchDirs;
|
||||||
|
private readonly string _toolsDir;
|
||||||
|
private readonly Func<string, CancellationToken, Task<byte[]>> _downloader;
|
||||||
|
|
||||||
|
public FfmpegLocator(
|
||||||
|
string[]? searchDirs = null,
|
||||||
|
string? toolsDir = null,
|
||||||
|
Func<string, CancellationToken, Task<byte[]>>? downloader = null)
|
||||||
|
{
|
||||||
|
_searchDirs = searchDirs ?? ParsePath();
|
||||||
|
_toolsDir = toolsDir ?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools");
|
||||||
|
_downloader = downloader ?? DefaultDownload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> LocateAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
foreach (var dir in _searchDirs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(dir)) continue;
|
||||||
|
var candidate = Path.Combine(dir, FileName);
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cached = Path.Combine(_toolsDir, FileName);
|
||||||
|
if (File.Exists(cached) && new FileInfo(cached).Length > 0) return cached;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var zip = await _downloader(PinnedUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (zip.Length == 0)
|
||||||
|
throw new IOException($"FFmpeg download from {PinnedUrl} returned an empty payload.");
|
||||||
|
Directory.CreateDirectory(_toolsDir);
|
||||||
|
ExtractBinaries(zip, _toolsDir);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract <c>ffmpeg.exe</c> and every <c>*.dll</c> into <c>toolsDir</c> via a
|
||||||
|
/// staging directory, so a failed extract never leaves a partially-populated
|
||||||
|
/// cache behind (the previous good cache stays until every move succeeds).
|
||||||
|
/// </summary>
|
||||||
|
private static void ExtractBinaries(byte[] zip, string toolsDir)
|
||||||
|
{
|
||||||
|
var staging = toolsDir + ".stage";
|
||||||
|
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||||
|
Directory.CreateDirectory(staging);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(zip, writable: false);
|
||||||
|
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||||
|
var exe = archive.Entries.FirstOrDefault(
|
||||||
|
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
|
||||||
|
ExtractOne(exe, Path.Combine(staging, FileName));
|
||||||
|
foreach (var entry in archive.Entries)
|
||||||
|
{
|
||||||
|
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||||
|
ExtractOne(entry, Path.Combine(staging, Path.GetFileName(entry.FullName)));
|
||||||
|
}
|
||||||
|
foreach (var file in Directory.GetFiles(staging))
|
||||||
|
File.Move(file, Path.Combine(toolsDir, Path.GetFileName(file)), overwrite: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExtractOne(ZipArchiveEntry entry, string destination)
|
||||||
|
{
|
||||||
|
var temp = destination + ".tmp";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var source = entry.Open())
|
||||||
|
using (var target = File.Create(temp))
|
||||||
|
source.CopyTo(target);
|
||||||
|
File.Move(temp, destination);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(temp)) File.Delete(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> DefaultDownload(string url, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||||
|
return await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ParsePath()
|
||||||
|
{
|
||||||
|
var raw = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>A decoded ffmpeg <c>-stats</c> progress line (pure data).</summary>
|
||||||
|
public readonly record struct FfmpegProgress(
|
||||||
|
long Frame,
|
||||||
|
double Fps,
|
||||||
|
double BitrateKbps,
|
||||||
|
TimeSpan Duration,
|
||||||
|
long SizeBytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure parser for ffmpeg's periodic <c>frame= fps= size= time= bitrate=</c> stderr
|
||||||
|
/// lines (the <c>-stats</c>/<c>-stats_period</c> output). Unit-tested in isolation
|
||||||
|
/// so the encoder loop stays a thin wire.
|
||||||
|
/// </summary>
|
||||||
|
public static class FfmpegProgressParser
|
||||||
|
{
|
||||||
|
// frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x
|
||||||
|
private static readonly Regex Line = new(
|
||||||
|
@"frame=\s*(?<frame>\d+)\s+fps=\s*(?<fps>[\d.]+).*?"
|
||||||
|
+ @"size=\s*(?<size>\d+)KiB.*?"
|
||||||
|
+ @"time=(?<time>\d{2}):(?<min>\d{2}):(?<sec>[\d.]+).*?"
|
||||||
|
+ @"bitrate=\s*(?<bitrate>[\d.]+)kbits/s",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
/// <summary>Returns null for non-progress lines (errors, warnings, banner).</summary>
|
||||||
|
public static FfmpegProgress? TryParse(string line)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) return null;
|
||||||
|
var m = Line.Match(line);
|
||||||
|
if (!m.Success) return null;
|
||||||
|
|
||||||
|
var frame = long.Parse(m.Groups["frame"].Value);
|
||||||
|
var fps = double.Parse(m.Groups["fps"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var size = long.Parse(m.Groups["size"].Value);
|
||||||
|
var bitrate = double.Parse(m.Groups["bitrate"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var hours = int.Parse(m.Groups["time"].Value);
|
||||||
|
var minutes = int.Parse(m.Groups["min"].Value);
|
||||||
|
var seconds = double.Parse(m.Groups["sec"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var duration = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
|
||||||
|
|
||||||
|
return new FfmpegProgress(frame, fps, bitrate, duration, size * 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||||||
|
/// managers + compositor and the encoder. While live it snapshots the active
|
||||||
|
/// scene each tick, resolves every element to its latest frame, composites it
|
||||||
|
/// into the tier's output frame, and paces frames into the encoder at the tier's
|
||||||
|
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||||||
|
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||||||
|
/// the capture managers and is fully hermetic in tests.
|
||||||
|
///
|
||||||
|
/// The RTMP URL comes from the options provider: until the live-stream create
|
||||||
|
/// flow lands (TASK 5) it yields null, so go-live runs the existing visual flow
|
||||||
|
/// without actually pushing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FramePump : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Func<Scene?> _sceneProvider;
|
||||||
|
private readonly Func<SceneElement, VideoFrame?> _frameResolver;
|
||||||
|
private readonly Func<CompositorOptions> _compositorOptions;
|
||||||
|
private readonly Func<EncoderOptions?> _encoderOptions;
|
||||||
|
private readonly Func<IFfmpegEncoder> _encoderFactory;
|
||||||
|
private readonly Action<string>? _log;
|
||||||
|
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||||
|
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||||
|
private readonly SceneCompositor _compositor = new();
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private IFfmpegEncoder? _encoder;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _pumpTask;
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||||||
|
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||||
|
|
||||||
|
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
|
||||||
|
public event EventHandler<string>? Failed;
|
||||||
|
|
||||||
|
public FramePump(
|
||||||
|
Func<Scene?> sceneProvider,
|
||||||
|
Func<SceneElement, VideoFrame?> frameResolver,
|
||||||
|
Func<CompositorOptions> compositorOptions,
|
||||||
|
Func<EncoderOptions?> encoderOptions,
|
||||||
|
Func<IFfmpegEncoder> encoderFactory,
|
||||||
|
Action<string>? log = null,
|
||||||
|
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||||
|
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
|
||||||
|
{
|
||||||
|
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||||
|
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||||
|
_compositorOptions = compositorOptions ?? throw new ArgumentNullException(nameof(compositorOptions));
|
||||||
|
_encoderOptions = encoderOptions ?? throw new ArgumentNullException(nameof(encoderOptions));
|
||||||
|
_encoderFactory = encoderFactory ?? throw new ArgumentNullException(nameof(encoderFactory));
|
||||||
|
_log = log;
|
||||||
|
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||||
|
_socialBar = socialBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||||||
|
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
IFfmpegEncoder? encoder = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = _encoderOptions();
|
||||||
|
if (options == null)
|
||||||
|
{
|
||||||
|
_log?.Invoke("FramePump: no RTMP URL available (live-stream create lands in TASK 5) — encoder skipped");
|
||||||
|
lock (_gate) _started = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder = _encoderFactory();
|
||||||
|
encoder.HealthUpdated += OnHealthUpdated;
|
||||||
|
encoder.ProcessFailed += OnProcessFailed;
|
||||||
|
await encoder.StartAsync(options, cancellationToken);
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_encoder = encoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning must be true before the loop starts: the loop reads it on
|
||||||
|
// its first iteration, and with a completed-task delay it can run
|
||||||
|
// synchronously on this thread before PumpAsync even returns.
|
||||||
|
IsRunning = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_pumpTask = PumpAsync(options, _cts.Token);
|
||||||
|
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: start failed: {ex.Message}");
|
||||||
|
if (encoder != null)
|
||||||
|
{
|
||||||
|
encoder.HealthUpdated -= OnHealthUpdated;
|
||||||
|
encoder.ProcessFailed -= OnProcessFailed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
encoder.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception disposeEx)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: disposing failed encoder: {disposeEx.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_started = false;
|
||||||
|
IsRunning = false;
|
||||||
|
}
|
||||||
|
Failed?.Invoke(this, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
IFfmpegEncoder? encoder;
|
||||||
|
Task? pump;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_started && _encoder == null) return;
|
||||||
|
_started = false;
|
||||||
|
IsRunning = false;
|
||||||
|
encoder = _encoder;
|
||||||
|
pump = _pumpTask;
|
||||||
|
_cts?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the encoder BEFORE awaiting the pump: closing its stdin unblocks a
|
||||||
|
// write stuck on pipe backpressure, otherwise the pump could await forever.
|
||||||
|
if (encoder != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await encoder.StopAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: encoder stop failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pump != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await pump;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: pump loop faulted during stop: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoder != null)
|
||||||
|
{
|
||||||
|
encoder.HealthUpdated -= OnHealthUpdated;
|
||||||
|
encoder.ProcessFailed -= OnProcessFailed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
encoder.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: encoder dispose failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_encoder = null;
|
||||||
|
_cts = null;
|
||||||
|
_pumpTask = null;
|
||||||
|
}
|
||||||
|
_log?.Invoke("FramePump stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StopAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var scene = _sceneProvider();
|
||||||
|
if (scene != null)
|
||||||
|
{
|
||||||
|
var compositorOptions = _compositorOptions();
|
||||||
|
VideoFrame? socialBarFrame = null;
|
||||||
|
var socialBarTop = 0;
|
||||||
|
if (_socialBar != null)
|
||||||
|
{
|
||||||
|
var (barFrame, position) = _socialBar();
|
||||||
|
socialBarFrame = barFrame;
|
||||||
|
if (barFrame != null)
|
||||||
|
socialBarTop = position == SocialBarPosition.Top
|
||||||
|
? 0
|
||||||
|
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||||||
|
}
|
||||||
|
var frame = _compositor.Render(
|
||||||
|
scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||||
|
IFfmpegEncoder? encoder;
|
||||||
|
lock (_gate) encoder = _encoder;
|
||||||
|
if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive
|
||||||
|
await encoder.SubmitFrameAsync(frame, ct);
|
||||||
|
}
|
||||||
|
await _pacingDelay(interval, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// normal stop
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A failure while the pump is supposed to run (encoder died under us,
|
||||||
|
// scene provider faulted, ...) stops the pump and surfaces once.
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: pump exited during stop: {ex.Message}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: pump loop faulted: {ex.Message}");
|
||||||
|
Failed?.Invoke(this, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate) IsRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||||
|
|
||||||
|
private void OnProcessFailed(object? sender, string message)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||||
|
Failed?.Invoke(this, message);
|
||||||
|
_ = StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seam around a spawned subprocess (the encoder's ffmpeg and the encoder probe).
|
||||||
|
/// Exposes the redirected stdin (binary frames), stdout (probe listing) and stderr
|
||||||
|
/// (progress lines) plus exit control, so the encoder logic never touches
|
||||||
|
/// <c>System.Diagnostics.Process</c> directly and tests can fake the whole thing.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEncoderProcess : IDisposable
|
||||||
|
{
|
||||||
|
void Start(ProcessStartInfo startInfo);
|
||||||
|
|
||||||
|
/// <summary>Raw binary stdin — the encoder writes BGRA frames here.</summary>
|
||||||
|
Stream StandardInput { get; }
|
||||||
|
|
||||||
|
TextReader StandardOutput { get; }
|
||||||
|
TextReader StandardError { get; }
|
||||||
|
|
||||||
|
bool HasExited { get; }
|
||||||
|
int ExitCode { get; }
|
||||||
|
void Kill();
|
||||||
|
Task WaitForExitAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The live encoder seam (TASK 4 ship step 3): start an FFmpeg subprocess that
|
||||||
|
/// encodes raw BGRA frames from stdin and pushes FLV to an RTMP ingestion URL,
|
||||||
|
/// raising parsed health stats from stderr. The frame producer (compositor →
|
||||||
|
/// capture managers, TASK 4 ship step 5) feeds <see cref="SubmitFrameAsync"/> at
|
||||||
|
/// capture rate; this service serializes writes, parses progress, and tears the
|
||||||
|
/// process down gracefully. Constructor-injected <see cref="IFfmpegLocator"/> +
|
||||||
|
/// process factory keep it hermetic (tests fake both).
|
||||||
|
/// </summary>
|
||||||
|
public interface IFfmpegEncoder : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Fires on each parsed ffmpeg <c>-stats</c> progress line (~2 Hz).</summary>
|
||||||
|
event EventHandler<StreamHealth>? HealthUpdated;
|
||||||
|
|
||||||
|
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
|
||||||
|
event EventHandler<string>? ProcessFailed;
|
||||||
|
|
||||||
|
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
|
||||||
|
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
|
||||||
|
Task StopAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves an absolute path to a usable <c>ffmpeg.exe</c>, downloading it on
|
||||||
|
/// first use if neither the user's PATH nor the local cache provides one — the
|
||||||
|
/// encoder's one external dependency is never shipped in the repo (TASK 4 ship
|
||||||
|
/// step 2; see TASKS.md). Seam so the encoder step and the tests can fake it.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFfmpegLocator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the path to <c>ffmpeg.exe</c>: the first PATH candidate that
|
||||||
|
/// exists, else the cached copy, else a freshly downloaded one (ffmpeg.exe +
|
||||||
|
/// its libav DLLs extracted from the pinned BtbN LGPL-shared zip into the
|
||||||
|
/// tools directory).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="IOException">The download/extract produced no usable
|
||||||
|
/// binary (offline, expired pin, corrupt archive) — recoverable, logged.</exception>
|
||||||
|
Task<string> LocateAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IGameAudioDetector"/>: samples a full-screen monitor
|
||||||
|
/// provider (the existing <c>IFullScreenDetector</c>) and the live loopback
|
||||||
|
/// level and advances a <see cref="GameAudioHysteresis"/>. The pure transitions
|
||||||
|
/// live in GameAudioHysteresis (unit-tested); this wrapper owns the providers
|
||||||
|
/// and the raised-changed event. WPF-free — the VM owns the poll timer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameAudioDetector : IGameAudioDetector
|
||||||
|
{
|
||||||
|
private const float SoundFloor = 0.005f;
|
||||||
|
|
||||||
|
private readonly Func<int?> _foregroundFullScreenMonitorProvider;
|
||||||
|
private readonly Func<float> _loopbackLevelProvider;
|
||||||
|
private readonly Func<DateTime> _now;
|
||||||
|
private readonly GameAudioHysteresis _hysteresis = new();
|
||||||
|
|
||||||
|
/// <param name="foregroundFullScreenMonitorProvider">Returns the monitor a
|
||||||
|
/// full-screen foreground window covers, or null when windowed/none.</param>
|
||||||
|
/// <param name="loopbackLevelProvider">Current smoothed desktop/game level (0..1).</param>
|
||||||
|
/// <param name="now">Clock for the hysteresis windows; injectable for tests.</param>
|
||||||
|
public GameAudioDetector(
|
||||||
|
Func<int?> foregroundFullScreenMonitorProvider,
|
||||||
|
Func<float> loopbackLevelProvider,
|
||||||
|
Func<DateTime>? now = null)
|
||||||
|
{
|
||||||
|
_foregroundFullScreenMonitorProvider = foregroundFullScreenMonitorProvider;
|
||||||
|
_loopbackLevelProvider = loopbackLevelProvider;
|
||||||
|
_now = now ?? (() => DateTime.Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsGameAudioActive => _hysteresis.IsActive;
|
||||||
|
|
||||||
|
public event Action<bool>? IsGameAudioActiveChanged;
|
||||||
|
|
||||||
|
public void Poll()
|
||||||
|
{
|
||||||
|
var before = _hysteresis.IsActive;
|
||||||
|
var isFullScreen = _foregroundFullScreenMonitorProvider() != null;
|
||||||
|
var hasSound = _loopbackLevelProvider() > SoundFloor;
|
||||||
|
_hysteresis.Update(isFullScreen, hasSound, _now());
|
||||||
|
if (before != _hysteresis.IsActive)
|
||||||
|
IsGameAudioActiveChanged?.Invoke(_hysteresis.IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure show/hide state machine for the game audio bar (TASK 4 game audio).
|
||||||
|
/// SHOW: a full-screen foreground app has been producing desktop audio for at
|
||||||
|
/// least <see cref="ShowAfterSound"/> — "a working game with sound". HIDE: the
|
||||||
|
/// app leaves fullscreen/foreground for <see cref="HideAfterWindowed"/> — the
|
||||||
|
/// game is no longer up in the preview. Silence NEVER hides an active bar; it
|
||||||
|
/// only matters for the initial show. No timers; <see cref="Update"/> is fed by
|
||||||
|
/// the caller with a clock.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameAudioHysteresis
|
||||||
|
{
|
||||||
|
private readonly TimeSpan _showAfterSound = TimeSpan.FromMilliseconds(500);
|
||||||
|
private readonly TimeSpan _hideAfterWindowed = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private DateTime? _soundSince;
|
||||||
|
private DateTime? _windowedSince;
|
||||||
|
|
||||||
|
public bool IsActive { get; private set; }
|
||||||
|
|
||||||
|
public void Update(bool isFullScreen, bool hasSound, DateTime now)
|
||||||
|
{
|
||||||
|
if (isFullScreen)
|
||||||
|
{
|
||||||
|
_windowedSince = null;
|
||||||
|
if (IsActive)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!hasSound)
|
||||||
|
{
|
||||||
|
_soundSince = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_soundSince ??= now;
|
||||||
|
if (now - _soundSince >= _showAfterSound)
|
||||||
|
IsActive = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_soundSince = null;
|
||||||
|
if (!IsActive)
|
||||||
|
{
|
||||||
|
_windowedSince = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowedSince ??= now;
|
||||||
|
if (now - _windowedSince >= _hideAfterWindowed)
|
||||||
|
{
|
||||||
|
IsActive = false;
|
||||||
|
_windowedSince = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates physical capture devices. Seam so the picker and CameraManager
|
||||||
|
/// never touch WinRT directly (tests inject fakes).
|
||||||
|
/// </summary>
|
||||||
|
public interface ICameraEnumerator
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A running capture source for one device. Raises normalized BGRA frames from
|
||||||
|
/// a worker thread; callers must marshal to the UI thread. Seam so CameraManager
|
||||||
|
/// is testable without WinRT.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICameraFrameSource
|
||||||
|
{
|
||||||
|
string DeviceId { get; }
|
||||||
|
|
||||||
|
/// <summary>Normalized BGRA frames from a worker thread; callers marshal to the UI thread.</summary>
|
||||||
|
event Action<VideoFrame>? FrameAvailable;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the capture session fails asynchronously after a successful start
|
||||||
|
/// (device lost, stream state Failed, media capture failure). Carries the reason.
|
||||||
|
/// </summary>
|
||||||
|
event Action<string>? SourceFailed;
|
||||||
|
|
||||||
|
Task StartAsync();
|
||||||
|
Task StopAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which monitor holds a full-screen window (a game/app running
|
||||||
|
/// borderless or exclusive fullscreen). Seam so ScreenCaptureManager is
|
||||||
|
/// testable without Win32 interop.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFullScreenDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The index of the monitor a full-screen foreground window covers, or null
|
||||||
|
/// when the foreground window is windowed, missing, or one of our own.
|
||||||
|
/// </summary>
|
||||||
|
int? GetForegroundFullScreenMonitorIndex();
|
||||||
|
|
||||||
|
/// <summary>Index of the primary (taskbar-hosting) monitor.</summary>
|
||||||
|
int PrimaryMonitorIndex();
|
||||||
|
|
||||||
|
/// <summary>All monitors in capture-key order (enumeration order).</summary>
|
||||||
|
IReadOnlyList<DisplayInfo> GetDisplays();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects "a working game with sound is up in the preview" (TASK 4 game audio
|
||||||
|
/// bar): becomes active once a full-screen foreground app is producing desktop
|
||||||
|
/// audio, and stays active as long as that full-screen app remains — silence
|
||||||
|
/// never hides the bar, only the game leaving fullscreen/foreground does. Seam
|
||||||
|
/// so the VM and tests never touch Win32 or audio interop directly.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGameAudioDetector
|
||||||
|
{
|
||||||
|
/// <summary>True while the game audio bar should be visible.</summary>
|
||||||
|
bool IsGameAudioActive { get; }
|
||||||
|
|
||||||
|
/// <summary>Raised when the bar should appear or disappear.</summary>
|
||||||
|
event Action<bool>? IsGameAudioActiveChanged;
|
||||||
|
|
||||||
|
/// <summary>Samples the injected providers and advances the state machine.</summary>
|
||||||
|
void Poll();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates audio capture (microphone) devices. Seam so the picker never
|
||||||
|
/// touches WinRT directly (tests inject fakes).
|
||||||
|
/// </summary>
|
||||||
|
public interface IMicrophoneEnumerator
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<MicrophoneDeviceInfo>> GetMicrophonesAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A running screen-capture source for one target key ("monitor:<n>" or
|
||||||
|
/// "window:<hwnd>"). Raises normalized BGRA frames from a worker thread;
|
||||||
|
/// callers must marshal to the UI thread. Seam so ScreenCaptureManager is
|
||||||
|
/// testable without WinRT.
|
||||||
|
/// </summary>
|
||||||
|
public interface IScreenCaptureSource
|
||||||
|
{
|
||||||
|
string Key { get; }
|
||||||
|
event Action<VideoFrame>? FrameAvailable;
|
||||||
|
Task StartAsync();
|
||||||
|
Task StopAsync();
|
||||||
|
}
|
||||||
+582
-15
@@ -12,6 +12,12 @@ public class LayoutStore : IDisposable
|
|||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
public string ActivePath { get; }
|
public string ActivePath { get; }
|
||||||
|
|
||||||
|
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
|
||||||
|
public Webcam? Webcam { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The app-wide social bar config loaded with the last Load() (null = never defined).</summary>
|
||||||
|
public SocialsConfig? Socials { get; private set; }
|
||||||
|
|
||||||
public LayoutStore(string path)
|
public LayoutStore(string path)
|
||||||
{
|
{
|
||||||
ActivePath = path;
|
ActivePath = path;
|
||||||
@@ -31,13 +37,13 @@ public class LayoutStore : IDisposable
|
|||||||
{
|
{
|
||||||
string[] statements =
|
string[] statements =
|
||||||
{
|
{
|
||||||
"PRAGMA user_version = 1;",
|
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS Scene (
|
CREATE TABLE IF NOT EXISTS Scene (
|
||||||
Id TEXT PRIMARY KEY,
|
Id TEXT PRIMARY KEY,
|
||||||
Name TEXT NOT NULL,
|
Name TEXT NOT NULL,
|
||||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||||
|
HasBackdrop INTEGER NOT NULL DEFAULT 1,
|
||||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
@@ -65,6 +71,56 @@ public class LayoutStore : IDisposable
|
|||||||
Opacity REAL NOT NULL DEFAULT 1,
|
Opacity REAL NOT NULL DEFAULT 1,
|
||||||
MonitorIndex INTEGER,
|
MonitorIndex INTEGER,
|
||||||
DeviceId TEXT,
|
DeviceId TEXT,
|
||||||
|
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||||
|
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
IsBackdrop INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CaptureKey TEXT,
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS Webcam (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
DeviceId TEXT NOT NULL UNIQUE,
|
||||||
|
Name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
|
||||||
|
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||||
|
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
|
||||||
|
IsVisible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
X REAL NOT NULL DEFAULT 0,
|
||||||
|
Y REAL NOT NULL DEFAULT 0,
|
||||||
|
Width REAL NOT NULL DEFAULT 0,
|
||||||
|
Height REAL NOT NULL DEFAULT 0,
|
||||||
|
Opacity REAL NOT NULL DEFAULT 1,
|
||||||
|
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||||
|
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
RectWidth REAL,
|
||||||
|
RectHeight REAL,
|
||||||
|
BorderColor TEXT,
|
||||||
|
BorderOpacity REAL NOT NULL DEFAULT 1,
|
||||||
|
BorderWidth INTEGER NOT NULL DEFAULT 0,
|
||||||
|
BorderAnimation TEXT NOT NULL DEFAULT 'None',
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (SceneId, WebcamId)
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS Socials (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
|
||||||
|
BarJustify TEXT NOT NULL DEFAULT 'Center'
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS SocialEntry (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
|
||||||
|
Service TEXT NOT NULL,
|
||||||
|
Handle TEXT NOT NULL,
|
||||||
|
ProfileUrl TEXT NOT NULL,
|
||||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
@@ -75,16 +131,288 @@ public class LayoutStore : IDisposable
|
|||||||
cmd.CommandText = sql;
|
cmd.CommandText = sql;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
MigrateSourceTable();
|
||||||
|
MigrateWebcamConfigTable();
|
||||||
|
MigrateSceneTable();
|
||||||
|
MigrateSceneSocialBarColumn();
|
||||||
|
MigrateSocialsTable();
|
||||||
|
MigrateSocialEntryTable();
|
||||||
|
if (GetUserVersion() < 3)
|
||||||
|
MigrateToV3();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA user_version = 8;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetUserVersion()
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA user_version;";
|
||||||
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
||||||
|
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
|
||||||
|
// get the columns here instead.
|
||||||
|
private void MigrateSourceTable()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(Source);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("ClipShape"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("IsMirrored"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("IsBackdrop"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackdrop INTEGER NOT NULL DEFAULT 0;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("CaptureKey"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
|
||||||
|
// so a reloaded Round webcam restores its aspect on toggle-back).
|
||||||
|
private void MigrateWebcamConfigTable()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("RectWidth"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("RectHeight"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
|
||||||
|
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
|
||||||
|
// per scene that had one, then the webcam rows are deleted. No backfill:
|
||||||
|
// scenes without the webcam stay webcam-free.
|
||||||
|
private void MigrateToV3()
|
||||||
|
{
|
||||||
|
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
|
||||||
|
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
|
||||||
|
""";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
rows.Add((
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
reader.GetDouble(4),
|
||||||
|
reader.GetDouble(5),
|
||||||
|
reader.GetDouble(6),
|
||||||
|
reader.GetDouble(7),
|
||||||
|
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
|
||||||
|
reader.GetString(9),
|
||||||
|
reader.GetInt32(10) != 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.Count == 0) return;
|
||||||
|
|
||||||
|
using var tx = _connection.BeginTransaction();
|
||||||
|
var webcamId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$id", webcamId);
|
||||||
|
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
|
||||||
|
cmd.Parameters.AddWithValue("$name", rows[0].Name);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO WebcamSceneConfig
|
||||||
|
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||||
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
|
||||||
|
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
|
||||||
|
$clip, $mirrored, NULL, 1, 0, 'None', 0);
|
||||||
|
""";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
|
||||||
|
cmd.Parameters.AddWithValue("$webcamId", webcamId);
|
||||||
|
cmd.Parameters.AddWithValue("$x", row.X);
|
||||||
|
cmd.Parameters.AddWithValue("$y", row.Y);
|
||||||
|
cmd.Parameters.AddWithValue("$w", row.W);
|
||||||
|
cmd.Parameters.AddWithValue("$h", row.H);
|
||||||
|
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
|
||||||
|
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
|
||||||
|
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
tx.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// v5 → v6: Scene gains HasBackdrop. Added columns default to true; the
|
||||||
|
// ONE-TIME backfill (runs only when the column is first added) turns every
|
||||||
|
// non-Live canonical scene off and drops their backdrop sources — the fix for
|
||||||
|
// DBs saved before the Live-only policy. MainViewModel.EnforceBackdropPolicy
|
||||||
|
// re-runs the same normalization on every load for DBs that miss the backfill.
|
||||||
|
private void MigrateSceneTable()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.Contains("HasBackdrop")) return;
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackdrop INTEGER NOT NULL DEFAULT 1;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
const string noBackdrop = "('starting', 'brb', 'chat', 'ending')";
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = $"UPDATE Scene SET HasBackdrop = 0 WHERE LOWER(TRIM(Name)) IN {noBackdrop};";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = $"""
|
||||||
|
DELETE FROM Source
|
||||||
|
WHERE IsBackdrop = 1 AND SceneId IN
|
||||||
|
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackdrop});
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
|
||||||
|
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
|
||||||
|
private void MigrateSceneSocialBarColumn()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.Contains("HasSocialBar")) return;
|
||||||
|
|
||||||
|
using var alter = _connection.CreateCommand();
|
||||||
|
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
|
||||||
|
alter.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
|
||||||
|
// BarJustify column stays for back-compat but is no longer read or written.
|
||||||
|
private void MigrateSocialsTable()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(Socials);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.Contains("BarEnabled")) return;
|
||||||
|
|
||||||
|
using var alter = _connection.CreateCommand();
|
||||||
|
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
|
||||||
|
alter.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
|
||||||
|
// software name) so a reloaded entry still shows the right service logo.
|
||||||
|
private void MigrateSocialEntryTable()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.Contains("Software")) return;
|
||||||
|
|
||||||
|
using var alter = _connection.CreateCommand();
|
||||||
|
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
|
||||||
|
alter.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Scene> Load()
|
public List<Scene> Load()
|
||||||
{
|
{
|
||||||
|
Webcam = null;
|
||||||
|
Socials = null;
|
||||||
var scenes = new List<Scene>();
|
var scenes = new List<Scene>();
|
||||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||||
|
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene FROM Scene ORDER BY SortOrder";
|
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
@@ -94,15 +422,63 @@ public class LayoutStore : IDisposable
|
|||||||
Name = reader.GetString(1),
|
Name = reader.GetString(1),
|
||||||
IsHidden = reader.GetInt32(2) != 0,
|
IsHidden = reader.GetInt32(2) != 0,
|
||||||
IsChatScene = reader.GetInt32(3) != 0,
|
IsChatScene = reader.GetInt32(3) != 0,
|
||||||
|
HasBackdrop = reader.GetInt32(4) != 0,
|
||||||
|
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
Webcam = new Webcam
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
DeviceId = reader.GetString(1),
|
||||||
|
Name = reader.GetString(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
Socials = new SocialsConfig
|
||||||
|
{
|
||||||
|
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||||
|
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
||||||
|
};
|
||||||
|
var socialsId = reader.GetString(0);
|
||||||
|
|
||||||
|
using var entryCmd = _connection.CreateCommand();
|
||||||
|
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||||
|
entryCmd.Parameters.AddWithValue("$id", socialsId);
|
||||||
|
using var entryReader = entryCmd.ExecuteReader();
|
||||||
|
while (entryReader.Read())
|
||||||
|
{
|
||||||
|
Socials.Entries.Add(new SocialEntry
|
||||||
|
{
|
||||||
|
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
|
||||||
|
Handle = entryReader.GetString(1),
|
||||||
|
ProfileUrl = entryReader.GetString(2),
|
||||||
|
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId
|
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
||||||
|
IsBackdrop, CaptureKey
|
||||||
FROM Source ORDER BY SortOrder
|
FROM Source ORDER BY SortOrder
|
||||||
""";
|
""";
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
@@ -122,7 +498,10 @@ public class LayoutStore : IDisposable
|
|||||||
Height = reader.GetDouble(9),
|
Height = reader.GetDouble(9),
|
||||||
Opacity = reader.GetDouble(10),
|
Opacity = reader.GetDouble(10),
|
||||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||||
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
|
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
||||||
|
IsMirrored = reader.GetInt32(13) != 0,
|
||||||
|
IsBackdrop = reader.GetInt32(14) != 0,
|
||||||
|
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||||
};
|
};
|
||||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||||
sourcesByScene[sceneId] = list = new List<Source>();
|
sourcesByScene[sceneId] = list = new List<Source>();
|
||||||
@@ -130,26 +509,90 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||||
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||||
|
RectWidth, RectHeight
|
||||||
|
FROM WebcamSceneConfig ORDER BY SortOrder
|
||||||
|
""";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var sceneId = reader.GetString(0);
|
||||||
|
var config = new WebcamSceneConfig
|
||||||
|
{
|
||||||
|
WebcamId = reader.GetString(1),
|
||||||
|
Name = Webcam?.Name ?? "Webcam",
|
||||||
|
IsVisible = reader.GetInt32(2) != 0,
|
||||||
|
X = reader.GetDouble(3),
|
||||||
|
Y = reader.GetDouble(4),
|
||||||
|
Width = reader.GetDouble(5),
|
||||||
|
Height = reader.GetDouble(6),
|
||||||
|
Opacity = reader.GetDouble(7),
|
||||||
|
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
|
||||||
|
IsMirrored = reader.GetInt32(9) != 0,
|
||||||
|
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||||
|
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
|
||||||
|
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
|
||||||
|
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
|
||||||
|
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
|
||||||
|
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
||||||
|
};
|
||||||
|
if (!configsByScene.TryGetValue(sceneId, out var list))
|
||||||
|
configsByScene[sceneId] = list = new List<WebcamSceneConfig>();
|
||||||
|
list.Add(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
{
|
{
|
||||||
if (sourcesByScene.TryGetValue(scene.Id, out var list))
|
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
||||||
foreach (var source in list)
|
foreach (var source in sources)
|
||||||
scene.Sources.Add(source);
|
scene.Elements.Add(source);
|
||||||
|
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||||
|
foreach (var config in configs)
|
||||||
|
scene.Elements.Add(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
return scenes;
|
return scenes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save(IEnumerable<Scene> scenes)
|
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials)
|
||||||
{
|
{
|
||||||
using var tx = _connection.BeginTransaction();
|
using var tx = _connection.BeginTransaction();
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Source;";
|
cmd.CommandText = "DELETE FROM Source;";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM Webcam;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM SocialEntry;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM Socials;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Scene;";
|
cmd.CommandText = "DELETE FROM Scene;";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
@@ -159,14 +602,16 @@ public class LayoutStore : IDisposable
|
|||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, SortOrder)
|
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
|
||||||
VALUES ($id, $name, $isHidden, $isChat, $sort)
|
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
|
||||||
""";
|
""";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||||
|
var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
|
||||||
|
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
var sort = 0;
|
var sort = 0;
|
||||||
@@ -176,6 +621,8 @@ public class LayoutStore : IDisposable
|
|||||||
nameP.Value = scene.Name;
|
nameP.Value = scene.Name;
|
||||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||||
|
backdropP.Value = scene.HasBackdrop ? 1 : 0;
|
||||||
|
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||||
sortP.Value = sort++;
|
sortP.Value = sort++;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
@@ -185,9 +632,11 @@ public class LayoutStore : IDisposable
|
|||||||
{
|
{
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, SortOrder)
|
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||||
|
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder)
|
||||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||||
$x, $y, $w, $h, $opacity, $monitor, $device, $sort)
|
$x, $y, $w, $h, $opacity, $monitor,
|
||||||
|
$clip, $mirrored, $isBackdrop, $captureKey, $sort)
|
||||||
""";
|
""";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
@@ -202,14 +651,18 @@ public class LayoutStore : IDisposable
|
|||||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||||
var deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
|
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||||
|
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||||
|
var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
|
||||||
|
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
|
||||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
{
|
{
|
||||||
var sort = 0;
|
var sort = 0;
|
||||||
foreach (var source in scene.Sources)
|
foreach (var element in scene.Elements)
|
||||||
{
|
{
|
||||||
|
if (element is not Source source) continue;
|
||||||
idP.Value = source.Id;
|
idP.Value = source.Id;
|
||||||
sceneIdP.Value = scene.Id;
|
sceneIdP.Value = scene.Id;
|
||||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||||
@@ -222,13 +675,127 @@ public class LayoutStore : IDisposable
|
|||||||
hP.Value = source.Height;
|
hP.Value = source.Height;
|
||||||
opacityP.Value = source.Opacity;
|
opacityP.Value = source.Opacity;
|
||||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||||
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
|
clipP.Value = source.ClipShape.ToString();
|
||||||
|
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||||
|
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
|
||||||
|
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
|
||||||
sortP.Value = sort++;
|
sortP.Value = sort++;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (webcam != null)
|
||||||
|
{
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$id", webcam.Id);
|
||||||
|
cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
|
||||||
|
cmd.Parameters.AddWithValue("$name", webcam.Name);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO WebcamSceneConfig
|
||||||
|
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||||
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||||
|
RectWidth, RectHeight, SortOrder)
|
||||||
|
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
|
||||||
|
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
|
||||||
|
$rectWidth, $rectHeight, $sort)
|
||||||
|
""";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||||
|
var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
|
||||||
|
var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
|
||||||
|
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||||
|
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||||
|
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||||
|
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||||
|
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||||
|
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||||
|
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||||
|
var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
|
||||||
|
var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
|
||||||
|
var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
|
||||||
|
var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
|
||||||
|
var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
|
||||||
|
var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
|
||||||
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
|
foreach (var scene in scenes)
|
||||||
|
{
|
||||||
|
var sort = 0;
|
||||||
|
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||||
|
{
|
||||||
|
sceneIdP.Value = scene.Id;
|
||||||
|
webcamIdP.Value = config.WebcamId;
|
||||||
|
visibleP.Value = config.IsVisible ? 1 : 0;
|
||||||
|
xP.Value = config.X;
|
||||||
|
yP.Value = config.Y;
|
||||||
|
wP.Value = config.Width;
|
||||||
|
hP.Value = config.Height;
|
||||||
|
opacityP.Value = config.Opacity;
|
||||||
|
clipP.Value = config.ClipShape.ToString();
|
||||||
|
mirroredP.Value = config.IsMirrored ? 1 : 0;
|
||||||
|
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
|
||||||
|
borderOpacityP.Value = config.BorderOpacity;
|
||||||
|
borderWidthP.Value = config.BorderWidth;
|
||||||
|
animationP.Value = config.BorderAnimation.ToString();
|
||||||
|
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
|
||||||
|
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
|
||||||
|
sortP.Value = sort++;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socials != null && socials.Entries.Count > 0)
|
||||||
|
{
|
||||||
|
var socialsId = Guid.NewGuid().ToString();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||||
|
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||||
|
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var entryCmd = _connection.CreateCommand();
|
||||||
|
entryCmd.CommandText = """
|
||||||
|
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
|
||||||
|
VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
|
||||||
|
""";
|
||||||
|
entryCmd.Transaction = tx;
|
||||||
|
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
|
var eSocialsId = entryCmd.Parameters.Add("$socialsId", SqliteType.Text);
|
||||||
|
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
|
||||||
|
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
|
||||||
|
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
|
||||||
|
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
|
||||||
|
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
|
var entrySort = 0;
|
||||||
|
foreach (var entry in socials.Entries)
|
||||||
|
{
|
||||||
|
eId.Value = Guid.NewGuid().ToString();
|
||||||
|
eSocialsId.Value = socialsId;
|
||||||
|
eService.Value = entry.Service.ToString();
|
||||||
|
eHandle.Value = entry.Handle;
|
||||||
|
eUrl.Value = entry.ProfileUrl;
|
||||||
|
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
|
||||||
|
eSort.Value = entrySort++;
|
||||||
|
entryCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Windows.Devices.Enumeration;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class MediaCaptureCameraEnumerator : ICameraEnumerator
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
|
||||||
|
{
|
||||||
|
var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture);
|
||||||
|
var result = new List<CameraDeviceInfo>(devices.Count);
|
||||||
|
foreach (var device in devices)
|
||||||
|
result.Add(new CameraDeviceInfo(device.Id, device.Name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.InteropServices.WindowsRuntime;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Windows.Graphics.Imaging;
|
||||||
|
using Windows.Media.Capture;
|
||||||
|
using Windows.Media.Capture.Frames;
|
||||||
|
using Windows.Media.Devices;
|
||||||
|
using Windows.Media.MediaProperties;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CPU-first MediaCapture source. Requests BGRA8 frames from the camera's video
|
||||||
|
/// preview source; the capture pipeline does any format conversion, so every
|
||||||
|
/// frame arrives as a normalized <see cref="VideoFrame"/>. Frames arrive on a
|
||||||
|
/// worker thread — marshal before touching WPF.
|
||||||
|
///
|
||||||
|
/// Allocation is validated, never taken on faith: after <c>InitializeAsync</c>
|
||||||
|
/// the bound device, its frame sources, and its stream properties are checked,
|
||||||
|
/// the frame reader's start status is read (not swallowed), and the live
|
||||||
|
/// state signals (<c>Failed</c>, <c>CameraStreamStateChanged</c>) are subscribed
|
||||||
|
/// so an async death surfaces as <see cref="SourceFailed"/> instead of a silent
|
||||||
|
/// empty preview.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||||
|
{
|
||||||
|
private readonly string _deviceId;
|
||||||
|
private MediaCapture? _capture;
|
||||||
|
private MediaFrameReader? _frameReader;
|
||||||
|
private string? _lastError;
|
||||||
|
private bool _isFailed;
|
||||||
|
|
||||||
|
public string DeviceId => _deviceId;
|
||||||
|
|
||||||
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
|
|
||||||
|
public event Action<string>? SourceFailed;
|
||||||
|
|
||||||
|
public MediaCaptureFrameSource(string deviceId)
|
||||||
|
{
|
||||||
|
_deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
// Fallback ladder: prefer the camera's VideoPreview stream, but if the
|
||||||
|
// reader refuses to start there (NoVideoFrameAvailable), retry against
|
||||||
|
// its VideoRecord stream — some devices only deliver through it.
|
||||||
|
// (MediaCaptureSharingMode.Exclusive is not projected by the 19041 SDK.)
|
||||||
|
if (!await TryStartAsync(preferVideoRecord: false))
|
||||||
|
{
|
||||||
|
if (!await TryStartAsync(preferVideoRecord: true))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
_lastError ?? $"Camera '{_deviceId}' could not be started.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
var reader = _frameReader;
|
||||||
|
_frameReader = null;
|
||||||
|
if (reader != null)
|
||||||
|
{
|
||||||
|
reader.FrameArrived -= OnFrameArrived;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await reader.StopAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"MediaCaptureFrameSource: stop frame reader failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
reader.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var capture = _capture;
|
||||||
|
_capture = null;
|
||||||
|
if (capture != null)
|
||||||
|
{
|
||||||
|
UnsubscribeCaptureEvents(capture);
|
||||||
|
capture.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryStartAsync(bool preferVideoRecord)
|
||||||
|
{
|
||||||
|
MediaCapture? capture = null;
|
||||||
|
MediaFrameReader? reader = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
capture = new MediaCapture();
|
||||||
|
var settings = new MediaCaptureInitializationSettings
|
||||||
|
{
|
||||||
|
VideoDeviceId = _deviceId,
|
||||||
|
StreamingCaptureMode = StreamingCaptureMode.Video,
|
||||||
|
MemoryPreference = MediaCaptureMemoryPreference.Cpu,
|
||||||
|
SharingMode = MediaCaptureSharingMode.SharedReadOnly,
|
||||||
|
};
|
||||||
|
await capture.InitializeAsync(settings);
|
||||||
|
|
||||||
|
// Post-init resource validation: the capture must actually be bound to
|
||||||
|
// the device we asked for and must expose a live video controller.
|
||||||
|
if (!string.Equals(capture.MediaCaptureSettings?.VideoDeviceId, _deviceId,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Camera '{_deviceId}' initialized but bound a different device.");
|
||||||
|
|
||||||
|
var colorSource = capture.FrameSources.Values
|
||||||
|
.OrderBy(s => preferVideoRecord
|
||||||
|
? (s.Info.MediaStreamType == MediaStreamType.VideoRecord ? 0 : 1)
|
||||||
|
: (s.Info.MediaStreamType == MediaStreamType.VideoPreview ? 0 : 1))
|
||||||
|
.FirstOrDefault(s => s.Info.MediaStreamType is MediaStreamType.VideoPreview or MediaStreamType.VideoRecord);
|
||||||
|
if (colorSource == null)
|
||||||
|
{
|
||||||
|
var kinds = string.Join(", ", capture.FrameSources.Values
|
||||||
|
.Select(s => s.Info.MediaStreamType).Distinct());
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Camera '{_deviceId}' exposes no video source (found: {kinds}). " +
|
||||||
|
"It may be locked by another app (e.g. NVIDIA Broadcast).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The device must answer for its preview stream — a dead, suspended, or
|
||||||
|
// locked device returns no stream properties even after "successful" init.
|
||||||
|
IReadOnlyList<IMediaEncodingProperties>? previewProps;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
previewProps = capture.VideoDeviceController?
|
||||||
|
.GetAvailableMediaStreamProperties(MediaStreamType.VideoPreview);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
previewProps = null;
|
||||||
|
}
|
||||||
|
if (previewProps == null || previewProps.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Camera '{_deviceId}' answered no video stream properties (offline, suspended, or locked).");
|
||||||
|
|
||||||
|
// The OS's live state signals: async failures must surface, not vanish.
|
||||||
|
capture.Failed += OnCaptureFailed;
|
||||||
|
capture.CameraStreamStateChanged += OnCameraStreamStateChanged;
|
||||||
|
|
||||||
|
reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8);
|
||||||
|
if (reader == null)
|
||||||
|
throw new InvalidOperationException($"Camera '{_deviceId}' created no frame reader.");
|
||||||
|
|
||||||
|
reader.FrameArrived += OnFrameArrived;
|
||||||
|
var status = await reader.StartAsync();
|
||||||
|
if (status != MediaFrameReaderStartStatus.Success)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Camera '{_deviceId}' frame reader refused to start: {status}.");
|
||||||
|
|
||||||
|
_capture = capture;
|
||||||
|
_frameReader = reader;
|
||||||
|
_lastError = null;
|
||||||
|
_isFailed = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_lastError = ex.Message;
|
||||||
|
if (reader != null)
|
||||||
|
{
|
||||||
|
reader.FrameArrived -= OnFrameArrived;
|
||||||
|
reader.Dispose();
|
||||||
|
}
|
||||||
|
if (capture != null)
|
||||||
|
{
|
||||||
|
UnsubscribeCaptureEvents(capture);
|
||||||
|
capture.Dispose();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeCaptureEvents(MediaCapture capture)
|
||||||
|
{
|
||||||
|
capture.Failed -= OnCaptureFailed;
|
||||||
|
capture.CameraStreamStateChanged -= OnCameraStreamStateChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCaptureFailed(MediaCapture sender, MediaCaptureFailedEventArgs args)
|
||||||
|
=> RaiseFailure($"capture failed ({args.Code}): {args.Message}");
|
||||||
|
|
||||||
|
private void OnCameraStreamStateChanged(MediaCapture sender, object args)
|
||||||
|
{
|
||||||
|
// CameraStreamState enum (Windows.Media.Devices): 0=NotStreaming, 1=Streaming,
|
||||||
|
// 2=Failed, 3=Shutdown. The 19041 SDK projection omits member names, so
|
||||||
|
// compare by value — the enum type itself resolves via the property return.
|
||||||
|
if ((int)sender.CameraStreamState == 2) // Failed
|
||||||
|
RaiseFailure("camera stream state is Failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseFailure(string message)
|
||||||
|
{
|
||||||
|
if (_isFailed) return;
|
||||||
|
_isFailed = true;
|
||||||
|
AppLog.Write($"MediaCaptureFrameSource: {message}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SourceFailed?.Invoke(message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"MediaCaptureFrameSource: SourceFailed handler threw: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
|
||||||
|
{
|
||||||
|
using var frame = sender.TryAcquireLatestFrame();
|
||||||
|
var videoFrame = frame?.VideoMediaFrame?.SoftwareBitmap;
|
||||||
|
if (videoFrame == null) return;
|
||||||
|
|
||||||
|
var bitmap = videoFrame.BitmapPixelFormat == BitmapPixelFormat.Bgra8
|
||||||
|
? videoFrame
|
||||||
|
: SoftwareBitmap.Convert(videoFrame, BitmapPixelFormat.Bgra8);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
|
||||||
|
using var reference = buffer.CreateReference();
|
||||||
|
if (WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var pixelsPtr, out var capacity))
|
||||||
|
{
|
||||||
|
var pixels = new byte[capacity];
|
||||||
|
Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity);
|
||||||
|
FrameAvailable?.Invoke(new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"MediaCaptureFrameSource: frame copy failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(bitmap, videoFrame))
|
||||||
|
bitmap.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class MicrophoneDeviceInfo
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public MicrophoneDeviceInfo(string id, string displayName)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
DisplayName = displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.InteropServices.WindowsRuntime;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Windows.Graphics;
|
||||||
|
using Windows.Graphics.Capture;
|
||||||
|
using Windows.Graphics.DirectX;
|
||||||
|
using Windows.Graphics.DirectX.Direct3D11;
|
||||||
|
using Windows.Graphics.Imaging;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A live screen capture for one target key ("monitor:<n>" or
|
||||||
|
/// "window:<hwnd>"). Owns the GraphicsCaptureItem, a free-threaded
|
||||||
|
/// Direct3D11CaptureFramePool and the capture session; frames are converted on
|
||||||
|
/// the capture worker thread from the GPU surface to a CPU BGRA VideoFrame.
|
||||||
|
/// Known OS limits: DRM content captures as black frames; capture pauses while
|
||||||
|
/// the app is minimized (the frame pool simply stops delivering frames).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private GraphicsCaptureItem _item;
|
||||||
|
private Direct3D11CaptureFramePool? _framePool;
|
||||||
|
private GraphicsCaptureSession? _session;
|
||||||
|
private SizeInt32 _poolSize;
|
||||||
|
private bool _started;
|
||||||
|
private bool _framePending;
|
||||||
|
private DateTime _lastErrorLog = DateTime.MinValue;
|
||||||
|
|
||||||
|
// The composition master frame (see ai.md "Resolution tiers"): the backdrop
|
||||||
|
// is an input layer, so we never hold a CPU frame bigger than the master.
|
||||||
|
private const int MaxBackdropWidth = 1920;
|
||||||
|
private const int MaxBackdropHeight = 1080;
|
||||||
|
|
||||||
|
// A failing conversion must not re-flood the log at frame rate.
|
||||||
|
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
public string Key { get; }
|
||||||
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
|
|
||||||
|
internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
_item = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wraps an item chosen through the OS GraphicsCapturePicker.</summary>
|
||||||
|
public static ScreenCaptureFrameSource CreateForPicker(string key, GraphicsCaptureItem item)
|
||||||
|
=> new(key, item);
|
||||||
|
|
||||||
|
public static ScreenCaptureFrameSource CreateForMonitor(int monitorIndex)
|
||||||
|
{
|
||||||
|
var hmonitor = Win32FullScreenDetector.GetMonitorHandle(monitorIndex);
|
||||||
|
if (hmonitor == IntPtr.Zero)
|
||||||
|
throw new InvalidOperationException($"Monitor {monitorIndex} is not connected");
|
||||||
|
var item = CaptureInterop.CreateForMonitor(hmonitor);
|
||||||
|
return new ScreenCaptureFrameSource($"monitor:{monitorIndex}", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ScreenCaptureFrameSource CreateForWindow(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
var item = CaptureInterop.CreateForWindow(hwnd);
|
||||||
|
return new ScreenCaptureFrameSource($"window:0x{hwnd.ToInt64():X}", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_started) return Task.CompletedTask;
|
||||||
|
var item = _item;
|
||||||
|
var device = Direct3D11Helper.CreateDevice();
|
||||||
|
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
|
||||||
|
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
|
||||||
|
_poolSize = item.Size;
|
||||||
|
var session = framePool.CreateCaptureSession(item);
|
||||||
|
framePool.FrameArrived += OnFrameArrived;
|
||||||
|
session.StartCapture();
|
||||||
|
_framePool = framePool;
|
||||||
|
_session = session;
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_started = false;
|
||||||
|
_framePending = false;
|
||||||
|
if (_framePool != null)
|
||||||
|
_framePool.FrameArrived -= OnFrameArrived;
|
||||||
|
_session?.Dispose();
|
||||||
|
_session = null;
|
||||||
|
_framePool?.Dispose();
|
||||||
|
_framePool = null;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
|
||||||
|
{
|
||||||
|
var frame = sender.TryGetNextFrame();
|
||||||
|
if (frame == null) return;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_started)
|
||||||
|
{
|
||||||
|
frame.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
|
||||||
|
{
|
||||||
|
sender.Recreate(Direct3D11Helper.CreateDevice(),
|
||||||
|
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
|
||||||
|
_poolSize = frame.ContentSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_framePending)
|
||||||
|
{
|
||||||
|
frame.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_framePending = true;
|
||||||
|
_ = ProcessFrameAsync(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (frame)
|
||||||
|
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
|
||||||
|
frame.Surface, BitmapAlphaMode.Ignore))
|
||||||
|
{
|
||||||
|
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (now - _lastErrorLog >= ErrorLogThrottle)
|
||||||
|
{
|
||||||
|
_lastErrorLog = now;
|
||||||
|
AppLog.Write($"ScreenCaptureFrameSource: frame conversion failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_framePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
|
||||||
|
{
|
||||||
|
var sw = bitmap.PixelWidth;
|
||||||
|
var sh = bitmap.PixelHeight;
|
||||||
|
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
|
||||||
|
using var reference = buffer.CreateReference();
|
||||||
|
if (!WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var data, out var capacity))
|
||||||
|
throw new InvalidOperationException("Could not access the frame buffer.");
|
||||||
|
|
||||||
|
var srcStride = sw * 4;
|
||||||
|
var count = (int)Math.Min(capacity, (uint)(sh * srcStride));
|
||||||
|
|
||||||
|
// The master frame is 1920×1080; a larger monitor is scaled down here so
|
||||||
|
// the CPU never holds a frame above the master (the rendered layer fills
|
||||||
|
// the frame with UniformToFill regardless).
|
||||||
|
if (sw > MaxBackdropWidth || sh > MaxBackdropHeight)
|
||||||
|
{
|
||||||
|
var scale = Math.Min(MaxBackdropWidth / (double)sw, MaxBackdropHeight / (double)sh);
|
||||||
|
var dw = Math.Max(1, (int)(sw * scale));
|
||||||
|
var dh = Math.Max(1, (int)(sh * scale));
|
||||||
|
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pixels = new byte[count];
|
||||||
|
Marshal.Copy(data, pixels, 0, pixels.Length);
|
||||||
|
return new VideoFrame(sw, sh, pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bilinear downscale to the master frame. Reads each source row pair through
|
||||||
|
// Marshal.Copy (no unsafe), writing tightly packed BGRA output.
|
||||||
|
private static byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh)
|
||||||
|
{
|
||||||
|
var row0 = new byte[srcStride];
|
||||||
|
var row1 = new byte[srcStride];
|
||||||
|
var dst = new byte[dw * dh * 4];
|
||||||
|
var xs = sw / (double)dw;
|
||||||
|
var ys = sh / (double)dh;
|
||||||
|
|
||||||
|
for (var y = 0; y < dh; y++)
|
||||||
|
{
|
||||||
|
var sy = Math.Min(sh - 1, (int)(y * ys));
|
||||||
|
var sy1 = Math.Min(sh - 1, sy + 1);
|
||||||
|
var fy = (y * ys) - sy;
|
||||||
|
Marshal.Copy(IntPtr.Add(src, sy * srcStride), row0, 0, srcStride);
|
||||||
|
Marshal.Copy(IntPtr.Add(src, sy1 * srcStride), row1, 0, srcStride);
|
||||||
|
|
||||||
|
var dRow = y * dw * 4;
|
||||||
|
for (var x = 0; x < dw; x++)
|
||||||
|
{
|
||||||
|
var sx = Math.Min(sw - 1, (int)(x * xs));
|
||||||
|
var sx1 = Math.Min(sw - 1, sx + 1);
|
||||||
|
var fx = (x * xs) - sx;
|
||||||
|
for (var c = 0; c < 4; c++)
|
||||||
|
{
|
||||||
|
var i0 = sx * 4 + c;
|
||||||
|
var i1 = sx1 * 4 + c;
|
||||||
|
var top = row0[i0] + (row0[i1] - row0[i0]) * fx;
|
||||||
|
var bottom = row1[i0] + (row1[i1] - row1[i0]) * fx;
|
||||||
|
dst[dRow + x * 4 + c] = (byte)(top + (bottom - top) * fy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns screen-capture sessions app-wide, refcounted by target key
|
||||||
|
/// ("monitor:<n>", "window:<hwnd>", "picker:<...>"). One target =
|
||||||
|
/// at most one capture, one shared WriteableBitmap; the last release stops and
|
||||||
|
/// disposes the source. Frames arrive on a worker thread and are coalesced onto
|
||||||
|
/// the UI dispatcher (at most one pending copy per session, using the latest
|
||||||
|
/// frame). Mirrors CameraManager.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScreenCaptureManager : IDisposable
|
||||||
|
{
|
||||||
|
private sealed class CaptureSession
|
||||||
|
{
|
||||||
|
public string Key { get; }
|
||||||
|
public IScreenCaptureSource Source { get; }
|
||||||
|
public Action<VideoFrame>? FrameHandler;
|
||||||
|
public int RefCount;
|
||||||
|
public bool Started;
|
||||||
|
public WriteableBitmap? PreviewBitmap;
|
||||||
|
public VideoFrame? LatestFrame;
|
||||||
|
public bool FramePending;
|
||||||
|
|
||||||
|
public CaptureSession(string key, IScreenCaptureSource source)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Source = source;
|
||||||
|
RefCount = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Func<string, IScreenCaptureSource?> _sourceFactory;
|
||||||
|
private readonly Dispatcher? _uiDispatcher;
|
||||||
|
private readonly Dictionary<string, CaptureSession> _sessions = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
/// <summary>Raised on the UI thread when a capture's shared preview bitmap is first created.</summary>
|
||||||
|
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised when a capture cannot be created or started (monitor gone, access denied, no target).</summary>
|
||||||
|
public event Action<string, string>? CaptureFailed;
|
||||||
|
|
||||||
|
public ScreenCaptureManager(Func<string, IScreenCaptureSource?> sourceFactory, Dispatcher? uiDispatcher = null)
|
||||||
|
{
|
||||||
|
_sourceFactory = sourceFactory;
|
||||||
|
_uiDispatcher = uiDispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Increments the refcount for a target key, starting capture the first time.</summary>
|
||||||
|
public async Task<bool> AcquireAsync(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key)) return false;
|
||||||
|
|
||||||
|
CaptureSession session;
|
||||||
|
bool shouldStart;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
existing.RefCount++;
|
||||||
|
shouldStart = false;
|
||||||
|
session = existing;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IScreenCaptureSource? source;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source = _sourceFactory(key);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
|
||||||
|
CaptureFailed?.Invoke(key, ex.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (source == null)
|
||||||
|
{
|
||||||
|
CaptureFailed?.Invoke(key, "No capture target for this key");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
session = new CaptureSession(key, source);
|
||||||
|
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||||
|
session.Source.FrameAvailable += session.FrameHandler;
|
||||||
|
_sessions[key] = session;
|
||||||
|
shouldStart = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldStart) return session.Started;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.Source.StartAsync();
|
||||||
|
session.Started = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
_sessions.Remove(key);
|
||||||
|
session.Source.FrameAvailable -= session.FrameHandler;
|
||||||
|
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
|
||||||
|
CaptureFailed?.Invoke(key, ex.Message);
|
||||||
|
await SafeStopAsync(session.Source);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrements the refcount; stops and disposes the source at zero.</summary>
|
||||||
|
public async Task ReleaseAsync(string key)
|
||||||
|
{
|
||||||
|
CaptureSession? toStop = null;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(key, out var session)) return;
|
||||||
|
if (--session.RefCount > 0) return;
|
||||||
|
_sessions.Remove(key);
|
||||||
|
toStop = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toStop == null) return;
|
||||||
|
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||||
|
await SafeStopAsync(toStop.Source);
|
||||||
|
toStop.PreviewBitmap = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases a target unconditionally (every ref), regardless of how many
|
||||||
|
/// scenes hold it. Used on capture re-designation and layout reloads where
|
||||||
|
/// the old key's refcount isn't known after the scenes are replaced.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ReleaseAllAsync(string key)
|
||||||
|
{
|
||||||
|
CaptureSession? toStop = null;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(key, out var session)) return;
|
||||||
|
session.RefCount = 0;
|
||||||
|
_sessions.Remove(key);
|
||||||
|
toStop = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toStop == null) return;
|
||||||
|
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||||
|
await SafeStopAsync(toStop.Source);
|
||||||
|
toStop.PreviewBitmap = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The most recent frame for a capture key, or null before the first
|
||||||
|
/// frame arrives (or if the key has no session). The live compositor reads
|
||||||
|
/// the backdrop from here.</summary>
|
||||||
|
public VideoFrame? GetLatestFrame(string key)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
List<CaptureSession> sessions;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
sessions = new List<CaptureSession>(_sessions.Values);
|
||||||
|
_sessions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
session.Source.FrameAvailable -= session.FrameHandler;
|
||||||
|
_ = SafeStopAsync(session.Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SafeStopAsync(IScreenCaptureSource source)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await source.StopAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetActiveSession(CaptureSession session)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _sessions.TryGetValue(session.Key, out var current) && ReferenceEquals(current, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFrameAvailable(CaptureSession session, VideoFrame frame)
|
||||||
|
{
|
||||||
|
if (!TryGetActiveSession(session)) return;
|
||||||
|
session.LatestFrame = frame;
|
||||||
|
|
||||||
|
if (session.PreviewBitmap == null)
|
||||||
|
{
|
||||||
|
if (_uiDispatcher == null) return;
|
||||||
|
if (_uiDispatcher.CheckAccess())
|
||||||
|
EnsurePreviewBitmap(session);
|
||||||
|
else
|
||||||
|
_uiDispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
if (TryGetActiveSession(session))
|
||||||
|
EnsurePreviewBitmap(session);
|
||||||
|
}, DispatcherPriority.Render);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_uiDispatcher == null || _uiDispatcher.CheckAccess())
|
||||||
|
{
|
||||||
|
CopyFrame(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.FramePending) return;
|
||||||
|
session.FramePending = true;
|
||||||
|
_uiDispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
session.FramePending = false;
|
||||||
|
if (TryGetActiveSession(session) && session.PreviewBitmap != null)
|
||||||
|
CopyFrame(session);
|
||||||
|
}, DispatcherPriority.Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePreviewBitmap(CaptureSession session)
|
||||||
|
{
|
||||||
|
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
|
||||||
|
var frame = session.LatestFrame;
|
||||||
|
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||||
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||||
|
session.PreviewBitmap = bitmap;
|
||||||
|
PreviewBitmapChanged?.Invoke(session.Key, bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyFrame(CaptureSession session)
|
||||||
|
{
|
||||||
|
var bitmap = session.PreviewBitmap;
|
||||||
|
var frame = session.LatestFrame;
|
||||||
|
if (bitmap == null || frame == null) return;
|
||||||
|
if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return;
|
||||||
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Windows.Graphics.Capture;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a capture key ("monitor:<n>", "window:<hwnd>", "picker:<...>")
|
||||||
|
/// into a live <see cref="IScreenCaptureSource"/>, and hosts the OS
|
||||||
|
/// GraphicsCapturePicker used by "Change Capture…". Picker picks are transient:
|
||||||
|
/// the picked GraphicsCaptureItem has no HWND/monitor identity, so a
|
||||||
|
/// "picker:" key resolves only within the current session and re-detection
|
||||||
|
/// takes over after a reload (documented v1 limit).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScreenCaptureSourceFactory
|
||||||
|
{
|
||||||
|
private readonly Func<IntPtr> _ownerHwndProvider;
|
||||||
|
private readonly Dictionary<string, GraphicsCaptureItem> _picked = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
public ScreenCaptureSourceFactory(Func<IntPtr> ownerHwndProvider)
|
||||||
|
{
|
||||||
|
_ownerHwndProvider = ownerHwndProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates the source for a persisted key, or null when it can't be resolved.</summary>
|
||||||
|
public IScreenCaptureSource? Resolve(string key)
|
||||||
|
{
|
||||||
|
if (key.StartsWith("monitor:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var indexText = key.AsSpan("monitor:".Length);
|
||||||
|
if (int.TryParse(indexText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index))
|
||||||
|
return ScreenCaptureFrameSource.CreateForMonitor(index);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var hexText = key.AsSpan("window:".Length);
|
||||||
|
if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd))
|
||||||
|
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.StartsWith("picker:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _picked.TryGetValue(key, out var item)
|
||||||
|
? ScreenCaptureFrameSource.CreateForPicker(key, item)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the OS capture picker (must run on the UI thread, owner window set
|
||||||
|
/// via IInitializeWithWindow). Returns the session key, or null if cancelled.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> PickAsync()
|
||||||
|
{
|
||||||
|
var picker = new GraphicsCapturePicker();
|
||||||
|
CaptureInterop.SetWindowOwner(picker, _ownerHwndProvider());
|
||||||
|
var item = await picker.PickSingleItemAsync();
|
||||||
|
if (item == null) return null;
|
||||||
|
|
||||||
|
var key = $"picker:{item.DisplayName}";
|
||||||
|
lock (_gate)
|
||||||
|
_picked[key] = item;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class SocialLookupResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string ProfileUrl { get; init; } = string.Empty;
|
||||||
|
public string Handle { get; init; } = string.Empty;
|
||||||
|
public string Error { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Fediverse instance software (nodeinfo `software.name`), when the handle is federated.</summary>
|
||||||
|
public string? FediverseSoftware { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when the caller canceled the lookup before it finished.</summary>
|
||||||
|
public bool Canceled { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISocialValidator
|
||||||
|
{
|
||||||
|
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Best-effort nodeinfo software name (mastodon/peertube/...) for a
|
||||||
|
/// fediverse domain, or null when it can't be resolved. The load-time heal
|
||||||
|
/// uses this to fix a bar entry whose stored software is missing.</summary>
|
||||||
|
Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a social handle/URL by constructing the canonical profile URL and
|
||||||
|
/// issuing an HTTP GET — the "act of validation" the creator trusts before the
|
||||||
|
/// icon lands on their bar. Best-effort: some platforms return challenges/blocks
|
||||||
|
/// to HEAD requests, so a 200 OR a 301/302 redirect counts as "exists"; 404 or
|
||||||
|
/// connection failure = rejected. Never blind trust.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HttpSocialValidator : ISocialValidator
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly Action<string>? _log;
|
||||||
|
|
||||||
|
public HttpSocialValidator(HttpClient? client = null, Action<string>? log = null)
|
||||||
|
{
|
||||||
|
_client = client ?? new HttpClient();
|
||||||
|
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||||
|
"Mozilla/5.0 (compatible; ytLlive social validator)");
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||||
|
=> TryFetchFediverseSoftwareAsync(domain, ct);
|
||||||
|
|
||||||
|
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var input = handleOrUrl.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return new SocialLookupResult { Error = "Enter a handle or URL." };
|
||||||
|
|
||||||
|
// Fediverse handle (@user@domain): the domain is part of the identity,
|
||||||
|
// so the canonical URL can't be rebuilt from a bare handle. After the
|
||||||
|
// profile validates, ask the instance which software it runs (nodeinfo)
|
||||||
|
// so the caller can show the right service logo.
|
||||||
|
if (input.StartsWith('@') && input.IndexOf('@', 1) > 0)
|
||||||
|
{
|
||||||
|
var at = input.IndexOf('@', 1);
|
||||||
|
var user = input[1..at];
|
||||||
|
var domain = input[(at + 1)..];
|
||||||
|
if (user.Length > 0 && domain.Length > 0)
|
||||||
|
{
|
||||||
|
var url = $"https://{domain}/@{user}";
|
||||||
|
var result = await CheckAsync(service, input, url, ct);
|
||||||
|
if (!result.Success) return result;
|
||||||
|
var software = await TryFetchFediverseSoftwareAsync(domain, ct);
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
return new SocialLookupResult { Canceled = true };
|
||||||
|
return new SocialLookupResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ProfileUrl = result.ProfileUrl,
|
||||||
|
Handle = result.Handle,
|
||||||
|
FediverseSoftware = software,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = input;
|
||||||
|
if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var uri = new System.Uri(input);
|
||||||
|
handle = uri.AbsolutePath.Trim('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonical = SocialServiceIcons.CanonicalUrlFor(service, handle);
|
||||||
|
return await CheckAsync(service, handle, canonical, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SocialLookupResult> CheckAsync(SocialService service, string handle, string url, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var resp = await _client.GetAsync(url, ct);
|
||||||
|
if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308)
|
||||||
|
return new SocialLookupResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ProfileUrl = url,
|
||||||
|
Handle = handle,
|
||||||
|
};
|
||||||
|
return new SocialLookupResult
|
||||||
|
{
|
||||||
|
Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new SocialLookupResult { Canceled = true };
|
||||||
|
}
|
||||||
|
catch (System.Net.Http.HttpRequestException)
|
||||||
|
{
|
||||||
|
return new SocialLookupResult
|
||||||
|
{
|
||||||
|
Error = $"Couldn't reach {service} to validate '{handle}'. Check your connection and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
return new SocialLookupResult { Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort nodeinfo lookup: GET /.well-known/nodeinfo, follow the first
|
||||||
|
/// nodeinfo link, read `software.name`. If the identity domain is itself a
|
||||||
|
/// redirect (e.g. a YunoHost domain whose default app lives on a subdomain),
|
||||||
|
/// the bare root 302s to the real instance — follow it and ask that host.
|
||||||
|
/// If neither answers, probe well-known subdomains (mastodon.example.com)
|
||||||
|
/// so a landing-page domain still resolves its real instance. The whole
|
||||||
|
/// resolution is bounded by a ~15s budget. Failures return null — validation
|
||||||
|
/// still succeeds, the icon just falls back to the generic fediverse glyph.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var budget = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
budget.CancelAfter(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
var software = await FetchSoftwareNameAsync(domain, budget.Token);
|
||||||
|
if (software != null) return software;
|
||||||
|
|
||||||
|
var resolved = await ResolveInstanceHostAsync(domain, budget.Token);
|
||||||
|
if (resolved != null
|
||||||
|
&& !string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
software = await FetchSoftwareNameAsync(resolved, budget.Token);
|
||||||
|
if (software != null) return software;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var sub in FediverseSubdomainCandidates)
|
||||||
|
{
|
||||||
|
var host = $"{sub}.{domain}";
|
||||||
|
software = await FetchSoftwareNameAsync(host, budget.Token);
|
||||||
|
if (software != null)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"SocialValidator: fediverse software '{software}' for '{domain}' resolved via {host}");
|
||||||
|
return software;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_log?.Invoke($"SocialValidator: no nodeinfo found for '{domain}'");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return null; // caller checks ct.IsCancellationRequested and reports Canceled
|
||||||
|
}
|
||||||
|
catch (System.OperationCanceledException)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' timed out");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' failed: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Common instance subdomains, probed in order when the identity
|
||||||
|
/// domain itself doesn't serve nodeinfo (a landing page or SSO gate in front
|
||||||
|
/// of a self-hosted instance).</summary>
|
||||||
|
private static readonly string[] FediverseSubdomainCandidates =
|
||||||
|
{
|
||||||
|
"mastodon", "social", "fediverse", "tube", "peertube",
|
||||||
|
"pixelfed", "lemmy", "pleroma", "misskey", "gotosocial", "fedi", "instance",
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
|
||||||
|
if (nodeInfoUrl == null) return null;
|
||||||
|
using var resp = await _client.GetAsync(nodeInfoUrl, ct);
|
||||||
|
if (!resp.IsSuccessStatusCode) return null;
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("software", out var software)
|
||||||
|
&& software.TryGetProperty("name", out var name))
|
||||||
|
return name.GetString();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follows the bare-domain root redirect (HttpClient follows 3xx automatically)
|
||||||
|
/// and returns the final host — the "default app" behind an identity domain.</summary>
|
||||||
|
private async Task<string?> ResolveInstanceHostAsync(string domain, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var resp = await _client.GetAsync($"https://{domain}/", ct);
|
||||||
|
return resp.RequestMessage?.RequestUri?.Host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> FetchNodeInfoUrlAsync(string domain, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var resp = await _client.GetAsync($"https://{domain}/.well-known/nodeinfo", ct);
|
||||||
|
if (!resp.IsSuccessStatusCode) return null;
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (!doc.RootElement.TryGetProperty("links", out var links)) return null;
|
||||||
|
foreach (var link in links.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!link.TryGetProperty("rel", out var rel)) continue;
|
||||||
|
var relStr = rel.GetString() ?? string.Empty;
|
||||||
|
if (!relStr.Contains("nodeinfo", System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
if (!link.TryGetProperty("href", out var href) || href.GetString() is not { } hrefStr) continue;
|
||||||
|
if (System.Uri.TryCreate(hrefStr, System.UriKind.RelativeOrAbsolute, out var parsed))
|
||||||
|
return parsed.IsAbsoluteUri
|
||||||
|
? parsed.AbsoluteUri
|
||||||
|
: new System.Uri(new System.Uri($"https://{domain}"), parsed).AbsoluteUri;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A normalized CPU frame (32bpp BGRA, tightly packed). The capture path hands
|
||||||
|
/// these to the UI thread, which copies them into the shared WriteableBitmap.
|
||||||
|
/// Deliberately the only pixel type the rest of the app knows about — every
|
||||||
|
/// future capture source (screen, background-removed webcam) feeds the same seam.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VideoFrame
|
||||||
|
{
|
||||||
|
public int Width { get; }
|
||||||
|
public int Height { get; }
|
||||||
|
public byte[] BgraPixels { get; }
|
||||||
|
public int Stride => Width * 4;
|
||||||
|
|
||||||
|
public VideoFrame(int width, int height, byte[] bgraPixels)
|
||||||
|
{
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
BgraPixels = bgraPixels;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>A physical display: its capture-key index, geometry, and primary flag.</summary>
|
||||||
|
public sealed record DisplayInfo(int Index, string Name, int Width, int Height, int X, int Y, bool IsPrimary)
|
||||||
|
{
|
||||||
|
public string Label
|
||||||
|
=> IsPrimary ? $"{Name} — {Width}×{Height} (primary)" : $"{Name} — {Width}×{Height}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Win32 detection: <c>GetForegroundWindow</c> + <c>DwmGetWindowAttribute</c>
|
||||||
|
/// (DWMWA_EXTENDED_FRAME_BOUNDS) + <c>MonitorFromWindow</c> + <c>GetMonitorInfo</c>.
|
||||||
|
/// A foreground window whose extended frame bounds cover an entire monitor is
|
||||||
|
/// treated as a full-screen game/app on that monitor's index (monitor order =
|
||||||
|
/// <c>EnumDisplayMonitors</c> enumeration order, the same order the capture
|
||||||
|
/// factory uses to map index → HMONITOR). Windows of our own process are excluded.
|
||||||
|
/// DRM-protected content captures as black frames — a documented OS limit.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Win32FullScreenDetector : IFullScreenDetector
|
||||||
|
{
|
||||||
|
private const int DwmwaExtendedFrameBounds = 9;
|
||||||
|
private const uint MonitorDefaultToNearest = 2;
|
||||||
|
private const uint MonitorInfoFPrimary = 0x00000001;
|
||||||
|
|
||||||
|
public int? GetForegroundFullScreenMonitorIndex()
|
||||||
|
{
|
||||||
|
var hwnd = GetForegroundWindow();
|
||||||
|
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return null;
|
||||||
|
if (!TryGetExtendedFrameBounds(hwnd, out var bounds)) return null;
|
||||||
|
|
||||||
|
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
|
||||||
|
if (monitor == IntPtr.Zero || !TryGetMonitorInfo(monitor, out var info)) return null;
|
||||||
|
|
||||||
|
var r = info.RcMonitor;
|
||||||
|
var coversMonitor = bounds.Left <= r.Left && bounds.Top <= r.Top &&
|
||||||
|
bounds.Right >= r.Right && bounds.Bottom >= r.Bottom;
|
||||||
|
if (!coversMonitor) return null;
|
||||||
|
|
||||||
|
return MonitorIndex(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOwnWindow(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
_ = GetWindowThreadProcessId(hwnd, out var pid);
|
||||||
|
return pid == Environment.ProcessId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetExtendedFrameBounds(IntPtr hwnd, out Win32Rect bounds)
|
||||||
|
{
|
||||||
|
bounds = default;
|
||||||
|
return DwmGetWindowAttribute(hwnd, DwmwaExtendedFrameBounds, ref bounds, Marshal.SizeOf<Win32Rect>()) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetMonitorInfo(IntPtr monitor, out MonitorInfo info)
|
||||||
|
{
|
||||||
|
info = new MonitorInfo { CbSize = Marshal.SizeOf<MonitorInfo>() };
|
||||||
|
return GetMonitorInfo(monitor, ref info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? MonitorIndex(IntPtr monitor)
|
||||||
|
{
|
||||||
|
var handles = EnumerateMonitors();
|
||||||
|
var index = handles.IndexOf(monitor);
|
||||||
|
return index >= 0 ? index : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int PrimaryMonitorIndex()
|
||||||
|
=> GetDisplays().FirstOrDefault(d => d.IsPrimary)?.Index ?? 0;
|
||||||
|
|
||||||
|
public IReadOnlyList<DisplayInfo> GetDisplays()
|
||||||
|
{
|
||||||
|
var result = new List<DisplayInfo>();
|
||||||
|
var handles = EnumerateMonitors();
|
||||||
|
for (var i = 0; i < handles.Count; i++)
|
||||||
|
{
|
||||||
|
if (!TryGetMonitorInfo(handles[i], out var info)) continue;
|
||||||
|
var name = GetDisplayName(handles[i]);
|
||||||
|
result.Add(new DisplayInfo(
|
||||||
|
i,
|
||||||
|
name,
|
||||||
|
info.RcMonitor.Right - info.RcMonitor.Left,
|
||||||
|
info.RcMonitor.Bottom - info.RcMonitor.Top,
|
||||||
|
info.RcMonitor.Left,
|
||||||
|
info.RcMonitor.Top,
|
||||||
|
(info.DwFlags & MonitorInfoFPrimary) != 0));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDisplayName(IntPtr monitor)
|
||||||
|
{
|
||||||
|
var info = new MonitorInfoEx { CbSize = Marshal.SizeOf<MonitorInfoEx>() };
|
||||||
|
if (!GetMonitorInfo(monitor, ref info)) return $"Display {monitor}";
|
||||||
|
var deviceName = info.SzDevice.TrimEnd('\0');
|
||||||
|
var dev = new DisplayDevice { Cb = Marshal.SizeOf<DisplayDevice>() };
|
||||||
|
if (!EnumDisplayDevices(deviceName, 0, ref dev, 0)) return deviceName;
|
||||||
|
var friendly = dev.DeviceString.TrimEnd('\0');
|
||||||
|
return string.IsNullOrWhiteSpace(friendly) ? deviceName : friendly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IntPtr GetMonitorHandle(int index)
|
||||||
|
{
|
||||||
|
var handles = EnumerateMonitors();
|
||||||
|
return index >= 0 && index < handles.Count ? handles[index] : IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<IntPtr> EnumerateMonitors()
|
||||||
|
{
|
||||||
|
var handles = new List<IntPtr>();
|
||||||
|
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero,
|
||||||
|
(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData) =>
|
||||||
|
{
|
||||||
|
handles.Add(hMonitor);
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
return handles;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct Win32Rect
|
||||||
|
{
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MonitorInfo
|
||||||
|
{
|
||||||
|
public int CbSize;
|
||||||
|
public Win32Rect RcMonitor;
|
||||||
|
public Win32Rect RcWork;
|
||||||
|
public uint DwFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MonitorInfoEx
|
||||||
|
{
|
||||||
|
public int CbSize;
|
||||||
|
public Win32Rect RcMonitor;
|
||||||
|
public Win32Rect RcWork;
|
||||||
|
public uint DwFlags;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||||
|
public string SzDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct DisplayDevice
|
||||||
|
{
|
||||||
|
public int Cb;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||||
|
public string DeviceName;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string DeviceString;
|
||||||
|
public uint StateFlags;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string DeviceId;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string DeviceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfoEx lpmi);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DisplayDevice lpDisplayDevice, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip,
|
||||||
|
MonitorEnumProc lpfnEnum, IntPtr dwData);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
|
||||||
|
[DllImport("dwmapi.dll")]
|
||||||
|
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, ref Win32Rect pvAttribute, int cbAttribute);
|
||||||
|
|
||||||
|
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Windows.Devices.Enumeration;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class WinRtMicrophoneEnumerator : IMicrophoneEnumerator
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<MicrophoneDeviceInfo>> GetMicrophonesAsync()
|
||||||
|
{
|
||||||
|
var devices = await DeviceInformation.FindAllAsync(DeviceClass.AudioCapture);
|
||||||
|
var result = new List<MicrophoneDeviceInfo>(devices.Count);
|
||||||
|
foreach (var device in devices)
|
||||||
|
result.Add(new MicrophoneDeviceInfo(device.Id, device.Name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,14 +19,30 @@ public class YouTubeAuthService
|
|||||||
|
|
||||||
private readonly string _clientId;
|
private readonly string _clientId;
|
||||||
private readonly string _clientSecret;
|
private readonly string _clientSecret;
|
||||||
private readonly HttpClient _http = new();
|
private readonly HttpClient _http;
|
||||||
|
private readonly Action<YouTubeChannel>? _sessionChanged;
|
||||||
|
|
||||||
public YouTubeChannel? CurrentChannel { get; private set; }
|
public YouTubeChannel? CurrentChannel { get; private set; }
|
||||||
|
|
||||||
public YouTubeAuthService(string clientId, string clientSecret)
|
public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null,
|
||||||
|
Action<YouTubeChannel>? sessionChanged = null)
|
||||||
{
|
{
|
||||||
_clientId = clientId;
|
_clientId = clientId;
|
||||||
_clientSecret = clientSecret;
|
_clientSecret = clientSecret;
|
||||||
|
_http = http ?? new HttpClient();
|
||||||
|
_sessionChanged = sessionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces the current session (used when a saved token loads at startup).</summary>
|
||||||
|
public void SetSession(YouTubeChannel channel)
|
||||||
|
{
|
||||||
|
CurrentChannel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signs out: drops the in-memory session (a graceful End Livestream clears the saved token via the caller).</summary>
|
||||||
|
public void ClearSession()
|
||||||
|
{
|
||||||
|
CurrentChannel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetAuthorizationUrl(string redirectUri)
|
public string GetAuthorizationUrl(string redirectUri)
|
||||||
@@ -136,6 +152,7 @@ public class YouTubeAuthService
|
|||||||
|
|
||||||
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
|
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
|
||||||
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
|
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
|
||||||
|
_sessionChanged?.Invoke(CurrentChannel);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +179,7 @@ public class YouTubeAuthService
|
|||||||
TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn)
|
TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_sessionChanged?.Invoke(CurrentChannel);
|
||||||
return CurrentChannel;
|
return CurrentChannel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 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. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
||||||
|
| `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; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load) |
|
||||||
|
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
||||||
|
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
|
||||||
|
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
|
||||||
|
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` + **`SourceFailed(string)`** — seam for a running capture source (tests inject fakes) |
|
||||||
|
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
|
||||||
|
| `MicrophoneDeviceInfo.cs` | `(Id, DisplayName)` for an audio capture (mic) device |
|
||||||
|
| `IMicrophoneEnumerator.cs` | `GetMicrophonesAsync()` — seam so the mic picker never touches WinRT (tests inject fakes) |
|
||||||
|
| `WinRtMicrophoneEnumerator.cs` | WinRT mic enumeration via `DeviceInformation.FindAllAsync(DeviceClass.AudioCapture)` (no NAudio needed — capture libs stay deferred to the audio pipeline milestone) |
|
||||||
|
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`). **Validated, not trusted:** post-init checks (`VideoDeviceId` match, `FrameSources` non-empty, `VideoDeviceController.GetAvailableMediaStreamProperties` ≥ 1); `reader.StartAsync()` status read (throws on non-`Success`); `capture.Failed` + `CameraStreamStateChanged` subscribed → `SourceFailed` event. Fallback ladder: VideoPreview → VideoRecord retry. `SharingMode.Exclusive` + `DeviceLost` not in 19041 SDK projection; `CameraStreamState.Failed` compared by `(int)2` (projection omits member names) |
|
||||||
|
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. **First-frame proof** (4s timeout, configurable via ctor `TimeSpan?`): `AcquireAsync` returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces `CameraFailed` with suspect-app names (`CameraConflictProbe`) instead of a silent empty box. `SourceFailed` forwarded from the frame source as async death signal |
|
||||||
|
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
|
||||||
|
| `Win32FullScreenDetector.cs` | `GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` + `GetMonitorInfo`; monitor covers all four edges → full-screen; own process excluded; monitor order = `EnumDisplayMonitors` order (static `GetMonitorHandle(int)` maps index→HMONITOR for the capture factory). `GetDisplays()` returns `DisplayInfo` (index/name/resolution/bounds/`IsPrimary`, friendly name via `EnumDisplayDevices`) for the in-app "Capture Display" picker; `PrimaryMonitorIndex()` is the auto-key fallback when no full-screen game is detected |
|
||||||
|
| `IScreenCaptureSource.cs` | `Key` + `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam so `ScreenCaptureManager` never touches WinRT (tests inject fakes) |
|
||||||
|
| `Direct3D11Helper.cs` | The one shared `IDirect3DDevice` per process: P/Invoke `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QI `IDXGIDevice` → the WinRT interop export `CreateDirect3D11DeviceFromDXGIDevice` → `MarshalInterface<IDirect3DDevice>.FromAbi`. CsWinRT does **not** project `Windows.Graphics.Direct3D11.Direct3D11Helper`, so this hand-rolls it. Do **not** revert to QI-for-`IDirect3DDxgiInterfaceAccess` — the device no longer exposes it on newer Windows (E_NOINTERFACE on build 26200) |
|
||||||
|
| `CaptureInterop.cs` | ComImport bridges the WinRT-projection gap: `IGraphicsCaptureItemInterop` (`CreateForMonitor`/`CreateForWindow`), `IInitializeWithWindow` (`GraphicsCapturePicker` owner window so the OS picker shows) |
|
||||||
|
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
|
||||||
|
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
|
||||||
|
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
|
||||||
|
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
|
||||||
|
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
|
||||||
|
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
|
||||||
|
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
|
||||||
|
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
||||||
|
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
|
||||||
|
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
|
||||||
|
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
|
||||||
|
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
|
||||||
|
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
|
||||||
|
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
|
||||||
|
| `Encoder/FfmpegEncoderProcess.cs` | The real process wrapper (`Process` with all three std streams redirected) |
|
||||||
|
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
|
||||||
|
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
|
||||||
|
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
|
||||||
|
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. See `ai.md` "Live frame pipeline" |
|
||||||
|
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||||
|
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes |
|
||||||
|
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
|
||||||
|
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
|
||||||
|
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
|
||||||
|
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
|
||||||
|
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead |
|
||||||
|
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
|
||||||
|
| `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" |
|
||||||
|
| `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` |
|
||||||
|
| `GameAudioDetector.cs` | Default `IGameAudioDetector`: composes a full-screen monitor provider (`IFullScreenDetector.GetForegroundFullScreenMonitorIndex`) + the live loopback level (floor 0.5%) into a `GameAudioHysteresis`. WPF-free; the VM owns the 250ms poll timer |
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<Window x:Class="ytLive.SocialsDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Social Media Site Promotion" Height="520" Width="520"
|
||||||
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="Social Media Site Promotion" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="#e0e0e0" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<!-- Show/hide the bar on the stream -->
|
||||||
|
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Margin="0,0,0,10">
|
||||||
|
<TextBlock Text="Show the social bar on the stream" Foreground="#e0e0e0" FontSize="13"/>
|
||||||
|
</CheckBox>
|
||||||
|
|
||||||
|
<!-- Sign-in gate: the first slot is always your YouTube channel -->
|
||||||
|
<Border Grid.Row="2" Background="#16213e" CornerRadius="5" Padding="10,8" Margin="0,0,0,10"
|
||||||
|
Visibility="{Binding ShowSignInBanner, Converter={StaticResource BoolToVis}}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Sign in to YouTube first — the first slot is your channel."
|
||||||
|
Foreground="#e0e0e0" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Left"
|
||||||
|
Margin="0,8,0,0" Padding="12,4" FontSize="11"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Row="2" Text="Signing in… open the browser to authorize, then come back here."
|
||||||
|
Foreground="#e94560" FontSize="12" Margin="0,0,0,10"
|
||||||
|
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
|
||||||
|
<!-- The six fixed slots -->
|
||||||
|
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding Slots}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Margin="0,6">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="34"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Service icon: logo, lock (Premium) or do-not (empty/invalid) -->
|
||||||
|
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
|
||||||
|
Background="#16213e" HorizontalAlignment="Left" VerticalAlignment="Top">
|
||||||
|
<Grid>
|
||||||
|
<Path Data="{Binding LogoData}" Fill="White" Stretch="Uniform"
|
||||||
|
Width="15" Height="15" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowLogo, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Path Data="{Binding LockedIconData}" Fill="#8a93a8" Stretch="Uniform"
|
||||||
|
Width="15" Height="15" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding IsLocked, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Path Data="{Binding DoNotIconData}" Fill="#5a5a6e" Stretch="Uniform"
|
||||||
|
Width="15" Height="15" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowDoNot, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Handle text / edit box / status -->
|
||||||
|
<Grid Grid.Column="1" Margin="10,0,8,0">
|
||||||
|
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0" FontSize="14"
|
||||||
|
VerticalAlignment="Top" Margin="0,6,0,0"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Visibility="{Binding ShowDisplay, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<TextBox Text="{Binding EditText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBox}" FontSize="13"
|
||||||
|
KeyDown="SocialEdit_KeyDown" LostFocus="SocialEdit_LostFocus"
|
||||||
|
Visibility="{Binding ShowEditBox, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<TextBlock Text="{Binding Error}" Foreground="#e94560" FontSize="11"
|
||||||
|
TextWrapping="Wrap" Margin="0,32,0,0"
|
||||||
|
Visibility="{Binding ShowError, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<TextBlock Text="Checking…" Foreground="#a0a0b0" FontSize="11" Margin="0,32,0,0"
|
||||||
|
Visibility="{Binding ShowBusy, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Row actions -->
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Top"
|
||||||
|
Margin="0,3,0,0">
|
||||||
|
<Button Content="Sign in" Click="SignInButton_Click"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Padding="10,3"
|
||||||
|
FontSize="11" Margin="0,0,6,0"
|
||||||
|
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Button Content="+" Click="SlotAdd_Click" Tag="{Binding Index}"
|
||||||
|
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||||
|
ToolTip="Add a social" FontSize="16" FontWeight="Bold"
|
||||||
|
Visibility="{Binding ShowAddButton, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Button Content="✏️" Click="SlotEdit_Click" Tag="{Binding Index}"
|
||||||
|
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||||
|
ToolTip="Edit" FontSize="13"
|
||||||
|
Visibility="{Binding ShowEditButton, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Button Content="🗑️" Click="SlotDelete_Click" Tag="{Binding Index}"
|
||||||
|
Style="{StaticResource IconButton}" Width="26" Height="26"
|
||||||
|
ToolTip="Remove" FontSize="12" Foreground="#e94560"
|
||||||
|
Visibility="{Binding ShowDeleteButton, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
|
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" IsCancel="True"/>
|
||||||
|
<Button Content="Save" Command="{Binding SaveCommand}" Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive;
|
||||||
|
|
||||||
|
public partial class SocialsDialog : Window
|
||||||
|
{
|
||||||
|
private readonly SocialsDialogViewModel _viewModel;
|
||||||
|
|
||||||
|
public SocialsDialog(SocialsDialogViewModel viewModel)
|
||||||
|
{
|
||||||
|
AppLog.Write("SocialsDialog ctor: before InitializeComponent");
|
||||||
|
InitializeComponent();
|
||||||
|
AppLog.Write("SocialsDialog ctor: after InitializeComponent");
|
||||||
|
DataContext = viewModel;
|
||||||
|
_viewModel = viewModel;
|
||||||
|
viewModel.SaveRequested += () => DialogResult = true;
|
||||||
|
viewModel.CancelRequested += () => DialogResult = false;
|
||||||
|
Closing += (_, _) => _viewModel.AbortPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SignInButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> _viewModel.SignInCommand.Execute(null);
|
||||||
|
|
||||||
|
private void SlotAdd_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { Tag: int index })
|
||||||
|
_viewModel.StartEdit(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SlotEdit_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { Tag: int index })
|
||||||
|
_viewModel.StartEdit(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void SlotDelete_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement { Tag: int index } element) return;
|
||||||
|
var slot = element.DataContext as SocialSlotViewModel;
|
||||||
|
var confirm = index == 0
|
||||||
|
? "Remove your YouTube channel from the bar and sign out of ytLlive?"
|
||||||
|
: slot is { IsFilled: true }
|
||||||
|
? $"Remove {slot.Handle}?"
|
||||||
|
: "Remove this social?";
|
||||||
|
|
||||||
|
if (MessageBox.Show(confirm, "Remove Social", MessageBoxButton.YesNo, MessageBoxImage.Question)
|
||||||
|
== MessageBoxResult.Yes)
|
||||||
|
await _viewModel.DeleteSlotAsync(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SocialEdit_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement { DataContext: SocialSlotViewModel slot }) return;
|
||||||
|
if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
_viewModel.ConfirmEdit(slot.Index);
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
slot.IsEditing = false;
|
||||||
|
slot.Error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SocialEdit_LostFocus(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: SocialSlotViewModel slot })
|
||||||
|
_viewModel.ConfirmEdit(slot.Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
# ytLlive — Task List
|
# 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.
|
||||||
|
|
||||||
|
> **Checklist markers** — every task's Status list uses the same states:
|
||||||
|
> 1. ✅ — completed (green check)
|
||||||
|
> 2. ☐ — not completed / pending (empty box)
|
||||||
|
> 3. ❌ — exception (blocked, known-issue, or deliberately excluded from this build)
|
||||||
|
|
||||||
## YouTube Live API — research facts (authoritative, v3 build)
|
## YouTube Live API — research facts (authoritative, v3 build)
|
||||||
|
|
||||||
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
|
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
|
||||||
|
|
||||||
- **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
1. **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
||||||
- **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
|
2. **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
|
||||||
- **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
3. **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
||||||
- **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
4. **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
||||||
- **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
|
5. **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
|
||||||
|
|
||||||
### Two features that reshape the design
|
### Two features that reshape the design
|
||||||
|
|
||||||
@@ -17,17 +25,17 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
|
|
||||||
### Compliance gotchas (maps perfectly to report-by-exception)
|
### Compliance gotchas (maps perfectly to report-by-exception)
|
||||||
|
|
||||||
- `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
|
1. `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
|
||||||
- Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
2. Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
||||||
- Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
3. Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
||||||
|
|
||||||
### Tips we should take advantage of
|
### Tips we should take advantage of
|
||||||
|
|
||||||
- **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
|
1. **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
|
||||||
- **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
2. **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
||||||
- **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
3. **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
||||||
- **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
4. **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
||||||
- **Broadcast ID == Video ID** — one ID to track everything.
|
5. **Broadcast ID == Video ID** — one ID to track everything.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,11 +44,12 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
|
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
|
||||||
|
|
||||||
### Status: ✅ Done
|
### Status: ✅ Done
|
||||||
- Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
|
||||||
- Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
1. ✅ Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
||||||
- MainViewModel: scene management, stream controls, chat
|
2. ✅ Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
||||||
- MainWindow: scene/source panel, preview area, chat panel, status bar
|
3. ✅ MainViewModel: scene management, stream controls, chat
|
||||||
- Clean build, 0 warnings (WSL + Windows)
|
4. ✅ MainWindow: scene/source panel, preview area, chat panel, status bar
|
||||||
|
5. ✅ Clean build, 0 warnings (WSL + Windows)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,6 +57,15 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
|
|
||||||
**Goal:** Fully working Google OAuth2 flow — user clicks "YouTube", browser opens, authorization callback lands, channel info is stored.
|
**Goal:** Fully working Google OAuth2 flow — user clicks "YouTube", browser opens, authorization callback lands, channel info is stored.
|
||||||
|
|
||||||
|
### Status: ✅ Done
|
||||||
|
|
||||||
|
1. ✅ 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
|
||||||
|
2. ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
|
||||||
|
3. ✅ Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh
|
||||||
|
4. ✅ Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in)
|
||||||
|
5. ✅ **End Livestream signs out** — a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast
|
||||||
|
6. ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump + `ClearSession` — 7 passing
|
||||||
|
|
||||||
**Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** — the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata.
|
**Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** — the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata.
|
||||||
|
|
||||||
**Two-state flow:** There is no separate "Connect" button. The top bar shows a single button — **Start Stream** when idle, **End Stream** when live. Clicking Start Stream opens one dialog that supplies everything: account (previously-saved account shown as default, with a Change Account action) + title/description/visibility.
|
**Two-state flow:** There is no separate "Connect" button. The top bar shows a single button — **Start Stream** when idle, **End Stream** when live. Clicking Start Stream opens one dialog that supplies everything: account (previously-saved account shown as default, with a Change Account action) + title/description/visibility.
|
||||||
@@ -65,15 +83,9 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
|
|
||||||
### Tests:
|
### Tests:
|
||||||
|
|
||||||
- Mock token exchange response, verify channel info parsed
|
1. Mock token exchange response, verify channel info parsed
|
||||||
- Verify token refresh triggers when near expiry
|
2. Verify token refresh triggers when near expiry
|
||||||
- Verify credential load/save roundtrip
|
3. Verify credential load/save roundtrip
|
||||||
|
|
||||||
### 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)
|
|
||||||
- ⬜ Token persistence via Windows DPAPI, reload on startup
|
|
||||||
- ⬜ Account sign-in/change wired to YouTubeAuthService (currently simulated in GoLiveViewModel)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,6 +93,31 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
|
|
||||||
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
|
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
|
||||||
|
|
||||||
|
### Status: 🔶 In progress
|
||||||
|
|
||||||
|
1. ✅ **Milestone 1 — webcam** — MediaCapture (WinRT SDK projection) with device enumeration, CPU-first frame source, refcounted `CameraManager`, picker dialog, clip shapes (Traditional + Round) + mirror, 480×270 default placement — schema v2
|
||||||
|
2. ✅ **Schema v3 (Ship Branch A)** — multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OBS-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`) — 25 tests passing
|
||||||
|
3. ✅ **Schema v4** — round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load
|
||||||
|
4. ✅ **Screen backdrop (ship task #1, schema v5)** — live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing
|
||||||
|
5. ✅ **Schema v6 — backdrop Live-only by policy** — `Scene.HasBackdrop`, enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix
|
||||||
|
6. ✅ **The five-scene catalog (`SceneCatalog`)** — Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones) — 62 tests passing
|
||||||
|
7. ✅ **Webcam-after-session-start fix** — a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 65 tests passing
|
||||||
|
8. ✅ **Chat scene webcam size cap** — raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better
|
||||||
|
9. ✅ **"Add Webcam" always opens the camera picker** — deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"
|
||||||
|
10. ✅ **Webcam resource validation + first-frame proof** — `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` → `SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
|
||||||
|
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
||||||
|
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip — monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` — not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) — the raw level is now mapped via `AudioLevelMeter.ToDisplay` (−60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker — both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** — dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) — `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix
|
||||||
|
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
||||||
|
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
|
||||||
|
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
||||||
|
16. ☐ **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
|
||||||
|
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
|
||||||
|
18. ☐ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video
|
||||||
|
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
|
||||||
|
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`)
|
||||||
|
21. ☐ **Logo + richer in-app About (2026-08-13, queued)** — the ytLlive wordmark in the top bar is clickable and opens the About overlay; make a real **logo** (CC0 per the icon guardrail), and expand the About overlay into the creator's hub: **links to premium unlock + coffee/thanks buys + socials** and a second (or third) **scrolling panel with the full licensing/legal texts on demand** — everything stays IN-APP (no Notepad, no browser tab; the app is standalone). The top-bar About button that opened `THIRD-PARTY-NOTICES.txt` in the OS viewer was removed on 2026-08-13 for exactly this reason
|
||||||
|
22. ☐ **Voice filters on the mic channel (2026-08-13, queued — KISS, always on)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Always-on — no UI knobs; the mic stays the creator's single audio control
|
||||||
|
|
||||||
### The Minimal Source Set (design decision — do not expand casually)
|
### The Minimal Source Set (design decision — do not expand casually)
|
||||||
|
|
||||||
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
|
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
|
||||||
@@ -102,17 +139,17 @@ Deliberately NOT supported: game capture, browser source, media playlist, VLC, c
|
|||||||
|
|
||||||
### Source memory model (design decision)
|
### Source memory model (design decision)
|
||||||
|
|
||||||
- A scene has resources. Resources can be shared across scenes.
|
1. A scene has resources. Resources can be shared across scenes.
|
||||||
- A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
2. A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
||||||
scenes = one loaded bitmap).
|
scenes = one loaded bitmap).
|
||||||
- Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
|
3. Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
|
||||||
entry dictating that scene's use — placement (X/Y/Width/Height), opacity, z-order, enabled,
|
entry dictating that scene's use — placement (X/Y/Width/Height), opacity, z-order, enabled,
|
||||||
scale mode, crop.
|
scale mode, crop.
|
||||||
- Usages are named `{resourceName}.{sceneName}` — whatever the user named the resource, dot, the
|
4. Usages are named `{resourceName}.{sceneName}` — whatever the user named the resource, dot, the
|
||||||
scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
|
scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
|
||||||
- A webcam in two scenes = one capture session, two catalog entries.
|
5. A webcam in two scenes = one capture session, two catalog entries.
|
||||||
- Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
6. Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
||||||
- The resource (not a per-scene node) owns everything `IDisposable`.
|
7. The resource (not a per-scene node) owns everything `IDisposable`.
|
||||||
|
|
||||||
### Scene transitions (design decision)
|
### Scene transitions (design decision)
|
||||||
|
|
||||||
@@ -131,17 +168,28 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
### Requirements:
|
### Requirements:
|
||||||
|
|
||||||
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
||||||
2. **Webcam** — MediaCapture (WinRT) with device enumeration
|
2. **Webcam** — MediaCapture (WinRT SDK projection) with device enumeration — ✅ **milestone 1 done**:
|
||||||
|
1. TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app)
|
||||||
|
2. `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`)
|
||||||
|
3. `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure
|
||||||
|
4. `CameraPickerDialog` (mirror of `ReuseImageDialog`) — "Searching for cameras…" / list / "No cameras found" states
|
||||||
|
5. One webcam app-wide: Add → Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load
|
||||||
|
6. Default placement 16:9 **480×270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources
|
||||||
|
7. **Clip shapes: Traditional + Round** (phone view dropped — the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip
|
||||||
|
8. **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) — not in this build
|
||||||
3. **Background / Image / Text** — static sources positioned/scaled/opacity
|
3. **Background / Image / Text** — static sources positioned/scaled/opacity
|
||||||
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
|
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
|
||||||
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
|
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
|
||||||
back-to-front), preview rendered via D3DImage or MediaElement
|
back-to-front), preview rendered via D3DImage or MediaElement
|
||||||
6. **Drag/drop placement & reorder** — intuitive, visual (per design principle):
|
6. **Branding flash** — the topmost full-frame "made with ytLlive!" layer at ~25% opacity, ~1s on /
|
||||||
- **Preview:** click-drag a source in the center panel to reposition it; resize via handles
|
300s off (see Monetization in `ai.md`), gated on `BrandFlashEnabled` + live/recording. Lives in the
|
||||||
- **Scenes list:** drag rows to reorder scenes
|
preview compositor now (`BrandFlashLayer` in `MainWindow.xaml` CanvasGrid, driven by
|
||||||
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
`BrandFlashActive`/`BrandFlashTimer` in `MainViewModel`); the encoder output renders the same layer,
|
||||||
|
and v0.2 local recordings carry it too
|
||||||
### Status: 🔶 In progress — scenes/sources UI built (add/reorder/rename, image + background overlays with move/resize/opacity/reuse); real capture/encoding pending
|
7. **Drag/drop placement & reorder** — intuitive, visual (per design principle):
|
||||||
|
1. **Preview:** click-drag a source in the center panel to reposition it; resize via handles
|
||||||
|
2. **Scenes list:** drag rows to reorder scenes
|
||||||
|
3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,17 +197,38 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
|
|
||||||
**Goal:** Push encoded video to YouTube's RTMP ingest.
|
**Goal:** Push encoded video to YouTube's RTMP ingest.
|
||||||
|
|
||||||
|
### Status: 🔶 In progress
|
||||||
|
|
||||||
|
1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10)
|
||||||
|
2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
|
||||||
|
3. ✅ **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
|
||||||
|
4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
|
||||||
|
5. ✅ **Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
|
||||||
|
6. ✅ **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
|
||||||
|
7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable)
|
||||||
|
|
||||||
|
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
|
||||||
|
without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode**
|
||||||
|
→ **vertical-tier crop/scale** → **RTMP push** → **health stats** into the bottom bar. Nothing can encode
|
||||||
|
until a frame source exists, so the compositor is ship step 1. The pipeline is
|
||||||
|
`CameraManager + ScreenCaptureManager → compositor resolver → compositor → encoder → RTMP`.
|
||||||
|
|
||||||
### Requirements:
|
### Requirements:
|
||||||
|
|
||||||
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only
|
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
|
||||||
2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL
|
2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push + reconnect. **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
|
||||||
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
||||||
- Phone 480p @ 2.5 Mbps
|
1. 720p30 @ 6 Mbps
|
||||||
- 720p60 @ 6 Mbps
|
2. 720p60 @ 6 Mbps
|
||||||
- 1080p30 @ 8 Mbps
|
3. 1080p30 @ 8 Mbps
|
||||||
- **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
4. **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
||||||
machine never notices; upload headroom stays comfortable)
|
machine never notices; upload headroom stays comfortable)
|
||||||
- 1440p/4K = the paid unlock tiers (monetization), not the standard offering
|
5. Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier)
|
||||||
|
The composition master is always 1920×1080; a tier is an output rect + target resolution
|
||||||
|
(see `ai.md` "Resolution tiers"). Vertical output = the centered 607×1080 crop of the master
|
||||||
|
scaled to 1080×1920 (semi-crop preview is already implemented; the encoder applies the same rect).
|
||||||
|
1080p60 is the ceiling by design — "if you want 1440 or 4K or 8K → OBS is your solution"; the app
|
||||||
|
targets the most mainstream creator, not power users.
|
||||||
Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
|
Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
|
||||||
Quality is greyed out while live because the declared resolution can't change mid-stream — but
|
Quality is greyed out while live because the declared resolution can't change mid-stream — but
|
||||||
with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
|
with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
|
||||||
@@ -168,8 +237,292 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
4. **Stream key management** — reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
|
4. **Stream key management** — reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
|
||||||
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
|
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
|
||||||
6. **One-click go live** — defaults that work out of the box
|
6. **One-click go live** — defaults that work out of the box
|
||||||
|
7. **Audio capture (feeds the meter — this task ships the wiring)** — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 — the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules.
|
||||||
|
8. **Private-only go live until v1 (reputation guard, decided 2026-08-10)** — until the v1 release, go-live is **locked to private streams only** so a software error can never publish something public/unlisted that damages the creator's reputation. RTMP push itself has no privacy — privacy lives on the YouTube **live broadcast object**, which this app already controls via its OAuth API calls. So the lock is purely API-side: the Go Live flow always creates/updates the broadcast with `privacyStatus = "private"` and a guard **refuses** to set anything else (same spirit as the Live-only backdrop policy). The UI shows a clear "PRIVATE" badge next to the stream state so the creator always knows who can see them. Enforcement must be verifiable in the auth-service tests (fake the broadcast-insert/update call, assert `privacyStatus` is forced to private).
|
||||||
|
9. **v1 release gate: bundle the full license texts (decided 2026-08-10)** — `THIRD-PARTY-NOTICES.txt` currently links the canonical license texts rather than embedding them. At the **v1 (GA) release**, the full texts of every license it names (LGPL v2.1+, BSD-2-Clause, MIT, Apache-2.0) MUST be bundled alongside it (shipped in the app output, e.g. a `licenses/` folder next to the notices file, still reachable from the About screen). This is a **release blocker for v1, not a task to queue early** — do it in the release pass. The repo should treat this like the private-only go-live gate: a checkbox that cannot silently lapse.
|
||||||
|
|
||||||
### Status: Not started
|
#### Ship step 1 — Scene compositor (the frame source)
|
||||||
|
|
||||||
|
**Goal:** a pure-CPU software compositor producing the encoder's master frame (BGRA8, the `VideoFrame`
|
||||||
|
seam) from the scene model. The preview stays XAML (the editing view); the compositor is the **output
|
||||||
|
view** — WPF's `RenderTargetBitmap` can't be used (software-rendered + captures chrome). Two renderers
|
||||||
|
must agree, so the XAML (`MainWindow.xaml` CanvasGrid + element DataTemplate) is the contract.
|
||||||
|
|
||||||
|
**Decisions (locked 2026-08-10):** **Path A CPU blitter** — GPU effort belongs to NVENC (the encoder),
|
||||||
|
not composition; with an FFmpeg subprocess the master crosses a CPU readback to the pipe every frame
|
||||||
|
anyway, so GPU compositing buys ~nothing at this layer count (2-3 live layers; static layers
|
||||||
|
pre-composite once). A D3D11 compositor can replace this one later **behind the same seam** (the CPU
|
||||||
|
master buffer stays the contract). **Render the output rect directly**: compositor is constructed with
|
||||||
|
`CompositorOptions {SourceRectX/Y/W/H, OutputWidth, OutputHeight}`; 16:9 tiers = full 1920×1080 1:1;
|
||||||
|
vertical (9:16) = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920. Reuses
|
||||||
|
`MainViewModel.OutputRectX/Y/W/H` (note `(1920−607)/2 = 656.5` → align to integer pixels for output).
|
||||||
|
|
||||||
|
**Render spec (back → front, mirror the XAML exactly):**
|
||||||
|
1. Backdrop — the Live scene's `IsBackdrop` Source (`CaptureKey` → live frame), `UniformToFill`
|
||||||
|
full-frame (XAML's separate `BackdropImage` layer; the backdrop *element* renders nothing — its
|
||||||
|
DataTemplate Image is Collapsed for DisplayCapture).
|
||||||
|
2. Background — the scene's `Background` Source, `UniformToFill` full-frame (the `ActiveBackgroundImage`
|
||||||
|
layer, not per-element).
|
||||||
|
3. Elements in `Scene.Elements` order (back→front), skip `IsVisible=false`. What actually renders:
|
||||||
|
1. `Source` Type `Image` → static asset, `UniformToFill` cover-crop into (X, Y, W, H)
|
||||||
|
2. `WebcamSceneConfig` → latest frame by `DeviceId`: Traditional = `UniformToFill` rect; Round = circle
|
||||||
|
diameter `min(W,H)` (alpha 0 outside — true circle, not oval); mirror = horizontal flip around
|
||||||
|
element center (`MirrorScale`); opacity = per-pixel multiply (content + border); border = stroked
|
||||||
|
rect / centered circle at `RoundBorderSize`, width `BorderWidth`, alpha `BorderOpacity`
|
||||||
|
3. `Background` / `IsBackdrop` / `TextOverlay` are NOT per-element (layers above; Text not shipped)
|
||||||
|
4. Branding flash — pre-rendered full-frame "made with ytLlive!" at 25% alpha when live +
|
||||||
|
`BrandFlashEnabled` + timer active. Passed in as a `VideoFrame?` (compositor core stays pure byte-math,
|
||||||
|
no WPF; likely a bundled asset rather than runtime text rendering).
|
||||||
|
5. NOT in output (preview chrome only): SelectionOverlay, DimRects, output-rect outline, badge, placeholder.
|
||||||
|
|
||||||
|
**New files (all in `Services/Compositor/`):**
|
||||||
|
1. `SceneCompositor.cs` — `Render(Scene, frameFor: Func<SceneElement, VideoFrame?>, flashFrame:
|
||||||
|
VideoFrame?, CompositorOptions) → VideoFrame` (output-sized). The caller's `frameFor` resolver maps
|
||||||
|
each element to its frame (webcam → DeviceId, image → AssetId via `StaticPixelCache`, backdrop →
|
||||||
|
CaptureKey) — the compositor stays pure/hermetic/no WPF.
|
||||||
|
2. `CompositorOptions.cs` — source-rect + output W×H.
|
||||||
|
3. `StretchMath.cs` — `UniformToFill` cover-crop, ellipse mask, bilinear scale (pure, unit-tested).
|
||||||
|
4. `StaticPixelCache.cs` — asset `byte[]` → cached BGRA `VideoFrame` (WPF `BitmapDecoder` + `CopyPixels`,
|
||||||
|
decode once per content hash).
|
||||||
|
|
||||||
|
**Test plan (Good Dog Rule — ONE integration test):** `SceneCompositorTests` — a scene with backdrop
|
||||||
|
(solid red fake frame) + round webcam (solid green) + image (solid blue) → render 16:9 master → assert
|
||||||
|
per-layer probe pixels (corner = backdrop color, element center = webcam color, outside the round clip =
|
||||||
|
backdrop color, mirrored element swaps left/right); a vertical-tier variant asserts 1080×1920 output +
|
||||||
|
crop fidelity. Focused unit tests on `StretchMath`. Tests push frames directly — no capture managers
|
||||||
|
involved (they wire in a later step).
|
||||||
|
|
||||||
|
**Same-PR housekeeping:** fix the stale comment `MainViewModel.cs:324` ("shown under the meter on line 2"
|
||||||
|
→ "shown left-justified INSIDE the meter bar" — `ai.md` is the authority); this task's requirements now
|
||||||
|
include the explicit audio-capture/meter wiring (#7 above).
|
||||||
|
|
||||||
|
**Out of scope (later ship steps):** FFmpeg locator + license posture (covered in requirements 1-2),
|
||||||
|
encoder + RTMP push, WASAPI audio capture (loopback + mic) feeding `AudioLevel`, wiring
|
||||||
|
`CameraManager`/`ScreenCaptureManager` into the frame pipeline, brand-flash timer wiring, health stats
|
||||||
|
(bitrate/FPS/dropped).
|
||||||
|
|
||||||
|
**Built (2026-08-10):** all four files shipped in `Services/Compositor/`, `SceneElement.TryGetBorderColor`
|
||||||
|
made public (shared hex parse with the compositor — no duplicated color parsing), the stale
|
||||||
|
`MainViewModel.cs:324` comment corrected, and the pre-existing CS1998 in `YouTubeAuthServiceTests`
|
||||||
|
cleaned up — build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master
|
||||||
|
pixels, vertical tier, flash) + 4 `StretchMath` units — **72 passing**.
|
||||||
|
|
||||||
|
#### Ship step 2 — FFmpeg locator (the encoder's binary)
|
||||||
|
|
||||||
|
**Goal:** resolve a usable `ffmpeg.exe` on demand (the encoder's one external dependency), never shipping
|
||||||
|
a binary in the repo. Returns an absolute path; downloads only when neither PATH nor the local cache
|
||||||
|
provides one.
|
||||||
|
|
||||||
|
**Decisions (locked 2026-08-10):**
|
||||||
|
1. **BtbN LGPL-shared win64 build** — not gyan.dev (gyan's "essentials" is GPLv3 and ships libx264, which
|
||||||
|
violates requirement 1's license posture) and **not the static lgpl build**: LGPLv2.1 §6 wants
|
||||||
|
relinkable object files for static linking, but the **shared** (dynamic-DLL) variant sidesteps that —
|
||||||
|
compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and
|
||||||
|
`ai.md` → Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal
|
||||||
|
H.264 software fallback) and native AAC — exactly the requirement-1 encoder profile.
|
||||||
|
2. **Pinned URL** — `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip`
|
||||||
|
(~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention
|
||||||
|
keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention
|
||||||
|
expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). Once
|
||||||
|
cached, the URL is never touched again. The pin is a single `const`, bumpable in one place — and must
|
||||||
|
always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing).
|
||||||
|
3. **Check-then-pull order** — (1) PATH probe (the user's own install wins), (2) cached
|
||||||
|
`%APPDATA%\ytLlive\tools\ffmpeg.exe`, (3) download + extract. Extract `ffmpeg.exe` **plus the
|
||||||
|
`libav*.dll` family** (the shared build's bin/ folder; Windows resolves the DLLs from the exe's own
|
||||||
|
directory) into a staging dir then move into place — a crash never leaves a corrupt or partial cache.
|
||||||
|
4. **Seam** — `IFfmpegLocator.LocateAsync(CancellationToken)`: search dirs, tools dir, and the downloader
|
||||||
|
(`Func<string, CancellationToken, Task<byte[]>>`) are constructor-injected with production defaults, so
|
||||||
|
tests fake the network (feeding a real in-memory zip) and never touch disk outside a temp dir.
|
||||||
|
|
||||||
|
**New files (all in `Services/Encoder/`):**
|
||||||
|
1. `IFfmpegLocator.cs` — the seam.
|
||||||
|
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
|
||||||
|
3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
|
||||||
|
output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
|
||||||
|
viewer is GONE — the notices are reachable in-app via the logo → About overlay instead.)*
|
||||||
|
|
||||||
|
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
|
||||||
|
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
|
||||||
|
downloading, cache hit skips the network, cold cache downloads → extracts → `ffmpeg.exe` lands in the
|
||||||
|
tools dir, and a second call serves the cache (downloader invoked exactly once). Focused unit tests:
|
||||||
|
**shared-build DLLs extract alongside the exe**, empty zip throws, missing entry throws, empty download
|
||||||
|
throws, downloader failure propagates, zero-byte cache is refreshed.
|
||||||
|
|
||||||
|
**Same-PR housekeeping:** requirement 2's stale binary facts corrected in this plan (~30 MB → ~75 MB zip;
|
||||||
|
"gyan.dev/BtB N" → BtbN LGPL-shared only, with the why); the "never do" licensing guardrails recorded in
|
||||||
|
`ai.md` so the reasoning survives.
|
||||||
|
|
||||||
|
**Out of scope (later ship steps):** the FFmpeg subprocess encoder (frames in via stdin, stderr health
|
||||||
|
parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health stats.
|
||||||
|
|
||||||
|
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
|
||||||
|
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
|
||||||
|
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
|
||||||
|
guardrails are recorded in `ai.md` — build **0 warnings**.
|
||||||
|
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
|
||||||
|
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
|
||||||
|
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
|
||||||
|
|
||||||
|
#### Ship step 3 — Encoder + RTMP push (the FFmpeg subprocess)
|
||||||
|
|
||||||
|
**Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP
|
||||||
|
ingestion URL — one battle-tested subprocess doing encode + mux + push + reconnect, the app feeding
|
||||||
|
frames via stdin and parsing stderr for health (req 2).
|
||||||
|
|
||||||
|
**Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the
|
||||||
|
app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS
|
||||||
|
-i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI
|
||||||
|
capture step replaces this input), `-c:v <encoder> -b:v K -maxrate K -bufsize 2K` + **`-g fps×4`
|
||||||
|
`-keyint_min fps×4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≤4s / closed-GOP /
|
||||||
|
H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv <rtmpUrl>`. Encoder choice is **probed from the
|
||||||
|
binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC → QSV → AMF, then OpenH264
|
||||||
|
software fallback — **never libx264** (GPL; see `ai.md` → Licensing). The seam (`IFfmpegEncoder` +
|
||||||
|
`IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic — tests fake the
|
||||||
|
whole subprocess (probe + encoder), no real binary.
|
||||||
|
|
||||||
|
**Behavior:** `StartAsync` (locate → probe → spawn → stderr loop), `SubmitFrameAsync` (serialized BGRA
|
||||||
|
stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` — bitrate/FPS/duration/dropped-from-frame-
|
||||||
|
count), `StopAsync` (stdin EOF → ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed`
|
||||||
|
on a non-zero unexpected exit.
|
||||||
|
|
||||||
|
**Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
|
||||||
|
`FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in
|
||||||
|
`Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it).
|
||||||
|
Tests: `FfmpegEncoderTests` integration (probe → spawn with NVENC preferred → frames into stdin →
|
||||||
|
progress parsed → graceful stop, no kill) + units (args compliance/GOP, progress parser, picker
|
||||||
|
preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) — **122 passing**.
|
||||||
|
|
||||||
|
#### Ship step 4 — WASAPI audio capture (the meter comes alive)
|
||||||
|
|
||||||
|
**Goal:** capture desktop/game audio (loopback) and the picked mic, feed the mic level into `AudioLevel`
|
||||||
|
so the realtime meter reads something other than 0, run capture **only while live** (req 7).
|
||||||
|
|
||||||
|
**Decisions (locked):**
|
||||||
|
1. **NAudio `NAudio.Wasapi` 2.2.1** — the wasapi feature package (not the `NAudio` meta-package): it
|
||||||
|
carries the capture types (`WasapiCapture`/`WasapiLoopbackCapture` + the MMDevice enumeration) with
|
||||||
|
`NAudio.Core`/`NAudio.Asio` pulled in transitively. MIT — recorded in `THIRD-PARTY-NOTICES.txt` (item 9).
|
||||||
|
2. **`IAudioSource` seam** (`Start`/`Stop`/`SampleReady`/`Failed`, IDisposable) — the app consumes the
|
||||||
|
seam; the two WASAPI implementations wrap NAudio; tests inject hermetic fakes (no real audio devices,
|
||||||
|
no timers). Loopback = `WasapiLoopbackCapture` on the default render device; mic = `WasapiCapture`
|
||||||
|
with the NAudio device resolved by `FriendlyName` matching `MicSourceName` (the app only persists the
|
||||||
|
DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at each
|
||||||
|
`Start` via a name provider so a mic picked mid-session takes effect next go-live.
|
||||||
|
3. **`AudioMixer` owns both sources** — starts/stops both with go-live (`BeginGoLive` success → `Start`,
|
||||||
|
`StopStream` → `Stop`). Mic samples feed a pure `AudioLevelMeter` (RMS, exponential smoothing) and
|
||||||
|
raise `MicLevelChanged`, marshalled to the UI thread into `AudioLevel`; desktop samples are currently
|
||||||
|
dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged via `AppLog`
|
||||||
|
(mic failure also zeroes the meter); loopback failure doesn't kill the mic.
|
||||||
|
4. **Byte→float** — pure `WaveToFloat.Convert` handles the WASAPI mix formats: IEEE float 32-bit (direct)
|
||||||
|
and PCM 16-bit (normalized to -1..1), including `WaveFormatExtensible` with the IEEE-float subformat
|
||||||
|
GUID. Trailing partial samples are ignored.
|
||||||
|
|
||||||
|
**Built (2026-08-12):** `Services/Audio/` ships `IAudioSource` + `AudioSample`, `WasapiLoopbackAudioSource`,
|
||||||
|
`WasapiMicAudioSource`, `AudioMixer`, `AudioLevelMeter`, `WaveToFloat`; `MainViewModel` constructs the
|
||||||
|
mixer (mic source fed `() => MicSourceName`), starts it on go-live and stops it on end-stream, and maps
|
||||||
|
`MicLevelChanged` → `AudioLevel`. A pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during this
|
||||||
|
step's rebuild and was fixed (`process!`) — build **0 warnings**. Tests: `AudioMixerTests` (mixer
|
||||||
|
lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFloat` float/PCM16/
|
||||||
|
extensible/truncation) — **139 passing**.
|
||||||
|
|
||||||
|
**Deferred (later ship steps):** wiring the desktop-capture samples into the encoder's AAC mix (replaces
|
||||||
|
the `-f lavfi -i anullsrc` placeholder; the encoder construction itself shipped in ship step 5).
|
||||||
|
*(The "capture while not live" + "audio UI beyond the mic controls" deferrals were SHIPPED on the
|
||||||
|
2026-08-13 game audio bar branch — capture is now always-on for preview and the game bar is the second
|
||||||
|
audio UI. The mixer's short-circuit meter fix + `Started`/`RestartMic` seams live in the same branch.)*
|
||||||
|
|
||||||
|
#### Ship step 5 — Frame-pipeline wiring (the encoder gets a frame source)
|
||||||
|
|
||||||
|
**Goal:** the chain `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven while
|
||||||
|
live by a paced frame pump: snapshot the active scene → resolve each element to its latest frame →
|
||||||
|
composite into the tier's output frame → pace into the encoder's stdin at the tier's FPS.
|
||||||
|
|
||||||
|
**Decisions (locked via user Q&A, 2026-08-12):**
|
||||||
|
1. **Video pipeline first** — the `-f lavfi -i anullsrc` silent track stays; mixing the loopback/mic
|
||||||
|
WASAPI samples into the encoder's AAC track is its own later step.
|
||||||
|
2. **RTMP URL via a provider seam** — `MainViewModel._rtmpUrlProvider` is a `Func<string?>` returning
|
||||||
|
null today (the reusable stream's ingest URL lands with TASK 5); when it yields null the pump logs
|
||||||
|
and skips the encoder entirely, so go-live runs the existing visual flow without pushing.
|
||||||
|
|
||||||
|
**Design:**
|
||||||
|
1. `Services/Encoder/FramePump.cs` — the frame producer. All collaborators constructor-injected seams
|
||||||
|
(`Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
|
||||||
|
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, injectable pacing delay) so it
|
||||||
|
stays free of WPF and of the capture managers and is hermetic in tests. `StartAsync` never throws
|
||||||
|
(failures log + surface via `Failed` — the VM fires-and-forgets from the sync command handler);
|
||||||
|
loop = snapshot → render → `SubmitFrameAsync`, paced at `1/options.Fps` (default `Task.Delay`; tests
|
||||||
|
inject `Task.Yield`). `StopAsync` stops the encoder (closes stdin) BEFORE awaiting the loop — closing
|
||||||
|
stdin unblocks a write stuck on pipe backpressure, so stop can't deadlock on the pump. `ProcessFailed`
|
||||||
|
self-stops the pump. `HealthUpdated` forwards the encoder's stats (ship step 6 binds the bottom bar).
|
||||||
|
2. `ScreenCaptureManager.GetLatestFrame(key)` — mirrors `CameraManager.GetLatestFrame(deviceId)`; the
|
||||||
|
backdrop's live frame for the compositor.
|
||||||
|
3. `MainViewModel` — owns the resolver (`WebcamSceneConfig` → `GetLatestFrame(WebcamId)`;
|
||||||
|
`Source.IsLiveCapture` → `GetLatestFrame(CaptureKey)`; image/background → `StaticPixelCache.Get(AssetId)`),
|
||||||
|
builds `CompositorOptions` from the tier + `OutputRect*` (doubles rounded to ints — the vertical
|
||||||
|
607.5 half-pixel crop rounds to a perfectly-centered 608), builds `EncoderOptions` from the tier when
|
||||||
|
the URL provider returns one, constructs the real `FfmpegEncoder(new FfmpegLocator())`, starts the
|
||||||
|
pump on go-live, stops it on end-stream, disposes in `Shutdown`, and flips `StreamStatus.Error` when
|
||||||
|
the pump fails while live (minimal — detailed health surfacing is ship step 6).
|
||||||
|
|
||||||
|
**Test plan (Good Dog Rule — ONE integration test):** `FramePumpTests.Start_CompositesScene_FeedsEncoder_StopsCleanly`
|
||||||
|
drives the full lifecycle against fakes — real `SceneCompositor` + real `FramePump`, fake `IFfmpegEncoder`
|
||||||
|
— asserting the composited red backdrop frame actually reaches the encoder at the tier size and that stop
|
||||||
|
tears everything down. Units: no-URL start skips the encoder, re-entrant start/stop no-ops, encoder
|
||||||
|
start-failure raises `Failed` + disposes, `ProcessFailed` self-stops the pump, `HealthUpdated` forwards.
|
||||||
|
`ScreenCaptureManagerTests.GetLatestFrame_ReturnsLatestPump_UntilReleased` pins the new accessor.
|
||||||
|
|
||||||
|
**Out of scope (later ship steps):** the loopback/mic → AAC mix (replaces `anullsrc`), health stats in the
|
||||||
|
bottom bar (ship step 6), scene-switching transitions, and any flash-frame wiring.
|
||||||
|
|
||||||
|
**Built (2026-08-12):** `FramePump` shipped in `Services/Encoder/`, `ScreenCaptureManager.GetLatestFrame`
|
||||||
|
added, `MainViewModel` wired end-to-end (resolver + both option builders + pump lifecycle), `FramePumpTests`
|
||||||
|
(7) + `GetLatestFrame` test (1) added — build **0 warnings**, **147 tests passing**. Known consideration:
|
||||||
|
the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation
|
||||||
|
exception is contained (logged + `Failed` + pump stops) rather than crashing.
|
||||||
|
|
||||||
|
#### Ship step 5.5 — Social bar bug fixes + the bar on the live output (2026-08-13)
|
||||||
|
|
||||||
|
**Bug 1 — bar wouldn't reliably change position (root cause found, then simplified):** the original
|
||||||
|
drag set `Canvas.SetTop(bar, …)` with a local value, which permanently overrides the
|
||||||
|
`Canvas.Top="{Binding SocialBarTop}"` binding — the release-time `SetSocialBarPosition` →
|
||||||
|
`PropertyChanged(SocialBarTop)` could never beat it. First fix added direction-snapping during the drag
|
||||||
|
(`SocialBarSnap.Decide`, ±6px deadzone) + `bar.ClearValue(Canvas.TopProperty)` on release — but that
|
||||||
|
still misbehaved for shaky hands (jitter around the deadzone: it snapped up reliably, then refused to
|
||||||
|
come back down and snapped back to top). **Superseded by a click-toggle (KISS, user decision):** clicking
|
||||||
|
the bar in the preview flips it top ⇄ bottom (`MainViewModel.ToggleSocialBarPosition` → the existing
|
||||||
|
`SetSocialBarPosition`), the bar rides `{Binding SocialBarTop}` alone (no local values, no deadzone, no
|
||||||
|
jitter sensitivity), and `SocialBarSnap` was removed. The `ClearValue` lesson stands: never set a local
|
||||||
|
value on a property the binding owns.
|
||||||
|
|
||||||
|
**Bug 2 — Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row
|
||||||
|
`@gramps@llamachile.tube` had `Software = NULL` — nodeinfo was only ever resolved against the identity domain
|
||||||
|
(`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`.
|
||||||
|
Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. → social. →
|
||||||
|
… `FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a
|
||||||
|
~15s linked-CTS budget, with an optional `Action<string>` log; `MainViewModel` **heals** any fediverse entry
|
||||||
|
missing a software name on layout load (`HealFediverseSoftwareAsync` — static, testable; instance wrapper
|
||||||
|
runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now
|
||||||
|
**settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user
|
||||||
|
later re-saves the entry with the icon fixed, the name persists with it.
|
||||||
|
|
||||||
|
**Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just
|
||||||
|
the preview — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha
|
||||||
|
BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap`
|
||||||
|
(WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional
|
||||||
|
`socialBarFrame` + `socialBarTop` (master space) and blits it **last — above the branding flash** (the old
|
||||||
|
`BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam
|
||||||
|
(`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or
|
||||||
|
`SourceRectHeight − bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on
|
||||||
|
load/save/notify) and feeds the seam.
|
||||||
|
|
||||||
|
**Tests (+6 → 153 passing, 0 warnings):** settable `FediverseSoftware` updates `LogoData`,
|
||||||
|
subdomain-probe unit + null-when-silent unit, the branch's **one integration test**
|
||||||
|
`Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` (temp-DB roundtrip: NULL software → healed via
|
||||||
|
the validator → persisted), compositor bar overlay (top/bottom + above-flash), `FramePump` bar pass-through
|
||||||
|
(Top then flipped to Bottom mid-run — the seam is re-read each frame), and both `ISocialValidator` fakes
|
||||||
|
(`FakeValidator`/`BlockingValidator`) gained `ResolveFediverseSoftwareAsync`. The drag-snap units
|
||||||
|
(`SocialBarSnap`) were removed with the click-toggle supersession.
|
||||||
|
|
||||||
|
**Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,6 +530,14 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
|
|
||||||
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
|
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
|
||||||
|
|
||||||
|
### Status: ⏳ Not started
|
||||||
|
|
||||||
|
1. ☐ Broadcast creation — title/description/privacy/scheduledStartTime via API, with the v3 flags above
|
||||||
|
2. ☐ Reusable stream — create once, cache + reuse; bind to broadcast
|
||||||
|
3. ☐ Health monitoring — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error
|
||||||
|
4. ☐ Live chat — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
|
||||||
|
5. ☐ Error handling — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
||||||
|
|
||||||
### Design decisions (v3)
|
### Design decisions (v3)
|
||||||
|
|
||||||
1. **One-click go-live** — `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts → YouTube brings it live by itself.
|
1. **One-click go-live** — `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts → YouTube brings it live by itself.
|
||||||
@@ -195,14 +556,21 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
4. **Live chat** — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
|
4. **Live chat** — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
|
||||||
5. **Error handling** — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
5. **Error handling** — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
||||||
|
|
||||||
### Status: Not started
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TASK 6 — Layout Persistence (SQLite)
|
## TASK 6 — Layout Persistence (SQLite)
|
||||||
|
|
||||||
**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
|
**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
|
||||||
|
|
||||||
|
### Status: ✅ Done
|
||||||
|
|
||||||
|
1. ✅ SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v8**)
|
||||||
|
2. ✅ Assets live in the DB (BLOB keyed by SHA-256 content hash), never file paths — deleting the original file never breaks a scene
|
||||||
|
3. ✅ File-model save/open — the active layout file is tracked (default is the AppData DB); **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active
|
||||||
|
4. ✅ Auto-save (invisible) — ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close
|
||||||
|
5. ✅ Startup — load the active file; seed the five canonical scenes only when the DB is empty; (+) re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected
|
||||||
|
6. ✅ Schema v1 → v8 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle — column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8→v9, no version bump — `user_version` stays 8); `WindowHandle` stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned
|
||||||
|
|
||||||
### Design decisions
|
### Design decisions
|
||||||
|
|
||||||
1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
|
1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
|
||||||
@@ -215,19 +583,49 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
|
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
|
||||||
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
|
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
|
||||||
add/remove/reorder, and any source transform change; flush on window close.
|
add/remove/reorder, and any source transform change; flush on window close.
|
||||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
|
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder), `Asset` (Id, Hash, Data,
|
||||||
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
||||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, SortOrder). `WindowHandle` stays in-memory
|
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id,
|
||||||
(per-session). Save = transactional rewrite; orphaned assets pruned.
|
BarPosition, BarJustify — back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id,
|
||||||
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
|
SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) — `user_version` **8** (v1 → v2 =
|
||||||
only when the DB is empty.
|
`ALTER TABLE` adds the two webcam columns; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`;
|
||||||
|
v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the round-to-rect restore; v5 = `Source.IsBackdrop`
|
||||||
|
+ `Source.CaptureKey` for the live-capture backdrop; v6 = `Scene.HasBackdrop` — the backdrop is
|
||||||
|
**Live-only by policy** (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
|
||||||
|
sources; `EnforceBackdropPolicy` re-normalizes every load); v7 = `Scene.HasSocialBar` (per-scene toggle
|
||||||
|
dropped — column back-compat, unread); v8 = `Socials.BarEnabled`). The `SocialEntry.Software`
|
||||||
|
fediverse-software column is a column-presence migration (commented v8→v9, no version bump).
|
||||||
|
`WindowHandle` stays in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||||
|
6. **Startup** — load the active file; seed the five canonical scenes
|
||||||
|
(Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+)
|
||||||
|
button re-adds a missing canonical scene and is hidden once all five are present;
|
||||||
|
adding beyond the five is rejected — work with less, never more.
|
||||||
|
|
||||||
### Status: ✅ Implemented
|
---
|
||||||
|
|
||||||
|
## TASK 7 — UI polish batch (scenes/sources rows, dedup naming, social bar)
|
||||||
|
|
||||||
|
**Goal:** clean up the two side lists and the social bar per gramps's review.
|
||||||
|
|
||||||
|
### Status: ✅ Done
|
||||||
|
|
||||||
|
1. ✅ Scene rows are pure selection rows — the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows)
|
||||||
|
2. ✅ Source rows gained the trio — edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash
|
||||||
|
3. ✅ Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) — next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it)
|
||||||
|
4. ✅ Social bar renders the full validated handle — `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off)
|
||||||
|
5. ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
|
||||||
|
6. ✅ Focus-loss capture lag documented as a known OS limit in `ai.md` — deferred by user decision (no code change)
|
||||||
|
|
||||||
|
### Design decisions
|
||||||
|
|
||||||
|
1. **Next free number from names, not type counts** — the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names.
|
||||||
|
2. **One WPF App per AppDomain** — the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backlog (future versions)
|
## Backlog (future versions)
|
||||||
|
|
||||||
- v0.2 — Recording to local file
|
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
|
||||||
- v0.3 — Stream scheduling
|
2. v0.3 — Stream scheduling
|
||||||
- v0.4 — Multi-destination restreaming
|
3. v0.4 — Multi-destination restreaming
|
||||||
|
4. v0.5 — Stream clipping
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
ytLlive — Third-Party Notices
|
||||||
|
================================
|
||||||
|
|
||||||
|
ytLlive is a paid, closed-source product. This file lists every third-party
|
||||||
|
component the product distributes or downloads, its license, and where to get
|
||||||
|
its source, so the LGPL/BSD/MIT obligations are met. Distribution obligations
|
||||||
|
are NOT optional: they attach because this product ships or automates the
|
||||||
|
download of these components.
|
||||||
|
|
||||||
|
If this file changes, update it here AND in the app's About screen (the About
|
||||||
|
overlay's licensing panel surfaces this file in-app). See TASKS.md (TASK 4) and
|
||||||
|
ai.md ("Licensing — do not violate") for the guardrails — the "never do" list is
|
||||||
|
there on purpose.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
1. FFmpeg (dynamic libraries + ffmpeg.exe, LGPL v2.1+)
|
||||||
|
Copyright (c) 2000-2026 the FFmpeg developers
|
||||||
|
License: GNU Lesser General Public License v2.1 or later
|
||||||
|
Home: https://ffmpeg.org/
|
||||||
|
Source: https://git.ffmpeg.org/ffmpeg.git
|
||||||
|
Used as: the streaming encoder/RTMP subprocess. This product distributes the
|
||||||
|
UNMODIFIED binaries; it never links FFmpeg into its own code (it is
|
||||||
|
launched as a separate process fed raw frames over a pipe).
|
||||||
|
Why LGPL (not GPL): a GPL build (e.g. gyan.dev, or BtbN's "gpl" variant)
|
||||||
|
would contaminate this proprietary product. Do NOT use one.
|
||||||
|
Why "shared" (not static): LGPL v2.1 §6 requires "relinkable" materials for
|
||||||
|
statically-linked libraries. The shared build links dynamically, so
|
||||||
|
the user can replace the DLLs — compliance is this notice plus the
|
||||||
|
source offer below, with no relink material required.
|
||||||
|
Compliance supplied by this product:
|
||||||
|
- The license text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
|
||||||
|
- The corresponding source / written offer to obtain it:
|
||||||
|
FFmpeg source https://ffmpeg.org/download.html
|
||||||
|
Exact binary https://github.com/BtbN/FFmpeg-Builds
|
||||||
|
Build tag: autobuild-2026-08-09-13-03 (variant lgpl-shared)
|
||||||
|
- The binaries are unmodified and the LGPL notices therein are intact.
|
||||||
|
|
||||||
|
2. BtbN FFmpeg-Builds (the exact binary this product downloads)
|
||||||
|
License: MIT (build scripts + repository) — the produced binaries are
|
||||||
|
covered by FFmpeg's LGPL (item 1).
|
||||||
|
Home: https://github.com/BtbN/FFmpeg-Builds
|
||||||
|
|
||||||
|
3. OpenH264 (libopenh264 — the H.264 software fallback encoder)
|
||||||
|
Copyright (c) 2010-2026 Cisco Systems, Inc. (and contributors)
|
||||||
|
License: BSD 2-Clause + Cisco's H.264 patent grant
|
||||||
|
Home: https://www.openh264.org/
|
||||||
|
Note: Cisco grants the patent license for its own H.264 implementation;
|
||||||
|
it ships inside the FFmpeg build above (LGPL obligations of item 1
|
||||||
|
apply to the library; the BSD terms apply to Cisco's code).
|
||||||
|
|
||||||
|
4. SQLite (bundled via SQLitePCLRaw's e_sqlite3 native bundle)
|
||||||
|
License: public domain (no rights reserved)
|
||||||
|
Home: https://www.sqlite.org/
|
||||||
|
|
||||||
|
5. Microsoft.Data.Sqlite (.NET data provider, statically linked into this app)
|
||||||
|
Copyright (c) .NET Foundation and contributors
|
||||||
|
License: MIT
|
||||||
|
Home: https://github.com/dotnet/efcore
|
||||||
|
|
||||||
|
6. SQLitePCLRaw (raw SQLite bindings + bundles)
|
||||||
|
Copyright (c) 2012-2026 Eric Sink and contributors
|
||||||
|
License: Apache-2.0
|
||||||
|
Home: https://github.com/ericstj/SQLitePCLRaw
|
||||||
|
|
||||||
|
7. .NET runtime / WPF / Windows SDK projections, incl.
|
||||||
|
System.Security.Cryptography.ProtectedData
|
||||||
|
Copyright (c) .NET Foundation and contributors
|
||||||
|
License: MIT
|
||||||
|
Home: https://github.com/dotnet/
|
||||||
|
|
||||||
|
8. Simple Icons (bundled SVG logo path data for the social bar, e.g. the
|
||||||
|
mastodon/peertube/pixelfed fediverse logos)
|
||||||
|
Copyright (c) Simple Icons contributors (public domain dedication)
|
||||||
|
License: CC0 1.0 Universal (no rights reserved, no attribution required)
|
||||||
|
Home: https://simpleicons.org/
|
||||||
|
Source: https://github.com/simple-icons/simple-icons
|
||||||
|
Used as: path data compiled into the app's `SocialServiceIcons` so each
|
||||||
|
social entry renders the service's real logo. CC0 imposes no
|
||||||
|
obligations; it is listed here per this file's "list everything"
|
||||||
|
policy.
|
||||||
|
|
||||||
|
9. NAudio (WASAPI audio capture — loopback + mic)
|
||||||
|
Copyright (c) Mark Heath and contributors
|
||||||
|
License: MIT
|
||||||
|
Home: https://github.com/naudio/NAudio
|
||||||
|
Used as: the WASAPI loopback (desktop/game audio) and mic capture sources
|
||||||
|
behind the `IAudioSource` seam (TASK 4 ship step 4). MIT imposes
|
||||||
|
no source offer; this notice is kept per this file's policy.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MISCELLANEOUS
|
||||||
|
- The full text of every license named above is available at the linked
|
||||||
|
canonical locations. The v1 (GA) release MUST additionally bundle the full
|
||||||
|
license texts alongside this file (a `licenses/` folder beside it, still
|
||||||
|
reachable from the About screen) — TASK 4 requirement 9, a release blocker.
|
||||||
|
- No warranty is expressed or implied for any third-party component.
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
<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="Foreground" Value="#d0d0d0"/>
|
||||||
|
<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: slim and dimensional — gradient track, beveled fill, gloss-sphere
|
||||||
|
thumb. The filled side sits on a 5px pill aligned with the track; the
|
||||||
|
click target stays the full track height. -->
|
||||||
|
<LinearGradientBrush x:Key="YtSliderTrackBrush" StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#0f1b33" Offset="0"/>
|
||||||
|
<GradientStop Color="#283b60" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="YtSliderFillBrush" StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#4fe097" Offset="0"/>
|
||||||
|
<GradientStop Color="#2dbd7a" Offset="0.5"/>
|
||||||
|
<GradientStop Color="#1a9e63" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<RadialGradientBrush x:Key="YtSliderKnobBrush" Center="0.35,0.3" GradientOrigin="0.35,0.3">
|
||||||
|
<GradientStop Color="#ffffff" Offset="0"/>
|
||||||
|
<GradientStop Color="#d7f5e6" Offset="0.22"/>
|
||||||
|
<GradientStop Color="#3ed588" Offset="0.72"/>
|
||||||
|
<GradientStop Color="#137a4d" Offset="1"/>
|
||||||
|
</RadialGradientBrush>
|
||||||
|
|
||||||
|
<RadialGradientBrush x:Key="YtSliderKnobHoverBrush" Center="0.35,0.3" GradientOrigin="0.35,0.3">
|
||||||
|
<GradientStop Color="#ffffff" Offset="0"/>
|
||||||
|
<GradientStop Color="#e6faef" Offset="0.22"/>
|
||||||
|
<GradientStop Color="#57e9a1" Offset="0.72"/>
|
||||||
|
<GradientStop Color="#1b9a63" Offset="1"/>
|
||||||
|
</RadialGradientBrush>
|
||||||
|
|
||||||
|
<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="YtSliderFillButton" TargetType="RepeatButton">
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="RepeatButton">
|
||||||
|
<Grid>
|
||||||
|
<Border Height="5" VerticalAlignment="Center"
|
||||||
|
Background="{StaticResource YtSliderFillBrush}"
|
||||||
|
BorderBrush="#0f7a4e" BorderThickness="1" CornerRadius="2.5"/>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="YtSliderThumb" TargetType="Thumb">
|
||||||
|
<Setter Property="IsTabStop" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Thumb">
|
||||||
|
<Grid Width="13" Height="13">
|
||||||
|
<Ellipse x:Name="Knob" Fill="{StaticResource YtSliderKnobBrush}"
|
||||||
|
Stroke="#0b1626" StrokeThickness="1">
|
||||||
|
<Ellipse.Effect>
|
||||||
|
<DropShadowEffect BlurRadius="3" ShadowDepth="1" Opacity="0.55"/>
|
||||||
|
</Ellipse.Effect>
|
||||||
|
</Ellipse>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Knob" Property="Fill" Value="{StaticResource YtSliderKnobHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsDragging" Value="True">
|
||||||
|
<Setter TargetName="Knob" Property="Fill" Value="{StaticResource YtSliderKnobHoverBrush}"/>
|
||||||
|
</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="5" CornerRadius="2.5" VerticalAlignment="Center"
|
||||||
|
Background="{StaticResource YtSliderTrackBrush}"
|
||||||
|
BorderBrush="#0a1424" BorderThickness="1"/>
|
||||||
|
<Track x:Name="PART_Track" Height="18">
|
||||||
|
<Track.DecreaseRepeatButton>
|
||||||
|
<RepeatButton Command="{x:Static Slider.DecreaseLarge}"
|
||||||
|
Style="{StaticResource YtSliderFillButton}"/>
|
||||||
|
</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}">
|
||||||
|
<Grid IsSharedSizeScope="True">
|
||||||
|
<ItemsPresenter/>
|
||||||
|
</Grid>
|
||||||
|
</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">
|
||||||
|
<Grid x:Name="Grid" SnapsToDevicePixels="True">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition x:Name="Col0" MinWidth="17" Width="Auto"
|
||||||
|
SharedSizeGroup="MenuItemIconColumnGroup"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="14"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border x:Name="Bd" Grid.ColumnSpan="3"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="3" Margin="0,1"/>
|
||||||
|
<TextBlock x:Name="CheckGlyph" Grid.Column="0" Text="✓"
|
||||||
|
Margin="4,0,6,0" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" Visibility="Collapsed"/>
|
||||||
|
<ContentPresenter x:Name="Content" Grid.Column="1"
|
||||||
|
ContentSource="Header"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
<TextBlock x:Name="Arrow" Grid.Column="2" Text="›"
|
||||||
|
Margin="6,0,0,0" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" Visibility="Collapsed"/>
|
||||||
|
<Popup x:Name="PART_Popup" Placement="Right"
|
||||||
|
HorizontalOffset="-1" VerticalOffset="-1"
|
||||||
|
IsOpen="{TemplateBinding IsSubmenuOpen}"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
PopupAnimation="{DynamicResource {x:Static SystemParameters.MenuPopupAnimationKey}}"
|
||||||
|
Focusable="False">
|
||||||
|
<Border Background="#16213e" BorderBrush="#2a3a5e"
|
||||||
|
BorderThickness="1" CornerRadius="5" Padding="3">
|
||||||
|
<Grid IsSharedSizeScope="True">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsPresenter/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSubmenuOpen" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="CheckGlyph" Property="Visibility" Value="Visible"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Opacity" Value="0.45"/>
|
||||||
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding HasItems, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
Value="True">
|
||||||
|
<Setter TargetName="Arrow" Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</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>
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public class CameraPickerCandidate
|
||||||
|
{
|
||||||
|
public CameraDeviceInfo Device { get; }
|
||||||
|
public string Name => Device.DisplayName;
|
||||||
|
|
||||||
|
public CameraPickerCandidate(CameraDeviceInfo device)
|
||||||
|
{
|
||||||
|
Device = device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CameraPickerViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ICameraEnumerator _enumerator;
|
||||||
|
private CameraPickerCandidate? _selectedCamera;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
|
||||||
|
public ObservableCollection<CameraPickerCandidate> Cameras { get; } = new();
|
||||||
|
|
||||||
|
public bool IsLoading
|
||||||
|
{
|
||||||
|
get => _isLoading;
|
||||||
|
private set => SetProperty(ref _isLoading, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasCameras => !IsLoading && Cameras.Count > 0;
|
||||||
|
public bool NoCameras => !IsLoading && Cameras.Count == 0;
|
||||||
|
|
||||||
|
public CameraPickerCandidate? SelectedCamera
|
||||||
|
{
|
||||||
|
get => _selectedCamera;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedCamera, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand UseSelectedCommand { get; }
|
||||||
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public event Action<CameraDeviceInfo>? UseRequested;
|
||||||
|
public event Action? CancelRequested;
|
||||||
|
|
||||||
|
public CameraPickerViewModel(ICameraEnumerator enumerator)
|
||||||
|
{
|
||||||
|
_enumerator = enumerator;
|
||||||
|
UseSelectedCommand = new RelayCommand(
|
||||||
|
_ => { if (SelectedCamera != null) UseRequested?.Invoke(SelectedCamera.Device); },
|
||||||
|
_ => SelectedCamera != null);
|
||||||
|
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||||
|
_ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var devices = await _enumerator.GetCamerasAsync();
|
||||||
|
Cameras.Clear();
|
||||||
|
foreach (var device in devices)
|
||||||
|
Cameras.Add(new CameraPickerCandidate(device));
|
||||||
|
if (Cameras.Count > 0)
|
||||||
|
SelectedCamera = Cameras[0];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"CameraPickerViewModel: enumerating cameras failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
OnPropertyChanged(nameof(HasCameras));
|
||||||
|
OnPropertyChanged(nameof(NoCameras));
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
namespace ytLive.ViewModels;
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
public class GoLiveViewModel : ViewModelBase
|
public class GoLiveViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
|
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
private string _streamDescription = string.Empty;
|
private string _streamDescription = string.Empty;
|
||||||
private string _visibility = "Public";
|
private string _visibility = "Public";
|
||||||
|
private bool _isSignedIn;
|
||||||
|
private string _accountDisplayName = string.Empty;
|
||||||
|
private string _accountAvatarUrl = string.Empty;
|
||||||
|
private bool _isBusy;
|
||||||
|
|
||||||
|
public GoLiveViewModel(Func<Task<YouTubeChannel?>> signInProvider, YouTubeChannel? account)
|
||||||
|
{
|
||||||
|
_signInProvider = signInProvider;
|
||||||
|
|
||||||
|
SignInCommand = new RelayCommand(_ => _ = SignInAsync());
|
||||||
|
StartCommand = new RelayCommand(_ => StartRequested?.Invoke(), _ => IsSignedIn && !IsBusy);
|
||||||
|
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke(), _ => !IsBusy);
|
||||||
|
|
||||||
|
if (account != null)
|
||||||
|
ApplyAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
public string StreamTitle
|
public string StreamTitle
|
||||||
{
|
{
|
||||||
@@ -29,15 +47,65 @@ public class GoLiveViewModel : ViewModelBase
|
|||||||
|
|
||||||
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
||||||
|
|
||||||
|
public bool IsSignedIn
|
||||||
|
{
|
||||||
|
get => _isSignedIn;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isSignedIn, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AccountDisplayName
|
||||||
|
{
|
||||||
|
get => _accountDisplayName;
|
||||||
|
private set => SetProperty(ref _accountDisplayName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AccountAvatarUrl
|
||||||
|
{
|
||||||
|
get => _accountAvatarUrl;
|
||||||
|
private set => SetProperty(ref _accountAvatarUrl, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBusy
|
||||||
|
{
|
||||||
|
get => _isBusy;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isBusy, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand SignInCommand { get; }
|
||||||
public ICommand StartCommand { get; }
|
public ICommand StartCommand { get; }
|
||||||
public ICommand CancelCommand { get; }
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
public GoLiveViewModel()
|
|
||||||
{
|
|
||||||
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
|
|
||||||
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
|
||||||
}
|
|
||||||
|
|
||||||
public event Action? StartRequested;
|
public event Action? StartRequested;
|
||||||
public event Action? CancelRequested;
|
public event Action? CancelRequested;
|
||||||
|
|
||||||
|
private async Task SignInAsync()
|
||||||
|
{
|
||||||
|
if (IsBusy) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var channel = await _signInProvider();
|
||||||
|
if (channel != null)
|
||||||
|
ApplyAccount(channel);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAccount(YouTubeChannel channel)
|
||||||
|
{
|
||||||
|
AccountDisplayName = channel.DisplayName;
|
||||||
|
AccountAvatarUrl = channel.ProfileImageUrl;
|
||||||
|
IsSignedIn = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1622
-122
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public class MicPickerCandidate
|
||||||
|
{
|
||||||
|
public MicrophoneDeviceInfo Device { get; }
|
||||||
|
public string Name => Device.DisplayName;
|
||||||
|
|
||||||
|
public MicPickerCandidate(MicrophoneDeviceInfo device)
|
||||||
|
{
|
||||||
|
Device = device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MicPickerViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IMicrophoneEnumerator _enumerator;
|
||||||
|
private MicPickerCandidate? _selectedMic;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
|
||||||
|
public ObservableCollection<MicPickerCandidate> Microphones { get; } = new();
|
||||||
|
|
||||||
|
public bool IsLoading
|
||||||
|
{
|
||||||
|
get => _isLoading;
|
||||||
|
private set => SetProperty(ref _isLoading, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasMicrophones => !IsLoading && Microphones.Count > 0;
|
||||||
|
public bool NoMicrophones => !IsLoading && Microphones.Count == 0;
|
||||||
|
|
||||||
|
public MicPickerCandidate? SelectedMic
|
||||||
|
{
|
||||||
|
get => _selectedMic;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedMic, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand UseSelectedCommand { get; }
|
||||||
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public event Action<MicrophoneDeviceInfo>? UseRequested;
|
||||||
|
public event Action? CancelRequested;
|
||||||
|
|
||||||
|
public MicPickerViewModel(IMicrophoneEnumerator enumerator)
|
||||||
|
{
|
||||||
|
_enumerator = enumerator;
|
||||||
|
UseSelectedCommand = new RelayCommand(
|
||||||
|
_ => { if (SelectedMic != null) UseRequested?.Invoke(SelectedMic.Device); },
|
||||||
|
_ => SelectedMic != null);
|
||||||
|
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||||
|
_ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var devices = await _enumerator.GetMicrophonesAsync();
|
||||||
|
Microphones.Clear();
|
||||||
|
foreach (var device in devices)
|
||||||
|
Microphones.Add(new MicPickerCandidate(device));
|
||||||
|
if (Microphones.Count > 0)
|
||||||
|
SelectedMic = Microphones[0];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"MicPickerViewModel: enumerating microphones failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
OnPropertyChanged(nameof(HasMicrophones));
|
||||||
|
OnPropertyChanged(nameof(NoMicrophones));
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A validated social entry in the dialog's working copy (non-YouTube slots).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One of the six fixed dialog rows. Row 0 is always YouTube (signed-in account
|
||||||
|
/// or a sign-in prompt); rows 1-5 hold validated entries, an empty slot, or — on
|
||||||
|
/// the free tier — a locked "Premium" slot. Rows are a positional projection of
|
||||||
|
/// the dialog's working list, so deleting an entry makes the ones above advance up.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SocialSlotViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public int Index { get; }
|
||||||
|
public bool IsYoutube => Index == 0;
|
||||||
|
|
||||||
|
private SocialService _service = SocialService.Link;
|
||||||
|
public SocialService Service
|
||||||
|
{
|
||||||
|
get => _service;
|
||||||
|
set { if (SetProperty(ref _service, value)) OnPropertyChanged(nameof(LogoData)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LogoData => Service == SocialService.Fediverse
|
||||||
|
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
|
||||||
|
: SocialServiceIcons.LogoDataFor(Service);
|
||||||
|
public string LockedIconData => SocialServiceIcons.LockedIconData;
|
||||||
|
public string DoNotIconData => SocialServiceIcons.DoNotIconData;
|
||||||
|
|
||||||
|
private bool _isLocked;
|
||||||
|
public bool IsLocked
|
||||||
|
{
|
||||||
|
get => _isLocked;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isLocked, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowAddButton));
|
||||||
|
OnPropertyChanged(nameof(ShowDoNot));
|
||||||
|
OnPropertyChanged(nameof(DisplayText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isFilled;
|
||||||
|
public bool IsFilled
|
||||||
|
{
|
||||||
|
get => _isFilled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isFilled, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowEditButton));
|
||||||
|
OnPropertyChanged(nameof(ShowDeleteButton));
|
||||||
|
OnPropertyChanged(nameof(ShowAddButton));
|
||||||
|
OnPropertyChanged(nameof(ShowDoNot));
|
||||||
|
OnPropertyChanged(nameof(DisplayText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isSignIn;
|
||||||
|
public bool IsSignIn
|
||||||
|
{
|
||||||
|
get => _isSignIn;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isSignIn, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowSignInButton));
|
||||||
|
OnPropertyChanged(nameof(ShowDeleteButton));
|
||||||
|
OnPropertyChanged(nameof(ShowLogo));
|
||||||
|
OnPropertyChanged(nameof(DisplayText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isEditing;
|
||||||
|
public bool IsEditing
|
||||||
|
{
|
||||||
|
get => _isEditing;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isEditing, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowEditBox));
|
||||||
|
OnPropertyChanged(nameof(ShowDisplay));
|
||||||
|
OnPropertyChanged(nameof(ShowEditButton));
|
||||||
|
OnPropertyChanged(nameof(ShowDeleteButton));
|
||||||
|
OnPropertyChanged(nameof(ShowAddButton));
|
||||||
|
OnPropertyChanged(nameof(ShowDoNot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isValidating;
|
||||||
|
public bool IsValidating
|
||||||
|
{
|
||||||
|
get => _isValidating;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isValidating, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowBusy));
|
||||||
|
OnPropertyChanged(nameof(ShowDoNot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _handle = string.Empty;
|
||||||
|
public string Handle
|
||||||
|
{
|
||||||
|
get => _handle;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _handle, value))
|
||||||
|
OnPropertyChanged(nameof(DisplayText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? _fediverseSoftware;
|
||||||
|
public string? FediverseSoftware
|
||||||
|
{
|
||||||
|
get => _fediverseSoftware;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _fediverseSoftware, value))
|
||||||
|
OnPropertyChanged(nameof(LogoData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProfileUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private string? _error;
|
||||||
|
public string? Error
|
||||||
|
{
|
||||||
|
get => _error;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _error, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasError));
|
||||||
|
OnPropertyChanged(nameof(ShowError));
|
||||||
|
OnPropertyChanged(nameof(ShowDoNot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _editText = string.Empty;
|
||||||
|
public string EditText
|
||||||
|
{
|
||||||
|
get => _editText;
|
||||||
|
set => SetProperty(ref _editText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The last text submitted for validation (used to skip re-validating
|
||||||
|
/// identical input — e.g. a LostFocus firing as the user clicks Cancel).</summary>
|
||||||
|
public string? LastValidatedInput { get; set; }
|
||||||
|
|
||||||
|
public string DisplayText =>
|
||||||
|
IsYoutube
|
||||||
|
? (IsSignIn ? "Sign in to YouTube" : Handle)
|
||||||
|
: IsLocked ? "Unlock with Premium"
|
||||||
|
: IsFilled ? Handle
|
||||||
|
: "No social yet";
|
||||||
|
|
||||||
|
public bool HasError => !string.IsNullOrEmpty(Error);
|
||||||
|
public bool ShowError => HasError;
|
||||||
|
public bool ShowLogo => IsYoutube ? !IsSignIn : IsFilled;
|
||||||
|
public bool ShowBusy => IsValidating;
|
||||||
|
public bool ShowEditBox => IsEditing;
|
||||||
|
public bool ShowDisplay => !IsEditing && !IsValidating;
|
||||||
|
public bool ShowSignInButton => IsYoutube && IsSignIn && !IsValidating;
|
||||||
|
public bool ShowAddButton => !IsYoutube && !IsLocked && !IsFilled && !IsEditing && !IsValidating;
|
||||||
|
public bool ShowEditButton => !IsYoutube && IsFilled && !IsEditing && !IsValidating;
|
||||||
|
public bool ShowDeleteButton => (IsFilled || IsYoutube) && !IsEditing && !IsValidating;
|
||||||
|
public bool ShowDoNot => !IsYoutube && !IsLocked && (!IsFilled || HasError || IsValidating);
|
||||||
|
|
||||||
|
public SocialSlotViewModel(int index) => Index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View model for the Social Media Site Promotion dialog. Owns the working copy
|
||||||
|
/// of the social bar config: the six fixed slots, the sign-in gate, per-row
|
||||||
|
/// validation, the freemium lock, and the YouTube sign-out. Deliberately WPF-free
|
||||||
|
/// (no MessageBox, no Window) so the whole flow is unit-testable with fakes.
|
||||||
|
/// The window surfaces the confirmations (delete, sign-in gate) as dialogs and
|
||||||
|
/// calls back into the VM.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SocialsDialogViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public const int MaxSlots = 6;
|
||||||
|
|
||||||
|
private readonly ISocialValidator _validator;
|
||||||
|
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||||
|
private readonly Func<Task> _signOutAction;
|
||||||
|
private readonly bool _isPremium;
|
||||||
|
private readonly List<DialogEntry> _working = new();
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private YouTubeChannel? _account;
|
||||||
|
|
||||||
|
private bool _isSignedIn;
|
||||||
|
private bool _isBusy;
|
||||||
|
private bool _barEnabled = true;
|
||||||
|
|
||||||
|
public SocialsDialogViewModel(
|
||||||
|
ISocialValidator validator,
|
||||||
|
Func<Task<YouTubeChannel?>> signInProvider,
|
||||||
|
Func<Task> signOutAction,
|
||||||
|
YouTubeChannel? account,
|
||||||
|
bool isPremium,
|
||||||
|
SocialsConfig? current = null)
|
||||||
|
{
|
||||||
|
_validator = validator;
|
||||||
|
_signInProvider = signInProvider;
|
||||||
|
_signOutAction = signOutAction;
|
||||||
|
_isPremium = isPremium;
|
||||||
|
_barEnabled = current?.BarEnabled ?? true;
|
||||||
|
_account = account;
|
||||||
|
_isSignedIn = account != null;
|
||||||
|
|
||||||
|
foreach (var entry in current?.Entries ?? [])
|
||||||
|
if (entry.Service != SocialService.YouTube)
|
||||||
|
_working.Add(new DialogEntry(entry.Service, entry.Handle, entry.ProfileUrl));
|
||||||
|
|
||||||
|
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => CanSignIn);
|
||||||
|
SaveCommand = new RelayCommand(_ => Save(), _ => CanSave);
|
||||||
|
CancelCommand = new RelayCommand(_ => Cancel());
|
||||||
|
|
||||||
|
RebuildSlots();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<SocialSlotViewModel> Slots { get; } = new();
|
||||||
|
|
||||||
|
public bool BarEnabled
|
||||||
|
{
|
||||||
|
get => _barEnabled;
|
||||||
|
set => SetProperty(ref _barEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSignedIn
|
||||||
|
{
|
||||||
|
get => _isSignedIn;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isSignedIn, value))
|
||||||
|
OnPropertyChanged(nameof(ShowSignInBanner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBusy
|
||||||
|
{
|
||||||
|
get => _isBusy;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isBusy, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowSignInBanner));
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShowSignInBanner => !IsSignedIn && !IsBusy;
|
||||||
|
public bool CanSignIn => !IsBusy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save is blocked while any slot has un-validated input, is mid-validation,
|
||||||
|
/// or failed validation — an empty slot or a confirmed entry never blocks.
|
||||||
|
/// </summary>
|
||||||
|
public bool CanSave =>
|
||||||
|
!IsBusy &&
|
||||||
|
Slots.All(s => !s.IsEditing || string.IsNullOrWhiteSpace(s.EditText)) &&
|
||||||
|
Slots.All(s => !s.IsValidating && !s.HasError);
|
||||||
|
|
||||||
|
public ICommand SignInCommand { get; }
|
||||||
|
public ICommand SaveCommand { get; }
|
||||||
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public event Action? SaveRequested;
|
||||||
|
public event Action? CancelRequested;
|
||||||
|
|
||||||
|
/// <summary>Entries to persist after a successful save (YouTube first when signed in).</summary>
|
||||||
|
public IReadOnlyList<SocialEntry>? CommittedEntries { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dismissal is a hard stop: abort every in-flight validation and get out.
|
||||||
|
/// The canceled continuations never touch slot state.
|
||||||
|
/// </summary>
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
CancelRequested?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aborts in-flight work without raising <see cref="CancelRequested"/>
|
||||||
|
/// (used by the window's Closing handler so a Save close isn't clobbered).</summary>
|
||||||
|
public void AbortPending() => _cts.Cancel();
|
||||||
|
|
||||||
|
private async Task SignInAsync()
|
||||||
|
{
|
||||||
|
if (IsBusy) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var channel = await _signInProvider();
|
||||||
|
if (channel != null)
|
||||||
|
{
|
||||||
|
_account = channel;
|
||||||
|
IsSignedIn = true;
|
||||||
|
RebuildSlots();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts editing the slot's input box (add or edit).</summary>
|
||||||
|
public void StartEdit(int slotIndex)
|
||||||
|
{
|
||||||
|
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
|
||||||
|
var slot = Slots[slotIndex];
|
||||||
|
if (slot.IsLocked || slot.IsYoutube || slot.IsValidating) return;
|
||||||
|
slot.EditText = slot.IsFilled ? slot.Handle : string.Empty;
|
||||||
|
slot.Error = null;
|
||||||
|
slot.IsEditing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Commits the slot's input — empty cancels, otherwise validates.
|
||||||
|
/// Re-submitting text that was already handled is a no-op, so a focus shift
|
||||||
|
/// (clicking Cancel, tabbing away) never re-fires a lookup.</summary>
|
||||||
|
public void ConfirmEdit(int slotIndex)
|
||||||
|
{
|
||||||
|
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
|
||||||
|
var slot = Slots[slotIndex];
|
||||||
|
if (!slot.IsEditing || slot.IsValidating) return;
|
||||||
|
if (string.IsNullOrWhiteSpace(slot.EditText))
|
||||||
|
{
|
||||||
|
slot.IsEditing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var text = slot.EditText.Trim();
|
||||||
|
if (slot.IsFilled && text == slot.Handle)
|
||||||
|
{
|
||||||
|
slot.IsEditing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (slot.LastValidatedInput == text)
|
||||||
|
{
|
||||||
|
if (slot.IsFilled) slot.IsEditing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot.LastValidatedInput = text;
|
||||||
|
_ = ValidateAsync(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the lookup; on success snaps the row back to its logo + handle.
|
||||||
|
/// A canceled lookup (dismissal) leaves the slot untouched.</summary>
|
||||||
|
private async Task ValidateAsync(SocialSlotViewModel slot)
|
||||||
|
{
|
||||||
|
var ct = _cts.Token;
|
||||||
|
slot.IsValidating = true;
|
||||||
|
slot.Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = slot.EditText.Trim();
|
||||||
|
var (service, handle, _) = SocialServiceIcons.DetectService(text);
|
||||||
|
var result = await _validator.LookupAsync(service, handle, ct);
|
||||||
|
if (ct.IsCancellationRequested || result.Canceled) return;
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
ApplyValidated(slot, new DialogEntry(service, result.Handle, result.ProfileUrl, result.FediverseSoftware));
|
||||||
|
else
|
||||||
|
slot.Error = result.Error;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
slot.IsValidating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyValidated(SocialSlotViewModel slot, DialogEntry entry)
|
||||||
|
{
|
||||||
|
var index = slot.Index - 1;
|
||||||
|
if (slot.IsFilled && index >= 0 && index < _working.Count)
|
||||||
|
_working[index] = entry;
|
||||||
|
else
|
||||||
|
_working.Add(entry);
|
||||||
|
slot.Service = entry.Service;
|
||||||
|
slot.Handle = entry.Handle;
|
||||||
|
slot.ProfileUrl = entry.ProfileUrl;
|
||||||
|
slot.FediverseSoftware = entry.FediverseSoftware;
|
||||||
|
slot.IsFilled = true;
|
||||||
|
slot.IsEditing = false;
|
||||||
|
RebuildSlots();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The window confirms the delete, then calls this. Slot 0 signs out of YouTube.</summary>
|
||||||
|
public async Task DeleteSlotAsync(int slotIndex)
|
||||||
|
{
|
||||||
|
if (slotIndex == 0)
|
||||||
|
{
|
||||||
|
if (!IsSignedIn) return;
|
||||||
|
await _signOutAction();
|
||||||
|
_account = null;
|
||||||
|
IsSignedIn = false;
|
||||||
|
RebuildSlots();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryIndex = slotIndex - 1;
|
||||||
|
if (entryIndex < 0 || entryIndex >= _working.Count) return;
|
||||||
|
_working.RemoveAt(entryIndex);
|
||||||
|
RebuildSlots();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
if (!CanSave) return;
|
||||||
|
var entries = new List<SocialEntry>();
|
||||||
|
if (IsSignedIn && _account != null)
|
||||||
|
{
|
||||||
|
var handle = _account.DisplayName.Trim().TrimStart('@');
|
||||||
|
entries.Add(new SocialEntry
|
||||||
|
{
|
||||||
|
Service = SocialService.YouTube,
|
||||||
|
Handle = handle,
|
||||||
|
ProfileUrl = string.IsNullOrWhiteSpace(_account.ChannelId)
|
||||||
|
? SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, handle)
|
||||||
|
: $"https://www.youtube.com/channel/{_account.ChannelId}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (var entry in _working)
|
||||||
|
entries.Add(new SocialEntry
|
||||||
|
{
|
||||||
|
Service = entry.Service,
|
||||||
|
Handle = entry.Handle,
|
||||||
|
ProfileUrl = entry.ProfileUrl,
|
||||||
|
FediverseSoftware = entry.FediverseSoftware,
|
||||||
|
});
|
||||||
|
CommittedEntries = entries;
|
||||||
|
SaveRequested?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the config the main window commits on save, or null when empty.</summary>
|
||||||
|
public SocialsConfig? BuildSocialsConfig()
|
||||||
|
{
|
||||||
|
var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed.");
|
||||||
|
if (entries.Count == 0) return null;
|
||||||
|
var config = new SocialsConfig { BarEnabled = BarEnabled };
|
||||||
|
foreach (var entry in entries)
|
||||||
|
config.Entries.Add(entry);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildSlots()
|
||||||
|
{
|
||||||
|
foreach (var slot in Slots)
|
||||||
|
slot.PropertyChanged -= OnSlotPropertyChanged;
|
||||||
|
Slots.Clear();
|
||||||
|
|
||||||
|
for (var i = 0; i < MaxSlots; i++)
|
||||||
|
{
|
||||||
|
var slot = new SocialSlotViewModel(i);
|
||||||
|
if (i == 0)
|
||||||
|
{
|
||||||
|
slot.Service = SocialService.YouTube;
|
||||||
|
slot.IsSignIn = !IsSignedIn;
|
||||||
|
slot.IsFilled = IsSignedIn;
|
||||||
|
if (IsSignedIn && _account != null)
|
||||||
|
slot.Handle = _account.DisplayName;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var entryIndex = i - 1;
|
||||||
|
if (entryIndex < _working.Count)
|
||||||
|
{
|
||||||
|
var entry = _working[entryIndex];
|
||||||
|
slot.Service = entry.Service;
|
||||||
|
slot.IsFilled = true;
|
||||||
|
slot.Handle = entry.Handle;
|
||||||
|
slot.ProfileUrl = entry.ProfileUrl;
|
||||||
|
slot.FediverseSoftware = entry.FediverseSoftware;
|
||||||
|
}
|
||||||
|
else if (!_isPremium && i >= 2)
|
||||||
|
{
|
||||||
|
slot.IsLocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slot.PropertyChanged += OnSlotPropertyChanged;
|
||||||
|
Slots.Add(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSlotPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(SocialSlotViewModel.IsEditing)
|
||||||
|
or nameof(SocialSlotViewModel.IsValidating)
|
||||||
|
or nameof(SocialSlotViewModel.HasError)
|
||||||
|
or nameof(SocialSlotViewModel.EditText))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CanSave));
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,17 +1,87 @@
|
|||||||
# ytLlive — AI Guide
|
# 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.
|
||||||
|
|
||||||
|
## Response style
|
||||||
|
|
||||||
|
No default "Plans & Pitfalls" / planning boilerplate. Respond directly and
|
||||||
|
concisely: **do the queued work, then report what changed and what's next.**
|
||||||
|
Skip feature pitch, step-by-step implementation plans, pros/cons tables, and
|
||||||
|
"potential pitfalls" sections unless the user explicitly asks for a plan first.
|
||||||
|
A short diff-style summary beats a proposal document every time.
|
||||||
|
|
||||||
|
### No-Fluff Mode (on demand)
|
||||||
|
|
||||||
|
Invoke with "no-fluff mode" (or similar) when you want ruthless review instead
|
||||||
|
of reassurance. In that mode:
|
||||||
|
|
||||||
|
- Strip all polite pleasantries, emojis, transitions, and conversational padding.
|
||||||
|
- Treat the user's input as a draft to be methodically deconstructed or
|
||||||
|
strengthened — argue, correct, and sharpen rather than agree.
|
||||||
|
- Give unvarnished truth, not reassurance.
|
||||||
|
|
||||||
|
This is an occasional, explicitly-invoked mode — **never the default**. The
|
||||||
|
default response style above stays in effect unless invoked.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet build # Windows only — WPF requires Windows target
|
# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
|
||||||
dotnet run
|
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
|
||||||
|
"/mnt/c/Program Files/dotnet/dotnet.exe" run
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project can be restored/built from WSL, but running requires Windows.
|
`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux
|
||||||
|
`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache
|
||||||
|
(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated
|
||||||
|
project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
|
||||||
|
packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads.
|
||||||
|
Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json`
|
||||||
|
produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
No test framework set up yet. When added: `dotnet test`.
|
xUnit in `ytLive.Tests` (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows —
|
||||||
|
WSL can't run net8.0-windows tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
|
||||||
|
```
|
||||||
|
|
||||||
|
Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI
|
||||||
|
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
|
||||||
|
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
|
||||||
|
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip,
|
||||||
|
LayoutStore backdrop roundtrip, LayoutStore HasBackdrop roundtrip + v5→v6 non-Live backfill,
|
||||||
|
ScreenCaptureManager refcount + shared-bitmap + coalescing
|
||||||
|
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackdropTests (EnsureBackdrop
|
||||||
|
insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
|
||||||
|
(the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests
|
||||||
|
(the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene
|
||||||
|
composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical
|
||||||
|
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests
|
||||||
|
(the PATH → cache → download decision ladder with a fake downloader serving a real in-memory zip; shared
|
||||||
|
build DLL extraction) —
|
||||||
|
78 passing.
|
||||||
|
|
||||||
|
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
|
||||||
|
|
||||||
|
The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore`
|
||||||
|
(`%APPDATA%\ytLlive\ytLlive.db`). `Shutdown()` on close **saves the layout** (full rewrite:
|
||||||
|
DELETE all scenes/sources, re-insert), so any source a test adds would be persisted over the
|
||||||
|
user's real ones — this happened and wiped the real webcam source (DeviceId replaced by the
|
||||||
|
test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first set
|
||||||
|
`MainViewModel.LayoutPathOverride` to a temp DB path and reset it (plus
|
||||||
|
`SqliteConnection.ClearAllPools()` + delete) in `finally`. The seam is
|
||||||
|
`internal static string? LayoutPathOverride` (line ~529 in `MainViewModel.cs`),
|
||||||
|
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
|
||||||
|
|
||||||
|
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
|
||||||
|
save/load round trips are exact: an element removed in the UI (`RemoveElement` →
|
||||||
|
`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
|
||||||
|
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -19,29 +89,443 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `Models/` | Plain data types — Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
|
||||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI |
|
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
|
||||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
|
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat` + game bar: `IGameAudioDetector`/`GameAudioHysteresis`/`GameAudioDetector` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")** |
|
||||||
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache |
|
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, 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) |
|
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
||||||
|
|
||||||
### Key patterns
|
### Key patterns
|
||||||
|
|
||||||
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
||||||
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline)
|
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
|
||||||
|
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC sign (2026-08-13):** the top-center indicator is an **always-visible REC chip** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`) — dark gray dot + dim "REC" offline, bright red (#e94560) while live, darker red (#8f1f1f) when live with a **private** stream (driven by the dialog's chosen `StreamVisibility`; the stream service still forces public — see TASK 4 ship step 7); the dot pulses while live, and the elapsed timer shows only when live. The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
|
||||||
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
||||||
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
|
- 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)
|
- 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)
|
||||||
|
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
|
||||||
|
- 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 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier; the bottom bar shows bitrate/FPS. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||||
|
- 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
|
### Current limitations / TODOs
|
||||||
|
|
||||||
- `OAuthCredentials.ClientId` / `ClientSecret` in `Helpers/OAuthCredentials.cs` are empty — the app
|
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
|
||||||
owner fills them in once (developer task, baked into the binary; creators never configure anything)
|
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
||||||
- `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)
|
|
||||||
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
||||||
- No capture/encoding/RTMP yet
|
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED** — full plan in `TASKS.md`; the frame-pipeline wiring follows (its own PR)
|
||||||
- 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
|
||||||
|
|
||||||
|
### Screen backdrop capture (TASK 3 ship task #1)
|
||||||
|
|
||||||
|
The backdrop is the **live desktop/game capture as a permanent, non-deletable bottom layer**
|
||||||
|
rendered in every scene — the "Screen" source from the minimal set, done as content-swap
|
||||||
|
instead of a normal draggable source.
|
||||||
|
|
||||||
|
- **Model (schema v6):** `Source.IsBackdrop` (persisted) marks the one backdrop per scene; `Source.CaptureKey`
|
||||||
|
(persisted) names the target — `monitor:<n>`, `window:<hwnd>`, or `picker:<displayname>`. The backdrop is
|
||||||
|
a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackdrop(scene)`,
|
||||||
|
internal static — runs on layout load + every `AddScene`, healing any scene missing one), fixed at
|
||||||
|
X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`;
|
||||||
|
`IsDraggableElement` never matches live types; the element template sets `IsHitTestVisible=false` for
|
||||||
|
backdrops; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackdrop`
|
||||||
|
(persisted, default off) is the Live-only policy flag** — the backdrop belongs to the canonical Live
|
||||||
|
scene alone (see `SceneCatalog`). `EnsureBackdrop` returns null for a flag-less scene, so
|
||||||
|
Starting/BRB/Chat/Ending compose their own layers. The one-time v5→v6 backfill turns those four scenes
|
||||||
|
off and drops their backdrop sources, and **`EnforceBackdropPolicy` (internal static, runs after every
|
||||||
|
load)** re-normalizes the flag by scene name and strips any backdrop that lingers in a non-Live scene —
|
||||||
|
the flag is owned by policy, never the user. There is no scene-list "Backdrop" checkbox anymore
|
||||||
|
(the old `ToggleSceneBackdropCommand` is gone); "Change Capture…"/"Refresh Capture"/"Capture Display"
|
||||||
|
only show in the Live scene's preview menu (`CanChangeBackdrop`). The static Background, if any,
|
||||||
|
renders **above** the backdrop.
|
||||||
|
- **Detection (launch + focus only, no live session listener):** `Win32FullScreenDetector` =
|
||||||
|
`GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` +
|
||||||
|
`GetMonitorInfo`; a window is full-screen when its frame covers all four monitor edges; own process is
|
||||||
|
excluded; monitor **index = `EnumDisplayMonitors` enumeration order** (the same order
|
||||||
|
`ScreenCaptureSourceFactory` uses to map index→HMONITOR via `Win32FullScreenDetector.GetMonitorHandle`).
|
||||||
|
`IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`,
|
||||||
|
friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display"
|
||||||
|
submenu. At launch `ReacquireScreenCaptures` keys the backdrop to the full-screen game's monitor (or the
|
||||||
|
**primary** display — never assumed monitor 0).
|
||||||
|
On **Deactivated**, `NoteBackgroundWindow` samples the foreground ~250ms later (so an alt-tab to a game
|
||||||
|
lands before the app re-activates); on the next **Activated**, `RefreshBackdropAutoCapture` re-runs
|
||||||
|
detection against that sample and re-designates. Null detection (our app, the desktop, a normal window)
|
||||||
|
leaves the current capture alone.
|
||||||
|
- **Capture (WinRT GraphicsCapture):** `ScreenCaptureFrameSource` creates a free-threaded
|
||||||
|
`Direct3D11CaptureFramePool` (2 buffers, `B8G8R8A8UIntNormalized`) + `GraphicsCaptureSession`; frames →
|
||||||
|
`SoftwareBitmap.CreateCopyFromSurfaceAsync` (alpha ignored) → `VideoFrame` (BGRA8), bytes read via
|
||||||
|
`WindowsRuntimeMarshal.TryGetDataUnsafe` (the same CsWinRT-safe read the webcam path uses) — the
|
||||||
|
`IMemoryBufferByteAccess` ComImport cast threw `Invalid cast` on **every frame** under CsWinRT, which
|
||||||
|
flooded `startup.log` (~5 MB in a session) and burned CPU, so it is gone. Surfaces larger than the
|
||||||
|
1920×1080 master are downscaled bilinearly to the master (`DownscaleBgra`) before the copy, and
|
||||||
|
per-frame conversion failures are logged at most once per 5 s (`ErrorLogThrottle`). DRM-protected
|
||||||
|
content delivers black frames (OS limitation, documented). Frame pool
|
||||||
|
pauses while the app is minimized — capture keeps running, the pool just stops delivering.
|
||||||
|
- **Ownership:** `ScreenCaptureManager` mirrors `CameraManager` — refcounted by target key, one shared
|
||||||
|
`WriteableBitmap` per key, dispatcher-coalesced latest-frame copies, `PreviewBitmapChanged`/`CaptureFailed`
|
||||||
|
events, `ReleaseAllAsync` on re-designation. `ScreenCaptureSourceFactory.Resolve(key)` parses the key into
|
||||||
|
a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the
|
||||||
|
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to
|
||||||
|
auto-detection.
|
||||||
|
- **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected,
|
||||||
|
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
|
||||||
|
feature array) → QI `IDXGIDevice` → the WinRT interop export
|
||||||
|
`CreateDirect3D11DeviceFromDXGIDevice` → `MarshalInterface<IDirect3DDevice>.FromAbi` (one shared device per
|
||||||
|
process). Do **not** switch back to the QI-for-`IDirect3DDxgiInterfaceAccess` trick: the raw D3D11 device
|
||||||
|
no longer exposes that interface on newer Windows (verified E_NOINTERFACE on build 26200, hardware and
|
||||||
|
WARP alike) while `CreateDirect3D11DeviceFromDXGIDevice` keeps working.
|
||||||
|
`IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by
|
||||||
|
reflection against the built `Microsoft.Windows.SDK.NET.dll` before writing the interop.
|
||||||
|
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
|
||||||
|
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
|
||||||
|
game are behind the picker; picker-based captures don't survive reload.
|
||||||
|
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
|
||||||
|
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
|
||||||
|
the only focus hooks (`NoteBackgroundWindow`/`RefreshBackdropAutoCapture`) re-target the backdrop, never
|
||||||
|
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
|
||||||
|
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
|
||||||
|
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
|
||||||
|
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
|
||||||
|
WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision).
|
||||||
|
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
|
||||||
|
to the encoder task.
|
||||||
|
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
|
||||||
|
`ShowPreviewPlaceholder` now also checks `BackdropImage` (raised on backdrop change), so a scene with
|
||||||
|
live capture shows the feed instead of the "nothing here" label.
|
||||||
|
|
||||||
|
### Webcam capture (TASK 3 milestone 1)
|
||||||
|
|
||||||
|
- **Seam-first:** everything above the WinRT layer speaks only `VideoFrame` (normalized tightly-packed
|
||||||
|
BGRA8) + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces. Tests inject fakes;
|
||||||
|
screen capture and background removal later feed the same seam.
|
||||||
|
- **CPU-first:** `MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video,
|
||||||
|
SharingMode = SharedReadOnly }`, frames pulled via
|
||||||
|
`CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)` — the pipeline does any format
|
||||||
|
conversion, so every `FrameArrived` yields a ready BGRA8 `SoftwareBitmap` (bytes read via
|
||||||
|
`WindowsRuntimeMarshal.TryGetDataUnsafe`, not marshalled copies).
|
||||||
|
- **Source pick, not first hit:** the frame reader is bound to the first source that is `VideoPreview`
|
||||||
|
(preferred) or `VideoRecord`, not blindly the first preview source. If a camera exposes neither, the
|
||||||
|
failure names the device and the stream types it *does* expose. `SharedReadOnly` lets the capture
|
||||||
|
coexist with other apps that share the camera.
|
||||||
|
- **Known failure: NVIDIA Broadcast** — it opens the physical webcam exclusively, so `InitializeAsync`
|
||||||
|
fails with "camera in use" (or, if init slips through, the device exposes no preview source). Fix:
|
||||||
|
quit Broadcast while streaming, or pick its virtual "NVIDIA Broadcast" device from the picker and the
|
||||||
|
app captures the processed feed. This is a real-device finding (Logitech `VID_046D&PID_082D`).
|
||||||
|
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
|
||||||
|
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
|
||||||
|
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
|
||||||
|
- **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity
|
||||||
|
(`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/
|
||||||
|
border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam
|
||||||
|
(`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture
|
||||||
|
sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last
|
||||||
|
release stops + disposes). **"Add Webcam" ALWAYS opens the Windows camera picker** — the creator is
|
||||||
|
never silently handed the previous camera (which used to happen after deleting one scene's webcam
|
||||||
|
while another scene still used it; that identity survived, so re-adding bypassed the choice).
|
||||||
|
Picking a different camera than the current app-wide one swaps it everywhere via
|
||||||
|
`SwapWebcamIdentityAsync` (the same path "Change Webcam…" uses), keeping the singleton honest;
|
||||||
|
picking the same one just places the config. The Add Webcam menu greys out when the **active**
|
||||||
|
scene already has a config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the
|
||||||
|
existing config (`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam" — which, on
|
||||||
|
a config-less canvas, delegates to Add Webcam and so also picks). **Removing the last webcam
|
||||||
|
config anywhere clears the identity** (`_webcam = null`), which also drops "Change Webcam…".
|
||||||
|
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
|
||||||
|
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
|
||||||
|
restores them when switching back — otherwise the Round resize lock (square) would leave a square
|
||||||
|
behind. The rect dims are **persisted** (`WebcamSceneConfig.RectWidth`/`RectHeight`, nullable), so a
|
||||||
|
reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-time
|
||||||
|
`HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
|
||||||
|
(keeps height; Round and explicit rect dims are untouched).
|
||||||
|
- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
|
||||||
|
release the old device with `ReleaseAllAsync` — a forced full drop that zeroes the refcount and
|
||||||
|
stops the source regardless of how many scenes held it (the per-config count isn't known once the
|
||||||
|
scenes are replaced) — then `AcquireAsync` the new device once per config.
|
||||||
|
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
|
||||||
|
at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
|
||||||
|
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
|
||||||
|
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
|
||||||
|
device never drowns the render thread.
|
||||||
|
- **Webcam added mid-session must get the live frames:** `PreviewBitmapChanged` fires **once** (the first
|
||||||
|
frame creates the shared bitmap); later frames only mutate that bitmap in place, so a config that didn't
|
||||||
|
exist at first-frame time would never receive it — the empty/transparent container you'd see adding a
|
||||||
|
webcam to Chat while Live already had the camera. `CameraManager.GetPreviewBitmap(deviceId)` exposes the
|
||||||
|
current shared bitmap; `AddWebcamToActiveSceneAsync` assigns it to the new config right before
|
||||||
|
`AcquireAsync`, and `ReacquireWebcam` re-propagates it to every config after a reload.
|
||||||
|
- **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
|
||||||
|
(`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
|
||||||
|
0–1, `BorderWidth` 0–20, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
|
||||||
|
Electricity|Sparkles`). Rendered in the preview DataTemplate; toggled from the element's right-click
|
||||||
|
context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist,
|
||||||
|
rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
|
||||||
|
this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
|
||||||
|
preview canvas has its own Show Webcam entry.
|
||||||
|
- The Round webcam is an `Image Stretch="UniformToFill"` with an `EllipseGeometry` clip
|
||||||
|
(`Center=0.5,0.5` `RadiusX/Y=0.5`), inside a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it
|
||||||
|
renders as a true circle (diameter = the shorter element dimension) instead of an oval stretched to
|
||||||
|
the element rect — the traditional `Image` keeps `UniformToFill` over the full rect. The clip is
|
||||||
|
geometry, **not an `ImageBrush`**: a brush re-rasterizes the frequently-updated `WriteableBitmap` per
|
||||||
|
frame on the render thread, which is what made the live webcam crawl while round. The Round border is
|
||||||
|
a centered `Ellipse` at `Width/Height = RoundBorderSize`.
|
||||||
|
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
|
||||||
|
- **Webcam size clamp:** `ClampWebcamToBounds(config, sceneName)` (internal — test seam) enforces the
|
||||||
|
max per dimension at resize + load and no less than 10% of the master (192×108). The cap is picked
|
||||||
|
**by canonical scene name**: 50% per dimension (960×540) everywhere except the **Chat scene**, which
|
||||||
|
may reach half the screen **area** (~1358×764 @16:9) so the viewer sees the creator better
|
||||||
|
(`MaxWebcamWidthFor`/`MaxWebcamHeightFor`; a renamed Chat loses the bigger cap).
|
||||||
|
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
|
||||||
|
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
|
||||||
|
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
|
||||||
|
the element — making the corner handle ungrabbable. The element Grid carries
|
||||||
|
`Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
|
||||||
|
right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
|
||||||
|
`IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
|
||||||
|
(1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
|
||||||
|
isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
|
||||||
|
IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
|
||||||
|
template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
|
||||||
|
sliders live in Items, so they render in a hover flyout), a `✓` checkmark column, and a `›` arrow
|
||||||
|
driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
|
||||||
|
opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
|
||||||
|
Click handlers were fine.
|
||||||
|
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
|
||||||
|
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
|
||||||
|
to the encoder task.
|
||||||
|
- **Background removal = milestone 2** — ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie
|
||||||
|
Segmentation (Apache-2.0), wired into the same `VideoFrame` seam. Not part of milestone 1.
|
||||||
|
|
||||||
|
### Scene compositor (TASK 4 ship step 1 — shipped 2026-08-10, plan in TASKS.md)
|
||||||
|
|
||||||
|
The encoder needs the master 1920×1080 frame **without** the preview's editing chrome (SelectionOverlay,
|
||||||
|
DimRects, output-rect outline, badge, placeholder). WPF's `RenderTargetBitmap` is software-rendered and
|
||||||
|
captures the visual tree *including* chrome, so the preview can't be captured — the output is a **second,
|
||||||
|
parallel software compositor** over the `VideoFrame` (BGRA8) seam, and the XAML preview
|
||||||
|
(`MainWindow.xaml` CanvasGrid + element DataTemplate) is the rendering contract it replicates. Two
|
||||||
|
renderers must agree: geometry, `UniformToFill` cover-crop, round clip, mirror, border, z-order. Preview
|
||||||
|
stays XAML (editing view); the compositor is the output view.
|
||||||
|
|
||||||
|
- **Render the active tier's output rect directly** (`CompositorOptions {SourceRectX/Y/W/H,
|
||||||
|
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080 1:1; the
|
||||||
|
vertical 9:16 tier = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920.
|
||||||
|
- **CPU posture:** with FFmpeg as a subprocess the master crosses a CPU readback to the pipe every frame
|
||||||
|
anyway, so GPU compositing buys little at this layer count (2-3 live layers; static layers
|
||||||
|
pre-composite once into a cached base). GPU effort belongs to **NVENC** (the encoder), not composition;
|
||||||
|
if composition grows (wipes, filters, many layers), a D3D11 compositor can replace this one **behind
|
||||||
|
the same seam** — the CPU master buffer stays the contract.
|
||||||
|
- **Branding flash is composited by the output path too** (it's on the live output, per Monetization),
|
||||||
|
passed in as a pre-rendered `VideoFrame?` — the compositor core stays pure byte-math, no WPF. Likely a
|
||||||
|
bundled asset rather than runtime text rendering (deterministic, no font/layout risk).
|
||||||
|
- **Frame sources are injected** via a `Func<SceneElement, VideoFrame?>` resolver
|
||||||
|
(`SceneCompositor.Render(scene, frameFor, flashFrame, options)`) — the caller maps each element to
|
||||||
|
its frame (webcam → `DeviceId`, image → `AssetId` via `StaticPixelCache`, backdrop → `CaptureKey`),
|
||||||
|
so the compositor is pure, WPF-free, and hermetic to test. The capture managers wire into that
|
||||||
|
resolver in the encoder step, not the compositor step. The master buffer (the compositor's return
|
||||||
|
value) is the seam a future D3D11 compositor would honor identically.
|
||||||
|
|
||||||
|
### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10, plan in TASKS.md)
|
||||||
|
|
||||||
|
The encoder's one external dependency is `ffmpeg.exe`; it's never shipped in the repo. `IFfmpegLocator`
|
||||||
|
resolves an absolute path on demand: **PATH probe first** (the user's own install wins — their choice,
|
||||||
|
their responsibility), then the cache (`%APPDATA%\ytLlive\tools\ffmpeg.exe`), then a **pinned** BtbN
|
||||||
|
LGPL-**shared** win64 zip (~75 MB) from which `ffmpeg.exe` **and the `libav*.dll` family** are extracted
|
||||||
|
(staged temp-write + move so a crash never corrupts the cache; Windows resolves the DLLs from the exe's
|
||||||
|
own directory). BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
|
||||||
|
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
|
||||||
|
source offer" with no static-relink (§6) material — see the Licensing guardrails below. The pin is a
|
||||||
|
dated autobuild tag (immutable); BtbN retention keeps the last 14 daily + each month-end for 2 years, so
|
||||||
|
a cold cache can outlive the pin → the seam throws a clear, logged error (recoverable; the pin is one
|
||||||
|
const). Constructor-injected search dirs / tools dir / downloader (`Func<string, CancellationToken,
|
||||||
|
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
|
||||||
|
encoder step (not yet — this PR ships the seam + impl + tests only).
|
||||||
|
|
||||||
|
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
|
||||||
|
|
||||||
|
The encoder is a **thin orchestrator over `ffmpeg.exe`** — no H.264/AAC code in the app. It spawns the
|
||||||
|
subprocess (path from `IFfmpegLocator`), feeds raw BGRA master frames into stdin, and parses `-stats`
|
||||||
|
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
|
||||||
|
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop),
|
||||||
|
`SubmitFrameAsync` (serialized stdin writes under `SemaphoreSlim`), `StopAsync` (stdin EOF → ffmpeg
|
||||||
|
finalizes + exits by itself; a 10s watchdog kills it), `Dispose` (force-kill + wait), and the
|
||||||
|
`HealthUpdated`/`ProcessFailed` events. **Pattern:** the encoder never touches `Process` — it drives the
|
||||||
|
`IEncoderProcess` seam (`FfmpegEncoderProcess` wraps the real `Process`, redirected stdin/stdout/stderr
|
||||||
|
+ exit control); a `Func<IEncoderProcess>` factory + the locator are constructor-injected, so the
|
||||||
|
integration test fakes the whole subprocess (probe + encoder) with a Channel-backed `TextReader` whose
|
||||||
|
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
|
||||||
|
|
||||||
|
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
|
||||||
|
`-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` + a **silent placeholder
|
||||||
|
`-f lavfi -i anullsrc`** track (WASAPI capture, ship step 4, replaces it) + `-c:v <enc> -b:v K
|
||||||
|
-maxrate K -bufsize 2K` + **`-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p`**
|
||||||
|
(≤4s keyframes, closed GOP, H.264 compliance) + `-c:a aac -ar 48000 -ac 2 -f flv <rtmpUrl>`.
|
||||||
|
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
|
||||||
|
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
|
||||||
|
forces one and skips the probe. `EncoderOptions` also carries W×H/FPS/bitrate from the quality tier and
|
||||||
|
`GopSize` = FPS×4. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`),
|
||||||
|
driven by the `FramePump` below.
|
||||||
|
|
||||||
|
### Live audio capture (TASK 4 ship step 4 — shipped 2026-08-12, game audio bar follow-up 2026-08-13, plans in TASKS.md)
|
||||||
|
|
||||||
|
**Capture runs for the app's lifetime and is KISS by rule**: desktop/game audio is automatic (WASAPI
|
||||||
|
loopback), the mic is the creator's only audio control (meter/mute/volume already shipped). The whole
|
||||||
|
layer sits behind an **`IAudioSource` seam** (`Services/Audio/`: `Start`/`Stop`/`Started`/`SampleReady`/
|
||||||
|
`Failed`, IDisposable) so the app never touches NAudio directly and tests inject hermetic fakes (no
|
||||||
|
devices, no timers).
|
||||||
|
|
||||||
|
- **Sources (NAudio `NAudio.Wasapi` 2.2.1, MIT — item 9 in `THIRD-PARTY-NOTICES.txt`):**
|
||||||
|
`WasapiLoopbackAudioSource` = `WasapiLoopbackCapture` on the default render device;
|
||||||
|
`WasapiMicAudioSource` = `WasapiCapture` with the device resolved by `FriendlyName` matching
|
||||||
|
`MicSourceName` (the app only persists the **DisplayName**), falling back to the default capture
|
||||||
|
endpoint. Mic device resolution re-reads the name provider `Func<string?>` at each `Start`, so a mic
|
||||||
|
picked mid-session takes effect **immediately** (the mixer restarts the mic on pick). Both sources
|
||||||
|
raise `Started` once their capture loop actually begins — the mixer turns that into `MicConnected`.
|
||||||
|
- **`AudioMixer`** owns both sources; **`StartMicCaptureAsync` starts the mixer once at startup** and
|
||||||
|
`Shutdown` disposes it — NOT go-live — so the meters preview live (`BeginGoLive`/`StopStream`
|
||||||
|
no longer touch the mixer). Mic samples feed a pure **`AudioLevelMeter`** (RMS with 0.2 exponential
|
||||||
|
smoothing) → `MicLevelChanged` → marshalled to the UI thread → `AudioLevel`; loopback samples feed a
|
||||||
|
second meter → `LoopbackLevelChanged` → the game bar's `GameAudioLevel`. The raw linear level is
|
||||||
|
mapped to the meter's display scale by `AudioLevelMeter.ToDisplay` (−60..0 dBFS → 0..1): real speech/
|
||||||
|
game RMS is ~0.01..0.1 linear, which would leave a flat scale looking dead. **Mic connection state
|
||||||
|
surfaces as events:** `MicConnected` (source `Started`), `MicFailed` (source `Failed`), and
|
||||||
|
`RestartMic()` re-resolves + restarts just the mic (loopback keeps running). Failures log via
|
||||||
|
`AppLog`; a mic failure zeroes the meter, a loopback failure never kills the mic. Meter `Push` is
|
||||||
|
unconditional (a `?.` on the event would skip the argument — and the meter update — when nothing is
|
||||||
|
subscribed yet).
|
||||||
|
- **Mic status dot (`Models/MicStatus.cs`)** on the footer's MIC button: green = `MicConnected`,
|
||||||
|
yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started,
|
||||||
|
so loopback and the game bar can't run either — no capture devices at all).
|
||||||
|
- **Game audio bar** (desktop/game, only while a full-screen game is up in the preview):
|
||||||
|
`IGameAudioDetector` seam (`Services/IGameAudioDetector.cs`), pure `GameAudioHysteresis` (SHOW after
|
||||||
|
~500ms of fullscreen + sound, HIDE after ~1s away from fullscreen, **silence never hides an active
|
||||||
|
bar**), and the default `GameAudioDetector` composing `IFullScreenDetector` + the live loopback level
|
||||||
|
(floor 0.5%). WPF-free — the VM owns a 250ms `DispatcherTimer` that polls it and flips
|
||||||
|
`IsGameAudioBarVisible`. The bar is **overlaid at the bottom of the preview window** (bottom-center,
|
||||||
|
dark translucent chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI
|
||||||
|
only — it lives in the XAML preview (`MainWindow.xaml`, the PreviewGrid) and never reaches the live
|
||||||
|
output (the `SceneCompositor` doesn't know about it).
|
||||||
|
- **`WaveToFloat`** (pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct,
|
||||||
|
PCM 16-bit normalized to -1..1, `WaveFormatExtensible` with the IEEE-float subformat GUID
|
||||||
|
(`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT`), trailing partial samples ignored.
|
||||||
|
- Build **0 warnings**; **169 passing** (mixer/hysteresis/game-detector/meter-scale unit tests).
|
||||||
|
|
||||||
|
**Deferred:** the loopback→encoder AAC mix wiring (a later step — the encoder construction + frame
|
||||||
|
pipeline shipped in ship step 5).
|
||||||
|
|
||||||
|
### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md)
|
||||||
|
|
||||||
|
The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active
|
||||||
|
scene each tick, resolves every element to its latest frame, composites it into the tier's output frame,
|
||||||
|
and paces frames into the encoder at the tier's FPS. **Pattern — everything is a constructor-injected
|
||||||
|
seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
|
||||||
|
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, and an injectable pacing delay
|
||||||
|
(default `Task.Delay`; tests inject `Task.Yield`). The pump is free of WPF and of the capture managers.
|
||||||
|
|
||||||
|
- **`StartAsync` never throws** — the VM fires-and-forgets it from the sync command handler; failures log
|
||||||
|
+ surface via the `Failed` event. `EncoderOptions == null` means "no RTMP URL": the pump logs and skips
|
||||||
|
the encoder entirely. `MainViewModel._rtmpUrlProvider` is that seam — a `Func<string?>` returning null
|
||||||
|
until TASK 5 supplies the reusable stream's ingest URL, so go-live runs the current visual flow.
|
||||||
|
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
|
||||||
|
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
|
||||||
|
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
|
||||||
|
`StreamStatus.Error` (minimal).
|
||||||
|
- **Health stats (ship step 6, shipped 2026-08-13):** `HealthUpdated` is bound to the bottom bar —
|
||||||
|
`MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the encoder's stderr loop raises on
|
||||||
|
a background thread) and copies into `CurrentHealth` (the bottom bar's existing `CurrentHealth.*`
|
||||||
|
bindings). `ResetHealth(status)` zeroes dropped/duration on go-live and on End so stats never linger
|
||||||
|
from a previous session; bitrate/FPS stay on the tier's targets (`ApplyStreamQuality`). The bar shows
|
||||||
|
real encoder values once TASK 5 fills `_rtmpUrlProvider`; until then the pump skips the encoder.
|
||||||
|
- **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig` →
|
||||||
|
`CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }` →
|
||||||
|
`ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/
|
||||||
|
background → `StaticPixelCache.Get(AssetId)`. `BuildCompositorOptions` rounds the VM's `OutputRect*`
|
||||||
|
doubles to ints — the vertical 607.5 half-pixel crop rounds to a perfectly-centered **608** (`Math.Round`,
|
||||||
|
ToEven); `BuildEncoderOptions` fills W×H/FPS/bitrate from the tier once the URL provider yields one.
|
||||||
|
- **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional
|
||||||
|
`socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a
|
||||||
|
mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs`
|
||||||
|
(WPF glue — WPF glue here is fine because the strip is rendered once per config change on the UI thread,
|
||||||
|
the resulting immutable frame is then composited pure-CPU by `SceneCompositor`), and `SceneCompositor.Render`
|
||||||
|
blits it **last — above the branding flash** at `socialBarTop` (0 = top, `SourceRectHeight − barHeight` =
|
||||||
|
bottom) in master space. `MainViewModel` owns the frame (`_socialBarFrame`, rebuilt by `RenderSocialBarFrame`
|
||||||
|
on load/save/`NotifySocialsChanged`).
|
||||||
|
- Known consideration: the pump reads the active scene on a background thread while the UI can still edit
|
||||||
|
it; a concurrent-mutation exception is contained (logged + `Failed` + the pump stops) rather than
|
||||||
|
crashing. The background thread + video pipeline is the new reality since this step.
|
||||||
|
|
||||||
|
### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13)
|
||||||
|
|
||||||
|
A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the
|
||||||
|
output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a
|
||||||
|
**click-toggle top ⇄ bottom** (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum
|
||||||
|
(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/
|
||||||
|
Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/
|
||||||
|
`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped,
|
||||||
|
column back-compat). Configured in the "Social Media Site Promotion" dialog (`SocialsDialog.xaml` +
|
||||||
|
`ViewModels/SocialsDialogViewModel`, WPF-free, injected `ISocialValidator` + sign-in/sign-out fakes):
|
||||||
|
ON/OFF bar switch (schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle;
|
||||||
|
signed-out → sign-in gate → OAuth; delete → confirm sign-out), row 2 free, rows 3–6 lock icons on
|
||||||
|
freemium. Service detection (`SocialServiceIcons.DetectService`): URL domain / fediverse `@user@domain` →
|
||||||
|
**Fediverse** / bare→Website. Validation (`Services/SocialValidator.cs`, `ISocialValidator` seam +
|
||||||
|
`HttpSocialValidator` default): async GET of the canonical profile URL; 200/redirect = valid, 404/failure =
|
||||||
|
rejected. Fediverse additionally does a **best-effort nodeinfo lookup** (`/.well-known/nodeinfo` →
|
||||||
|
`software.name`, stored in `SocialEntry.FediverseSoftware` / the `SocialEntry.Software` column,
|
||||||
|
column-presence migration, no version bump) so the entry shows the **instance's real logo**
|
||||||
|
(`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse
|
||||||
|
honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodeinfo failure still validates
|
||||||
|
(glyph falls back). If the identity domain's nodeinfo is blocked (YunoHost SSO gates
|
||||||
|
`/.well-known/nodeinfo` behind the login page) but the bare root 302s to the real instance, the lookup
|
||||||
|
**follows the root redirect** and asks the resolved host. Cancel is a hard stop: `LookupAsync` takes a
|
||||||
|
`CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations
|
||||||
|
never touch slot state).
|
||||||
|
|
||||||
|
**Bar bug-fix branch (2026-08-13) — three changes:**
|
||||||
|
|
||||||
|
1. **Positioning is a click-toggle (KISS)** — the original drag set a local `Canvas.SetTop` value that
|
||||||
|
permanently overrides the `{Binding SocialBarTop}` (a binding can never win over a local value), so the
|
||||||
|
bar stayed wherever it was dropped. The first drag-snap fix (`SocialBarSnap.Decide` + `ClearValue` on
|
||||||
|
release) still failed for shaky hands — jitter around the ±6px deadzone snapped the bar up but wouldn't
|
||||||
|
let it come back down. **Superseded by the user's click-toggle:** clicking the bar flips it top ⇄ bottom
|
||||||
|
(`MainViewModel.ToggleSocialBarPosition`), the bar rides the binding alone, and `SocialBarSnap` is gone.
|
||||||
|
2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because
|
||||||
|
nodeinfo was only ever asked of the identity domain (a landing page; the real instance is
|
||||||
|
`mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` now **probes
|
||||||
|
well-known subdomains** (`mastodon.` → `social.` → … `FediverseSubdomainCandidates`) when the identity
|
||||||
|
domain and its redirect both come up empty, under a ~15s linked-CTS budget. `MainViewModel` runs the
|
||||||
|
static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher,
|
||||||
|
and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place.
|
||||||
|
3. **The bar renders on the live output** — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a
|
||||||
|
transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in,
|
||||||
|
Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame
|
||||||
|
pipeline").
|
||||||
|
|
||||||
|
### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`)
|
||||||
|
|
||||||
|
This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
|
||||||
|
guardrails below — written down so a future "quick fix" never reintroduces a GPL binary. **NEVER:**
|
||||||
|
|
||||||
|
- **Use a GPL FFmpeg build** — gyan.dev's builds are GPLv3 and ship libx264; BtbN's `gpl` variant is
|
||||||
|
GPL too. GPL in a distributed paid product is the #1 lawsuit risk. Only BtbN `lgpl` / `lgpl-shared`
|
||||||
|
builds are allowed.
|
||||||
|
- **Distribute the static lgpl build** — LGPLv2.1 §6 wants relinkable object files for static linking.
|
||||||
|
The **shared** (dynamic-DLL) build sidesteps that: compliance is "license text + source offer +
|
||||||
|
unmodified binaries". The pin is `lgpl-shared`; when the pin is refreshed, keep the shared variant.
|
||||||
|
- **Use BtbN's `nonfree` variant** — it adds fdk-aac (Fraunhofer code licensing). The native FFmpeg AAC
|
||||||
|
encoder is fine (no Fraunhofer code) but grants no AAC patent license — accepted low-risk posture for
|
||||||
|
RTMP→YouTube, since encoder vendors cover their implementations (Cisco OpenH264, NVIDIA NVENC, Intel
|
||||||
|
QSV, AMD AMF).
|
||||||
|
- **Link FFmpeg into the app** — it stays a separate subprocess fed frames over a pipe; that separation
|
||||||
|
keeps the app's own code out of LGPL reach.
|
||||||
|
- **Drop `THIRD-PARTY-NOTICES.txt`** from the shipped app or the About screen, or alter the FFmpeg
|
||||||
|
copyright/LGPL notices inside the downloaded binaries. Automating the download counts as distribution
|
||||||
|
— the obligations are not optional.
|
||||||
|
- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give
|
||||||
|
a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves.
|
||||||
|
- **Use non-CC0 icon art** — the social bar's bundled SVG logo path data comes from **Simple Icons**
|
||||||
|
(CC0 1.0, public domain — see `THIRD-PARTY-NOTICES.txt`). Replacing or adding logos must stay CC0 or
|
||||||
|
another public-domain source; a logo asset under a copyleft or attribution license would contaminate
|
||||||
|
the paid product.
|
||||||
|
- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at
|
||||||
|
**v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the
|
||||||
|
release blocker). Queued early is wrong; the release pass owns it.
|
||||||
|
|
||||||
## Design Principle
|
## Design Principle
|
||||||
|
|
||||||
@@ -53,35 +537,44 @@ Apply this to every UI decision:
|
|||||||
- Visual/drag-and-drop scene building over property panels
|
- Visual/drag-and-drop scene building over property panels
|
||||||
- Every action produces a visible outcome — no dead ends
|
- Every action produces a visible outcome — no dead ends
|
||||||
|
|
||||||
## Monetization (design decision — the watermark is the sword)
|
## Monetization (design decision — the branding flash is the sword)
|
||||||
|
|
||||||
Free forever: all streams unlimited, no time caps, no subscription, no per-feature paywalls. The
|
Free forever: all streams unlimited, no time caps, no subscription, no per-feature paywalls. The
|
||||||
**one paid line is a one-time unlock** (delivered via itch.io — they handle hosting, payment, and key
|
**one paid line is a one-time unlock** (delivered via itch.io — they handle hosting, payment, and key
|
||||||
delivery; we never own a server or a key shop):
|
delivery; we never own a server or a key shop):
|
||||||
|
|
||||||
- **Free:** a small "made with ytLlive" watermark is always on, every frame, every stream — the sword
|
- **Free:** a periodic full-frame branding flash — "made with ytLlive!" rendered big and centered at
|
||||||
of Damocles. Standard practice; only Streamlabs runs watermark-nagging to a capitalist extreme.
|
~25% opacity for about one second (soft 250ms fade in/out), repeated every 300s, on the live output
|
||||||
- **Paid (one-time):** watermark removed + **Alerts** (Super Chat / membership / subscribe pop-ins).
|
(and on v0.2 local recordings). Implemented as `BrandFlashLayer` in the preview compositor
|
||||||
|
(`MainWindow.xaml` CanvasGrid) + `BrandFlashTimer` in `MainViewModel` — cadence 300s, first flash
|
||||||
|
~5s after go-live, only while live or recording. An always-on watermark can be cropped or covered;
|
||||||
|
an intermittent full-frame flash can't be cropped and is impractical to edit around on a live feed.
|
||||||
|
- **Paid (one-time):** branding flash removed (flips `BrandFlashEnabled` off) + **Alerts**
|
||||||
|
(Super Chat / membership / subscribe pop-ins).
|
||||||
|
|
||||||
Deliberately rejected: hard stream-time cutoffs (the worst dead end — a stream dying mid-broadcast
|
Deliberately rejected: always-on watermark (obscurable — replaced by the flash), hard stream-time
|
||||||
reads as broken, and YouTube streams routinely run 2-4 hours), soft-limit nagging, freemium tiers,
|
cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams
|
||||||
and donation-only (relies on the kindness of strangers). Resolution/quality ceilings are **deferred** —
|
routinely run 2-4 hours), soft-limit nagging, freemium tiers, and donation-only (relies on the
|
||||||
that decision belongs to the resolution & streaming-constraints conversation, not monetization.
|
kindness of strangers). Resolution/quality ceilings are **deferred** — that decision belongs to the
|
||||||
|
resolution & streaming-constraints conversation, not monetization.
|
||||||
|
|
||||||
## Auth gates Go Live, but not exploration
|
## Auth gates Go Live, but not exploration
|
||||||
|
|
||||||
The app is fully usable without authentication: users can build scenes, add sources, compose
|
The app is fully usable without authentication: users can build scenes, add sources, compose
|
||||||
previews, and audition the software with zero commitment. But **going live requires authentication** —
|
previews, and audition the software with zero commitment. But **going live requires authentication** —
|
||||||
it's the one capability gated behind YouTube sign-in. The sign-in button should never pressure the
|
it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user
|
||||||
user ("sign in (optional)", not a modal wall), but "Go Live" only appears once connected.
|
("sign in (optional)", not a modal wall): the two-state top bar shows **Start Stream** (offline) /
|
||||||
|
**End Stream** (live), and the Start Stream dialog hosts the account — a saved session appears as
|
||||||
|
the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and
|
||||||
|
the Start button stays disabled until signed in.
|
||||||
|
|
||||||
## Account assumption (do not build an account setup flow)
|
## Account assumption (do not build an account setup flow)
|
||||||
|
|
||||||
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
|
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
|
||||||
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
|
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
|
||||||
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
|
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
|
||||||
replaced by an in-app account-creation step. Zero state = a Connect button that starts OAuth; going
|
replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to
|
||||||
live is unreachable until the account is connected.
|
YouTube" button; going live is unreachable until an account is connected.
|
||||||
|
|
||||||
## YouTube Live API — design constraints (do not violate)
|
## YouTube Live API — design constraints (do not violate)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
WINPATH=$(wslpath -w "$PWD")
|
||||||
|
"/mnt/c/Program Files/dotnet/dotnet.exe" run --project "$WINPATH\\ytLive.csproj"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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? |
|
||||||
|
|------|------|--------------|
|
||||||
|
| `AGENTS.md` | Auto-read on-ramp for AI sessions — points to the map and the working rules | **always read first** |
|
||||||
|
| `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 |
|
||||||
|
| `HANDOFF.md` | Current operational state: what's in flight, landmines, next step, secret/DB/port locations | yes — trust it as current state |
|
||||||
|
| `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. `AGENTS.md` — auto-read on-ramp; it points you here.
|
||||||
|
2. `ai.md` — what this app is, how it's built, current state, open decisions.
|
||||||
|
3. `TASKS.md` — what's done, what's next, and the YouTube API constraints.
|
||||||
|
4. `<dir>/index.md` — to understand a directory before reading its files.
|
||||||
|
5. The source files themselves — always the authority over any memory file.
|
||||||
|
|
||||||
|
## Integration rule
|
||||||
|
|
||||||
|
Every feature change ships with its memory update **in the same commit**:
|
||||||
|
`ai.md` for architecture / patterns, `TASKS.md` for status, index files when
|
||||||
|
the layout changes. No code commit without its docs — a follow-up "docs
|
||||||
|
backfill" commit is a broken rule, not a style. That is what keeps the map
|
||||||
|
accurate enough to trust next session.
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
using NAudio.Wave;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Services.Audio;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TASK 4 ship step 4: WASAPI audio capture behind the <see cref="IAudioSource"/>
|
||||||
|
/// seam. The units pin down the pure pieces — byte→float conversion, the level
|
||||||
|
/// meter math, and the mixer lifecycle/forwarding against fakes. No real audio
|
||||||
|
/// devices (NAudio device resolution is a thin wrapper left to a manual smoke
|
||||||
|
/// test), no timers.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioMixerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeSource : IAudioSource
|
||||||
|
{
|
||||||
|
public int StartCount { get; private set; }
|
||||||
|
public int StopCount { get; private set; }
|
||||||
|
public bool Disposed { get; private set; }
|
||||||
|
public event Action? Started;
|
||||||
|
public event Action<AudioSample>? SampleReady;
|
||||||
|
public event Action<Exception>? Failed;
|
||||||
|
|
||||||
|
public void Start() => StartCount++;
|
||||||
|
public void Stop() => StopCount++;
|
||||||
|
public void Dispose() => Disposed = true;
|
||||||
|
|
||||||
|
public void MarkStarted() => Started?.Invoke();
|
||||||
|
public void Emit(AudioSample sample) => SampleReady?.Invoke(sample);
|
||||||
|
public void Fail(Exception ex) => Failed?.Invoke(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_StartsBothSources()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, loopback);
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
|
||||||
|
Assert.Equal(1, mic.StartCount);
|
||||||
|
Assert.Equal(1, loopback.StartCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_IsIdempotent()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource());
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
mixer.Start();
|
||||||
|
|
||||||
|
Assert.Equal(1, mic.StartCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stop_StopsBothAndResetsLevel()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, loopback);
|
||||||
|
mixer.Start();
|
||||||
|
mic.Emit(new AudioSample(new[] { 0.8f }, 48000, 1));
|
||||||
|
|
||||||
|
var last = -1f;
|
||||||
|
mixer.MicLevelChanged += l => last = l;
|
||||||
|
mixer.Stop();
|
||||||
|
|
||||||
|
Assert.Equal(1, mic.StopCount);
|
||||||
|
Assert.Equal(1, loopback.StopCount);
|
||||||
|
Assert.Equal(0f, last);
|
||||||
|
Assert.Equal(0f, mixer.MicLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stop_WithNoStart_DoesNothing()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource());
|
||||||
|
|
||||||
|
mixer.Stop();
|
||||||
|
|
||||||
|
Assert.Equal(0, mic.StopCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MicSamples_DriveMicLevelChanged()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource());
|
||||||
|
var levels = new List<float>();
|
||||||
|
mixer.MicLevelChanged += l => levels.Add(l);
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||||
|
mic.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 1));
|
||||||
|
|
||||||
|
Assert.NotEmpty(levels);
|
||||||
|
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoopbackSamples_DoNotChangeMicLevel()
|
||||||
|
{
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(new FakeSource(), loopback);
|
||||||
|
var levels = new List<float>();
|
||||||
|
mixer.MicLevelChanged += l => levels.Add(l);
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
||||||
|
|
||||||
|
Assert.Empty(levels);
|
||||||
|
Assert.Equal(0f, mixer.MicLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MicSamples_DoNotChangeLoopbackLevel()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource());
|
||||||
|
var levels = new List<float>();
|
||||||
|
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||||
|
|
||||||
|
Assert.Empty(levels);
|
||||||
|
Assert.Equal(0f, mixer.LoopbackLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoopbackSamples_DriveLoopbackLevelChanged()
|
||||||
|
{
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(new FakeSource(), loopback);
|
||||||
|
var levels = new List<float>();
|
||||||
|
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
||||||
|
loopback.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 2));
|
||||||
|
|
||||||
|
Assert.NotEmpty(levels);
|
||||||
|
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MicStarted_RaisesMicConnected()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource());
|
||||||
|
var connected = 0;
|
||||||
|
mixer.MicConnected += () => connected++;
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
mic.MarkStarted();
|
||||||
|
|
||||||
|
Assert.Equal(1, connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MicFailure_RaisesMicFailed()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource());
|
||||||
|
Exception? failed = null;
|
||||||
|
mixer.MicFailed += ex => failed = ex;
|
||||||
|
|
||||||
|
mixer.Start();
|
||||||
|
mic.Fail(new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
Assert.NotNull(failed);
|
||||||
|
Assert.Equal("boom", failed!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestartMic_StopsAndRestartsMic_KeepsLoopbackRunning()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, loopback);
|
||||||
|
mixer.Start();
|
||||||
|
|
||||||
|
mixer.RestartMic();
|
||||||
|
|
||||||
|
Assert.Equal(2, mic.StartCount);
|
||||||
|
Assert.Equal(1, mic.StopCount);
|
||||||
|
Assert.Equal(1, loopback.StartCount);
|
||||||
|
Assert.Equal(0, loopback.StopCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestartMic_ResetsLevel()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource());
|
||||||
|
mixer.Start();
|
||||||
|
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||||
|
Assert.True(mixer.MicLevel > 0);
|
||||||
|
|
||||||
|
float? reset = null;
|
||||||
|
mixer.MicLevelChanged += l => reset = l;
|
||||||
|
mixer.RestartMic();
|
||||||
|
|
||||||
|
Assert.Equal(0f, reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MicFailure_LogsAndResetsLevel()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var logs = new List<string>();
|
||||||
|
var mixer = new AudioMixer(mic, new FakeSource(), m => logs.Add(m));
|
||||||
|
mixer.Start();
|
||||||
|
mic.Emit(new AudioSample(new[] { 0.5f }, 48000, 1));
|
||||||
|
|
||||||
|
var last = -1f;
|
||||||
|
mixer.MicLevelChanged += l => last = l;
|
||||||
|
mic.Fail(new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
Assert.Contains(logs, l => l.Contains("boom"));
|
||||||
|
Assert.Equal(0f, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoopbackFailure_LogsButKeepsMic()
|
||||||
|
{
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var logs = new List<string>();
|
||||||
|
var mixer = new AudioMixer(new FakeSource(), loopback, m => logs.Add(m));
|
||||||
|
mixer.Start();
|
||||||
|
|
||||||
|
var last = -1f;
|
||||||
|
mixer.MicLevelChanged += l => last = l;
|
||||||
|
loopback.Fail(new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
Assert.Contains(logs, l => l.Contains("boom"));
|
||||||
|
Assert.Equal(-1f, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_StopsAndDisposesSources()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, loopback);
|
||||||
|
mixer.Start();
|
||||||
|
|
||||||
|
mixer.Dispose();
|
||||||
|
|
||||||
|
Assert.True(mic.Disposed);
|
||||||
|
Assert.True(loopback.Disposed);
|
||||||
|
Assert.Equal(1, mic.StopCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_UnsubscribesEvents()
|
||||||
|
{
|
||||||
|
var mic = new FakeSource();
|
||||||
|
var loopback = new FakeSource();
|
||||||
|
var mixer = new AudioMixer(mic, loopback);
|
||||||
|
mixer.Dispose();
|
||||||
|
|
||||||
|
var levels = new List<float>();
|
||||||
|
mixer.MicLevelChanged += l => levels.Add(l);
|
||||||
|
mic.Emit(new AudioSample(new[] { 1f }, 48000, 1));
|
||||||
|
|
||||||
|
Assert.Empty(levels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AudioLevelMeterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ConstantSine_ConvergesToRms()
|
||||||
|
{
|
||||||
|
var meter = new AudioLevelMeter();
|
||||||
|
var sample = new AudioSample(new[] { 0.5f, -0.5f, 0.5f, -0.5f }, 48000, 1);
|
||||||
|
|
||||||
|
float level = 0;
|
||||||
|
for (var i = 0; i < 30; i++)
|
||||||
|
level = meter.Push(sample);
|
||||||
|
|
||||||
|
Assert.InRange(level, 0.45f, 0.55f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Silence_DrivesTowardZero()
|
||||||
|
{
|
||||||
|
var meter = new AudioLevelMeter();
|
||||||
|
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||||
|
var samples = new float[1024];
|
||||||
|
var sample = new AudioSample(samples, 48000, 1);
|
||||||
|
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
meter.Push(sample);
|
||||||
|
|
||||||
|
Assert.Equal(0f, meter.Level, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptySample_KeepsLevel()
|
||||||
|
{
|
||||||
|
var meter = new AudioLevelMeter();
|
||||||
|
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||||
|
var before = meter.Level;
|
||||||
|
|
||||||
|
var level = meter.Push(new AudioSample(Array.Empty<float>(), 48000, 1));
|
||||||
|
|
||||||
|
Assert.Equal(before, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reset_ZerosLevel()
|
||||||
|
{
|
||||||
|
var meter = new AudioLevelMeter();
|
||||||
|
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
||||||
|
|
||||||
|
meter.Reset();
|
||||||
|
|
||||||
|
Assert.Equal(0f, meter.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDisplay_IsLogarithmic()
|
||||||
|
{
|
||||||
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f));
|
||||||
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2);
|
||||||
|
|
||||||
|
// -60 dBFS floor → 0, 0 dBFS → 1, and a decade (~-20 dB) is 1/3 up the bar.
|
||||||
|
Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3);
|
||||||
|
Assert.Equal(0.5f, AudioLevelMeter.ToDisplay(0.0316f), 2);
|
||||||
|
Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.66f, 0.67f);
|
||||||
|
Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.32f, 0.34f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDisplay_ClampsAtFloor()
|
||||||
|
{
|
||||||
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.0001f));
|
||||||
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(-1f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WaveToFloatTests
|
||||||
|
{
|
||||||
|
private static byte[] FloatSamples(params float[] values)
|
||||||
|
{
|
||||||
|
var bytes = new byte[values.Length * 4];
|
||||||
|
Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IeeeFloat32_PreservesValues()
|
||||||
|
{
|
||||||
|
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||||
|
var samples = WaveToFloat.Convert(FloatSamples(0.25f, -0.5f, 1f), 12, format);
|
||||||
|
|
||||||
|
Assert.Equal(3, samples.Length);
|
||||||
|
Assert.Equal(0.25f, samples[0], 4);
|
||||||
|
Assert.Equal(-0.5f, samples[1], 4);
|
||||||
|
Assert.Equal(1f, samples[2], 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pcm16_NormalizesToUnitRange()
|
||||||
|
{
|
||||||
|
var format = WaveFormat.CreateCustomFormat(WaveFormatEncoding.Pcm, 48000, 1, 48000 * 2, 2, 16);
|
||||||
|
var bytes = new byte[] { 0x00, 0x00, 0xFF, 0x7F, 0x00, 0x80 };
|
||||||
|
var samples = WaveToFloat.Convert(bytes, 6, format);
|
||||||
|
|
||||||
|
Assert.Equal(3, samples.Length);
|
||||||
|
Assert.Equal(0f, samples[0], 4);
|
||||||
|
Assert.Equal(1f, samples[1], 4);
|
||||||
|
Assert.Equal(-1f, samples[2], 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TruncatedTrailingBytes_AreIgnored()
|
||||||
|
{
|
||||||
|
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||||
|
var bytes = new byte[] { 0, 0, 0x80, 0x3F, 1, 2, 3 }; // 1 float + 3 stray bytes
|
||||||
|
|
||||||
|
var samples = WaveToFloat.Convert(bytes, bytes.Length, format);
|
||||||
|
|
||||||
|
Assert.Single(samples);
|
||||||
|
Assert.Equal(1f, samples[0], 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The backdrop is a permanent, non-deletable bottom layer holding live
|
||||||
|
/// desktop/game capture, and it exists only in the canonical Live scene (see
|
||||||
|
/// <see cref="SceneCatalog"/>). Every backdrop-enabled scene
|
||||||
|
/// (<see cref="Scene.HasBackdrop"/>) is healed to have exactly one, inserted
|
||||||
|
/// first; it is never reorderable or removable by the UI. Scenes without the
|
||||||
|
/// flag never get one.
|
||||||
|
/// </summary>
|
||||||
|
public class BackdropTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EnsureBackdrop_OnEmptyScene_InsertsFullFrameBackdrop()
|
||||||
|
{
|
||||||
|
var scene = new Scene { Name = "S", HasBackdrop = true };
|
||||||
|
|
||||||
|
var backdrop = MainViewModel.EnsureBackdrop(scene);
|
||||||
|
|
||||||
|
Assert.NotNull(backdrop);
|
||||||
|
Assert.Single(scene.Elements);
|
||||||
|
Assert.Same(backdrop, scene.Elements[0]);
|
||||||
|
Assert.True(backdrop.IsBackdrop);
|
||||||
|
Assert.True(backdrop.IsEnabled);
|
||||||
|
Assert.Equal(SourceType.DisplayCapture, backdrop.Type);
|
||||||
|
Assert.Equal(0, backdrop.X);
|
||||||
|
Assert.Equal(0, backdrop.Y);
|
||||||
|
Assert.Equal(1920, backdrop.Width);
|
||||||
|
Assert.Equal(1080, backdrop.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureBackdrop_ExistingBackdrop_IsIdempotent()
|
||||||
|
{
|
||||||
|
var scene = new Scene { Name = "S", HasBackdrop = true };
|
||||||
|
var first = MainViewModel.EnsureBackdrop(scene);
|
||||||
|
var second = MainViewModel.EnsureBackdrop(scene);
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
Assert.Single(scene.Elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureBackdrop_HealsMissingBackdropAtIndexZero()
|
||||||
|
{
|
||||||
|
var scene = new Scene { Name = "S", HasBackdrop = true };
|
||||||
|
var image = new Source { Name = "Logo", Type = SourceType.Image };
|
||||||
|
scene.Elements.Add(image);
|
||||||
|
|
||||||
|
MainViewModel.EnsureBackdrop(scene);
|
||||||
|
|
||||||
|
Assert.Equal(2, scene.Elements.Count);
|
||||||
|
Assert.True(((Source)scene.Elements[0]).IsBackdrop);
|
||||||
|
Assert.Same(image, scene.Elements[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureBackdrop_SceneWithoutHasBackdrop_ReturnsNullAndInsertsNothing()
|
||||||
|
{
|
||||||
|
var scene = new Scene { Name = "Ending", HasBackdrop = false };
|
||||||
|
|
||||||
|
var backdrop = MainViewModel.EnsureBackdrop(scene);
|
||||||
|
|
||||||
|
Assert.Null(backdrop);
|
||||||
|
Assert.Empty(scene.Elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(SourceType.DisplayCapture, true)]
|
||||||
|
[InlineData(SourceType.WindowCapture, true)]
|
||||||
|
[InlineData(SourceType.Background, false)]
|
||||||
|
[InlineData(SourceType.Image, false)]
|
||||||
|
[InlineData(SourceType.TextOverlay, false)]
|
||||||
|
public void Source_IsLiveCapture_OnlyForDisplayAndWindow(SourceType type, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, new Source { Type = type }.IsLiveCapture);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Source_DisplaySource_IsVideoImageSource_WhenLiveCapture()
|
||||||
|
{
|
||||||
|
var source = new Source { Type = SourceType.DisplayCapture };
|
||||||
|
var bitmap = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||||
|
2, 2, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
|
||||||
|
source.VideoImageSource = bitmap;
|
||||||
|
|
||||||
|
Assert.Same(bitmap, source.DisplaySource);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Source_DisplaySource_IsImageSource_WhenNotLiveCapture()
|
||||||
|
{
|
||||||
|
var source = new Source { Type = SourceType.Background };
|
||||||
|
var bitmap = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||||
|
2, 2, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
|
||||||
|
source.VideoImageSource = bitmap;
|
||||||
|
|
||||||
|
Assert.NotSame(bitmap, source.DisplaySource);
|
||||||
|
Assert.Null(source.DisplaySource);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Source_TypeChange_RaisesDisplaySource()
|
||||||
|
{
|
||||||
|
var source = new Source { Type = SourceType.Image };
|
||||||
|
var raised = new List<string>();
|
||||||
|
source.PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? string.Empty);
|
||||||
|
|
||||||
|
source.Type = SourceType.DisplayCapture;
|
||||||
|
|
||||||
|
Assert.Contains(nameof(Source.IsLiveCapture), raised);
|
||||||
|
Assert.Contains(nameof(Source.DisplaySource), raised);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
public class CameraManagerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeFrameSource : ICameraFrameSource
|
||||||
|
{
|
||||||
|
private readonly List<string> _started;
|
||||||
|
private readonly List<string> _stopped;
|
||||||
|
private readonly VideoFrame? _pumpOnStart;
|
||||||
|
private readonly string? _failOnStart;
|
||||||
|
|
||||||
|
public string DeviceId { get; }
|
||||||
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
|
public event Action<string>? SourceFailed;
|
||||||
|
|
||||||
|
public FakeFrameSource(string deviceId, List<string> started, List<string> stopped,
|
||||||
|
VideoFrame? pumpOnStart = null, string? failOnStart = null)
|
||||||
|
{
|
||||||
|
DeviceId = deviceId;
|
||||||
|
_started = started;
|
||||||
|
_stopped = stopped;
|
||||||
|
_pumpOnStart = pumpOnStart;
|
||||||
|
_failOnStart = failOnStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync()
|
||||||
|
{
|
||||||
|
if (_failOnStart != null) throw new InvalidOperationException(_failOnStart);
|
||||||
|
_started.Add(DeviceId);
|
||||||
|
if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync()
|
||||||
|
{
|
||||||
|
_stopped.Add(DeviceId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||||
|
public void Fail(string message) => SourceFailed?.Invoke(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refcount/coalescing tests model a camera whose frame pump is driven by
|
||||||
|
// the test after acquire, so they skip the first-frame proof (TimeSpan.Zero);
|
||||||
|
// the proof itself is exercised by the dedicated tests below.
|
||||||
|
private static CameraManager CreateManager(List<string> started, List<string> stopped)
|
||||||
|
=> new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped),
|
||||||
|
null, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private sealed class FakeEnumerator : ICameraEnumerator
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
|
||||||
|
=> Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(new[]
|
||||||
|
{
|
||||||
|
new CameraDeviceInfo("dev1", "Logitech C920"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
|
||||||
|
{
|
||||||
|
var started = new List<string>();
|
||||||
|
var stopped = new List<string>();
|
||||||
|
var manager = CreateManager(started, stopped);
|
||||||
|
|
||||||
|
Assert.True(await manager.AcquireAsync("dev1"));
|
||||||
|
Assert.True(await manager.AcquireAsync("dev1"));
|
||||||
|
Assert.Single(started);
|
||||||
|
|
||||||
|
await manager.ReleaseAsync("dev1");
|
||||||
|
Assert.Empty(stopped);
|
||||||
|
|
||||||
|
await manager.ReleaseAsync("dev1");
|
||||||
|
Assert.Single(stopped);
|
||||||
|
Assert.Null(manager.GetLatestFrame("dev1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FramePump_UpdatesLatestFrame()
|
||||||
|
{
|
||||||
|
var started = new List<string>();
|
||||||
|
var stopped = new List<string>();
|
||||||
|
FakeFrameSource? captured = null;
|
||||||
|
var manager = new CameraManager(
|
||||||
|
new FakeEnumerator(),
|
||||||
|
id => captured = new FakeFrameSource(id, started, stopped),
|
||||||
|
null, TimeSpan.Zero);
|
||||||
|
|
||||||
|
await manager.AcquireAsync("dev1");
|
||||||
|
var frame = new VideoFrame(2, 2, new byte[16]);
|
||||||
|
captured!.Pump(frame);
|
||||||
|
|
||||||
|
Assert.Same(frame, manager.GetLatestFrame("dev1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_FailingCamera_ReturnsFalseAndStops()
|
||||||
|
{
|
||||||
|
var manager = new CameraManager(
|
||||||
|
new FakeEnumerator(),
|
||||||
|
id => new FakeFrameSource(id, new List<string>(), new List<string>(), failOnStart: "camera in use"),
|
||||||
|
null, TimeSpan.Zero);
|
||||||
|
string? failedDevice = null;
|
||||||
|
manager.CameraFailed += (device, _) => failedDevice = device;
|
||||||
|
|
||||||
|
Assert.False(await manager.AcquireAsync("dev1"));
|
||||||
|
Assert.Equal("dev1", failedDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_EmptyDeviceId_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var manager = CreateManager(new List<string>(), new List<string>());
|
||||||
|
Assert.False(await manager.AcquireAsync(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── First-frame proof (the integration test for this change) ───
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_SilentCamera_NoFirstFrame_FailsAndSurfacesCameraFailed()
|
||||||
|
{
|
||||||
|
var stopped = new List<string>();
|
||||||
|
var manager = new CameraManager(
|
||||||
|
new FakeEnumerator(),
|
||||||
|
id => new FakeFrameSource(id, new List<string>(), stopped),
|
||||||
|
null, TimeSpan.FromMilliseconds(150));
|
||||||
|
string? failedDevice = null;
|
||||||
|
string? failedMessage = null;
|
||||||
|
manager.CameraFailed += (device, message) =>
|
||||||
|
{
|
||||||
|
failedDevice = device;
|
||||||
|
failedMessage = message;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reader "starts" fine but never delivers a frame — the exact
|
||||||
|
// silent-empty-box scenario. Must fail, be reported, and be rolled back.
|
||||||
|
Assert.False(await manager.AcquireAsync("dev1"));
|
||||||
|
Assert.Equal("dev1", failedDevice);
|
||||||
|
Assert.Contains("no frames", failedMessage);
|
||||||
|
Assert.Null(manager.GetLatestFrame("dev1"));
|
||||||
|
Assert.Contains("dev1", stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_FirstFrameProvesAlive_ReturnsTrueWithoutWaitingTimeout()
|
||||||
|
{
|
||||||
|
var manager = new CameraManager(
|
||||||
|
new FakeEnumerator(),
|
||||||
|
id => new FakeFrameSource(id, new List<string>(), new List<string>(),
|
||||||
|
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
|
||||||
|
null, TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var started = await manager.AcquireAsync("dev1");
|
||||||
|
|
||||||
|
Assert.True(started);
|
||||||
|
Assert.NotNull(manager.GetLatestFrame("dev1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_SourceFailureAfterStart_SurfacesCameraFailedAndRollsBack()
|
||||||
|
{
|
||||||
|
var stopped = new List<string>();
|
||||||
|
FakeFrameSource? source = null;
|
||||||
|
var manager = new CameraManager(
|
||||||
|
new FakeEnumerator(),
|
||||||
|
id => source = new FakeFrameSource(id, new List<string>(), stopped,
|
||||||
|
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
|
||||||
|
null, TimeSpan.Zero);
|
||||||
|
string? failedMessage = null;
|
||||||
|
manager.CameraFailed += (_, message) => failedMessage = message;
|
||||||
|
|
||||||
|
Assert.True(await manager.AcquireAsync("dev1"));
|
||||||
|
source!.Fail("capture failed (0x8007001F): device not available");
|
||||||
|
|
||||||
|
Assert.Contains("device not available", failedMessage);
|
||||||
|
Assert.Null(manager.GetLatestFrame("dev1"));
|
||||||
|
Assert.Contains("dev1", stopped);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The FFmpeg subprocess encoder (TASK 4 ship step 3). The integration test drives
|
||||||
|
/// the full lifecycle against fakes: encoder probe → subprocess start with the right
|
||||||
|
/// args → raw frames into stdin → stderr progress parsed into health → graceful stop.
|
||||||
|
/// The units pin down the pure pieces (args, progress parser, encoder picker) and
|
||||||
|
/// the failure edges. No real ffmpeg binary, no network.
|
||||||
|
/// </summary>
|
||||||
|
public class FfmpegEncoderTests
|
||||||
|
{
|
||||||
|
private sealed class QueuedReader : TextReader
|
||||||
|
{
|
||||||
|
private readonly Channel<string?> _channel = Channel.CreateUnbounded<string?>();
|
||||||
|
public void Enqueue(string s) => _channel.Writer.TryWrite(s);
|
||||||
|
public void Complete() => _channel.Writer.TryComplete();
|
||||||
|
public override string? ReadLine() => ReadLineAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
public override async Task<string?> ReadLineAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _channel.Reader.ReadAsync().AsTask().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (ChannelClosedException)
|
||||||
|
{
|
||||||
|
return null; // stream EOF — the process exited and the pipe closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeEncoderProcess : IEncoderProcess
|
||||||
|
{
|
||||||
|
public ProcessStartInfo? StartInfo { get; private set; }
|
||||||
|
public MemoryStream Stdin { get; } = new();
|
||||||
|
public Stream StandardInput => Stdin;
|
||||||
|
public TextReader StandardOutput { get; }
|
||||||
|
public bool HasExited { get; private set; }
|
||||||
|
public int ExitCode { get; private set; }
|
||||||
|
public bool Killed { get; private set; }
|
||||||
|
public bool Started { get; private set; }
|
||||||
|
|
||||||
|
private readonly TaskCompletionSource _exit =
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly QueuedReader _error = new();
|
||||||
|
|
||||||
|
public FakeEncoderProcess(string probeOutput = "")
|
||||||
|
=> StandardOutput = new StringReader(probeOutput);
|
||||||
|
|
||||||
|
public TextReader StandardError => _error;
|
||||||
|
public void EnqueueStderr(string line) => _error.Enqueue(line);
|
||||||
|
|
||||||
|
public void Start(ProcessStartInfo startInfo)
|
||||||
|
{
|
||||||
|
StartInfo = startInfo;
|
||||||
|
Started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SignalExit(int code = 0)
|
||||||
|
{
|
||||||
|
ExitCode = code;
|
||||||
|
HasExited = true;
|
||||||
|
_error.Complete();
|
||||||
|
_exit.TrySetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill()
|
||||||
|
{
|
||||||
|
Killed = true;
|
||||||
|
_error.Complete();
|
||||||
|
_exit.TrySetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _exit.Task;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_error.Complete();
|
||||||
|
_exit.TrySetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeEncoderProcessFactory
|
||||||
|
{
|
||||||
|
private readonly Queue<FakeEncoderProcess> _processes = new();
|
||||||
|
public void Return(FakeEncoderProcess p) => _processes.Enqueue(p);
|
||||||
|
public FakeEncoderProcess Create() => _processes.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubLocator : IFfmpegLocator
|
||||||
|
{
|
||||||
|
public Task<string> LocateAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(@"C:\tools\ffmpeg.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BgraFrame(int w, int h, byte b, byte g, byte r)
|
||||||
|
{
|
||||||
|
var bytes = new byte[w * h * 4];
|
||||||
|
for (var i = 0; i < bytes.Length; i += 4)
|
||||||
|
{
|
||||||
|
bytes[i] = b;
|
||||||
|
bytes[i + 1] = g;
|
||||||
|
bytes[i + 2] = r;
|
||||||
|
bytes[i + 3] = 255;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_ProbesEncoder_FeedsFrames_ParsesHealth_StopsGracefully()
|
||||||
|
{
|
||||||
|
const string encoders =
|
||||||
|
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n" +
|
||||||
|
" V..... libopenh264 OpenH264 H.264 (codec h264)\n";
|
||||||
|
var probe = new FakeEncoderProcess(encoders);
|
||||||
|
var encoderProc = new FakeEncoderProcess();
|
||||||
|
var factory = new FakeEncoderProcessFactory();
|
||||||
|
factory.Return(probe);
|
||||||
|
factory.Return(encoderProc);
|
||||||
|
|
||||||
|
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
|
||||||
|
|
||||||
|
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
encoder.HealthUpdated += (_, h) => health.TrySetResult(h);
|
||||||
|
|
||||||
|
var options = new EncoderOptions
|
||||||
|
{
|
||||||
|
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc-xyz",
|
||||||
|
Width = 1920,
|
||||||
|
Height = 1080,
|
||||||
|
Fps = 60,
|
||||||
|
BitrateKbps = 8000,
|
||||||
|
};
|
||||||
|
|
||||||
|
await encoder.StartAsync(options);
|
||||||
|
|
||||||
|
Assert.True(probe.Started, "the -encoders probe must run");
|
||||||
|
Assert.True(encoderProc.Started, "the encoder subprocess must start");
|
||||||
|
Assert.Equal(@"C:\tools\ffmpeg.exe", encoderProc.StartInfo!.FileName);
|
||||||
|
var args = encoderProc.StartInfo.ArgumentList.ToArray();
|
||||||
|
var c = Array.IndexOf(args, "-c:v");
|
||||||
|
Assert.True(
|
||||||
|
args[c + 1] == "h264_nvenc",
|
||||||
|
"hardware NVENC must be preferred over the listed openh264");
|
||||||
|
|
||||||
|
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 255, 0, 0)));
|
||||||
|
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 0, 255, 0)));
|
||||||
|
Assert.Equal(2 * 1920 * 1080 * 4, encoderProc.Stdin.Length);
|
||||||
|
|
||||||
|
encoderProc.EnqueueStderr(
|
||||||
|
"frame= 120 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.00 bitrate= 4000.1kbits/s speed=1.00x");
|
||||||
|
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(4000.1, h.CurrentBitrate, 1);
|
||||||
|
Assert.Equal(59.9, h.FPS, 1);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(2), h.StreamDuration);
|
||||||
|
|
||||||
|
encoderProc.SignalExit();
|
||||||
|
await encoder.StopAsync();
|
||||||
|
Assert.False(encoder.IsRunning);
|
||||||
|
Assert.False(encoderProc.Killed, "graceful stop must not kill the process");
|
||||||
|
Assert.Equal(StreamStatus.Offline, h.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_WithoutRtmpUrl_Throws()
|
||||||
|
{
|
||||||
|
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitFrame_WhenNotRunning_Throws()
|
||||||
|
{
|
||||||
|
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => encoder.SubmitFrameAsync(new VideoFrame(2, 2, new byte[16])));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stop_WithoutStart_IsNoop()
|
||||||
|
{
|
||||||
|
using var encoder = new FfmpegEncoder(new StubLocator());
|
||||||
|
await encoder.StopAsync();
|
||||||
|
Assert.False(encoder.IsRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessDeath_RaisesFailed()
|
||||||
|
{
|
||||||
|
var encoderProc = new FakeEncoderProcess();
|
||||||
|
var factory = new FakeEncoderProcessFactory();
|
||||||
|
factory.Return(new FakeEncoderProcess());
|
||||||
|
factory.Return(encoderProc);
|
||||||
|
|
||||||
|
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
|
||||||
|
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
encoder.ProcessFailed += (_, msg) => failed.TrySetResult(msg);
|
||||||
|
|
||||||
|
await encoder.StartAsync(new EncoderOptions { RtmpUrl = "rtmp://x/y" });
|
||||||
|
encoderProc.SignalExit(1);
|
||||||
|
var msg = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("1", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Args_Gop_IsFpsTimesFour()
|
||||||
|
{
|
||||||
|
var options = new EncoderOptions { Fps = 60 };
|
||||||
|
Assert.Equal(240, options.GopSize);
|
||||||
|
|
||||||
|
var args = FfmpegArgs.Build(options, "libopenh264").ToArray();
|
||||||
|
var g = Array.IndexOf(args, "-g");
|
||||||
|
Assert.Equal("240", args[g + 1]);
|
||||||
|
var keyint = Array.IndexOf(args, "-keyint_min");
|
||||||
|
Assert.Equal("240", args[keyint + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Args_IncludeInputOutputAndCompliance()
|
||||||
|
{
|
||||||
|
var args = FfmpegArgs.Build(
|
||||||
|
new EncoderOptions
|
||||||
|
{
|
||||||
|
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
|
||||||
|
Width = 1920,
|
||||||
|
Height = 1080,
|
||||||
|
Fps = 60,
|
||||||
|
BitrateKbps = 8000,
|
||||||
|
},
|
||||||
|
"libopenh264").ToArray();
|
||||||
|
|
||||||
|
Assert.Contains("-f", args);
|
||||||
|
Assert.Contains("rawvideo", args);
|
||||||
|
Assert.Contains("pipe:0", args);
|
||||||
|
Assert.Contains("1920x1080", args);
|
||||||
|
Assert.Contains("anullsrc=channel_layout=stereo:sample_rate=48000", args);
|
||||||
|
Assert.Contains("-sc_threshold", args);
|
||||||
|
Assert.Contains("-bf", args);
|
||||||
|
Assert.Contains("yuv420p", args);
|
||||||
|
Assert.Contains("aac", args);
|
||||||
|
Assert.Contains("flv", args);
|
||||||
|
Assert.Contains("rtmp://a.rtmp.youtube.com/live2/key", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProgressParser_ParsesRealStatsLine()
|
||||||
|
{
|
||||||
|
var p = FfmpegProgressParser.TryParse(
|
||||||
|
"frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x");
|
||||||
|
Assert.NotNull(p);
|
||||||
|
Assert.Equal(123, p.Value.Frame);
|
||||||
|
Assert.Equal(59.9, p.Value.Fps, 1);
|
||||||
|
Assert.Equal(4000.1, p.Value.BitrateKbps, 1);
|
||||||
|
Assert.Equal(2.04, p.Value.Duration.TotalSeconds, 2);
|
||||||
|
Assert.Equal(1024 * 1024, p.Value.SizeBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProgressParser_IgnoresBannerAndErrors()
|
||||||
|
{
|
||||||
|
Assert.Null(FfmpegProgressParser.TryParse("ffmpeg version 6.1 Copyright (c) 2000-2026 the FFmpeg developers"));
|
||||||
|
Assert.Null(FfmpegProgressParser.TryParse("Error while opening encoder for output stream #0:0"));
|
||||||
|
Assert.Null(FfmpegProgressParser.TryParse(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncoderPicker_PrefersHardware_NeverLibx264()
|
||||||
|
{
|
||||||
|
var listing =
|
||||||
|
" V..... libx264 libx264 H.264 / AVC (codec h264)\n" +
|
||||||
|
" V..... libopenh264 OpenH264 H.264 (codec h264)\n" +
|
||||||
|
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n";
|
||||||
|
Assert.Equal("h264_nvenc", FfmpegEncoderPicker.Pick(listing));
|
||||||
|
|
||||||
|
var onlyGpl = " V..... libx264 libx264 H.264 / AVC (codec h264)\n";
|
||||||
|
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(onlyGpl));
|
||||||
|
|
||||||
|
var software = " V..... libopenh264 OpenH264 H.264 (codec h264)\n";
|
||||||
|
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(software));
|
||||||
|
|
||||||
|
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick("no encoders at all"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user