Compare commits
52 Commits
b3c81f0af8
..
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 | |||
| 7dc8490d96 | |||
| d6ae048583 | |||
| aae17a1511 | |||
| 3caccfff1f | |||
| 4caf7eec8d | |||
| 639e1bb323 | |||
| 4293ae11a4 | |||
| 8ee44f571e | |||
| d4945f4fd0 | |||
| 8aebbff318 |
@@ -1,5 +1,6 @@
|
||||
bin/
|
||||
obj/
|
||||
Helpers/OAuthCredentials.cs
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
@@ -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"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Controls.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
||||
+25
-4
@@ -1,6 +1,6 @@
|
||||
using System.Configuration;
|
||||
using System.Data;
|
||||
using System.Windows;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
@@ -9,5 +9,26 @@ namespace ytLive;
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
public App()
|
||||
{
|
||||
AppLog.Write("App ctor");
|
||||
}
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
// The app is a WinExe (no visible console) and is often debugged from
|
||||
// WSL where it can't even run, so startup checkpoints and any unhandled
|
||||
// exception are written to %APPDATA%\ytLlive\startup.log via AppLog.
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
AppLog.Write(args.ExceptionObject is Exception ex
|
||||
? "AppDomain unhandled exception: " + ex
|
||||
: "AppDomain unhandled exception object: " + args.ExceptionObject);
|
||||
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||
|
||||
AppLog.Write("App OnStartup begin");
|
||||
base.OnStartup(e);
|
||||
AppLog.Write("App OnStartup end");
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 777 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<Window x:Class="ytLive.GoLiveWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Start Stream" Height="480" 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="Start Stream" FontSize="18" FontWeight="Bold"
|
||||
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 -->
|
||||
<StackPanel Grid.Row="2">
|
||||
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
||||
|
||||
<TextBlock Text="Title" Style="{StaticResource YtLabel}"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Text="Description" Style="{StaticResource YtLabel}"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="60" AcceptsReturn="True" Margin="0,0,0,12"
|
||||
Text="{Binding StreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,12"
|
||||
ItemsSource="{Binding Visibilities}"
|
||||
SelectedItem="{Binding Visibility}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ACTIONS -->
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Start Stream" Command="{Binding StartCommand}"
|
||||
Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Windows;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
public partial class GoLiveWindow : Window
|
||||
{
|
||||
public GoLiveWindow(GoLiveViewModel viewModel)
|
||||
{
|
||||
AppLog.Write("GoLiveWindow ctor: before InitializeComponent");
|
||||
InitializeComponent();
|
||||
AppLog.Write("GoLiveWindow ctor: after InitializeComponent");
|
||||
DataContext = viewModel;
|
||||
viewModel.StartRequested += () => DialogResult = true;
|
||||
viewModel.CancelRequested += () => DialogResult = false;
|
||||
}
|
||||
}
|
||||
+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,46 @@
|
||||
using System.IO;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public static class ImageCache
|
||||
{
|
||||
private static readonly Dictionary<string, BitmapImage> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static BitmapImage? Get(string assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||
if (Cache.TryGetValue(assetId, out var image)) return image;
|
||||
|
||||
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||
if (bytes == null || bytes.Length == 0) return null;
|
||||
|
||||
var bitmap = Decode(bytes);
|
||||
if (bitmap != null) Cache[assetId] = bitmap;
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public static void Put(string key, BitmapImage image) => Cache[key] = image;
|
||||
|
||||
public static BitmapImage? FromBytes(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(bytes, writable: false);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = stream;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapImage? Decode(byte[] bytes) => FromBytes(bytes);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public class InverseBoolToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is true ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is Visibility.Collapsed;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public class NotNullToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value != null ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is Visibility.Visible;
|
||||
}
|
||||
@@ -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.
|
||||
+1014
-120
File diff suppressed because it is too large
Load Diff
+474
-1
@@ -1,11 +1,484 @@
|
||||
using System.Windows;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _viewModel;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
AppLog.Write("MainWindow ctor: before InitializeComponent");
|
||||
InitializeComponent();
|
||||
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
||||
_viewModel = (MainViewModel)DataContext;
|
||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
// When a focused scene is deleted, never land focus on a hidden scene.
|
||||
SceneList.IsSelectable = item => item is Scene { IsHidden: false };
|
||||
UpdateTaskbarOverlay();
|
||||
UpdateSelectionOverlay();
|
||||
AppLog.Write("MainWindow ctor: end");
|
||||
}
|
||||
|
||||
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
AppLog.Write("MainWindow loaded");
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(MainViewModel.IsLive))
|
||||
UpdateTaskbarOverlay();
|
||||
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
|
||||
UpdateSelectionOverlay();
|
||||
}
|
||||
|
||||
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
_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()
|
||||
{
|
||||
TaskbarInfo.Overlay = _viewModel.IsLive
|
||||
? (ImageSource)FindResource("LiveOverlay")
|
||||
: null;
|
||||
}
|
||||
|
||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { IsVisible: true } box)
|
||||
{
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void SceneList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.AddedItems.Count > 0 && e.AddedItems[0] is Scene { IsHidden: true } hidden)
|
||||
{
|
||||
var list = (ListBox)sender;
|
||||
list.SelectedItem = _viewModel.ActiveScene;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSourceButton_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;
|
||||
}
|
||||
}
|
||||
|
||||
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 ───
|
||||
private bool _isDraggingOverlay;
|
||||
private bool _isResizing;
|
||||
private Point _grabOffset;
|
||||
private double _resizeAspect;
|
||||
|
||||
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is not DependencyObject original) return;
|
||||
if (IsDescendantOf(original, PreviewGrid)) return;
|
||||
if (IsDescendantOf(original, SourceList)) return;
|
||||
if (IsDescendantOf(original, OpacityChip)) return;
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
child = VisualTreeHelper.GetParent(child);
|
||||
return child != null;
|
||||
}
|
||||
|
||||
private void UpdateSelectionOverlay()
|
||||
{
|
||||
var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
|
||||
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (selected)
|
||||
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)
|
||||
=> 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)
|
||||
{
|
||||
if (OpacityChip.IsMouseOver) return;
|
||||
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = _viewModel.SelectedElement;
|
||||
|
||||
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||
{
|
||||
_isResizing = true;
|
||||
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var hit = HitElement(p);
|
||||
if (hit != null)
|
||||
{
|
||||
_viewModel.SelectedElement = hit;
|
||||
_isDraggingOverlay = true;
|
||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_isDraggingOverlay && !_isResizing) return;
|
||||
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = _viewModel.SelectedElement;
|
||||
if (selected == null)
|
||||
{
|
||||
EndPreviewDrag(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDraggingOverlay)
|
||||
{
|
||||
var minX = -(selected.Width - 20);
|
||||
var maxX = 1920 - 20;
|
||||
var minY = -(selected.Height - 20);
|
||||
var maxY = 1080 - 20;
|
||||
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
||||
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
||||
}
|
||||
else if (_isResizing)
|
||||
{
|
||||
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;
|
||||
if (newH > maxH)
|
||||
{
|
||||
newH = maxH;
|
||||
newW = newH * _resizeAspect;
|
||||
}
|
||||
selected.Width = newW;
|
||||
selected.Height = newH;
|
||||
}
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
=> EndPreviewDrag((Grid)sender);
|
||||
|
||||
private void EndPreviewDrag(Grid grid)
|
||||
{
|
||||
if (_isDraggingOverlay || _isResizing)
|
||||
grid.ReleaseMouseCapture();
|
||||
_isDraggingOverlay = false;
|
||||
_isResizing = false;
|
||||
}
|
||||
|
||||
private bool HitHandle(Point mouseScreen, SceneElement element)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private SceneElement? HitElement(Point p)
|
||||
{
|
||||
var scene = _viewModel.ActiveScene;
|
||||
if (scene == null) return null;
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!IsDraggableElement(element)) continue;
|
||||
if (element is Source { IsEnabled: false }) continue;
|
||||
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||
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;
|
||||
}
|
||||
|
||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Enter or Key.Escape)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
}
|
||||
|
||||
private static void CommitSourceEdit(object sender)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||
element.IsEditing = false;
|
||||
}
|
||||
|
||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||
private Point _dragStartPoint;
|
||||
private int _dragIndex = -1;
|
||||
private bool _isDragging;
|
||||
private object? _lastHoveredItem;
|
||||
|
||||
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var listBox = (ListBox)sender;
|
||||
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
|
||||
if (item == null)
|
||||
{
|
||||
if (ReferenceEquals(listBox, SourceList))
|
||||
_viewModel.SelectedElement = null;
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (listBox.ItemsSource is not IList items || items.Count == 0)
|
||||
{
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
_dragIndex = listBox.Items.IndexOf(item.DataContext);
|
||||
_dragStartPoint = e.GetPosition(listBox);
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (_dragIndex < 0) return;
|
||||
var listBox = (ListBox)sender;
|
||||
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
|
||||
{
|
||||
EndListDrag(listBox);
|
||||
return;
|
||||
}
|
||||
|
||||
var position = e.GetPosition(listBox);
|
||||
if (!_isDragging)
|
||||
{
|
||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
return;
|
||||
_isDragging = true;
|
||||
listBox.CaptureMouse();
|
||||
}
|
||||
|
||||
var item = FindItemContainerAt(listBox, position);
|
||||
if (item == null) return;
|
||||
|
||||
var targetItem = item.DataContext;
|
||||
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
|
||||
return;
|
||||
|
||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
||||
if (targetIndex < 0) return;
|
||||
|
||||
var dragged = items[_dragIndex];
|
||||
items.RemoveAt(_dragIndex);
|
||||
items.Insert(targetIndex, dragged);
|
||||
_dragIndex = targetIndex;
|
||||
_lastHoveredItem = targetItem;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
EndListDrag((ListBox)sender);
|
||||
}
|
||||
|
||||
private void EndListDrag(ListBox listBox)
|
||||
{
|
||||
if (_isDragging)
|
||||
listBox.ReleaseMouseCapture();
|
||||
_dragIndex = -1;
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
}
|
||||
|
||||
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
|
||||
{
|
||||
var hit = listBox.InputHitTest(position) as DependencyObject;
|
||||
while (hit != null && hit != listBox)
|
||||
{
|
||||
if (hit is ListBoxItem item) return item;
|
||||
hit = VisualTreeHelper.GetParent(hit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
+62
-3
@@ -1,8 +1,67 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
public class Scene
|
||||
public class Scene : INotifyPropertyChanged
|
||||
{
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public List<Source> Sources { get; set; } = new();
|
||||
|
||||
private string _name = string.Empty;
|
||||
private bool _isHidden;
|
||||
private bool _hasBackdrop;
|
||||
private bool _hasSocialBar;
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set => Set(ref _name, value);
|
||||
}
|
||||
|
||||
public bool IsHidden
|
||||
{
|
||||
get => _isHidden;
|
||||
set => Set(ref _isHidden, value);
|
||||
}
|
||||
|
||||
/// <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 event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
-18
@@ -1,35 +1,89 @@
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
public enum SourceType
|
||||
{
|
||||
DisplayCapture,
|
||||
WindowCapture,
|
||||
Webcam,
|
||||
Background,
|
||||
Image,
|
||||
TextOverlay
|
||||
}
|
||||
|
||||
public class Source
|
||||
public enum ClipShape
|
||||
{
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public SourceType Type { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
Traditional,
|
||||
Round
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
{
|
||||
get => _type;
|
||||
set
|
||||
{
|
||||
if (Set(ref _type, value))
|
||||
{
|
||||
Raise(nameof(IsLiveCapture));
|
||||
Raise(nameof(DisplaySource));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The permanent scene backdrop (live desktop/game capture). Never removable/reorderable.</summary>
|
||||
private bool _isBackdrop;
|
||||
public bool IsBackdrop { get => _isBackdrop; set => Set(ref _isBackdrop, value); }
|
||||
|
||||
private bool _isEnabled = true;
|
||||
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
|
||||
|
||||
// Display/Window capture
|
||||
public int? MonitorIndex { get; set; }
|
||||
public IntPtr? WindowHandle { get; set; }
|
||||
private int? _monitorIndex;
|
||||
public int? MonitorIndex { get => _monitorIndex; set => Set(ref _monitorIndex, value); }
|
||||
|
||||
// Webcam
|
||||
public string? DeviceId { get; set; }
|
||||
private IntPtr? _windowHandle;
|
||||
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
||||
|
||||
// Image
|
||||
public string? FilePath { get; set; }
|
||||
/// <summary>
|
||||
/// Identifies what this live-capture source points at: "monitor:<index>" or
|
||||
/// "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); }
|
||||
|
||||
// Position/transform
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
public double Opacity { get; set; } = 1.0;
|
||||
public bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
||||
|
||||
// Image (asset stored in the layout database)
|
||||
private string? _assetId;
|
||||
private ImageSource? _imageSource;
|
||||
|
||||
public string? AssetId
|
||||
{
|
||||
get => _assetId;
|
||||
set
|
||||
{
|
||||
if (Set(ref _assetId, value))
|
||||
{
|
||||
_imageSource = ImageCache.Get(value ?? string.Empty);
|
||||
Raise(nameof(ImageSource));
|
||||
Raise(nameof(DisplaySource));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ImageSource? ImageSource => _imageSource;
|
||||
|
||||
public override ImageSource? DisplaySource => IsLiveCapture ? VideoImageSource : _imageSource;
|
||||
|
||||
public override bool IsImageSource => Type == SourceType.Image;
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# ytLlive
|
||||
|
||||
A creator-proof live-streaming and recording app for YouTube — native Windows (C# / WPF / .NET 8).
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
## Design Principle
|
||||
|
||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||
|
||||
Every feature is measured against this. One-click go-live, sane YouTube defaults, visual scene
|
||||
building, and no dead ends — every action has a visible outcome.
|
||||
|
||||
## Why ytLlive
|
||||
|
||||
OBS treats YouTube as an afterthought; SLOBS is Twitch-first with YouTube bolted on. ytLlive is
|
||||
designed and dedicated to YouTube livestreaming and recording, and to YouTube's specific quirks.
|
||||
|
||||
## Account Assumption
|
||||
|
||||
ytLlive assumes you already have a YouTube creator account — connecting uses "Sign in with Google"
|
||||
to link that existing account. **ytLlive does not create accounts.** If you don't have a YouTube
|
||||
channel yet, set one up on YouTube first, then connect it here.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dotnet build # Windows only — WPF
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Version Roadmap
|
||||
|
||||
| Version | Scope |
|
||||
|---------|-------|
|
||||
| v0.1 | Scene/source management, YouTube RTMP ingest, YouTube OAuth2, live chat, stream health |
|
||||
| v0.2 | Recording to local file |
|
||||
| v0.3 | Stream scheduling |
|
||||
| v0.4 | Multi-destination restreaming |
|
||||
| 1.0 | General availability |
|
||||
|
||||
## Structure
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) |
|
||||
| `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 |
|
||||
| `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`.
|
||||
@@ -0,0 +1,83 @@
|
||||
<Window x:Class="ytLive.ReuseImageDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Add Image" Height="380" Width="460"
|
||||
Icon="/Assets/llama-logo-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
|
||||
<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 Image" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,14"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Re-use an image that's already in your stream, or add a new one?"
|
||||
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||
|
||||
<ListBox Grid.Row="2" Background="Transparent" BorderThickness="0"
|
||||
ItemContainerStyle="{StaticResource CandidateItem}"
|
||||
ItemsSource="{Binding Candidates}"
|
||||
SelectedItem="{Binding SelectedCandidate, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Background="#0f3460" CornerRadius="3" Width="48" Height="27" ClipToBounds="True">
|
||||
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="1"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding Header}" Foreground="#e0e0e0" FontSize="13"
|
||||
VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="New Image…" Command="{Binding NewImageCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,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,30 @@
|
||||
using System.Windows;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
public partial class ReuseImageDialog : Window
|
||||
{
|
||||
public string? PickedAssetId { get; private set; }
|
||||
public bool WantsNew { get; private set; }
|
||||
|
||||
public ReuseImageDialog(ReuseImageViewModel viewModel)
|
||||
{
|
||||
AppLog.Write("ReuseImageDialog ctor: before InitializeComponent");
|
||||
InitializeComponent();
|
||||
AppLog.Write("ReuseImageDialog ctor: after InitializeComponent");
|
||||
DataContext = viewModel;
|
||||
viewModel.ReuseRequested += () =>
|
||||
{
|
||||
PickedAssetId = viewModel.SelectedCandidate?.AssetId;
|
||||
if (PickedAssetId != null) DialogResult = true;
|
||||
};
|
||||
viewModel.NewImageRequested += () =>
|
||||
{
|
||||
WantsNew = true;
|
||||
DialogResult = true;
|
||||
};
|
||||
viewModel.CancelRequested += () => DialogResult = false;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public class LayoutStore : IDisposable
|
||||
{
|
||||
public static LayoutStore? Instance { get; private set; }
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
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)
|
||||
{
|
||||
ActivePath = path;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
_connection = new SqliteConnection($"Data Source={path}");
|
||||
_connection.Open();
|
||||
using (var pragma = _connection.CreateCommand())
|
||||
{
|
||||
pragma.CommandText = "PRAGMA foreign_keys = ON;";
|
||||
pragma.ExecuteNonQuery();
|
||||
}
|
||||
EnsureSchema();
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void EnsureSchema()
|
||||
{
|
||||
string[] statements =
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Scene (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Name TEXT NOT NULL,
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
HasBackdrop INTEGER NOT NULL DEFAULT 1,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Asset (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Hash TEXT NOT NULL UNIQUE,
|
||||
Data BLOB NOT NULL,
|
||||
PixelWidth INTEGER NOT NULL DEFAULT 0,
|
||||
PixelHeight INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Source (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
AssetId TEXT REFERENCES Asset(Id),
|
||||
Type TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
IsEnabled 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,
|
||||
MonitorIndex INTEGER,
|
||||
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
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
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()
|
||||
{
|
||||
Webcam = null;
|
||||
Socials = null;
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
scenes.Add(new Scene
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Name = reader.GetString(1),
|
||||
IsHidden = reader.GetInt32(2) != 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())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
||||
IsBackdrop, CaptureKey
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(1);
|
||||
var source = new Source
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Type = Enum.Parse<SourceType>(reader.GetString(3)),
|
||||
Name = reader.GetString(4),
|
||||
IsEnabled = reader.GetInt32(5) != 0,
|
||||
X = reader.GetDouble(6),
|
||||
Y = reader.GetDouble(7),
|
||||
Width = reader.GetDouble(8),
|
||||
Height = reader.GetDouble(9),
|
||||
Opacity = reader.GetDouble(10),
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
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))
|
||||
sourcesByScene[sceneId] = list = new List<Source>();
|
||||
list.Add(source);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
||||
foreach (var source in sources)
|
||||
scene.Elements.Add(source);
|
||||
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||
foreach (var config in configs)
|
||||
scene.Elements.Add(config);
|
||||
}
|
||||
|
||||
return scenes;
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
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.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
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.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", 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 sort = 0;
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
idP.Value = scene.Id;
|
||||
nameP.Value = scene.Name;
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
backdropP.Value = scene.HasBackdrop ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackdrop, $captureKey, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||
var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
|
||||
var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var enabledP = cmd.Parameters.Add("$isEnabled", 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 monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||
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);
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var sort = 0;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
if (element is not Source source) continue;
|
||||
idP.Value = source.Id;
|
||||
sceneIdP.Value = scene.Id;
|
||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||
typeP.Value = source.Type.ToString();
|
||||
nameP.Value = source.Name;
|
||||
enabledP.Value = source.IsEnabled ? 1 : 0;
|
||||
xP.Value = source.X;
|
||||
yP.Value = source.Y;
|
||||
wP.Value = source.Width;
|
||||
hP.Value = source.Height;
|
||||
opacityP.Value = source.Opacity;
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? 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++;
|
||||
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())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
public byte[]? GetAssetBytes(string assetId)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
|
||||
cmd.Parameters.AddWithValue("$id", assetId);
|
||||
return cmd.ExecuteScalar() as byte[];
|
||||
}
|
||||
|
||||
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
|
||||
{
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data));
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
|
||||
VALUES ($id, $hash, $data, $w, $h)
|
||||
ON CONFLICT(Hash) DO NOTHING;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$hash", hash);
|
||||
cmd.Parameters.AddWithValue("$data", data);
|
||||
cmd.Parameters.AddWithValue("$w", pixelWidth);
|
||||
cmd.Parameters.AddWithValue("$h", pixelHeight);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
|
||||
return (string)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
@@ -18,14 +19,30 @@ public class YouTubeAuthService
|
||||
|
||||
private readonly string _clientId;
|
||||
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 YouTubeAuthService(string clientId, string clientSecret)
|
||||
public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null,
|
||||
Action<YouTubeChannel>? sessionChanged = null)
|
||||
{
|
||||
_clientId = clientId;
|
||||
_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)
|
||||
@@ -46,6 +63,49 @@ public class YouTubeAuthService
|
||||
return $"{AuthorizationEndpoint}?{encoded}";
|
||||
}
|
||||
|
||||
public async Task<YouTubeChannel?> AuthenticateAsync()
|
||||
{
|
||||
const int port = 8765;
|
||||
var redirectUri = $"http://localhost:{port}/oauth2/callback";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret))
|
||||
return null;
|
||||
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add($"{redirectUri}/");
|
||||
listener.Start();
|
||||
|
||||
Process.Start(new ProcessStartInfo(GetAuthorizationUrl(redirectUri)) { UseShellExecute = true });
|
||||
|
||||
HttpListenerContext context;
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
context = await listener.GetContextAsync().WaitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var code = context.Request.QueryString["code"];
|
||||
var error = context.Request.QueryString["error"];
|
||||
|
||||
var html = error != null
|
||||
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to ytLlive.</p></body></html>"
|
||||
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to ytLlive.</p></body></html>";
|
||||
var buffer = Encoding.UTF8.GetBytes(html);
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
context.Response.ContentLength64 = buffer.Length;
|
||||
await context.Response.OutputStream.WriteAsync(buffer);
|
||||
context.Response.Close();
|
||||
|
||||
if (error != null || string.IsNullOrEmpty(code))
|
||||
return null;
|
||||
|
||||
return await ExchangeCodeForToken(code, redirectUri);
|
||||
}
|
||||
|
||||
public async Task<YouTubeChannel?> ExchangeCodeForToken(string code, string redirectUri)
|
||||
{
|
||||
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
@@ -92,6 +152,7 @@ public class YouTubeAuthService
|
||||
|
||||
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
|
||||
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
|
||||
_sessionChanged?.Invoke(CurrentChannel);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -118,6 +179,7 @@ public class YouTubeAuthService
|
||||
TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn)
|
||||
};
|
||||
|
||||
_sessionChanged?.Invoke(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
# 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)
|
||||
|
||||
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
|
||||
|
||||
1. **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
||||
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.
|
||||
3. **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
||||
4. **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
||||
5. **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
|
||||
|
||||
### Two features that reshape the design
|
||||
|
||||
1. **enableAutoStart / enableAutoStop** — instant one-click go-live, no transition call. With `enableAutoStart=true` we never call `transition(live)`: the broadcast auto-goes-live the moment the encoder starts. Combined with `enableMonitorStream=false` (our preview pane replaces YouTube's monitor stream — the thing that forces a testing stage), the flow is **create → bind → Start Stream → encoder starts → YouTube brings it live**. No testing, no transition polling, no liveStarting stuck-state handling.
|
||||
2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** — free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature.
|
||||
|
||||
### Compliance gotchas (maps perfectly to report-by-exception)
|
||||
|
||||
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.
|
||||
2. Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
||||
3. Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
||||
|
||||
### Tips we should take advantage of
|
||||
|
||||
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.
|
||||
2. **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
||||
3. **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
||||
4. **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
||||
5. **Broadcast ID == Video ID** — one ID to track everything.
|
||||
|
||||
---
|
||||
|
||||
## TASK 1 — Initial Scaffold
|
||||
|
||||
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
||||
2. ✅ Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
||||
3. ✅ MainViewModel: scene management, stream controls, chat
|
||||
4. ✅ MainWindow: scene/source panel, preview area, chat panel, status bar
|
||||
5. ✅ Clean build, 0 warnings (WSL + Windows)
|
||||
|
||||
---
|
||||
|
||||
## TASK 2 — YouTube OAuth2 Authentication
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**Live indicators (unmissable):** Top bar + window title bar flip red, a pulsing **● LIVE** badge with elapsed timer appears in the top bar, the preview area gets a red glow, and the taskbar icon shows a red overlay dot. Window title bar shows the stream title once validated.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Google Cloud OAuth credentials** — client ID + secret, **baked into `Helpers/OAuthCredentials.cs`** (desktop "Desktop app" OAuth client; loopback callback — no console redirect URI registration needed; creators never configure)
|
||||
2. **Local HTTP listener** — `HttpListener` on `http://localhost:PORT/oauth2/callback` to catch the redirect
|
||||
3. **Browser launch** — open the authorization URL in the default browser
|
||||
4. **Token persistence** — store access/refresh tokens securely (Windows DPAPI), reload on startup
|
||||
5. **UI state** — account shown in the Start Stream dialog; "Change Account" action triggers re-auth
|
||||
6. **Go Live gated on auth** — Start Stream dialog requires sign-in to enable the Start button; scene building works without it
|
||||
|
||||
### Tests:
|
||||
|
||||
1. Mock token exchange response, verify channel info parsed
|
||||
2. Verify token refresh triggers when near expiry
|
||||
3. Verify credential load/save roundtrip
|
||||
|
||||
---
|
||||
|
||||
## TASK 3 — Capture Pipeline (Scenes/Sources)
|
||||
|
||||
**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)
|
||||
|
||||
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
|
||||
the hot few and nothing esoteric. If a user needs more, they've graduated to OBS.
|
||||
|
||||
1. **Webcam** — the face cam. Non-negotiable.
|
||||
2. **Screen** — the main event (game, slides, browser). One source; a picker chooses a monitor *or* a
|
||||
window. (Window capture is absorbed here — no separate source type.)
|
||||
3. **Background** — a full-canvas backdrop image. Fills the whole scene automatically, zero fiddling.
|
||||
Kept separate from Image on purpose: same pixels, but this one needs no positioning.
|
||||
4. **Image** — a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
|
||||
5. **Text** — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
|
||||
6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native.
|
||||
7. **Alerts** — Super Chat / membership / subscribe pop-ins. The dopamine source. **The one big lift**
|
||||
(Super Chat event streaming + on-stream rendering/animation); build after the six. **Also the one
|
||||
paid feature** — see Monetization in `ai.md`.
|
||||
|
||||
Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI.
|
||||
|
||||
### Source memory model (design decision)
|
||||
|
||||
1. A scene has resources. Resources can be shared across scenes.
|
||||
2. A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
||||
scenes = one loaded bitmap).
|
||||
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,
|
||||
scale mode, crop.
|
||||
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".
|
||||
5. A webcam in two scenes = one capture session, two catalog entries.
|
||||
6. Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
||||
7. The resource (not a per-scene node) owns everything `IDisposable`.
|
||||
|
||||
### Scene transitions (design decision)
|
||||
|
||||
Scene switching while live must never stutter. Supported types, most → least economical:
|
||||
|
||||
1. **Cut** — instant switch. The default. Zero cost.
|
||||
2. **Fade** — short crossfade (~300ms).
|
||||
3. **Move** — a simple, economical move transition, done to perfection and memory-efficient. The
|
||||
smart streamer's bread and butter.
|
||||
4. **Custom (media) transitions** — require media elements (video/stinger playback during the
|
||||
transition). Heavier, but creators pay for these, so we support them. Their media follows the
|
||||
same resource memory model: loaded once, catalogued by scene.
|
||||
|
||||
Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four above.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
||||
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
|
||||
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
|
||||
back-to-front), preview rendered via D3DImage or MediaElement
|
||||
6. **Branding flash** — the topmost full-frame "made with ytLlive!" layer at ~25% opacity, ~1s on /
|
||||
300s off (see Monetization in `ai.md`), gated on `BrandFlashEnabled` + live/recording. Lives in the
|
||||
preview compositor now (`BrandFlashLayer` in `MainWindow.xaml` CanvasGrid, driven by
|
||||
`BrandFlashActive`/`BrandFlashTimer` in `MainViewModel`); the encoder output renders the same layer,
|
||||
and v0.2 local recordings carry it too
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## TASK 4 — RTMP Ingest to YouTube
|
||||
|
||||
**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:
|
||||
|
||||
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 (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**:
|
||||
1. 720p30 @ 6 Mbps
|
||||
2. 720p60 @ 6 Mbps
|
||||
3. 1080p30 @ 8 Mbps
|
||||
4. **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
||||
machine never notices; upload headroom stays comfortable)
|
||||
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).
|
||||
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
|
||||
(no stream recreation); 60fps presumes a hardware encoder — no hardware encoder → auto
|
||||
fallback to 720p60/1080p30
|
||||
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)
|
||||
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.
|
||||
|
||||
#### 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`.
|
||||
|
||||
---
|
||||
|
||||
## TASK 5 — YouTube Live Stream Management
|
||||
|
||||
**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)
|
||||
|
||||
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.
|
||||
2. **Variable reusable stream** — `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`. Create once per channel, cache ingestion URL + stream name, reuse for every broadcast. Any quality tier works without recreation; auto step-down needs no API calls.
|
||||
3. **Report-by-exception** — poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable → opens the dialog).
|
||||
4. **One dialog, three states** — `not connected` (sign-in) / `connected-offline` (all editable) / `live` (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile.
|
||||
5. **Live edits** — `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy.
|
||||
6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net.
|
||||
7. **Broadcast ID == Video ID** — one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`).
|
||||
|
||||
### Requirements:
|
||||
|
||||
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`
|
||||
|
||||
---
|
||||
|
||||
## TASK 6 — Layout Persistence (SQLite)
|
||||
|
||||
**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
|
||||
|
||||
1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
|
||||
via `PRAGMA user_version`.
|
||||
2. **Assets live in the DB, not on disk** — `Asset` table stores image bytes (BLOB) keyed by a
|
||||
SHA-256 content hash (unique). Identical image content collapses to one row regardless of file
|
||||
name — the 1:M resource memory model, enforced by the database. No 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 add/remove/reorder/rename/hide, source
|
||||
add/remove/reorder, and any source transform change; flush on window close.
|
||||
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,
|
||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id,
|
||||
BarPosition, BarJustify — back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id,
|
||||
SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) — `user_version` **8** (v1 → v2 =
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
|
||||
2. v0.3 — Stream scheduling
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public class GoLiveViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||
private string _streamTitle = string.Empty;
|
||||
private string _streamDescription = string.Empty;
|
||||
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
|
||||
{
|
||||
get => _streamTitle;
|
||||
set => SetProperty(ref _streamTitle, value);
|
||||
}
|
||||
|
||||
public string StreamDescription
|
||||
{
|
||||
get => _streamDescription;
|
||||
set => SetProperty(ref _streamDescription, value);
|
||||
}
|
||||
|
||||
public string Visibility
|
||||
{
|
||||
get => _visibility;
|
||||
set => SetProperty(ref _visibility, value);
|
||||
}
|
||||
|
||||
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 CancelCommand { get; }
|
||||
|
||||
public event Action? StartRequested;
|
||||
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;
|
||||
}
|
||||
}
|
||||
+2254
-46
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,52 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public class ReuseImageCandidate
|
||||
{
|
||||
public string SceneName { get; set; } = string.Empty;
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
public string AssetId { get; set; } = string.Empty;
|
||||
|
||||
public ImageSource? Thumbnail => ImageCache.Get(AssetId);
|
||||
public string Header => $"{SourceName} · in {SceneName}";
|
||||
}
|
||||
|
||||
public class ReuseImageViewModel : ViewModelBase
|
||||
{
|
||||
private ReuseImageCandidate? _selectedCandidate;
|
||||
|
||||
public ObservableCollection<ReuseImageCandidate> Candidates { get; }
|
||||
|
||||
public ReuseImageCandidate? SelectedCandidate
|
||||
{
|
||||
get => _selectedCandidate;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedCandidate, value))
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand UseSelectedCommand { get; }
|
||||
public ICommand NewImageCommand { get; }
|
||||
public ICommand CancelCommand { get; }
|
||||
|
||||
public event Action? ReuseRequested;
|
||||
public event Action? NewImageRequested;
|
||||
public event Action? CancelRequested;
|
||||
|
||||
public ReuseImageViewModel(IEnumerable<ReuseImageCandidate> candidates)
|
||||
{
|
||||
Candidates = new ObservableCollection<ReuseImageCandidate>(candidates);
|
||||
if (Candidates.Count > 0)
|
||||
_selectedCandidate = Candidates[0];
|
||||
|
||||
UseSelectedCommand = new RelayCommand(_ => ReuseRequested?.Invoke(), _ => SelectedCandidate != null);
|
||||
NewImageCommand = new RelayCommand(_ => NewImageRequested?.Invoke());
|
||||
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||
}
|
||||
}
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user