Compare commits

...

10 Commits

25 changed files with 2866 additions and 140 deletions
+1
View File
@@ -1,5 +1,6 @@
bin/
obj/
Helpers/OAuthCredentials.cs
*.user
*.suo
.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

+103
View File
@@ -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>
+15
View File
@@ -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;
}
}
+46
View File
@@ -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;
}
+14
View File
@@ -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;
}
+534 -63
View File
@@ -5,16 +5,64 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:ytLive.ViewModels"
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"
WindowStartupLocation="CenterScreen">
WindowStartupLocation="CenterScreen"
Closing="MainWindow_Closing"
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown">
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
<Window.TaskbarItemInfo>
<TaskbarItemInfo x:Name="TaskbarInfo"/>
</Window.TaskbarItemInfo>
<Window.Resources>
<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 -->
<Style x:Key="YtButton" TargetType="Button">
@@ -42,6 +90,48 @@
<Setter Property="Background" Value="#16213e"/>
</Style>
<Style x:Key="IconButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#26ffffff"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Eye icon: open when visible, slashed when hidden -->
<Style x:Key="EyeIconStyle" TargetType="Path">
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
<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">
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
@@ -51,6 +141,15 @@
<Setter Property="CaretBrush" Value="White"/>
</Style>
<Style x:Key="YtComboBox" TargetType="ComboBox">
<Setter Property="Background" Value="#0f3460"/>
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
</Style>
<Style x:Key="YtLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="#a0a0b0"/>
<Setter Property="FontSize" Value="12"/>
@@ -72,63 +171,47 @@
</Grid.RowDefinitions>
<!-- ═══ 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.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo / Brand -->
<TextBlock Grid.Column="0" Text="ytLive"
<TextBlock Grid.Column="0" Text="ytLlive"
Foreground="#e94560" FontSize="22" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,24,0"/>
<!-- Stream Title -->
<TextBox Grid.Column="1" Style="{StaticResource YtTextBox}"
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"
Width="400" HorizontalAlignment="Left"
VerticalAlignment="Center"/>
<!-- Stream Status -->
<Border Grid.Column="2" Background="#0f3460" CornerRadius="4"
Padding="12,6" Margin="16,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<Ellipse Width="10" Height="10" Margin="0,0,8,0">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="#555"/>
<Style.Triggers>
<DataTrigger Binding="{Binding StreamStatus}" Value="Streaming">
<Setter Property="Fill" Value="#00ff00"/>
</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"/>
<!-- LIVE badge (center) -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding LiveIndicatorVisible, Converter={StaticResource BoolToVis}}">
<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>
</Border>
<!-- Stream Controls -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="▶ GO LIVE" Style="{StaticResource YtButton}"
<!-- Single three-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource YtButtonSecondary}" Command="{Binding ConnectCommand}"
Visibility="{Binding IsNotConnected, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Image Source="{StaticResource YoutubeLogo}" Width="22" Height="16"
VerticalAlignment="Center"/>
<TextBlock Text="Connect" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Content="Start Stream" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding StreamStatus, Converter={StaticResource BoolToVis},
ConverterParameter=Offline}"/>
<Button Content="■ STOP" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding StopStreamCommand}"/>
<Button Content="YouTube" Style="{StaticResource YtButtonSecondary}"
Command="{Binding ConnectYouTubeCommand}" Margin="8,0,0,0"/>
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
<Button Content="End Stream" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</Grid>
</Border>
@@ -148,41 +231,265 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="180"/>
</Grid.RowDefinitions>
<!-- 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}"
SelectedItem="{Binding ActiveScene}"
SelectionChanged="SceneList_SelectionChanged"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
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>
<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>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="+" Style="{StaticResource YtButtonSecondary}"
Command="{Binding AddSceneCommand}" Padding="8,4" Margin="0,0,4,0"/>
<Button Content="" Style="{StaticResource YtButtonSecondary}"
Command="{Binding RemoveSceneCommand}" Padding="8,4"/>
<!-- Sources (for active scene) -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,0">
<TextBlock Text="SOURCES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
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>
<!-- Sources (for active scene) -->
<TextBlock Grid.Row="3" Text="SOURCES" Style="{StaticResource SectionHeader}" Margin="0,12,0,0"/>
<ListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ActiveScene.Sources}"
SelectedItem="{Binding SelectedSource, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
PreviewMouseMove="List_PreviewMouseMove"
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
Foreground="#e0e0e0" FontSize="13">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<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>
</Border>
<!-- 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.RowDefinitions>
<RowDefinition Height="Auto"/>
<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"/>
<!-- TODO: D3DImage or MediaElement for video preview -->
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>
</Border>
@@ -196,8 +503,19 @@
<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"
ItemsSource="{Binding ChatMessages}"
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
Foreground="#e0e0e0" FontSize="12">
<ListBox.ItemTemplate>
<DataTemplate>
@@ -217,14 +535,25 @@
<!-- ═══ BOTTOM BAR: Stream Health ═══ -->
<Border Grid.Row="2" Background="#0f3460" Padding="16,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Text="{Binding CurrentHealth.CurrentBitrate}" Style="{StaticResource YtLabel}"
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="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Text="{Binding CurrentHealth.FPS}" Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,20,0"/>
<TextBlock Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,20,0">
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
</TextBlock>
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
@@ -237,6 +566,148 @@
<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>
<!-- ═══ 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>
</Window>
+317 -1
View File
@@ -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;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
public MainWindow()
{
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
View File
@@ -1,8 +1,44 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ytLive.Models;
public class Scene
public class Scene : INotifyPropertyChanged
{
public string Id { get; init; } = Guid.NewGuid().ToString();
public string Name { get; set; } = string.Empty;
public List<Source> Sources { get; set; } = new();
private string _name = string.Empty;
private bool _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
View File
@@ -1,3 +1,8 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
using ytLive.Helpers;
namespace ytLive.Models;
public enum SourceType
@@ -5,31 +10,80 @@ public enum SourceType
DisplayCapture,
WindowCapture,
Webcam,
Background,
Image,
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 Name { get; set; } = string.Empty;
public SourceType Type { get; set; }
public bool IsEnabled { get; set; } = true;
private string _name = string.Empty;
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
public int? MonitorIndex { get; set; }
public IntPtr? WindowHandle { get; set; }
private int? _monitorIndex;
public int? MonitorIndex { get => _monitorIndex; set => Set(ref _monitorIndex, value); }
private IntPtr? _windowHandle;
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
// Webcam
public string? DeviceId { get; set; }
private string? _deviceId;
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); }
// Image
public string? FilePath { get; set; }
// Image (asset stored in the layout database)
private string? _assetId;
private ImageSource? _imageSource;
// Position/transform
public double X { get; set; }
public double Y { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public double Opacity { get; set; } = 1.0;
public string? AssetId
{
get => _assetId;
set
{
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); }
}
+48
View File
@@ -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 |
+105
View File
@@ -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>
+27
View File
@@ -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;
}
}
+276
View File
@@ -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();
}
}
+44
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -46,6 +47,49 @@ public class YouTubeAuthService
return $"{AuthorizationEndpoint}?{encoded}";
}
public async Task<YouTubeChannel?> AuthenticateAsync()
{
const int port = 8765;
var redirectUri = $"http://localhost:{port}/oauth2/callback";
if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret))
return null;
using var listener = new HttpListener();
listener.Prefixes.Add($"{redirectUri}/");
listener.Start();
Process.Start(new ProcessStartInfo(GetAuthorizationUrl(redirectUri)) { UseShellExecute = true });
HttpListenerContext context;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
context = await listener.GetContextAsync().WaitAsync(cts.Token);
}
catch (OperationCanceledException)
{
return null;
}
var code = context.Request.QueryString["code"];
var error = context.Request.QueryString["error"];
var html = error != null
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to ytLlive.</p></body></html>"
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to ytLlive.</p></body></html>";
var buffer = Encoding.UTF8.GetBytes(html);
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength64 = buffer.Length;
await context.Response.OutputStream.WriteAsync(buffer);
context.Response.Close();
if (error != null || string.IsNullOrEmpty(code))
return null;
return await ExchangeCodeForToken(code, redirectUri);
}
public async Task<YouTubeChannel?> ExchangeCodeForToken(string code, string redirectUri)
{
var body = new FormUrlEncodedContent(new Dictionary<string, string>
+233
View File
@@ -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
+43
View File
@@ -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;
}
+749 -41
View File
@@ -1,5 +1,13 @@
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.Media;
using System.Windows.Threading;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
@@ -11,27 +19,129 @@ public class MainViewModel : ViewModelBase
private readonly YouTubeAuthService _youtubeAuth;
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer;
private Scene? _activeScene;
private Source? _selectedSource;
private ImageSource? _activeBackgroundImage;
private bool _isConnected;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
private string _streamKey = string.Empty;
private bool _isYouTubeConnected;
private string _streamDescription = string.Empty;
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<ChatMessage> ChatMessages { get; } = new();
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public Scene? 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
{
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
@@ -46,59 +156,375 @@ public class MainViewModel : ViewModelBase
set => SetProperty(ref _streamTitle, value);
}
public string StreamKey
public string StreamDescription
{
get => _streamKey;
set => SetProperty(ref _streamKey, value);
get => _streamDescription;
set => SetProperty(ref _streamDescription, value);
}
public bool IsYouTubeConnected
public string StreamVisibility
{
get => _isYouTubeConnected;
set => SetProperty(ref _isYouTubeConnected, value);
get => _streamVisibility;
set => SetProperty(ref _streamVisibility, value);
}
public string StatusDisplay => StreamStatus switch
public string WindowTitle
{
StreamStatus.Offline => "OFFLINE",
StreamStatus.Connecting => "CONNECTING...",
StreamStatus.Streaming => "LIVE",
StreamStatus.Error => "ERROR",
_ => "UNKNOWN"
get => _windowTitle;
set => SetProperty(ref _windowTitle, value);
}
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
public ICommand AddSceneCommand { get; }
public ICommand EditSceneCommand { 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 StopStreamCommand { get; }
public ICommand ConnectYouTubeCommand { get; }
public ICommand EndStreamCommand { 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()
{
_youtubeAuth = new YouTubeAuthService("", ""); // TODO: load from config
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret);
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth);
_youtubeChat.MessageReceived += OnChatMessageReceived;
AddSceneCommand = new RelayCommand(_ => AddScene());
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
StartStreamCommand = new RelayCommand(_ => StartStream(), _ => StreamStatus == StreamStatus.Offline);
StopStreamCommand = new RelayCommand(_ => StopStream(), _ => StreamStatus == StreamStatus.Streaming);
ConnectYouTubeCommand = new RelayCommand(_ => ConnectYouTube());
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
// Start with a default scene
AddScene("Scene 1");
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_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);
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)
{
if (scene == null) return;
@@ -107,6 +533,257 @@ public class MainViewModel : ViewModelBase
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)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
@@ -117,28 +794,59 @@ public class MainViewModel : ViewModelBase
});
}
private async void StartStream()
private void BeginGoLive()
{
StreamStatus = StreamStatus.Connecting;
OnPropertyChanged(nameof(StatusDisplay));
// TODO: Initialize capture pipeline, encode, and push to RTMP
// For now, simulate connection
await Task.Delay(500);
var dialog = new GoLiveViewModel
{
StreamTitle = DefaultStreamTitle,
StreamDescription = DefaultStreamDescription,
Visibility = DefaultStreamVisibility,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
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;
OnPropertyChanged(nameof(StatusDisplay));
}
}
private void StopStream()
{
StreamStatus = StreamStatus.Offline;
OnPropertyChanged(nameof(StatusDisplay));
WindowTitle = "ytLlive";
}
private void ConnectYouTube()
private void UpdateLiveVisuals()
{
// TODO: Launch OAuth2 flow in browser
// For now, this is a stub
IsYouTubeConnected = false;
var live = IsLive;
TopBarBackground = live ? "#e94560" : "#16213e";
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;
}
}
+52
View File
@@ -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());
}
}
+110
View File
@@ -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
View File
@@ -7,9 +7,19 @@
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon></ApplicationIcon>
<ApplicationIcon>Assets\llama-logo.ico</ApplicationIcon>
<AssemblyName>ytLive</AssemblyName>
<RootNamespace>ytLive</RootNamespace>
</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>