Scene/source builder with image overlays, reuse dialog, and SQLite layout persistence

This commit is contained in:
2026-08-04 17:12:32 -07:00
parent d6ae048583
commit 7dc8490d96
24 changed files with 2375 additions and 170 deletions
+1 -43
View File
@@ -5,34 +5,10 @@ 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;
@@ -53,33 +29,15 @@ public class GoLiveViewModel : ViewModelBase
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);
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
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;
}
}
+642 -11
View File
@@ -1,7 +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;
@@ -16,12 +22,15 @@ public class MainViewModel : ViewModelBase
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 _streamDescription = string.Empty;
private string _streamVisibility = "Public";
private string _windowTitle = "ytLive";
private string _windowTitle = "ytLlive";
private string _topBarBackground = "#16213e";
private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0);
@@ -29,13 +38,60 @@ public class MainViewModel : ViewModelBase
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
@@ -48,14 +104,45 @@ public class MainViewModel : ViewModelBase
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
{
@@ -117,15 +204,134 @@ public class MainViewModel : ViewModelBase
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 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);
@@ -134,22 +340,191 @@ public class MainViewModel : ViewModelBase
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
_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());
// Start with a default scene
AddScene("Scene 1");
_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;
@@ -158,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(() =>
@@ -170,7 +796,12 @@ public class MainViewModel : ViewModelBase
private void BeginGoLive()
{
var dialog = new GoLiveViewModel();
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)
{
@@ -178,8 +809,8 @@ public class MainViewModel : ViewModelBase
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility;
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "ytLive"
: $"{dialog.StreamTitle} — ytLive";
? "ytLlive"
: $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming;
}
}
@@ -187,7 +818,7 @@ public class MainViewModel : ViewModelBase
private void StopStream()
{
StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLive";
WindowTitle = "ytLlive";
}
private void UpdateLiveVisuals()
+52
View File
@@ -0,0 +1,52 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Helpers;
namespace ytLive.ViewModels;
public class ReuseImageCandidate
{
public string SceneName { get; set; } = string.Empty;
public string SourceName { get; set; } = string.Empty;
public string AssetId { get; set; } = string.Empty;
public ImageSource? Thumbnail => ImageCache.Get(AssetId);
public string Header => $"{SourceName} · in {SceneName}";
}
public class ReuseImageViewModel : ViewModelBase
{
private ReuseImageCandidate? _selectedCandidate;
public ObservableCollection<ReuseImageCandidate> Candidates { get; }
public ReuseImageCandidate? SelectedCandidate
{
get => _selectedCandidate;
set
{
if (SetProperty(ref _selectedCandidate, value))
CommandManager.InvalidateRequerySuggested();
}
}
public ICommand UseSelectedCommand { get; }
public ICommand NewImageCommand { get; }
public ICommand CancelCommand { get; }
public event Action? ReuseRequested;
public event Action? NewImageRequested;
public event Action? CancelRequested;
public ReuseImageViewModel(IEnumerable<ReuseImageCandidate> candidates)
{
Candidates = new ObservableCollection<ReuseImageCandidate>(candidates);
if (Candidates.Count > 0)
_selectedCandidate = Candidates[0];
UseSelectedCommand = new RelayCommand(_ => ReuseRequested?.Invoke(), _ => SelectedCandidate != null);
NewImageCommand = new RelayCommand(_ => NewImageRequested?.Invoke());
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
}
}