Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dc8490d96 | |||
| d6ae048583 | |||
| aae17a1511 | |||
| 3caccfff1f | |||
| 4caf7eec8d | |||
| 639e1bb323 | |||
| 4293ae11a4 | |||
| 8ee44f571e | |||
| d4945f4fd0 | |||
| 8aebbff318 |
@@ -1,5 +1,6 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
Helpers/OAuthCredentials.cs
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
.vs/
|
.vs/
|
||||||
|
|||||||
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,103 @@
|
|||||||
|
<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="430" Width="520"
|
||||||
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
|
||||||
|
<Style x:Key="GoLiveButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="#e94560"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Padding" Value="16,8"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="GoLiveButtonSecondary" TargetType="Button" BasedOn="{StaticResource GoLiveButton}">
|
||||||
|
<Setter Property="Background" Value="#16213e"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="GoLiveLabel" TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="#a0a0b0"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="GoLiveTextBox" TargetType="TextBox">
|
||||||
|
<Setter Property="Background" Value="#0f3460"/>
|
||||||
|
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="CaretBrush" Value="White"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,0,12"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="GoLiveCombo" TargetType="ComboBox">
|
||||||
|
<Setter Property="Background" Value="#0f3460"/>
|
||||||
|
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,0,12"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||||
|
</Style>
|
||||||
|
</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="Start Stream" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="#e0e0e0" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<!-- STREAM METADATA -->
|
||||||
|
<StackPanel Grid.Row="1">
|
||||||
|
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Title" Style="{StaticResource GoLiveLabel}"/>
|
||||||
|
<TextBox Style="{StaticResource GoLiveTextBox}"
|
||||||
|
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Description" Style="{StaticResource GoLiveLabel}"/>
|
||||||
|
<TextBox Style="{StaticResource GoLiveTextBox}" Height="60" AcceptsReturn="True"
|
||||||
|
Text="{Binding StreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Visibility" Style="{StaticResource GoLiveLabel}"/>
|
||||||
|
<ComboBox Style="{StaticResource GoLiveCombo}"
|
||||||
|
ItemsSource="{Binding Visibilities}"
|
||||||
|
SelectedItem="{Binding Visibility}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ACTIONS -->
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||||
|
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Start Stream" Command="{Binding StartCommand}"
|
||||||
|
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive;
|
||||||
|
|
||||||
|
public partial class GoLiveWindow : Window
|
||||||
|
{
|
||||||
|
public GoLiveWindow(GoLiveViewModel viewModel)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = viewModel;
|
||||||
|
viewModel.StartRequested += () => DialogResult = true;
|
||||||
|
viewModel.CancelRequested += () => DialogResult = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+548
-77
@@ -5,16 +5,64 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="clr-namespace:ytLive.ViewModels"
|
xmlns:vm="clr-namespace:ytLive.ViewModels"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="ytLive" Height="720" Width="1280"
|
Title="{Binding WindowTitle}" Height="720" Width="1280"
|
||||||
|
MinWidth="1180" MinHeight="600"
|
||||||
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
Background="#1a1a2e"
|
Background="#1a1a2e"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Closing="MainWindow_Closing"
|
||||||
|
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown">
|
||||||
|
|
||||||
<Window.DataContext>
|
<Window.DataContext>
|
||||||
<vm:MainViewModel/>
|
<vm:MainViewModel/>
|
||||||
</Window.DataContext>
|
</Window.DataContext>
|
||||||
|
|
||||||
|
<Window.TaskbarItemInfo>
|
||||||
|
<TaskbarItemInfo x:Name="TaskbarInfo"/>
|
||||||
|
</Window.TaskbarItemInfo>
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
<Helpers:InverseBoolToVisibilityConverter x:Key="InverseBoolToVis"
|
||||||
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
||||||
|
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
|
||||||
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
||||||
|
|
||||||
|
<!-- YouTube logo shown on the Connect button -->
|
||||||
|
<DrawingImage x:Key="YoutubeLogo">
|
||||||
|
<DrawingImage.Drawing>
|
||||||
|
<DrawingGroup>
|
||||||
|
<GeometryDrawing Brush="#FF0000">
|
||||||
|
<GeometryDrawing.Geometry>
|
||||||
|
<RectangleGeometry Rect="0,0,66,47" RadiusX="10" RadiusY="10"/>
|
||||||
|
</GeometryDrawing.Geometry>
|
||||||
|
</GeometryDrawing>
|
||||||
|
<GeometryDrawing Brush="White">
|
||||||
|
<GeometryDrawing.Geometry>
|
||||||
|
<PathGeometry>
|
||||||
|
<PathFigure IsClosed="True" StartPoint="25,14">
|
||||||
|
<LineSegment Point="25,33"/>
|
||||||
|
<LineSegment Point="45,23.5"/>
|
||||||
|
</PathFigure>
|
||||||
|
</PathGeometry>
|
||||||
|
</GeometryDrawing.Geometry>
|
||||||
|
</GeometryDrawing>
|
||||||
|
</DrawingGroup>
|
||||||
|
</DrawingImage.Drawing>
|
||||||
|
</DrawingImage>
|
||||||
|
|
||||||
|
<!-- Red dot shown in the taskbar icon while live -->
|
||||||
|
<DrawingImage x:Key="LiveOverlay">
|
||||||
|
<DrawingImage.Drawing>
|
||||||
|
<DrawingGroup>
|
||||||
|
<GeometryDrawing Brush="#e94560">
|
||||||
|
<GeometryDrawing.Geometry>
|
||||||
|
<EllipseGeometry Center="8,8" RadiusX="8" RadiusY="8"/>
|
||||||
|
</GeometryDrawing.Geometry>
|
||||||
|
</GeometryDrawing>
|
||||||
|
</DrawingGroup>
|
||||||
|
</DrawingImage.Drawing>
|
||||||
|
</DrawingImage>
|
||||||
|
|
||||||
<!-- Base button style -->
|
<!-- Base button style -->
|
||||||
<Style x:Key="YtButton" TargetType="Button">
|
<Style x:Key="YtButton" TargetType="Button">
|
||||||
@@ -42,6 +90,48 @@
|
|||||||
<Setter Property="Background" Value="#16213e"/>
|
<Setter Property="Background" Value="#16213e"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="IconButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="4"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="3"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="#26ffffff"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Eye icon: open when visible, slashed when hidden -->
|
||||||
|
<Style x:Key="EyeIconStyle" TargetType="Path">
|
||||||
|
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsHidden}" Value="True">
|
||||||
|
<Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
|
||||||
|
<Setter Property="ToolTip" Value="Hide Scene"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsHidden}" Value="True">
|
||||||
|
<Setter Property="ToolTip" Value="Show Scene"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style x:Key="YtTextBox" TargetType="TextBox">
|
<Style x:Key="YtTextBox" TargetType="TextBox">
|
||||||
<Setter Property="Background" Value="#0f3460"/>
|
<Setter Property="Background" Value="#0f3460"/>
|
||||||
<Setter Property="Foreground" Value="#e0e0e0"/>
|
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||||
@@ -51,6 +141,15 @@
|
|||||||
<Setter Property="CaretBrush" Value="White"/>
|
<Setter Property="CaretBrush" Value="White"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="YtComboBox" TargetType="ComboBox">
|
||||||
|
<Setter Property="Background" Value="#0f3460"/>
|
||||||
|
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style x:Key="YtLabel" TargetType="TextBlock">
|
<Style x:Key="YtLabel" TargetType="TextBlock">
|
||||||
<Setter Property="Foreground" Value="#a0a0b0"/>
|
<Setter Property="Foreground" Value="#a0a0b0"/>
|
||||||
<Setter Property="FontSize" Value="12"/>
|
<Setter Property="FontSize" Value="12"/>
|
||||||
@@ -72,63 +171,47 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- ═══ TOP BAR: Stream Controls ═══ -->
|
<!-- ═══ TOP BAR: Stream Controls ═══ -->
|
||||||
<Border Grid.Row="0" Background="#16213e" Padding="16,10">
|
<Border Grid.Row="0" Background="{Binding TopBarBackground}" Padding="16,10">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Logo / Brand -->
|
<!-- Logo / Brand -->
|
||||||
<TextBlock Grid.Column="0" Text="ytLive"
|
<TextBlock Grid.Column="0" Text="ytLlive"
|
||||||
Foreground="#e94560" FontSize="22" FontWeight="Bold"
|
Foreground="#e94560" FontSize="22" FontWeight="Bold"
|
||||||
VerticalAlignment="Center" Margin="0,0,24,0"/>
|
VerticalAlignment="Center" Margin="0,0,24,0"/>
|
||||||
|
|
||||||
<!-- Stream Title -->
|
<!-- LIVE badge (center) -->
|
||||||
<TextBox Grid.Column="1" Style="{StaticResource YtTextBox}"
|
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||||
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
Width="400" HorizontalAlignment="Left"
|
Visibility="{Binding LiveIndicatorVisible, Converter={StaticResource BoolToVis}}">
|
||||||
VerticalAlignment="Center"/>
|
<Ellipse Width="12" Height="12" Fill="White" VerticalAlignment="Center"
|
||||||
|
Opacity="{Binding LivePulseOpacity}"/>
|
||||||
|
<TextBlock Text="LIVE" Foreground="White" FontSize="16" FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||||
|
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||||
|
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Stream Status -->
|
<!-- Single three-state action button -->
|
||||||
<Border Grid.Column="2" Background="#0f3460" CornerRadius="4"
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
Padding="12,6" Margin="16,0" VerticalAlignment="Center">
|
<Button Style="{StaticResource YtButtonSecondary}" Command="{Binding ConnectCommand}"
|
||||||
<StackPanel Orientation="Horizontal">
|
Visibility="{Binding IsNotConnected, Converter={StaticResource BoolToVis}}">
|
||||||
<Ellipse Width="10" Height="10" Margin="0,0,8,0">
|
<StackPanel Orientation="Horizontal">
|
||||||
<Ellipse.Style>
|
<Image Source="{StaticResource YoutubeLogo}" Width="22" Height="16"
|
||||||
<Style TargetType="Ellipse">
|
VerticalAlignment="Center"/>
|
||||||
<Setter Property="Fill" Value="#555"/>
|
<TextBlock Text="Connect" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||||
<Style.Triggers>
|
</StackPanel>
|
||||||
<DataTrigger Binding="{Binding StreamStatus}" Value="Streaming">
|
</Button>
|
||||||
<Setter Property="Fill" Value="#00ff00"/>
|
<Button Content="Start Stream" Style="{StaticResource YtButton}"
|
||||||
</DataTrigger>
|
|
||||||
<DataTrigger Binding="{Binding StreamStatus}" Value="Connecting">
|
|
||||||
<Setter Property="Fill" Value="#ffaa00"/>
|
|
||||||
</DataTrigger>
|
|
||||||
<DataTrigger Binding="{Binding StreamStatus}" Value="Error">
|
|
||||||
<Setter Property="Fill" Value="#ff0000"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Ellipse.Style>
|
|
||||||
</Ellipse>
|
|
||||||
<TextBlock Text="{Binding StatusDisplay}" Foreground="White"
|
|
||||||
FontSize="13" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Stream Controls -->
|
|
||||||
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<Button Content="▶ GO LIVE" Style="{StaticResource YtButton}"
|
|
||||||
Command="{Binding StartStreamCommand}"
|
Command="{Binding StartStreamCommand}"
|
||||||
Visibility="{Binding StreamStatus, Converter={StaticResource BoolToVis},
|
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
|
||||||
ConverterParameter=Offline}"/>
|
<Button Content="End Stream" Style="{StaticResource YtButton}"
|
||||||
<Button Content="■ STOP" Style="{StaticResource YtButton}"
|
Background="#333" Command="{Binding EndStreamCommand}"
|
||||||
Background="#333" Command="{Binding StopStreamCommand}"/>
|
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
||||||
<Button Content="YouTube" Style="{StaticResource YtButtonSecondary}"
|
|
||||||
Command="{Binding ConnectYouTubeCommand}" Margin="8,0,0,0"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -148,41 +231,265 @@
|
|||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="180"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Scenes -->
|
<!-- Scenes -->
|
||||||
<TextBlock Grid.Row="0" Text="SCENES" Style="{StaticResource SectionHeader}"/>
|
<StackPanel Grid.Row="0" Orientation="Horizontal">
|
||||||
|
<TextBlock Text="SCENES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||||
|
<Button ToolTip="Add Scene" Command="{Binding AddSceneCommand}"
|
||||||
|
Style="{StaticResource IconButton}" Margin="4,-4,0,0">
|
||||||
|
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
|
<ListBox x:Name="SceneList" Grid.Row="1" Background="Transparent" BorderThickness="0"
|
||||||
ItemsSource="{Binding Scenes}"
|
ItemsSource="{Binding Scenes}"
|
||||||
SelectedItem="{Binding ActiveScene}"
|
SelectedItem="{Binding ActiveScene}"
|
||||||
|
SelectionChanged="SceneList_SelectionChanged"
|
||||||
|
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
||||||
|
PreviewMouseMove="List_PreviewMouseMove"
|
||||||
|
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
||||||
Foreground="#e0e0e0" FontSize="13">
|
Foreground="#e0e0e0" FontSize="13">
|
||||||
|
<ListBox.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListBoxItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsHidden}" Value="True">
|
||||||
|
<Setter Property="Opacity" Value="0.45"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ListBox.ItemContainerStyle>
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding Name}" Padding="4,4"/>
|
<Grid Margin="0,2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
||||||
|
Padding="4,2" TextTrimming="CharacterEllipsis">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
|
||||||
|
Padding="4,2" VerticalContentAlignment="Center"
|
||||||
|
IsVisibleChanged="SceneNameBox_IsVisibleChanged"
|
||||||
|
KeyDown="SceneNameBox_KeyDown" LostFocus="SceneNameBox_LostFocus">
|
||||||
|
<TextBox.Style>
|
||||||
|
<Style TargetType="TextBox">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBox.Style>
|
||||||
|
</TextBox>
|
||||||
|
|
||||||
|
<Button Grid.Column="1" ToolTip="Edit Scene Name"
|
||||||
|
Command="{Binding DataContext.EditSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||||
|
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
||||||
|
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
|
||||||
|
Command="{Binding DataContext.ToggleSceneVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
|
||||||
|
Width="13" Height="13" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="3" ToolTip="Delete Scene"
|
||||||
|
Command="{Binding DataContext.RemoveSceneCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||||
|
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
|
||||||
|
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,0">
|
<!-- Sources (for active scene) -->
|
||||||
<Button Content="+" Style="{StaticResource YtButtonSecondary}"
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,0">
|
||||||
Command="{Binding AddSceneCommand}" Padding="8,4" Margin="0,0,4,0"/>
|
<TextBlock Text="SOURCES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||||
<Button Content="−" Style="{StaticResource YtButtonSecondary}"
|
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
||||||
Command="{Binding RemoveSceneCommand}" Padding="8,4"/>
|
Click="AddSourceButton_Click">
|
||||||
|
<Button.ContextMenu>
|
||||||
|
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||||
|
<MenuItem Header="Webcam" Command="{Binding AddSourceCommand}" CommandParameter="webcam"/>
|
||||||
|
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="screen"/>
|
||||||
|
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/>
|
||||||
|
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
||||||
|
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="text"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Button.ContextMenu>
|
||||||
|
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Sources (for active scene) -->
|
<ListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
|
||||||
<TextBlock Grid.Row="3" Text="SOURCES" Style="{StaticResource SectionHeader}" Margin="0,12,0,0"/>
|
ItemsSource="{Binding ActiveScene.Sources}"
|
||||||
|
SelectedItem="{Binding SelectedSource, Mode=TwoWay}"
|
||||||
|
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
||||||
|
PreviewMouseMove="List_PreviewMouseMove"
|
||||||
|
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
||||||
|
Foreground="#e0e0e0" FontSize="13">
|
||||||
|
<ListBox.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListBoxItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
</Style>
|
||||||
|
</ListBox.ItemContainerStyle>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Margin="0,2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
||||||
|
Padding="4,2" TextTrimming="CharacterEllipsis"/>
|
||||||
|
|
||||||
|
<Button Grid.Column="1" ToolTip="Remove Source"
|
||||||
|
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||||
|
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
|
||||||
|
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Text="No sources yet" Foreground="#555" FontSize="12"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- CENTER: Preview Area -->
|
<!-- CENTER: Preview Area -->
|
||||||
<Border Grid.Column="1" Background="#0a0a1a" CornerRadius="6" Margin="8,0">
|
<Border Grid.Column="1" Background="#0a0a1a" CornerRadius="6" Margin="8,0"
|
||||||
|
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextBlock Text="Preview" Foreground="#333" FontSize="24"
|
<Grid.RowDefinitions>
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<!-- TODO: D3DImage or MediaElement for video preview -->
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="{Binding ActiveScene.Name}"
|
||||||
|
Foreground="#e0e0e0" FontSize="15" FontWeight="SemiBold"
|
||||||
|
Margin="14,12,14,0"/>
|
||||||
|
|
||||||
|
<Grid x:Name="PreviewGrid" Grid.Row="1"
|
||||||
|
PreviewMouseLeftButtonDown="Preview_MouseLeftButtonDown"
|
||||||
|
PreviewMouseMove="Preview_MouseMove"
|
||||||
|
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
|
||||||
|
<Viewbox Stretch="Uniform">
|
||||||
|
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True">
|
||||||
|
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
|
||||||
|
IsHitTestVisible="False"/>
|
||||||
|
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
|
||||||
|
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
|
||||||
|
<ItemsControl ItemsSource="{Binding ActiveScene.Sources}"
|
||||||
|
Width="1920" Height="1080">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<Canvas/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemContainerStyle>
|
||||||
|
<Style TargetType="ContentPresenter">
|
||||||
|
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||||
|
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||||
|
</Style>
|
||||||
|
</ItemsControl.ItemContainerStyle>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Image Width="{Binding Width}" Height="{Binding Height}"
|
||||||
|
Opacity="{Binding Opacity}" Stretch="UniformToFill"
|
||||||
|
Source="{Binding ImageSource}"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality">
|
||||||
|
<Image.Style>
|
||||||
|
<Style TargetType="Image">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Type}" Value="Image">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Image.Style>
|
||||||
|
</Image>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Grid x:Name="SelectionOverlay" IsHitTestVisible="False"
|
||||||
|
Canvas.Left="{Binding SelectedSource.X}" Canvas.Top="{Binding SelectedSource.Y}"
|
||||||
|
Width="{Binding SelectedSource.Width}" Height="{Binding SelectedSource.Height}">
|
||||||
|
<Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/>
|
||||||
|
<Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
|
||||||
|
</Grid>
|
||||||
|
</Canvas>
|
||||||
|
</Grid>
|
||||||
|
</Viewbox>
|
||||||
|
<TextBlock Text="Preview" Foreground="#333" FontSize="24"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowEmptySceneHint, Converter={StaticResource BoolToVis}}">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="🖥️" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
|
||||||
|
<TextBlock Text="📷" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
|
||||||
|
<TextBlock Text="🖼️" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
|
||||||
|
<TextBlock Text="✏️" Foreground="White" FontSize="30"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Make it yours!" Foreground="#e0e0e0" FontSize="18"
|
||||||
|
FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,18,0,0"/>
|
||||||
|
<TextBlock Text="Add sources — your webcam, your screen, images, and text — to craft a brand and identity that's unmistakably you."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" Margin="0,8,0,0"
|
||||||
|
TextWrapping="Wrap" MaxWidth="440" TextAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border x:Name="OpacityChip" HorizontalAlignment="Left" VerticalAlignment="Bottom"
|
||||||
|
Background="#D9000000" CornerRadius="6" Padding="10,8" Margin="12,0,0,12"
|
||||||
|
Visibility="Collapsed">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="Opacity" Foreground="#e0e0e0" FontSize="13" VerticalAlignment="Center"/>
|
||||||
|
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1" Margin="10,0,0,0"
|
||||||
|
Value="{Binding SelectedSource.Opacity, Mode=TwoWay}"
|
||||||
|
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13"
|
||||||
|
Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||||
|
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -196,8 +503,19 @@
|
|||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
|
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Chat is inactive" Foreground="#e0e0e0" FontSize="18"
|
||||||
|
FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Chat becomes active once ytLlive is connected and streaming on YouTube."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" Margin="0,8,0,0"
|
||||||
|
TextWrapping="Wrap" MaxWidth="260" TextAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
|
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
|
||||||
ItemsSource="{Binding ChatMessages}"
|
ItemsSource="{Binding ChatMessages}"
|
||||||
|
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
|
||||||
Foreground="#e0e0e0" FontSize="12">
|
Foreground="#e0e0e0" FontSize="12">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
@@ -217,26 +535,179 @@
|
|||||||
|
|
||||||
<!-- ═══ BOTTOM BAR: Stream Health ═══ -->
|
<!-- ═══ BOTTOM BAR: Stream Health ═══ -->
|
||||||
<Border Grid.Row="2" Background="#0f3460" Padding="16,6">
|
<Border Grid.Row="2" Background="#0f3460" Padding="16,6">
|
||||||
<StackPanel Orientation="Horizontal">
|
<Grid>
|
||||||
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
<Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="{Binding CurrentHealth.CurrentBitrate}" Style="{StaticResource YtLabel}"
|
<ColumnDefinition Width="*"/>
|
||||||
Foreground="White" Margin="0,0,20,0"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock Text="{Binding CurrentHealth.FPS}" Style="{StaticResource YtLabel}"
|
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||||
Foreground="White" Margin="0,0,20,0"/>
|
<TextBlock Style="{StaticResource YtLabel}"
|
||||||
|
Foreground="White" Margin="0,0,20,0">
|
||||||
|
<Run Text="{Binding CurrentHealth.CurrentBitrate, Mode=OneWay}"/>
|
||||||
|
<Run Text=" Mbps"/>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||||
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
|
<TextBlock Style="{StaticResource YtLabel}"
|
||||||
Foreground="White" Margin="0,0,20,0"/>
|
Foreground="White" Margin="0,0,20,0">
|
||||||
|
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||||
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
|
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
|
||||||
Foreground="White"/>
|
Foreground="White" Margin="0,0,20,0"/>
|
||||||
|
|
||||||
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
|
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||||
Foreground="#e94560" Margin="20,0,0,0"/>
|
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
|
||||||
</StackPanel>
|
Foreground="White"/>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
|
||||||
|
Foreground="#e94560" Margin="20,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="1" ToolTip="Menu" Style="{StaticResource IconButton}" Click="GearButton_Click">
|
||||||
|
<Button.ContextMenu>
|
||||||
|
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||||
|
<MenuItem Header="Save Layout" Command="{Binding SaveLayoutCommand}"/>
|
||||||
|
<MenuItem Header="Save Layout As…" Command="{Binding SaveLayoutAsCommand}"/>
|
||||||
|
<MenuItem Header="Open Layout…" Command="{Binding OpenLayoutCommand}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
||||||
|
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||||
|
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||||
|
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Button.ContextMenu>
|
||||||
|
<Path Data="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
|
||||||
|
Fill="#d0d0d0" Width="16" Height="16" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||||
|
<Grid Grid.RowSpan="3"
|
||||||
|
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
|
||||||
|
Command="{Binding CloseOverlayCommand}">
|
||||||
|
<Button.Template>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Button.Template>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Border Background="#1a1a2e" CornerRadius="8" Width="520" MaxHeight="560"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
|
Padding="24" BorderBrush="#333" BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||||
|
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
|
||||||
|
Command="{Binding CloseOverlayCommand}">
|
||||||
|
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
|
||||||
|
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- App Settings -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||||
|
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Default Stream Title" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||||
|
Text="{Binding DefaultStreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Default Stream Description" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Height="70" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,12"
|
||||||
|
Text="{Binding DefaultStreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Default Visibility" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<ComboBox Style="{StaticResource YtComboBox}" HorizontalAlignment="Left" MinWidth="140"
|
||||||
|
ItemsSource="{Binding Visibilities}"
|
||||||
|
SelectedItem="{Binding DefaultStreamVisibility}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="These prefill the Start Stream dialog. Scene and stream settings are stored on the stream itself."
|
||||||
|
Style="{StaticResource YtLabel}" Margin="0,16,0,0" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<TextBlock Text="STREAM SETTINGS" Style="{StaticResource SectionHeader}" Margin="0,20,0,8"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Stream Quality" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<ComboBox Style="{StaticResource YtComboBox}" HorizontalAlignment="Left" MinWidth="140"
|
||||||
|
ItemsSource="{Binding StreamQualities}"
|
||||||
|
SelectedItem="{Binding StreamQuality}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Switching quality updates the stream metrics in the status bar. 1080p60 is the standard."
|
||||||
|
Style="{StaticResource YtLabel}" Margin="0,16,0,0" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Report Bug -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||||
|
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,12"
|
||||||
|
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||||
|
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Opens a pre-filled email to the ytLlive team." Style="{StaticResource YtLabel}"/>
|
||||||
|
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
|
||||||
|
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Feature Request -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||||
|
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,12"
|
||||||
|
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||||
|
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Opens a pre-filled email to the ytLlive team." Style="{StaticResource YtLabel}"/>
|
||||||
|
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
|
||||||
|
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||||
|
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Image Source="/Assets/llama-logo.png" Height="140" Stretch="Uniform"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||||
|
<TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
|
||||||
|
HorizontalAlignment="Center" Margin="0,4,0,0"/>
|
||||||
|
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
||||||
|
TextAlignment="Center" Margin="0,16,0,0"/>
|
||||||
|
<TextBlock Text="Free forever with a watermark. A one-time unlock removes the watermark and adds alerts."
|
||||||
|
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
|
||||||
|
TextAlignment="Center" Margin="0,12,0,0"/>
|
||||||
|
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
|
||||||
|
HorizontalAlignment="Center" Margin="0,16,0,8"/>
|
||||||
|
<Button Content="Visit llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center" Margin="0,0,0,4"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
+317
-1
@@ -1,11 +1,327 @@
|
|||||||
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.Models;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
namespace ytLive;
|
namespace ytLive;
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
|
private readonly MainViewModel _viewModel;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
_viewModel = (MainViewModel)DataContext;
|
||||||
|
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
|
UpdateTaskbarOverlay();
|
||||||
|
UpdateSelectionOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(MainViewModel.IsLive))
|
||||||
|
UpdateTaskbarOverlay();
|
||||||
|
else if (e.PropertyName == nameof(MainViewModel.SelectedSource))
|
||||||
|
UpdateSelectionOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||||
|
{
|
||||||
|
_viewModel.Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTaskbarOverlay()
|
||||||
|
{
|
||||||
|
TaskbarInfo.Overlay = _viewModel.IsLive
|
||||||
|
? (ImageSource)FindResource("LiveOverlay")
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SceneNameBox_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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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.SelectedSource = 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.SelectedSource is { Type: SourceType.Image };
|
||||||
|
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (selected)
|
||||||
|
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedSource!.Opacity * 100)}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||||
|
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||||
|
|
||||||
|
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.SelectedSource;
|
||||||
|
|
||||||
|
if (selected is { Type: SourceType.Image } && HitHandle(e.GetPosition(grid), selected))
|
||||||
|
{
|
||||||
|
_isResizing = true;
|
||||||
|
_resizeAspect = selected.Width / Math.Max(1, selected.Height);
|
||||||
|
grid.CaptureMouse();
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hit = HitImage(p);
|
||||||
|
if (hit != null)
|
||||||
|
{
|
||||||
|
_viewModel.SelectedSource = hit;
|
||||||
|
_isDraggingOverlay = true;
|
||||||
|
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||||
|
grid.CaptureMouse();
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_viewModel.SelectedSource = 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.SelectedSource;
|
||||||
|
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 newW = Math.Clamp(p.X - selected.X, 32, 1920);
|
||||||
|
var newH = newW / _resizeAspect;
|
||||||
|
if (newH > 1080)
|
||||||
|
{
|
||||||
|
newH = 1080;
|
||||||
|
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, Source source)
|
||||||
|
{
|
||||||
|
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(source.X + source.Width, source.Y + source.Height));
|
||||||
|
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Source? HitImage(Point p)
|
||||||
|
{
|
||||||
|
var scene = _viewModel.ActiveScene;
|
||||||
|
if (scene == null) return null;
|
||||||
|
for (var i = scene.Sources.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var source = scene.Sources[i];
|
||||||
|
if (source.Type != SourceType.Image || !source.IsEnabled) continue;
|
||||||
|
if (p.X >= source.X && p.X <= source.X + source.Width &&
|
||||||
|
p.Y >= source.Y && p.Y <= source.Y + source.Height)
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SceneNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key is Key.Enter or Key.Escape)
|
||||||
|
{
|
||||||
|
CommitSceneEdit(sender);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SceneNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
CommitSceneEdit(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CommitSceneEdit(object sender)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: Scene scene })
|
||||||
|
scene.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.SelectedSource = 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-3
@@ -1,8 +1,44 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace ytLive.Models;
|
namespace ytLive.Models;
|
||||||
|
|
||||||
public class Scene
|
public class Scene : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
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 _isEditing;
|
||||||
|
private bool _isHidden;
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get => _name;
|
||||||
|
set => Set(ref _name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEditing
|
||||||
|
{
|
||||||
|
get => _isEditing;
|
||||||
|
set => Set(ref _isEditing, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsHidden
|
||||||
|
{
|
||||||
|
get => _isHidden;
|
||||||
|
set => Set(ref _isHidden, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<Source> Sources { get; } = new();
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-15
@@ -1,3 +1,8 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
namespace ytLive.Models;
|
namespace ytLive.Models;
|
||||||
|
|
||||||
public enum SourceType
|
public enum SourceType
|
||||||
@@ -5,31 +10,80 @@ public enum SourceType
|
|||||||
DisplayCapture,
|
DisplayCapture,
|
||||||
WindowCapture,
|
WindowCapture,
|
||||||
Webcam,
|
Webcam,
|
||||||
|
Background,
|
||||||
Image,
|
Image,
|
||||||
TextOverlay
|
TextOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Source
|
public class Source : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private void Raise([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
|
||||||
|
private 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();
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public SourceType Type { get; set; }
|
private string _name = string.Empty;
|
||||||
public bool IsEnabled { get; set; } = true;
|
public string Name { get => _name; set => Set(ref _name, value); }
|
||||||
|
|
||||||
|
private SourceType _type;
|
||||||
|
public SourceType Type { get => _type; set => Set(ref _type, value); }
|
||||||
|
|
||||||
|
private bool _isEnabled = true;
|
||||||
|
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
|
||||||
|
|
||||||
// Display/Window capture
|
// Display/Window capture
|
||||||
public int? MonitorIndex { get; set; }
|
private int? _monitorIndex;
|
||||||
public IntPtr? WindowHandle { get; set; }
|
public int? MonitorIndex { get => _monitorIndex; set => Set(ref _monitorIndex, value); }
|
||||||
|
|
||||||
|
private IntPtr? _windowHandle;
|
||||||
|
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
||||||
|
|
||||||
// Webcam
|
// Webcam
|
||||||
public string? DeviceId { get; set; }
|
private string? _deviceId;
|
||||||
|
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); }
|
||||||
|
|
||||||
// Image
|
// Image (asset stored in the layout database)
|
||||||
public string? FilePath { get; set; }
|
private string? _assetId;
|
||||||
|
private ImageSource? _imageSource;
|
||||||
|
|
||||||
// Position/transform
|
public string? AssetId
|
||||||
public double X { get; set; }
|
{
|
||||||
public double Y { get; set; }
|
get => _assetId;
|
||||||
public double Width { get; set; }
|
set
|
||||||
public double Height { get; set; }
|
{
|
||||||
public double Opacity { get; set; } = 1.0;
|
if (Set(ref _assetId, value))
|
||||||
|
{
|
||||||
|
_imageSource = ImageCache.Get(value ?? string.Empty);
|
||||||
|
Raise(nameof(ImageSource));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImageSource? ImageSource => _imageSource;
|
||||||
|
|
||||||
|
// 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 => Set(ref _width, value); }
|
||||||
|
|
||||||
|
private double _height;
|
||||||
|
public double Height { get => _height; set => Set(ref _height, value); }
|
||||||
|
|
||||||
|
private double _opacity = 1.0;
|
||||||
|
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ytLlive
|
||||||
|
|
||||||
|
A creator-proof live-streaming and recording app for YouTube — native Windows (C# / WPF / .NET 8).
|
||||||
|
|
||||||
|
## 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, StreamConfig, StreamHealth, YouTube channel/chat |
|
||||||
|
| `ViewModels/` | MainViewModel — scenes, stream controls, chat |
|
||||||
|
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling) |
|
||||||
|
| `Helpers/` | ViewModelBase, RelayCommand |
|
||||||
|
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<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>
|
||||||
|
<Style x:Key="GoLiveButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="#e94560"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Padding" Value="16,8"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="GoLiveButtonSecondary" TargetType="Button" BasedOn="{StaticResource GoLiveButton}">
|
||||||
|
<Setter Property="Background" Value="#16213e"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="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>
|
||||||
|
</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 GoLiveButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||||
|
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
|
||||||
|
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Windows;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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,276 @@
|
|||||||
|
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; }
|
||||||
|
|
||||||
|
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 =
|
||||||
|
{
|
||||||
|
"PRAGMA user_version = 1;",
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
};
|
||||||
|
foreach (var sql in statements)
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Scene> Load()
|
||||||
|
{
|
||||||
|
var scenes = new List<Scene>();
|
||||||
|
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||||
|
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId
|
||||||
|
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),
|
||||||
|
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||||
|
};
|
||||||
|
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||||
|
sourcesByScene[sceneId] = list = new List<Source>();
|
||||||
|
list.Add(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var scene in scenes)
|
||||||
|
{
|
||||||
|
if (sourcesByScene.TryGetValue(scene.Id, out var list))
|
||||||
|
foreach (var source in list)
|
||||||
|
scene.Sources.Add(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scenes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(IEnumerable<Scene> scenes)
|
||||||
|
{
|
||||||
|
using var tx = _connection.BeginTransaction();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM Source;";
|
||||||
|
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, SortOrder)
|
||||||
|
VALUES ($id, $name, $isHidden, $isChat, $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 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;
|
||||||
|
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, DeviceId, SortOrder)
|
||||||
|
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||||
|
$x, $y, $w, $h, $opacity, $monitor, $device, $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 deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
|
||||||
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
|
foreach (var scene in scenes)
|
||||||
|
{
|
||||||
|
var sort = 0;
|
||||||
|
foreach (var source in scene.Sources)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
|
||||||
|
sortP.Value = sort++;
|
||||||
|
cmd.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -46,6 +47,49 @@ public class YouTubeAuthService
|
|||||||
return $"{AuthorizationEndpoint}?{encoded}";
|
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)
|
public async Task<YouTubeChannel?> ExchangeCodeForToken(string code, string redirectUri)
|
||||||
{
|
{
|
||||||
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# ytLlive — Task List
|
||||||
|
|
||||||
|
## YouTube Live API — research facts (authoritative, v3 build)
|
||||||
|
|
||||||
|
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
|
||||||
|
|
||||||
|
- **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
||||||
|
- **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
|
||||||
|
- **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
||||||
|
- **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
- `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
|
||||||
|
- Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
||||||
|
- Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
||||||
|
|
||||||
|
### Tips we should take advantage of
|
||||||
|
|
||||||
|
- **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
|
||||||
|
- **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
||||||
|
- **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
||||||
|
- **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
||||||
|
- **Broadcast ID == Video ID** — one ID to track everything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 1 — Initial Scaffold
|
||||||
|
|
||||||
|
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
|
||||||
|
|
||||||
|
### Status: ✅ Done
|
||||||
|
- Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
||||||
|
- Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
||||||
|
- MainViewModel: scene management, stream controls, chat
|
||||||
|
- MainWindow: scene/source panel, preview area, chat panel, status bar
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
- Mock token exchange response, verify channel info parsed
|
||||||
|
- Verify token refresh triggers when near expiry
|
||||||
|
- Verify credential load/save roundtrip
|
||||||
|
|
||||||
|
### Status: 🔶 UI flow done — auth wiring pending
|
||||||
|
- ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
|
||||||
|
- ⬜ Real OAuth2 with the baked-in Google credentials (desktop client type; loopback callback, no redirect registration)
|
||||||
|
- ⬜ Token persistence via Windows DPAPI, reload on startup
|
||||||
|
- ⬜ Account sign-in/change wired to YouTubeAuthService (currently simulated in GoLiveViewModel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 3 — Capture Pipeline (Scenes/Sources)
|
||||||
|
|
||||||
|
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
- A scene has resources. Resources can be shared across scenes.
|
||||||
|
- A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
||||||
|
scenes = one loaded bitmap).
|
||||||
|
- 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.
|
||||||
|
- 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".
|
||||||
|
- A webcam in two scenes = one capture session, two catalog entries.
|
||||||
|
- Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
||||||
|
- 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) with device enumeration
|
||||||
|
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. **Drag/drop placement & reorder** — intuitive, visual (per design principle):
|
||||||
|
- **Preview:** click-drag a source in the center panel to reposition it; resize via handles
|
||||||
|
- **Scenes list:** drag rows to reorder scenes
|
||||||
|
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||||
|
|
||||||
|
### Status: 🔶 In progress — scenes/sources UI built (add/reorder/rename, image + background overlays with move/resize/opacity/reuse); real capture/encoding pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 4 — RTMP Ingest to YouTube
|
||||||
|
|
||||||
|
**Goal:** Push encoded video to YouTube's RTMP ingest.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL
|
||||||
|
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
||||||
|
- Phone 480p @ 2.5 Mbps
|
||||||
|
- 720p60 @ 6 Mbps
|
||||||
|
- 1080p30 @ 8 Mbps
|
||||||
|
- **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
||||||
|
machine never notices; upload headroom stays comfortable)
|
||||||
|
- 1440p/4K = the paid unlock tiers (monetization), not the standard offering
|
||||||
|
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
|
||||||
|
|
||||||
|
### Status: Not started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 5 — YouTube Live Stream Management
|
||||||
|
|
||||||
|
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
|
||||||
|
### Status: Not started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 6 — Layout Persistence (SQLite)
|
||||||
|
|
||||||
|
**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
|
||||||
|
|
||||||
|
### 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, SortOrder), `Asset` (Id, Hash, Data,
|
||||||
|
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
||||||
|
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, SortOrder). `WindowHandle` stays in-memory
|
||||||
|
(per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||||
|
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
|
||||||
|
only when the DB is empty.
|
||||||
|
|
||||||
|
### Status: ✅ Implemented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog (future versions)
|
||||||
|
|
||||||
|
- v0.2 — Recording to local file
|
||||||
|
- v0.3 — Stream scheduling
|
||||||
|
- v0.4 — Multi-destination restreaming
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public class GoLiveViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private string _streamTitle = string.Empty;
|
||||||
|
private string _streamDescription = string.Empty;
|
||||||
|
private string _visibility = "Public";
|
||||||
|
|
||||||
|
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 ICommand StartCommand { get; }
|
||||||
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public GoLiveViewModel()
|
||||||
|
{
|
||||||
|
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
|
||||||
|
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? StartRequested;
|
||||||
|
public event Action? CancelRequested;
|
||||||
|
}
|
||||||
+751
-43
@@ -1,5 +1,13 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using Microsoft.Win32;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
using ytLive.Models;
|
using ytLive.Models;
|
||||||
using ytLive.Services;
|
using ytLive.Services;
|
||||||
@@ -11,27 +19,129 @@ public class MainViewModel : ViewModelBase
|
|||||||
private readonly YouTubeAuthService _youtubeAuth;
|
private readonly YouTubeAuthService _youtubeAuth;
|
||||||
private readonly YouTubeStreamService _youtubeStream;
|
private readonly YouTubeStreamService _youtubeStream;
|
||||||
private readonly YouTubeChatService _youtubeChat;
|
private readonly YouTubeChatService _youtubeChat;
|
||||||
|
private readonly DispatcherTimer _liveTimer;
|
||||||
|
|
||||||
private Scene? _activeScene;
|
private Scene? _activeScene;
|
||||||
|
private Source? _selectedSource;
|
||||||
|
private ImageSource? _activeBackgroundImage;
|
||||||
|
private bool _isConnected;
|
||||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||||
private StreamHealth _currentHealth = new();
|
private StreamHealth _currentHealth = new();
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
private string _streamKey = string.Empty;
|
private string _streamDescription = string.Empty;
|
||||||
private bool _isYouTubeConnected;
|
private string _streamVisibility = "Public";
|
||||||
|
private string _windowTitle = "ytLlive";
|
||||||
|
private string _topBarBackground = "#16213e";
|
||||||
|
private string _previewGlowBrush = "Transparent";
|
||||||
|
private Thickness _previewGlowThickness = new(0);
|
||||||
|
private string _liveElapsedText = "00:00:00";
|
||||||
|
private double _livePulseOpacity = 1.0;
|
||||||
|
private TimeSpan _liveElapsed;
|
||||||
|
|
||||||
|
private bool _isSettingsOpen;
|
||||||
|
private bool _isBugOpen;
|
||||||
|
private bool _isFeatureOpen;
|
||||||
|
private bool _isAboutOpen;
|
||||||
|
private string _overlayTitle = string.Empty;
|
||||||
|
private string _defaultStreamTitle = string.Empty;
|
||||||
|
private string _defaultStreamDescription = string.Empty;
|
||||||
|
private string _defaultStreamVisibility = "Public";
|
||||||
|
private string _bugReportText = string.Empty;
|
||||||
|
private string _bugReportEmail = string.Empty;
|
||||||
|
private string _featureRequestText = string.Empty;
|
||||||
|
private string _featureRequestEmail = string.Empty;
|
||||||
|
|
||||||
|
private LayoutStore _layoutStore;
|
||||||
|
private string? _activeLayoutPath;
|
||||||
|
private DispatcherTimer? _saveDebounce;
|
||||||
|
private bool _isLoading;
|
||||||
|
|
||||||
|
private const string SupportEmail = "gramps@llamachile.shop";
|
||||||
|
private const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
||||||
|
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
||||||
|
|
||||||
public ObservableCollection<Scene> Scenes { get; } = new();
|
public ObservableCollection<Scene> Scenes { get; } = new();
|
||||||
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
||||||
|
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
||||||
|
|
||||||
public Scene? ActiveScene
|
public Scene? ActiveScene
|
||||||
{
|
{
|
||||||
get => _activeScene;
|
get => _activeScene;
|
||||||
set => SetProperty(ref _activeScene, value);
|
set
|
||||||
|
{
|
||||||
|
if (value != null && value.IsHidden) return;
|
||||||
|
if (SetProperty(ref _activeScene, value))
|
||||||
|
{
|
||||||
|
SelectedSource = null;
|
||||||
|
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImageSource? ActiveBackgroundImage
|
||||||
|
{
|
||||||
|
get => _activeBackgroundImage;
|
||||||
|
private set => SetProperty(ref _activeBackgroundImage, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Source? SelectedSource
|
||||||
|
{
|
||||||
|
get => _selectedSource;
|
||||||
|
set => SetProperty(ref _selectedSource, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamStatus StreamStatus
|
public StreamStatus StreamStatus
|
||||||
{
|
{
|
||||||
get => _streamStatus;
|
get => _streamStatus;
|
||||||
set => SetProperty(ref _streamStatus, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _streamStatus, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsOffline));
|
||||||
|
OnPropertyChanged(nameof(IsLive));
|
||||||
|
OnPropertyChanged(nameof(LiveIndicatorVisible));
|
||||||
|
OnPropertyChanged(nameof(ShowStartStream));
|
||||||
|
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||||
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||||
|
UpdateLiveVisuals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConnected
|
||||||
|
{
|
||||||
|
get => _isConnected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isConnected, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsNotConnected));
|
||||||
|
OnPropertyChanged(nameof(ShowStartStream));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||||
|
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||||
|
public bool LiveIndicatorVisible => IsLive;
|
||||||
|
public bool IsNotConnected => !IsConnected;
|
||||||
|
public bool ShowStartStream => IsConnected && IsOffline;
|
||||||
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
|
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
|
||||||
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
|
||||||
|
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Sources.Count == 0;
|
||||||
|
|
||||||
|
private void UpdateActiveBackground()
|
||||||
|
{
|
||||||
|
var background = ActiveScene?.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
|
||||||
|
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
|
||||||
|
? ImageCache.Get(background.AssetId)
|
||||||
|
: null;
|
||||||
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamHealth CurrentHealth
|
public StreamHealth CurrentHealth
|
||||||
@@ -46,59 +156,375 @@ public class MainViewModel : ViewModelBase
|
|||||||
set => SetProperty(ref _streamTitle, value);
|
set => SetProperty(ref _streamTitle, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string StreamKey
|
public string StreamDescription
|
||||||
{
|
{
|
||||||
get => _streamKey;
|
get => _streamDescription;
|
||||||
set => SetProperty(ref _streamKey, value);
|
set => SetProperty(ref _streamDescription, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsYouTubeConnected
|
public string StreamVisibility
|
||||||
{
|
{
|
||||||
get => _isYouTubeConnected;
|
get => _streamVisibility;
|
||||||
set => SetProperty(ref _isYouTubeConnected, value);
|
set => SetProperty(ref _streamVisibility, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string StatusDisplay => StreamStatus switch
|
public string WindowTitle
|
||||||
{
|
{
|
||||||
StreamStatus.Offline => "OFFLINE",
|
get => _windowTitle;
|
||||||
StreamStatus.Connecting => "CONNECTING...",
|
set => SetProperty(ref _windowTitle, value);
|
||||||
StreamStatus.Streaming => "LIVE",
|
}
|
||||||
StreamStatus.Error => "ERROR",
|
|
||||||
_ => "UNKNOWN"
|
public string TopBarBackground
|
||||||
};
|
{
|
||||||
|
get => _topBarBackground;
|
||||||
|
set => SetProperty(ref _topBarBackground, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PreviewGlowBrush
|
||||||
|
{
|
||||||
|
get => _previewGlowBrush;
|
||||||
|
set => SetProperty(ref _previewGlowBrush, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Thickness PreviewGlowThickness
|
||||||
|
{
|
||||||
|
get => _previewGlowThickness;
|
||||||
|
set => SetProperty(ref _previewGlowThickness, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LiveElapsedText
|
||||||
|
{
|
||||||
|
get => _liveElapsedText;
|
||||||
|
set => SetProperty(ref _liveElapsedText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double LivePulseOpacity
|
||||||
|
{
|
||||||
|
get => _livePulseOpacity;
|
||||||
|
set => SetProperty(ref _livePulseOpacity, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay panels
|
||||||
|
public bool IsSettingsOpen
|
||||||
|
{
|
||||||
|
get => _isSettingsOpen;
|
||||||
|
private set => SetPanel(ref _isSettingsOpen, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBugOpen
|
||||||
|
{
|
||||||
|
get => _isBugOpen;
|
||||||
|
private set => SetPanel(ref _isBugOpen, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsFeatureOpen
|
||||||
|
{
|
||||||
|
get => _isFeatureOpen;
|
||||||
|
private set => SetPanel(ref _isFeatureOpen, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAboutOpen
|
||||||
|
{
|
||||||
|
get => _isAboutOpen;
|
||||||
|
private set => SetPanel(ref _isAboutOpen, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
||||||
|
|
||||||
|
public string OverlayTitle
|
||||||
|
{
|
||||||
|
get => _overlayTitle;
|
||||||
|
private set => SetProperty(ref _overlayTitle, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DefaultStreamTitle
|
||||||
|
{
|
||||||
|
get => _defaultStreamTitle;
|
||||||
|
set => SetProperty(ref _defaultStreamTitle, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DefaultStreamDescription
|
||||||
|
{
|
||||||
|
get => _defaultStreamDescription;
|
||||||
|
set => SetProperty(ref _defaultStreamDescription, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DefaultStreamVisibility
|
||||||
|
{
|
||||||
|
get => _defaultStreamVisibility;
|
||||||
|
set => SetProperty(ref _defaultStreamVisibility, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] StreamQualities { get; } = { "1080p60", "1080p30", "720p60" };
|
||||||
|
|
||||||
|
private string _streamQuality = "1080p60";
|
||||||
|
public string StreamQuality
|
||||||
|
{
|
||||||
|
get => _streamQuality;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _streamQuality, value))
|
||||||
|
ApplyStreamQuality(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyStreamQuality(string quality)
|
||||||
|
{
|
||||||
|
var (bitrate, fps) = quality switch
|
||||||
|
{
|
||||||
|
"1080p30" => (8.0, 30.0),
|
||||||
|
"720p60" => (6.0, 60.0),
|
||||||
|
_ => (8.0, 60.0),
|
||||||
|
};
|
||||||
|
var health = CurrentHealth;
|
||||||
|
health.CurrentBitrate = bitrate;
|
||||||
|
health.FPS = fps;
|
||||||
|
OnPropertyChanged(nameof(CurrentHealth));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BugReportText
|
||||||
|
{
|
||||||
|
get => _bugReportText;
|
||||||
|
set => SetProperty(ref _bugReportText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BugReportEmail
|
||||||
|
{
|
||||||
|
get => _bugReportEmail;
|
||||||
|
set => SetProperty(ref _bugReportEmail, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FeatureRequestText
|
||||||
|
{
|
||||||
|
get => _featureRequestText;
|
||||||
|
set => SetProperty(ref _featureRequestText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FeatureRequestEmail
|
||||||
|
{
|
||||||
|
get => _featureRequestEmail;
|
||||||
|
set => SetProperty(ref _featureRequestEmail, value);
|
||||||
|
}
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
public ICommand AddSceneCommand { get; }
|
public ICommand AddSceneCommand { get; }
|
||||||
|
public ICommand EditSceneCommand { get; }
|
||||||
public ICommand RemoveSceneCommand { get; }
|
public ICommand RemoveSceneCommand { get; }
|
||||||
|
public ICommand ToggleSceneVisibilityCommand { get; }
|
||||||
|
public ICommand AddSourceCommand { get; }
|
||||||
|
public ICommand AddImageCommand { get; }
|
||||||
|
public ICommand RemoveSourceCommand { get; }
|
||||||
|
public ICommand ConnectCommand { get; }
|
||||||
public ICommand StartStreamCommand { get; }
|
public ICommand StartStreamCommand { get; }
|
||||||
public ICommand StopStreamCommand { get; }
|
public ICommand EndStreamCommand { get; }
|
||||||
public ICommand ConnectYouTubeCommand { get; }
|
public ICommand OpenSettingsCommand { get; }
|
||||||
|
public ICommand OpenBugCommand { get; }
|
||||||
|
public ICommand OpenFeatureCommand { get; }
|
||||||
|
public ICommand OpenAboutCommand { get; }
|
||||||
|
public ICommand CloseOverlayCommand { get; }
|
||||||
|
public ICommand SubmitBugCommand { get; }
|
||||||
|
public ICommand SubmitFeatureCommand { get; }
|
||||||
|
public ICommand OpenChannelCommand { get; }
|
||||||
|
public ICommand SaveLayoutCommand { get; }
|
||||||
|
public ICommand SaveLayoutAsCommand { get; }
|
||||||
|
public ICommand OpenLayoutCommand { get; }
|
||||||
|
|
||||||
public MainViewModel()
|
public MainViewModel()
|
||||||
{
|
{
|
||||||
_youtubeAuth = new YouTubeAuthService("", ""); // TODO: load from config
|
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret);
|
||||||
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
|
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
|
||||||
_youtubeChat = new YouTubeChatService(_youtubeAuth);
|
_youtubeChat = new YouTubeChatService(_youtubeAuth);
|
||||||
|
|
||||||
_youtubeChat.MessageReceived += OnChatMessageReceived;
|
_youtubeChat.MessageReceived += OnChatMessageReceived;
|
||||||
|
|
||||||
AddSceneCommand = new RelayCommand(_ => AddScene());
|
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||||
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
|
_liveTimer.Tick += OnLiveTimerTick;
|
||||||
StartStreamCommand = new RelayCommand(_ => StartStream(), _ => StreamStatus == StreamStatus.Offline);
|
|
||||||
StopStreamCommand = new RelayCommand(_ => StopStream(), _ => StreamStatus == StreamStatus.Streaming);
|
|
||||||
ConnectYouTubeCommand = new RelayCommand(_ => ConnectYouTube());
|
|
||||||
|
|
||||||
// Start with a default scene
|
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
|
||||||
AddScene("Scene 1");
|
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
||||||
|
Scenes.CollectionChanged += OnScenesChanged;
|
||||||
|
|
||||||
|
AddSceneCommand = new RelayCommand(_ => AddScene());
|
||||||
|
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
|
||||||
|
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
|
||||||
|
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
|
||||||
|
AddSourceCommand = new RelayCommand(type => AddSource(type as string));
|
||||||
|
AddImageCommand = new RelayCommand(_ => AddImage());
|
||||||
|
RemoveSourceCommand = new RelayCommand(source => RemoveSource(source as Source));
|
||||||
|
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
|
||||||
|
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
||||||
|
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
|
||||||
|
OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
|
||||||
|
CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
|
||||||
|
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
|
||||||
|
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
|
||||||
|
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
|
||||||
|
ConnectCommand = new RelayCommand(_ => _ = ConnectAsync());
|
||||||
|
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
|
||||||
|
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
|
||||||
|
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||||
|
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
||||||
|
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
||||||
|
|
||||||
|
_layoutStore = new LayoutStore(DefaultLayoutPath);
|
||||||
|
_activeLayoutPath = _layoutStore.ActivePath;
|
||||||
|
LoadLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddScene(string? name = null)
|
private static string DefaultLayoutPath => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"ytLlive",
|
||||||
|
"ytLlive.db");
|
||||||
|
|
||||||
|
private void LoadLayout()
|
||||||
{
|
{
|
||||||
var scene = new Scene { Name = name ?? $"Scene {Scenes.Count + 1}" };
|
_isLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scenes = _layoutStore.Load();
|
||||||
|
Scenes.Clear();
|
||||||
|
foreach (var scene in scenes)
|
||||||
|
Scenes.Add(scene);
|
||||||
|
|
||||||
|
if (Scenes.Count == 0)
|
||||||
|
{
|
||||||
|
AddScene("Starting");
|
||||||
|
AddScene("Live");
|
||||||
|
AddScene("BRB");
|
||||||
|
AddScene("Chat", isChatScene: true);
|
||||||
|
AddScene("Ending");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
ActiveScene = Scenes.FirstOrDefault();
|
||||||
|
UpdateActiveBackground();
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Shutdown()
|
||||||
|
{
|
||||||
|
_saveDebounce?.Stop();
|
||||||
|
SaveLayoutNow();
|
||||||
|
_layoutStore.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveLayoutNow()
|
||||||
|
{
|
||||||
|
_saveDebounce?.Stop();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_layoutStore.Save(Scenes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Layout save failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveLayoutAs()
|
||||||
|
{
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Save Layout As",
|
||||||
|
Filter = "ytLlive layout (*.yll)|*.yll|All files (*.*)|*.*",
|
||||||
|
DefaultExt = ".yll",
|
||||||
|
FileName = "my-layout.yll",
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
_layoutStore.Dispose();
|
||||||
|
_layoutStore = new LayoutStore(dialog.FileName);
|
||||||
|
_activeLayoutPath = dialog.FileName;
|
||||||
|
SaveLayoutNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenLayoutFile()
|
||||||
|
{
|
||||||
|
var dialog = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Open Layout",
|
||||||
|
Filter = "ytLlive layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*",
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
_layoutStore.Dispose();
|
||||||
|
_layoutStore = new LayoutStore(dialog.FileName);
|
||||||
|
_activeLayoutPath = dialog.FileName;
|
||||||
|
LoadLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auto-save wiring ───
|
||||||
|
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.NewItems != null)
|
||||||
|
foreach (Scene scene in e.NewItems)
|
||||||
|
WireScene(scene);
|
||||||
|
if (e.OldItems != null)
|
||||||
|
foreach (Scene scene in e.OldItems)
|
||||||
|
UnwireScene(scene);
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WireScene(Scene scene)
|
||||||
|
{
|
||||||
|
scene.PropertyChanged += OnScenePropertyChanged;
|
||||||
|
scene.Sources.CollectionChanged += OnSourcesChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnwireScene(Scene scene)
|
||||||
|
{
|
||||||
|
scene.PropertyChanged -= OnScenePropertyChanged;
|
||||||
|
scene.Sources.CollectionChanged -= OnSourcesChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
=> ScheduleSave();
|
||||||
|
|
||||||
|
private void OnSourcesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.NewItems != null)
|
||||||
|
foreach (Source source in e.NewItems)
|
||||||
|
source.PropertyChanged += OnSourcePropertyChanged;
|
||||||
|
if (e.OldItems != null)
|
||||||
|
foreach (Source source in e.OldItems)
|
||||||
|
source.PropertyChanged -= OnSourcePropertyChanged;
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
=> ScheduleSave();
|
||||||
|
|
||||||
|
private void ScheduleSave()
|
||||||
|
{
|
||||||
|
if (_isLoading || _saveDebounce == null) return;
|
||||||
|
_saveDebounce.Stop();
|
||||||
|
_saveDebounce.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddScene(string? name = null, bool isChatScene = false)
|
||||||
|
{
|
||||||
|
var scene = new Scene { Name = name ?? $"New Scene {Scenes.Count + 1}", IsChatScene = isChatScene };
|
||||||
Scenes.Add(scene);
|
Scenes.Add(scene);
|
||||||
ActiveScene = scene;
|
ActiveScene = scene;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BeginEditScene(Scene? scene)
|
||||||
|
{
|
||||||
|
if (scene == null) return;
|
||||||
|
scene.IsEditing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSceneVisibility(Scene? scene)
|
||||||
|
{
|
||||||
|
if (scene == null) return;
|
||||||
|
scene.IsHidden = !scene.IsHidden;
|
||||||
|
if (scene.IsHidden && ActiveScene == scene)
|
||||||
|
ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
|
||||||
|
}
|
||||||
|
|
||||||
private void RemoveScene(Scene? scene)
|
private void RemoveScene(Scene? scene)
|
||||||
{
|
{
|
||||||
if (scene == null) return;
|
if (scene == null) return;
|
||||||
@@ -107,6 +533,257 @@ public class MainViewModel : ViewModelBase
|
|||||||
ActiveScene = Scenes.FirstOrDefault();
|
ActiveScene = Scenes.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddSource(string? type)
|
||||||
|
{
|
||||||
|
var scene = ActiveScene;
|
||||||
|
if (scene == null) return;
|
||||||
|
|
||||||
|
var sourceType = type?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"webcam" => SourceType.Webcam,
|
||||||
|
"screen" => SourceType.DisplayCapture,
|
||||||
|
"window" => SourceType.WindowCapture,
|
||||||
|
"background" => SourceType.Background,
|
||||||
|
"text" => SourceType.TextOverlay,
|
||||||
|
_ => SourceType.Image,
|
||||||
|
};
|
||||||
|
var baseName = sourceType switch
|
||||||
|
{
|
||||||
|
SourceType.Webcam => "Webcam",
|
||||||
|
SourceType.DisplayCapture => "Screen",
|
||||||
|
SourceType.WindowCapture => "Window",
|
||||||
|
SourceType.Background => "Background",
|
||||||
|
SourceType.Image => "Image",
|
||||||
|
SourceType.TextOverlay => "Text",
|
||||||
|
_ => "Source",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sourceType == SourceType.Background)
|
||||||
|
{
|
||||||
|
var bytes = PickImageBytes("Choose a backdrop image");
|
||||||
|
if (bytes == null) return;
|
||||||
|
var assetId = AddAsset(bytes);
|
||||||
|
if (assetId == null) return;
|
||||||
|
|
||||||
|
var existing = scene.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.AssetId = assetId;
|
||||||
|
UpdateActiveBackground();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.Sources.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = scene.Sources.Count(s => s.Type == sourceType);
|
||||||
|
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
|
||||||
|
|
||||||
|
scene.Sources.Add(new Source { Name = name, Type = sourceType });
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddImage()
|
||||||
|
{
|
||||||
|
var scene = ActiveScene;
|
||||||
|
if (scene == null) return;
|
||||||
|
|
||||||
|
var candidates = BuildImageCandidates();
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
var bytes = PickImageBytes("Choose an image");
|
||||||
|
if (bytes != null) AddImageSource(bytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
|
||||||
|
{
|
||||||
|
Owner = Application.Current.MainWindow
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
if (dialog.WantsNew)
|
||||||
|
{
|
||||||
|
var bytes = PickImageBytes("Choose an image");
|
||||||
|
if (bytes != null) AddImageSource(bytes);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AddReusedImage(dialog.PickedAssetId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReuseImageCandidate> BuildImageCandidates()
|
||||||
|
{
|
||||||
|
var candidates = new List<ReuseImageCandidate>();
|
||||||
|
foreach (var scene in Scenes)
|
||||||
|
foreach (var source in scene.Sources.Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
|
||||||
|
{
|
||||||
|
candidates.Add(new ReuseImageCandidate
|
||||||
|
{
|
||||||
|
SceneName = scene.Name,
|
||||||
|
SourceName = source.Name,
|
||||||
|
AssetId = source.AssetId!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? PickImageBytes(string title)
|
||||||
|
{
|
||||||
|
var dialog = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.ReadAllBytes(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Couldn't read that image: {ex.Message}", "ytLlive",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? AddAsset(byte[] bytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var image = ImageCache.FromBytes(bytes);
|
||||||
|
var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
|
||||||
|
if (id != null && image != null) ImageCache.Put(id, image);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Asset store failed: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddImageSource(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length == 0) return;
|
||||||
|
var assetId = AddAsset(bytes);
|
||||||
|
if (assetId != null) AddReusedImage(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddReusedImage(string assetId)
|
||||||
|
{
|
||||||
|
var scene = ActiveScene;
|
||||||
|
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
|
||||||
|
|
||||||
|
var count = scene.Sources.Count(s => s.Type == SourceType.Image);
|
||||||
|
var name = count == 0 ? "Image" : $"Image {count + 1}";
|
||||||
|
|
||||||
|
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
|
||||||
|
|
||||||
|
var image = ImageCache.Get(assetId);
|
||||||
|
if (image != null)
|
||||||
|
{
|
||||||
|
var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
|
||||||
|
source.Width = image.PixelWidth * scale;
|
||||||
|
source.Height = image.PixelHeight * scale;
|
||||||
|
source.X = (1920 - source.Width) / 2;
|
||||||
|
source.Y = (1080 - source.Height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.Sources.Add(source);
|
||||||
|
SelectedSource = source;
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSource(Source? source)
|
||||||
|
{
|
||||||
|
var scene = ActiveScene;
|
||||||
|
if (scene == null || source == null) return;
|
||||||
|
scene.Sources.Remove(source);
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPanel(ref bool field, bool value, [System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
if (SetProperty(ref field, value, propertyName))
|
||||||
|
OnPropertyChanged(nameof(IsAnyOverlayOpen));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowOverlay(string? panel = null, string title = "")
|
||||||
|
{
|
||||||
|
IsSettingsOpen = panel == nameof(IsSettingsOpen);
|
||||||
|
IsBugOpen = panel == nameof(IsBugOpen);
|
||||||
|
IsFeatureOpen = panel == nameof(IsFeatureOpen);
|
||||||
|
IsAboutOpen = panel == nameof(IsAboutOpen);
|
||||||
|
OverlayTitle = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubmitBug()
|
||||||
|
{
|
||||||
|
var body = string.Join(Environment.NewLine,
|
||||||
|
BugReportText.Trim(),
|
||||||
|
string.Empty,
|
||||||
|
$"ytLlive {AppVersionLabel}",
|
||||||
|
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
|
||||||
|
ComposeEmail("[ytLlive Bug Report]", body);
|
||||||
|
ShowOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubmitFeature()
|
||||||
|
{
|
||||||
|
var body = string.Join(Environment.NewLine,
|
||||||
|
FeatureRequestText.Trim(),
|
||||||
|
string.Empty,
|
||||||
|
$"ytLlive {AppVersionLabel}",
|
||||||
|
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
|
||||||
|
ComposeEmail("[ytLlive Feature Request]", body);
|
||||||
|
ShowOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ComposeEmail(string subject, string body)
|
||||||
|
{
|
||||||
|
var uri = $"mailto:{SupportEmail}?subject={Uri.EscapeDataString(subject)}&body={Uri.EscapeDataString(body)}";
|
||||||
|
OpenUrl(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OpenUrl(string url)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
|
||||||
|
if (channel == null)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsConnected = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnChatMessageReceived(ChatMessage message)
|
private void OnChatMessageReceived(ChatMessage message)
|
||||||
{
|
{
|
||||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||||
@@ -117,28 +794,59 @@ public class MainViewModel : ViewModelBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void StartStream()
|
private void BeginGoLive()
|
||||||
{
|
{
|
||||||
StreamStatus = StreamStatus.Connecting;
|
var dialog = new GoLiveViewModel
|
||||||
OnPropertyChanged(nameof(StatusDisplay));
|
{
|
||||||
|
StreamTitle = DefaultStreamTitle,
|
||||||
// TODO: Initialize capture pipeline, encode, and push to RTMP
|
StreamDescription = DefaultStreamDescription,
|
||||||
// For now, simulate connection
|
Visibility = DefaultStreamVisibility,
|
||||||
await Task.Delay(500);
|
};
|
||||||
StreamStatus = StreamStatus.Streaming;
|
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
||||||
OnPropertyChanged(nameof(StatusDisplay));
|
if (window.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
StreamTitle = dialog.StreamTitle;
|
||||||
|
StreamDescription = dialog.StreamDescription;
|
||||||
|
StreamVisibility = dialog.Visibility;
|
||||||
|
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
|
||||||
|
? "ytLlive"
|
||||||
|
: $"{dialog.StreamTitle} — ytLlive";
|
||||||
|
StreamStatus = StreamStatus.Streaming;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StopStream()
|
private void StopStream()
|
||||||
{
|
{
|
||||||
StreamStatus = StreamStatus.Offline;
|
StreamStatus = StreamStatus.Offline;
|
||||||
OnPropertyChanged(nameof(StatusDisplay));
|
WindowTitle = "ytLlive";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ConnectYouTube()
|
private void UpdateLiveVisuals()
|
||||||
{
|
{
|
||||||
// TODO: Launch OAuth2 flow in browser
|
var live = IsLive;
|
||||||
// For now, this is a stub
|
TopBarBackground = live ? "#e94560" : "#16213e";
|
||||||
IsYouTubeConnected = false;
|
PreviewGlowBrush = live ? "#e94560" : "Transparent";
|
||||||
|
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
|
||||||
|
|
||||||
|
if (live)
|
||||||
|
{
|
||||||
|
_liveElapsed = TimeSpan.Zero;
|
||||||
|
LiveElapsedText = "00:00:00";
|
||||||
|
LivePulseOpacity = 1.0;
|
||||||
|
_liveTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_liveTimer.Stop();
|
||||||
|
LiveElapsedText = "00:00:00";
|
||||||
|
LivePulseOpacity = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLiveTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||||
|
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||||
|
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,110 @@
|
|||||||
|
# ytLlive — AI Guide
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build # Windows only — WPF requires Windows target
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project can be restored/built from WSL, but running requires Windows.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
No test framework set up yet. When added: `dotnet test`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
C# / WPF (.NET 8) following MVVM:
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|------|------|
|
||||||
|
| `Models/` | Plain data types — Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
||||||
|
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI |
|
||||||
|
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
|
||||||
|
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache |
|
||||||
|
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
||||||
|
|
||||||
|
### Key patterns
|
||||||
|
|
||||||
|
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
||||||
|
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline)
|
||||||
|
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
||||||
|
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
|
||||||
|
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
|
||||||
|
|
||||||
|
### Current limitations / TODOs
|
||||||
|
|
||||||
|
- `OAuthCredentials.ClientId` / `ClientSecret` in `Helpers/OAuthCredentials.cs` are empty — the app
|
||||||
|
owner fills them in once (developer task, baked into the binary; creators never configure anything)
|
||||||
|
- `GoLiveViewModel.SignIn`/`ChangeAccount` removed — Connect (OAuth) is the only entry to streaming
|
||||||
|
- No token persistence yet (Windows DPAPI planned) — scene/source/asset layout *does* persist (SQLite)
|
||||||
|
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
||||||
|
- No capture/encoding/RTMP yet
|
||||||
|
- Stream config (title/description/visibility/quality) still in-memory
|
||||||
|
|
||||||
|
## Design Principle
|
||||||
|
|
||||||
|
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||||
|
|
||||||
|
Apply this to every UI decision:
|
||||||
|
- One-click go-live with working defaults
|
||||||
|
- Prefilled YouTube defaults (RTMP URL, bitrate, resolution, latency)
|
||||||
|
- Visual/drag-and-drop scene building over property panels
|
||||||
|
- Every action produces a visible outcome — no dead ends
|
||||||
|
|
||||||
|
## Monetization (design decision — the watermark is the sword)
|
||||||
|
|
||||||
|
Free forever: all streams unlimited, no time caps, no subscription, no per-feature paywalls. The
|
||||||
|
**one paid line is a one-time unlock** (delivered via itch.io — they handle hosting, payment, and key
|
||||||
|
delivery; we never own a server or a key shop):
|
||||||
|
|
||||||
|
- **Free:** a small "made with ytLlive" watermark is always on, every frame, every stream — the sword
|
||||||
|
of Damocles. Standard practice; only Streamlabs runs watermark-nagging to a capitalist extreme.
|
||||||
|
- **Paid (one-time):** watermark removed + **Alerts** (Super Chat / membership / subscribe pop-ins).
|
||||||
|
|
||||||
|
Deliberately rejected: hard stream-time cutoffs (the worst dead end — a stream dying mid-broadcast
|
||||||
|
reads as broken, and YouTube streams routinely run 2-4 hours), soft-limit nagging, freemium tiers,
|
||||||
|
and donation-only (relies on the kindness of strangers). Resolution/quality ceilings are **deferred** —
|
||||||
|
that decision belongs to the resolution & streaming-constraints conversation, not monetization.
|
||||||
|
|
||||||
|
## Auth gates Go Live, but not exploration
|
||||||
|
|
||||||
|
The app is fully usable without authentication: users can build scenes, add sources, compose
|
||||||
|
previews, and audition the software with zero commitment. But **going live requires authentication** —
|
||||||
|
it's the one capability gated behind YouTube sign-in. The sign-in button should never pressure the
|
||||||
|
user ("sign in (optional)", not a modal wall), but "Go Live" only appears once connected.
|
||||||
|
|
||||||
|
## Account assumption (do not build an account setup flow)
|
||||||
|
|
||||||
|
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
|
||||||
|
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
|
||||||
|
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
|
||||||
|
replaced by an in-app account-creation step. Zero state = a Connect button that starts OAuth; going
|
||||||
|
live is unreachable until the account is connected.
|
||||||
|
|
||||||
|
## YouTube Live API — design constraints (do not violate)
|
||||||
|
|
||||||
|
These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||||
|
|
||||||
|
- **One-click go-live** — never call `transition(live)`. Insert the broadcast with
|
||||||
|
`enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
|
||||||
|
`selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
|
||||||
|
`enableMonitorStream=false` is what lets us skip the testing stage.
|
||||||
|
- **Variable reusable stream** — `liveStreams.insert` once per channel with
|
||||||
|
`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`; cache the ingestion URL +
|
||||||
|
stream name and reuse for every broadcast. Any quality tier works without recreating the stream,
|
||||||
|
and auto step-down is done by us dropping bitrate on the fly (zero API calls).
|
||||||
|
- **Quality is greyed out while live** — resolution/frameRate/ingestionType are immutable after
|
||||||
|
stream creation; editing title/description/privacy is fine at any time.
|
||||||
|
- **Report-by-exception health** — poll `liveStreams.list`; render nothing on `good`/`ok`, surface a
|
||||||
|
banner only on `configurationIssues[]` with `warning`/`error` severity. Bottom strip = YouTube logo
|
||||||
|
+ green/red connection dot (clickable → opens the dialog).
|
||||||
|
- **One dialog, three states** — not connected / 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.
|
||||||
|
- **End stream** — stop encoder → `transition(complete)`, `enableAutoStop` as the safety net.
|
||||||
|
- **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz,
|
||||||
|
mono/stereo only. YouTube flags violations via health status.
|
||||||
|
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
|
||||||
|
(`recordFromStart` + `enableDvr` default true).
|
||||||
+11
-1
@@ -7,9 +7,19 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
<ApplicationIcon></ApplicationIcon>
|
<ApplicationIcon>Assets\llama-logo.ico</ApplicationIcon>
|
||||||
<AssemblyName>ytLive</AssemblyName>
|
<AssemblyName>ytLive</AssemblyName>
|
||||||
<RootNamespace>ytLive</RootNamespace>
|
<RootNamespace>ytLive</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Resource Include="Assets\llama-logo.png"/>
|
||||||
|
<Resource Include="Assets\llama-logo-icon.png"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user