Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dc8490d96 | |||
| d6ae048583 | |||
| aae17a1511 | |||
| 3caccfff1f | |||
| 4caf7eec8d | |||
| 639e1bb323 | |||
| 4293ae11a4 | |||
| 8ee44f571e | |||
| d4945f4fd0 | |||
| 8aebbff318 |
@@ -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 |
@@ -0,0 +1,103 @@
|
||||
<Window x:Class="ytLive.GoLiveWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Start Stream" Height="430" Width="520"
|
||||
Icon="/Assets/llama-logo-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
<Window.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||
|
||||
<Style x:Key="GoLiveButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="#e94560"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Padding" Value="16,8"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GoLiveButtonSecondary" TargetType="Button" BasedOn="{StaticResource GoLiveButton}">
|
||||
<Setter Property="Background" Value="#16213e"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GoLiveLabel" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="#a0a0b0"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GoLiveTextBox" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="#0f3460"/>
|
||||
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="CaretBrush" Value="White"/>
|
||||
<Setter Property="Margin" Value="0,0,0,12"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GoLiveCombo" TargetType="ComboBox">
|
||||
<Setter Property="Background" Value="#0f3460"/>
|
||||
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Margin" Value="0,0,0,12"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,16"/>
|
||||
|
||||
<!-- STREAM METADATA -->
|
||||
<StackPanel Grid.Row="1">
|
||||
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
||||
|
||||
<TextBlock Text="Title" Style="{StaticResource GoLiveLabel}"/>
|
||||
<TextBox Style="{StaticResource GoLiveTextBox}"
|
||||
Text="{Binding StreamTitle, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Text="Description" Style="{StaticResource GoLiveLabel}"/>
|
||||
<TextBox Style="{StaticResource GoLiveTextBox}" Height="60" AcceptsReturn="True"
|
||||
Text="{Binding StreamDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<TextBlock Text="Visibility" Style="{StaticResource GoLiveLabel}"/>
|
||||
<ComboBox Style="{StaticResource GoLiveCombo}"
|
||||
ItemsSource="{Binding Visibilities}"
|
||||
SelectedItem="{Binding Visibility}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ACTIONS -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Start Stream" Command="{Binding StartCommand}"
|
||||
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Windows;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
public partial class GoLiveWindow : Window
|
||||
{
|
||||
public GoLiveWindow(GoLiveViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
viewModel.StartRequested += () => DialogResult = true;
|
||||
viewModel.CancelRequested += () => DialogResult = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.IO;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public static class ImageCache
|
||||
{
|
||||
private static readonly Dictionary<string, BitmapImage> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static BitmapImage? Get(string assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||
if (Cache.TryGetValue(assetId, out var image)) return image;
|
||||
|
||||
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||
if (bytes == null || bytes.Length == 0) return null;
|
||||
|
||||
var bitmap = Decode(bytes);
|
||||
if (bitmap != null) Cache[assetId] = bitmap;
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public static void Put(string key, BitmapImage image) => Cache[key] = image;
|
||||
|
||||
public static BitmapImage? FromBytes(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(bytes, writable: false);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = stream;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapImage? Decode(byte[] bytes) => FromBytes(bytes);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public class InverseBoolToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is true ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is Visibility.Collapsed;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public class NotNullToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value != null ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is Visibility.Visible;
|
||||
}
|
||||
+534
-63
@@ -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
@@ -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
@@ -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
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# ytLlive
|
||||
|
||||
A creator-proof live-streaming and recording app for YouTube — native Windows (C# / WPF / .NET 8).
|
||||
|
||||
## Design Principle
|
||||
|
||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||
|
||||
Every feature is measured against this. One-click go-live, sane YouTube defaults, visual scene
|
||||
building, and no dead ends — every action has a visible outcome.
|
||||
|
||||
## Why ytLlive
|
||||
|
||||
OBS treats YouTube as an afterthought; SLOBS is Twitch-first with YouTube bolted on. ytLlive is
|
||||
designed and dedicated to YouTube livestreaming and recording, and to YouTube's specific quirks.
|
||||
|
||||
## Account Assumption
|
||||
|
||||
ytLlive assumes you already have a YouTube creator account — connecting uses "Sign in with Google"
|
||||
to link that existing account. **ytLlive does not create accounts.** If you don't have a YouTube
|
||||
channel yet, set one up on YouTube first, then connect it here.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dotnet build # Windows only — WPF
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Version Roadmap
|
||||
|
||||
| Version | Scope |
|
||||
|---------|-------|
|
||||
| v0.1 | Scene/source management, YouTube RTMP ingest, YouTube OAuth2, live chat, stream health |
|
||||
| v0.2 | Recording to local file |
|
||||
| v0.3 | Stream scheduling |
|
||||
| v0.4 | Multi-destination restreaming |
|
||||
| 1.0 | General availability |
|
||||
|
||||
## Structure
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Scene, Source, StreamConfig, StreamHealth, YouTube channel/chat |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling) |
|
||||
| `Helpers/` | ViewModelBase, RelayCommand |
|
||||
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
|
||||
@@ -0,0 +1,105 @@
|
||||
<Window x:Class="ytLive.ReuseImageDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Add Image" Height="380" Width="460"
|
||||
Icon="/Assets/llama-logo-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
<Window.Resources>
|
||||
<Style x:Key="GoLiveButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="#e94560"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Padding" Value="16,8"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GoLiveButtonSecondary" TargetType="Button" BasedOn="{StaticResource GoLiveButton}">
|
||||
<Setter Property="Background" Value="#16213e"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CandidateItem" TargetType="ListBoxItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="6,5" Margin="0,2">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Add Image" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,14"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Re-use an image that's already in your stream, or add a new one?"
|
||||
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||
|
||||
<ListBox Grid.Row="2" Background="Transparent" BorderThickness="0"
|
||||
ItemContainerStyle="{StaticResource CandidateItem}"
|
||||
ItemsSource="{Binding Candidates}"
|
||||
SelectedItem="{Binding SelectedCandidate, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Background="#0f3460" CornerRadius="3" Width="48" Height="27" ClipToBounds="True">
|
||||
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="1"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding Header}" Foreground="#e0e0e0" FontSize="13"
|
||||
VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="New Image…" Command="{Binding NewImageCommand}"
|
||||
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
|
||||
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Windows;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
public partial class ReuseImageDialog : Window
|
||||
{
|
||||
public string? PickedAssetId { get; private set; }
|
||||
public bool WantsNew { get; private set; }
|
||||
|
||||
public ReuseImageDialog(ReuseImageViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
viewModel.ReuseRequested += () =>
|
||||
{
|
||||
PickedAssetId = viewModel.SelectedCandidate?.AssetId;
|
||||
if (PickedAssetId != null) DialogResult = true;
|
||||
};
|
||||
viewModel.NewImageRequested += () =>
|
||||
{
|
||||
WantsNew = true;
|
||||
DialogResult = true;
|
||||
};
|
||||
viewModel.CancelRequested += () => DialogResult = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public class LayoutStore : IDisposable
|
||||
{
|
||||
public static LayoutStore? Instance { get; private set; }
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
public string ActivePath { get; }
|
||||
|
||||
public LayoutStore(string path)
|
||||
{
|
||||
ActivePath = path;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
_connection = new SqliteConnection($"Data Source={path}");
|
||||
_connection.Open();
|
||||
using (var pragma = _connection.CreateCommand())
|
||||
{
|
||||
pragma.CommandText = "PRAGMA foreign_keys = ON;";
|
||||
pragma.ExecuteNonQuery();
|
||||
}
|
||||
EnsureSchema();
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void EnsureSchema()
|
||||
{
|
||||
string[] statements =
|
||||
{
|
||||
"PRAGMA user_version = 1;",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Scene (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Name TEXT NOT NULL,
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Asset (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Hash TEXT NOT NULL UNIQUE,
|
||||
Data BLOB NOT NULL,
|
||||
PixelWidth INTEGER NOT NULL DEFAULT 0,
|
||||
PixelHeight INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Source (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
AssetId TEXT REFERENCES Asset(Id),
|
||||
Type TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
IsEnabled INTEGER NOT NULL DEFAULT 1,
|
||||
X REAL NOT NULL DEFAULT 0,
|
||||
Y REAL NOT NULL DEFAULT 0,
|
||||
Width REAL NOT NULL DEFAULT 0,
|
||||
Height REAL NOT NULL DEFAULT 0,
|
||||
Opacity REAL NOT NULL DEFAULT 1,
|
||||
MonitorIndex INTEGER,
|
||||
DeviceId TEXT,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Scene> Load()
|
||||
{
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
scenes.Add(new Scene
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Name = reader.GetString(1),
|
||||
IsHidden = reader.GetInt32(2) != 0,
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(1);
|
||||
var source = new Source
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Type = Enum.Parse<SourceType>(reader.GetString(3)),
|
||||
Name = reader.GetString(4),
|
||||
IsEnabled = reader.GetInt32(5) != 0,
|
||||
X = reader.GetDouble(6),
|
||||
Y = reader.GetDouble(7),
|
||||
Width = reader.GetDouble(8),
|
||||
Height = reader.GetDouble(9),
|
||||
Opacity = reader.GetDouble(10),
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<Source>();
|
||||
list.Add(source);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
if (sourcesByScene.TryGetValue(scene.Id, out var list))
|
||||
foreach (var source in list)
|
||||
scene.Sources.Add(source);
|
||||
}
|
||||
|
||||
return scenes;
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<Scene> scenes)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Source;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Scene;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
idP.Value = scene.Id;
|
||||
nameP.Value = scene.Name;
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, SortOrder)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor, $device, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||
var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
|
||||
var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
|
||||
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||
var deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var sort = 0;
|
||||
foreach (var source in scene.Sources)
|
||||
{
|
||||
idP.Value = source.Id;
|
||||
sceneIdP.Value = scene.Id;
|
||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||
typeP.Value = source.Type.ToString();
|
||||
nameP.Value = source.Name;
|
||||
enabledP.Value = source.IsEnabled ? 1 : 0;
|
||||
xP.Value = source.X;
|
||||
yP.Value = source.Y;
|
||||
wP.Value = source.Width;
|
||||
hP.Value = source.Height;
|
||||
opacityP.Value = source.Opacity;
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
public byte[]? GetAssetBytes(string assetId)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
|
||||
cmd.Parameters.AddWithValue("$id", assetId);
|
||||
return cmd.ExecuteScalar() as byte[];
|
||||
}
|
||||
|
||||
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
|
||||
{
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data));
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
|
||||
VALUES ($id, $hash, $data, $w, $h)
|
||||
ON CONFLICT(Hash) DO NOTHING;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$hash", hash);
|
||||
cmd.Parameters.AddWithValue("$data", data);
|
||||
cmd.Parameters.AddWithValue("$w", pixelWidth);
|
||||
cmd.Parameters.AddWithValue("$h", pixelHeight);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
|
||||
return (string)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.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>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# ytLlive — Task List
|
||||
|
||||
## YouTube Live API — research facts (authoritative, v3 build)
|
||||
|
||||
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
|
||||
|
||||
- **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
||||
- **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
|
||||
- **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
||||
- **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
||||
- **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
|
||||
|
||||
### Two features that reshape the design
|
||||
|
||||
1. **enableAutoStart / enableAutoStop** — instant one-click go-live, no transition call. With `enableAutoStart=true` we never call `transition(live)`: the broadcast auto-goes-live the moment the encoder starts. Combined with `enableMonitorStream=false` (our preview pane replaces YouTube's monitor stream — the thing that forces a testing stage), the flow is **create → bind → Start Stream → encoder starts → YouTube brings it live**. No testing, no transition polling, no liveStarting stuck-state handling.
|
||||
2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** — free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature.
|
||||
|
||||
### Compliance gotchas (maps perfectly to report-by-exception)
|
||||
|
||||
- `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
|
||||
- Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
||||
- Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
||||
|
||||
### Tips we should take advantage of
|
||||
|
||||
- **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
|
||||
- **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
||||
- **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
||||
- **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
||||
- **Broadcast ID == Video ID** — one ID to track everything.
|
||||
|
||||
---
|
||||
|
||||
## TASK 1 — Initial Scaffold
|
||||
|
||||
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
|
||||
|
||||
### Status: ✅ Done
|
||||
- Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
||||
- Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
||||
- MainViewModel: scene management, stream controls, chat
|
||||
- MainWindow: scene/source panel, preview area, chat panel, status bar
|
||||
- Clean build, 0 warnings (WSL + Windows)
|
||||
|
||||
---
|
||||
|
||||
## TASK 2 — YouTube OAuth2 Authentication
|
||||
|
||||
**Goal:** Fully working Google OAuth2 flow — user clicks "YouTube", browser opens, authorization callback lands, channel info is stored.
|
||||
|
||||
**Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** — the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata.
|
||||
|
||||
**Two-state flow:** There is no separate "Connect" button. The top bar shows a single button — **Start Stream** when idle, **End Stream** when live. Clicking Start Stream opens one dialog that supplies everything: account (previously-saved account shown as default, with a Change Account action) + title/description/visibility.
|
||||
|
||||
**Live indicators (unmissable):** Top bar + window title bar flip red, a pulsing **● LIVE** badge with elapsed timer appears in the top bar, the preview area gets a red glow, and the taskbar icon shows a red overlay dot. Window title bar shows the stream title once validated.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Google Cloud OAuth credentials** — client ID + secret, **baked into `Helpers/OAuthCredentials.cs`** (desktop "Desktop app" OAuth client; loopback callback — no console redirect URI registration needed; creators never configure)
|
||||
2. **Local HTTP listener** — `HttpListener` on `http://localhost:PORT/oauth2/callback` to catch the redirect
|
||||
3. **Browser launch** — open the authorization URL in the default browser
|
||||
4. **Token persistence** — store access/refresh tokens securely (Windows DPAPI), reload on startup
|
||||
5. **UI state** — account shown in the Start Stream dialog; "Change Account" action triggers re-auth
|
||||
6. **Go Live gated on auth** — Start Stream dialog requires sign-in to enable the Start button; scene building works without it
|
||||
|
||||
### Tests:
|
||||
|
||||
- Mock token exchange response, verify channel info parsed
|
||||
- Verify token refresh triggers when near expiry
|
||||
- Verify credential load/save roundtrip
|
||||
|
||||
### Status: 🔶 UI flow done — auth wiring pending
|
||||
- ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
|
||||
- ⬜ Real OAuth2 with the baked-in Google credentials (desktop client type; loopback callback, no redirect registration)
|
||||
- ⬜ Token persistence via Windows DPAPI, reload on startup
|
||||
- ⬜ Account sign-in/change wired to YouTubeAuthService (currently simulated in GoLiveViewModel)
|
||||
|
||||
---
|
||||
|
||||
## TASK 3 — Capture Pipeline (Scenes/Sources)
|
||||
|
||||
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
|
||||
|
||||
### The Minimal Source Set (design decision — do not expand casually)
|
||||
|
||||
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
|
||||
the hot few and nothing esoteric. If a user needs more, they've graduated to OBS.
|
||||
|
||||
1. **Webcam** — the face cam. Non-negotiable.
|
||||
2. **Screen** — the main event (game, slides, browser). One source; a picker chooses a monitor *or* a
|
||||
window. (Window capture is absorbed here — no separate source type.)
|
||||
3. **Background** — a full-canvas backdrop image. Fills the whole scene automatically, zero fiddling.
|
||||
Kept separate from Image on purpose: same pixels, but this one needs no positioning.
|
||||
4. **Image** — a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
|
||||
5. **Text** — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
|
||||
6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native.
|
||||
7. **Alerts** — Super Chat / membership / subscribe pop-ins. The dopamine source. **The one big lift**
|
||||
(Super Chat event streaming + on-stream rendering/animation); build after the six. **Also the one
|
||||
paid feature** — see Monetization in `ai.md`.
|
||||
|
||||
Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI.
|
||||
|
||||
### Source memory model (design decision)
|
||||
|
||||
- A scene has resources. Resources can be shared across scenes.
|
||||
- A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
||||
scenes = one loaded bitmap).
|
||||
- Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
|
||||
entry dictating that scene's use — placement (X/Y/Width/Height), opacity, z-order, enabled,
|
||||
scale mode, crop.
|
||||
- Usages are named `{resourceName}.{sceneName}` — whatever the user named the resource, dot, the
|
||||
scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
|
||||
- A webcam in two scenes = one capture session, two catalog entries.
|
||||
- Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
||||
- The resource (not a per-scene node) owns everything `IDisposable`.
|
||||
|
||||
### Scene transitions (design decision)
|
||||
|
||||
Scene switching while live must never stutter. Supported types, most → least economical:
|
||||
|
||||
1. **Cut** — instant switch. The default. Zero cost.
|
||||
2. **Fade** — short crossfade (~300ms).
|
||||
3. **Move** — a simple, economical move transition, done to perfection and memory-efficient. The
|
||||
smart streamer's bread and butter.
|
||||
4. **Custom (media) transitions** — require media elements (video/stinger playback during the
|
||||
transition). Heavier, but creators pay for these, so we support them. Their media follows the
|
||||
same resource memory model: loaded once, catalogued by scene.
|
||||
|
||||
Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four above.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
||||
2. **Webcam** — MediaCapture (WinRT) with device enumeration
|
||||
3. **Background / Image / Text** — static sources positioned/scaled/opacity
|
||||
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
|
||||
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
|
||||
back-to-front), preview rendered via D3DImage or MediaElement
|
||||
6. **Drag/drop placement & reorder** — intuitive, visual (per design principle):
|
||||
- **Preview:** click-drag a source in the center panel to reposition it; resize via handles
|
||||
- **Scenes list:** drag rows to reorder scenes
|
||||
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||
|
||||
### Status: 🔶 In progress — scenes/sources UI built (add/reorder/rename, image + background overlays with move/resize/opacity/reuse); real capture/encoding pending
|
||||
|
||||
---
|
||||
|
||||
## TASK 4 — RTMP Ingest to YouTube
|
||||
|
||||
**Goal:** Push encoded video to YouTube's RTMP ingest.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only
|
||||
2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL
|
||||
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
||||
- Phone 480p @ 2.5 Mbps
|
||||
- 720p60 @ 6 Mbps
|
||||
- 1080p30 @ 8 Mbps
|
||||
- **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
||||
machine never notices; upload headroom stays comfortable)
|
||||
- 1440p/4K = the paid unlock tiers (monetization), not the standard offering
|
||||
Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
|
||||
Quality is greyed out while live because the declared resolution can't change mid-stream — but
|
||||
with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
|
||||
(no stream recreation); 60fps presumes a hardware encoder — no hardware encoder → auto
|
||||
fallback to 720p60/1080p30
|
||||
4. **Stream key management** — reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
|
||||
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
|
||||
6. **One-click go live** — defaults that work out of the box
|
||||
|
||||
### Status: Not started
|
||||
|
||||
---
|
||||
|
||||
## TASK 5 — YouTube Live Stream Management
|
||||
|
||||
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
|
||||
|
||||
### Design decisions (v3)
|
||||
|
||||
1. **One-click go-live** — `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts → YouTube brings it live by itself.
|
||||
2. **Variable reusable stream** — `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`. Create once per channel, cache ingestion URL + stream name, reuse for every broadcast. Any quality tier works without recreation; auto step-down needs no API calls.
|
||||
3. **Report-by-exception** — poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable → opens the dialog).
|
||||
4. **One dialog, three states** — `not connected` (sign-in) / `connected-offline` (all editable) / `live` (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile.
|
||||
5. **Live edits** — `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy.
|
||||
6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net.
|
||||
7. **Broadcast ID == Video ID** — one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`).
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Broadcast creation** — title/description/privacy/scheduledStartTime via API, with the v3 flags above
|
||||
2. **Reusable stream** — create once, cache + reuse; bind to broadcast
|
||||
3. **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error
|
||||
4. **Live chat** — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
|
||||
5. **Error handling** — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
||||
|
||||
### Status: Not started
|
||||
|
||||
---
|
||||
|
||||
## TASK 6 — Layout Persistence (SQLite)
|
||||
|
||||
**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
|
||||
|
||||
### Design decisions
|
||||
|
||||
1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
|
||||
via `PRAGMA user_version`.
|
||||
2. **Assets live in the DB, not on disk** — `Asset` table stores image bytes (BLOB) keyed by a
|
||||
SHA-256 content hash (unique). Identical image content collapses to one row regardless of file
|
||||
name — the 1:M resource memory model, enforced by the database. No file paths; deleting the
|
||||
original file never breaks a scene.
|
||||
3. **File-model save/open** — the active layout file is tracked (default is the AppData DB).
|
||||
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
|
||||
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
|
||||
add/remove/reorder, and any source transform change; flush on window close.
|
||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
|
||||
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, SortOrder). `WindowHandle` stays in-memory
|
||||
(per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
|
||||
only when the DB is empty.
|
||||
|
||||
### Status: ✅ Implemented
|
||||
|
||||
---
|
||||
|
||||
## Backlog (future versions)
|
||||
|
||||
- v0.2 — Recording to local file
|
||||
- v0.3 — Stream scheduling
|
||||
- v0.4 — Multi-destination restreaming
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public class GoLiveViewModel : ViewModelBase
|
||||
{
|
||||
private string _streamTitle = string.Empty;
|
||||
private string _streamDescription = string.Empty;
|
||||
private string _visibility = "Public";
|
||||
|
||||
public string StreamTitle
|
||||
{
|
||||
get => _streamTitle;
|
||||
set => SetProperty(ref _streamTitle, value);
|
||||
}
|
||||
|
||||
public string StreamDescription
|
||||
{
|
||||
get => _streamDescription;
|
||||
set => SetProperty(ref _streamDescription, value);
|
||||
}
|
||||
|
||||
public string Visibility
|
||||
{
|
||||
get => _visibility;
|
||||
set => SetProperty(ref _visibility, value);
|
||||
}
|
||||
|
||||
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
||||
|
||||
public ICommand StartCommand { get; }
|
||||
public ICommand CancelCommand { get; }
|
||||
|
||||
public GoLiveViewModel()
|
||||
{
|
||||
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
|
||||
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||
}
|
||||
|
||||
public event Action? StartRequested;
|
||||
public event Action? CancelRequested;
|
||||
}
|
||||
+749
-41
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public class ReuseImageCandidate
|
||||
{
|
||||
public string SceneName { get; set; } = string.Empty;
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
public string AssetId { get; set; } = string.Empty;
|
||||
|
||||
public ImageSource? Thumbnail => ImageCache.Get(AssetId);
|
||||
public string Header => $"{SourceName} · in {SceneName}";
|
||||
}
|
||||
|
||||
public class ReuseImageViewModel : ViewModelBase
|
||||
{
|
||||
private ReuseImageCandidate? _selectedCandidate;
|
||||
|
||||
public ObservableCollection<ReuseImageCandidate> Candidates { get; }
|
||||
|
||||
public ReuseImageCandidate? SelectedCandidate
|
||||
{
|
||||
get => _selectedCandidate;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedCandidate, value))
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand UseSelectedCommand { get; }
|
||||
public ICommand NewImageCommand { get; }
|
||||
public ICommand CancelCommand { get; }
|
||||
|
||||
public event Action? ReuseRequested;
|
||||
public event Action? NewImageRequested;
|
||||
public event Action? CancelRequested;
|
||||
|
||||
public ReuseImageViewModel(IEnumerable<ReuseImageCandidate> candidates)
|
||||
{
|
||||
Candidates = new ObservableCollection<ReuseImageCandidate>(candidates);
|
||||
if (Candidates.Count > 0)
|
||||
_selectedCandidate = Candidates[0];
|
||||
|
||||
UseSelectedCommand = new RelayCommand(_ => ReuseRequested?.Invoke(), _ => SelectedCandidate != null);
|
||||
NewImageCommand = new RelayCommand(_ => NewImageRequested?.Invoke());
|
||||
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# ytLlive — AI Guide
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dotnet build # Windows only — WPF requires Windows target
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project can be restored/built from WSL, but running requires Windows.
|
||||
|
||||
## Tests
|
||||
|
||||
No test framework set up yet. When added: `dotnet test`.
|
||||
|
||||
## Architecture
|
||||
|
||||
C# / WPF (.NET 8) following MVVM:
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Plain data types — Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI |
|
||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
|
||||
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache |
|
||||
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
||||
|
||||
### Key patterns
|
||||
|
||||
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
||||
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline)
|
||||
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
||||
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
|
||||
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
|
||||
|
||||
### Current limitations / TODOs
|
||||
|
||||
- `OAuthCredentials.ClientId` / `ClientSecret` in `Helpers/OAuthCredentials.cs` are empty — the app
|
||||
owner fills them in once (developer task, baked into the binary; creators never configure anything)
|
||||
- `GoLiveViewModel.SignIn`/`ChangeAccount` removed — Connect (OAuth) is the only entry to streaming
|
||||
- No token persistence yet (Windows DPAPI planned) — scene/source/asset layout *does* persist (SQLite)
|
||||
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
||||
- No capture/encoding/RTMP yet
|
||||
- Stream config (title/description/visibility/quality) still in-memory
|
||||
|
||||
## Design Principle
|
||||
|
||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||
|
||||
Apply this to every UI decision:
|
||||
- One-click go-live with working defaults
|
||||
- Prefilled YouTube defaults (RTMP URL, bitrate, resolution, latency)
|
||||
- Visual/drag-and-drop scene building over property panels
|
||||
- Every action produces a visible outcome — no dead ends
|
||||
|
||||
## Monetization (design decision — the watermark is the sword)
|
||||
|
||||
Free forever: all streams unlimited, no time caps, no subscription, no per-feature paywalls. The
|
||||
**one paid line is a one-time unlock** (delivered via itch.io — they handle hosting, payment, and key
|
||||
delivery; we never own a server or a key shop):
|
||||
|
||||
- **Free:** a small "made with ytLlive" watermark is always on, every frame, every stream — the sword
|
||||
of Damocles. Standard practice; only Streamlabs runs watermark-nagging to a capitalist extreme.
|
||||
- **Paid (one-time):** watermark removed + **Alerts** (Super Chat / membership / subscribe pop-ins).
|
||||
|
||||
Deliberately rejected: hard stream-time cutoffs (the worst dead end — a stream dying mid-broadcast
|
||||
reads as broken, and YouTube streams routinely run 2-4 hours), soft-limit nagging, freemium tiers,
|
||||
and donation-only (relies on the kindness of strangers). Resolution/quality ceilings are **deferred** —
|
||||
that decision belongs to the resolution & streaming-constraints conversation, not monetization.
|
||||
|
||||
## Auth gates Go Live, but not exploration
|
||||
|
||||
The app is fully usable without authentication: users can build scenes, add sources, compose
|
||||
previews, and audition the software with zero commitment. But **going live requires authentication** —
|
||||
it's the one capability gated behind YouTube sign-in. The sign-in button should never pressure the
|
||||
user ("sign in (optional)", not a modal wall), but "Go Live" only appears once connected.
|
||||
|
||||
## Account assumption (do not build an account setup flow)
|
||||
|
||||
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
|
||||
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
|
||||
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
|
||||
replaced by an in-app account-creation step. Zero state = a Connect button that starts OAuth; going
|
||||
live is unreachable until the account is connected.
|
||||
|
||||
## YouTube Live API — design constraints (do not violate)
|
||||
|
||||
These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
|
||||
- **One-click go-live** — never call `transition(live)`. Insert the broadcast with
|
||||
`enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
|
||||
`selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
|
||||
`enableMonitorStream=false` is what lets us skip the testing stage.
|
||||
- **Variable reusable stream** — `liveStreams.insert` once per channel with
|
||||
`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`; cache the ingestion URL +
|
||||
stream name and reuse for every broadcast. Any quality tier works without recreating the stream,
|
||||
and auto step-down is done by us dropping bitrate on the fly (zero API calls).
|
||||
- **Quality is greyed out while live** — resolution/frameRate/ingestionType are immutable after
|
||||
stream creation; editing title/description/privacy is fine at any time.
|
||||
- **Report-by-exception health** — poll `liveStreams.list`; render nothing on `good`/`ok`, surface a
|
||||
banner only on `configurationIssues[]` with `warning`/`error` severity. Bottom strip = YouTube logo
|
||||
+ green/red connection dot (clickable → opens the dialog).
|
||||
- **One dialog, three states** — not connected / connected-offline (all editable) / live
|
||||
(title + description + visibility editable; quality + account greyed out). Both entry points
|
||||
(Start Stream button + bottom strip) open it; prefilled from saved session profile.
|
||||
- **End stream** — stop encoder → `transition(complete)`, `enableAutoStop` as the safety net.
|
||||
- **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz,
|
||||
mono/stereo only. YouTube flags violations via health status.
|
||||
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
|
||||
(`recordFromStart` + `enableDvr` default true).
|
||||
+11
-1
@@ -7,9 +7,19 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user