Two-state flow with go-live dialog, unmissable live indicators, taskbar overlay

This commit is contained in:
2026-08-04 10:54:21 -07:00
parent aae17a1511
commit d6ae048583
7 changed files with 397 additions and 52 deletions
+131
View File
@@ -0,0 +1,131 @@
<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"
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="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,16"/>
<!-- ACCOUNT -->
<StackPanel Grid.Row="1" Margin="0,0,0,16">
<TextBlock Text="YOUTUBE ACCOUNT" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
<!-- Not signed in -->
<StackPanel Visibility="{Binding ShowSignIn, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Sign in to connect your channel." Style="{StaticResource GoLiveLabel}"/>
<Button Content="Sign In" Command="{Binding SignInCommand}"
Style="{StaticResource GoLiveButtonSecondary}" HorizontalAlignment="Left"
Padding="20,8" Margin="0,4,0,0"/>
</StackPanel>
<!-- Signed in: saved account as default + change option -->
<StackPanel Visibility="{Binding ShowAccount, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{Binding AccountName}" FontSize="14" FontWeight="SemiBold"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
<TextBlock Text="(signed in)" Foreground="#7fb069" FontSize="12"
VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<Button Content="Change Account" Command="{Binding ChangeAccountCommand}"
Style="{StaticResource GoLiveButtonSecondary}" HorizontalAlignment="Left"
Padding="16,6" Margin="0,4,0,0" FontSize="12"/>
</StackPanel>
</StackPanel>
<!-- STREAM METADATA -->
<StackPanel Grid.Row="2">
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
<TextBlock Text="Title" Style="{StaticResource 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="5" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource GoLiveButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Start Stream" Command="{Binding StartCommand}"
Style="{StaticResource GoLiveButton}" MinWidth="110"/>
</StackPanel>
</Grid>
</Window>
+15
View File
@@ -0,0 +1,15 @@
using System.Windows;
using ytLive.ViewModels;
namespace ytLive;
public partial class GoLiveWindow : Window
{
public GoLiveWindow(GoLiveViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
viewModel.StartRequested += () => DialogResult = true;
viewModel.CancelRequested += () => DialogResult = false;
}
}
+36 -15
View File
@@ -5,7 +5,7 @@
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"
Background="#1a1a2e"
WindowStartupLocation="CenterScreen">
@@ -13,9 +13,26 @@
<vm:MainViewModel/>
</Window.DataContext>
<Window.TaskbarItemInfo>
<TaskbarItemInfo x:Name="TaskbarInfo"/>
</Window.TaskbarItemInfo>
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<!-- 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">
<Setter Property="Background" Value="#e94560"/>
@@ -72,7 +89,7 @@
</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"/>
@@ -85,20 +102,23 @@
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"/>
<!-- 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>
<!-- Primary actions: one relevant button per state -->
<!-- Single two-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="Connect" Style="{StaticResource YtButton}"
Command="{Binding ConnectCommand}"
Visibility="{Binding ShowConnect, Converter={StaticResource BoolToVis}}"/>
<Button Content="Go Live" Style="{StaticResource YtButton}"
Command="{Binding GoLiveCommand}"
Visibility="{Binding ShowGoLive, Converter={StaticResource BoolToVis}}"/>
<Button Content="Start Stream" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding IsOffline, Converter={StaticResource BoolToVis}}"/>
<Button Content="End Stream" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
@@ -151,7 +171,8 @@
</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>
<TextBlock Text="Preview" Foreground="#333" FontSize="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
+22 -1
View File
@@ -1,11 +1,32 @@
using System.Windows;
using System.ComponentModel;
using System.Windows;
using System.Windows.Media;
using ytLive.ViewModels;
namespace ytLive;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
public MainWindow()
{
InitializeComponent();
_viewModel = (MainViewModel)DataContext;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
UpdateTaskbarOverlay();
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.IsLive))
UpdateTaskbarOverlay();
}
private void UpdateTaskbarOverlay()
{
TaskbarInfo.Overlay = _viewModel.IsLive
? (ImageSource)FindResource("LiveOverlay")
: null;
}
}
+7 -3
View File
@@ -17,7 +17,11 @@
**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**"Go Live" only appears once connected. OAuth is what unlocks the actual broadcast (plus live chat, scheduling, and YouTube-side health).
**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:
@@ -25,8 +29,8 @@
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 name/avatar shown when connected; "sign in (optional)" affordance, never a gate
6. **Go Live gated on auth**"Go Live" button only visible once authenticated; scene building works without it
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:
+85
View File
@@ -0,0 +1,85 @@
using System.Windows.Input;
using ytLive.Helpers;
namespace ytLive.ViewModels;
public class GoLiveViewModel : ViewModelBase
{
private bool _isSignedIn;
private string _accountName = string.Empty;
private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty;
private string _visibility = "Public";
public bool IsSignedIn
{
get => _isSignedIn;
set
{
if (SetProperty(ref _isSignedIn, value))
{
OnPropertyChanged(nameof(ShowSignIn));
OnPropertyChanged(nameof(ShowAccount));
}
}
}
public bool ShowSignIn => !IsSignedIn;
public bool ShowAccount => IsSignedIn;
public string AccountName
{
get => _accountName;
set => SetProperty(ref _accountName, value);
}
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 SignInCommand { get; }
public ICommand ChangeAccountCommand { get; }
public ICommand StartCommand { get; }
public ICommand CancelCommand { get; }
public GoLiveViewModel()
{
SignInCommand = new RelayCommand(_ => SignIn());
ChangeAccountCommand = new RelayCommand(_ => ChangeAccount());
StartCommand = new RelayCommand(_ => StartRequested?.Invoke(), _ => IsSignedIn);
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
}
public event Action? StartRequested;
public event Action? CancelRequested;
private void SignIn()
{
// TODO: OAuth2 flow; for now simulate a successful sign-in
AccountName = "Connected Channel";
IsSignedIn = true;
}
private void ChangeAccount()
{
// TODO: re-run OAuth2; for now simulate signing out
IsSignedIn = false;
AccountName = string.Empty;
}
}
+100 -32
View File
@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Input;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
@@ -11,13 +13,21 @@ public class MainViewModel : ViewModelBase
private readonly YouTubeAuthService _youtubeAuth;
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer;
private Scene? _activeScene;
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 = "ytLive";
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;
public ObservableCollection<Scene> Scenes { get; } = new();
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
@@ -37,13 +47,15 @@ public class MainViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(ShowGoLive));
OnPropertyChanged(nameof(LiveIndicatorVisible));
UpdateLiveVisuals();
}
}
}
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive;
public StreamHealth CurrentHealth
{
@@ -57,33 +69,58 @@ 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
{
if (SetProperty(ref _isYouTubeConnected, value))
{
OnPropertyChanged(nameof(ShowConnect));
OnPropertyChanged(nameof(ShowGoLive));
}
}
get => _streamVisibility;
set => SetProperty(ref _streamVisibility, value);
}
public bool ShowConnect => !IsYouTubeConnected;
public bool ShowGoLive => IsYouTubeConnected && IsOffline;
public string WindowTitle
{
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);
}
// Commands
public ICommand AddSceneCommand { get; }
public ICommand RemoveSceneCommand { get; }
public ICommand ConnectCommand { get; }
public ICommand GoLiveCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public MainViewModel()
@@ -94,10 +131,12 @@ public class MainViewModel : ViewModelBase
_youtubeChat.MessageReceived += OnChatMessageReceived;
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
AddSceneCommand = new RelayCommand(_ => AddScene());
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
ConnectCommand = new RelayCommand(_ => ConnectYouTube());
GoLiveCommand = new RelayCommand(_ => StartStream(), _ => IsYouTubeConnected && IsOffline);
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
// Start with a default scene
@@ -129,25 +168,54 @@ public class MainViewModel : ViewModelBase
});
}
private async void StartStream()
private void BeginGoLive()
{
StreamStatus = StreamStatus.Connecting;
// TODO: Initialize capture pipeline, encode, and push to RTMP
// For now, simulate connection
await Task.Delay(500);
var dialog = new GoLiveViewModel();
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)
? "ytLive"
: $"{dialog.StreamTitle} — ytLive";
StreamStatus = StreamStatus.Streaming;
}
}
private void StopStream()
{
StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLive";
}
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;
}
}