Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1efde5a1e6 | |||
| 3b77a132be |
@@ -0,0 +1,194 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
|
||||||
|
private readonly ChatBoxRenderer _chatBoxRenderer;
|
||||||
|
|
||||||
|
// Mock chat messages for the ChatBox preview — branded placeholders so the
|
||||||
|
// creator sees where the overlay lands before going live.
|
||||||
|
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
|
||||||
|
{
|
||||||
|
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
|
||||||
|
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
|
||||||
|
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
|
||||||
|
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
|
||||||
|
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
|
||||||
|
};
|
||||||
|
// Chat fade timer: clears chat overlay after N seconds of inactivity.
|
||||||
|
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
|
||||||
|
|
||||||
|
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
|
||||||
|
/// anywhere — one chat overlay per layout.</summary>
|
||||||
|
public bool CanAddYouTubeChat
|
||||||
|
=> StagedScene != null
|
||||||
|
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
||||||
|
|
||||||
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
|
|
||||||
|
private void OnChatMessageReceived(ChatMessage message)
|
||||||
|
{
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ChatMessages.Add(message);
|
||||||
|
if (ChatMessages.Count > 500)
|
||||||
|
ChatMessages.RemoveAt(0);
|
||||||
|
|
||||||
|
// Reset chat fade timer on every message
|
||||||
|
ResetChatFadeTimer();
|
||||||
|
|
||||||
|
StopMockChatPreview();
|
||||||
|
|
||||||
|
// Switch ChatBox previews from mock to real messages
|
||||||
|
if (StagedScene != null)
|
||||||
|
foreach (var chatBox in StagedScene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
|
||||||
|
UpdateChatBoxPreview(chatBox);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetChatFadeTimer()
|
||||||
|
{
|
||||||
|
_chatFadeTimer?.Stop();
|
||||||
|
var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||||
|
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
|
||||||
|
if (chatBoxes.Count == 0) return;
|
||||||
|
|
||||||
|
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
|
||||||
|
_chatFadeTimer = new System.Windows.Threading.DispatcherTimer
|
||||||
|
{ Interval = TimeSpan.FromSeconds(delay) };
|
||||||
|
_chatFadeTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
_chatFadeTimer?.Stop();
|
||||||
|
_chatFadeTimer = null;
|
||||||
|
ChatMessages.Clear();
|
||||||
|
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||||
|
.Where(s => s.Type == SourceType.ChatBox))
|
||||||
|
UpdateChatBoxPreview(chatBox);
|
||||||
|
StartMockChatPreview();
|
||||||
|
};
|
||||||
|
_chatFadeTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VideoFrame? RenderChatBox(Source chatBox)
|
||||||
|
{
|
||||||
|
if (ChatMessages.Count == 0) return null;
|
||||||
|
|
||||||
|
var width = (int)Math.Max(1, chatBox.Width);
|
||||||
|
var height = (int)Math.Max(1, chatBox.Height);
|
||||||
|
|
||||||
|
return _chatBoxRenderer.Render(
|
||||||
|
ChatMessages,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
chatBox.ChatFontSize,
|
||||||
|
chatBox.ChatFontFamily,
|
||||||
|
chatBox.ChatFontColor,
|
||||||
|
chatBox.ChatBackgroundColor,
|
||||||
|
chatBox.ChatMaxMessages,
|
||||||
|
chatBox.ChatShowTimestamps,
|
||||||
|
chatBox.ChatShowBadges,
|
||||||
|
chatBox.ChatMemberColor,
|
||||||
|
chatBox.ChatSuperChatColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ChatBox preview: real messages when connected, mock when not.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
messages ??= ChatMessages.Count > 0 ? (IReadOnlyList<ChatMessage>)ChatMessages : MockChatMessages;
|
||||||
|
var width = (int)Math.Max(32, chatBox.Width);
|
||||||
|
var height = (int)Math.Max(32, chatBox.Height);
|
||||||
|
|
||||||
|
var frame = _chatBoxRenderer.Render(
|
||||||
|
messages,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
chatBox.ChatFontSize,
|
||||||
|
chatBox.ChatFontFamily,
|
||||||
|
chatBox.ChatFontColor,
|
||||||
|
chatBox.ChatBackgroundColor,
|
||||||
|
chatBox.ChatMaxMessages,
|
||||||
|
chatBox.ChatShowTimestamps,
|
||||||
|
chatBox.ChatShowBadges,
|
||||||
|
chatBox.ChatMemberColor,
|
||||||
|
chatBox.ChatSuperChatColor);
|
||||||
|
|
||||||
|
var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
|
||||||
|
bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
||||||
|
chatBox.VideoImageSource = bitmap;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private CancellationTokenSource? _mockChatCts;
|
||||||
|
|
||||||
|
private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var n = 0;
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList());
|
||||||
|
await Task.Delay(1000, ct);
|
||||||
|
n++;
|
||||||
|
if (n == MockChatMessages.Count)
|
||||||
|
{
|
||||||
|
await Task.Delay(3000, ct);
|
||||||
|
n = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartMockChatPreview()
|
||||||
|
{
|
||||||
|
StopMockChatPreview();
|
||||||
|
_mockChatCts = new CancellationTokenSource();
|
||||||
|
var ct = _mockChatCts.Token;
|
||||||
|
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||||
|
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
|
||||||
|
_ = RunMockChatPreviewAsync(chatBox, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopMockChatPreview()
|
||||||
|
{
|
||||||
|
_mockChatCts?.Cancel();
|
||||||
|
_mockChatCts?.Dispose();
|
||||||
|
_mockChatCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render previews + wire property-change subscriptions for all ChatBox
|
||||||
|
/// sources across every scene. Called after layout load so existing ChatBox
|
||||||
|
/// sources show mock content and respond to config changes.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateActiveChatBoxPreviews()
|
||||||
|
{
|
||||||
|
foreach (var scene in Scenes)
|
||||||
|
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
|
||||||
|
{
|
||||||
|
chatBox.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
|
||||||
|
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
|
||||||
|
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
|
||||||
|
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
|
||||||
|
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
|
||||||
|
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
|
||||||
|
UpdateChatBoxPreview(chatBox);
|
||||||
|
};
|
||||||
|
UpdateChatBoxPreview(chatBox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
|
||||||
|
private bool _isSettingsOpen;
|
||||||
|
private bool _isBugOpen;
|
||||||
|
private bool _isFeatureOpen;
|
||||||
|
private bool _isAboutOpen;
|
||||||
|
private bool _isLicensesOpen;
|
||||||
|
private string _overlayTitle = string.Empty;
|
||||||
|
private string _bugReportText = string.Empty;
|
||||||
|
private string _bugReportEmail = string.Empty;
|
||||||
|
private string _featureRequestText = string.Empty;
|
||||||
|
private string _featureRequestEmail = string.Empty;
|
||||||
|
|
||||||
|
private const string SupportEmail = "gramps@llamachile.shop";
|
||||||
|
// The creator-hub link targets (About overlay) — public so the hub's
|
||||||
|
// integration test can assert they point at real destinations.
|
||||||
|
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
||||||
|
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
|
||||||
|
public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw";
|
||||||
|
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
||||||
|
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
||||||
|
|
||||||
|
/// <summary>The About hub's Unlock Premium button lights up only once the
|
||||||
|
/// billing URL is set (Polar — polar.sh, pending TASK 10 implementation).</summary>
|
||||||
|
public bool IsPremiumAvailable => PremiumUrl.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>Full in-app licensing/legal text, loaded on demand from the
|
||||||
|
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
|
||||||
|
public string LicensesText { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>About hub sub-view: the full licensing/legal texts panel
|
||||||
|
/// (in-app scrolling — no Notepad, no browser tab).</summary>
|
||||||
|
public bool IsLicensesOpen
|
||||||
|
{
|
||||||
|
get => _isLicensesOpen;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isLicensesOpen, value))
|
||||||
|
OnPropertyChanged(nameof(IsHubVisible));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
||||||
|
|
||||||
|
public string OverlayTitle
|
||||||
|
{
|
||||||
|
get => _overlayTitle;
|
||||||
|
private set => SetProperty(ref _overlayTitle, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 OpenMastodonCommand { get; }
|
||||||
|
public ICommand OpenCoffeeCommand { get; }
|
||||||
|
public ICommand OpenPremiumCommand { get; }
|
||||||
|
public ICommand OpenLicensesCommand { get; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (panel != nameof(IsAboutOpen))
|
||||||
|
IsLicensesOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shows the in-app licensing panel, loading the full texts from
|
||||||
|
/// the shipped THIRD-PARTY-NOTICES.txt on first open (never the OS viewer).
|
||||||
|
/// Falls back to a "not found" note if the file is missing so the panel
|
||||||
|
/// never hangs on nothing.</summary>
|
||||||
|
private void ShowLicenses()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(LicensesText))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
|
||||||
|
LicensesText = File.Exists(path)
|
||||||
|
? File.ReadAllText(path)
|
||||||
|
: "THIRD-PARTY-NOTICES.txt not found next to the executable.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LicensesText = $"Could not read THIRD-PARTY-NOTICES.txt: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IsLicensesOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubmitBug()
|
||||||
|
{
|
||||||
|
var body = string.Join(Environment.NewLine,
|
||||||
|
BugReportText.Trim(),
|
||||||
|
string.Empty,
|
||||||
|
$"LlamaCasty {AppVersionLabel}",
|
||||||
|
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
|
||||||
|
ComposeEmail("[LlamaCasty Bug Report]", body);
|
||||||
|
ShowOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubmitFeature()
|
||||||
|
{
|
||||||
|
var body = string.Join(Environment.NewLine,
|
||||||
|
FeatureRequestText.Trim(),
|
||||||
|
string.Empty,
|
||||||
|
$"LlamaCasty {AppVersionLabel}",
|
||||||
|
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
|
||||||
|
ComposeEmail("[LlamaCasty 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,17 +29,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
|
|
||||||
private bool _isSettingsOpen;
|
|
||||||
private bool _isBugOpen;
|
|
||||||
private bool _isFeatureOpen;
|
|
||||||
private bool _isAboutOpen;
|
|
||||||
private bool _isLicensesOpen;
|
|
||||||
private string _overlayTitle = string.Empty;
|
|
||||||
private string _bugReportText = string.Empty;
|
|
||||||
private string _bugReportEmail = string.Empty;
|
|
||||||
private string _featureRequestText = string.Empty;
|
|
||||||
private string _featureRequestEmail = string.Empty;
|
|
||||||
|
|
||||||
private LayoutStore _layoutStore;
|
private LayoutStore _layoutStore;
|
||||||
private string? _activeLayoutPath;
|
private string? _activeLayoutPath;
|
||||||
private DispatcherTimer? _saveDebounce;
|
private DispatcherTimer? _saveDebounce;
|
||||||
@@ -55,22 +44,8 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private readonly Func<string?> _rtmpUrlProvider;
|
private readonly Func<string?> _rtmpUrlProvider;
|
||||||
private readonly TransitionService _transition = new();
|
private readonly TransitionService _transition = new();
|
||||||
|
|
||||||
private readonly ChatBoxRenderer _chatBoxRenderer;
|
|
||||||
private WebView2Manager? _webView2Manager;
|
private WebView2Manager? _webView2Manager;
|
||||||
|
|
||||||
// Mock chat messages for the ChatBox preview — branded placeholders so the
|
|
||||||
// creator sees where the overlay lands before going live.
|
|
||||||
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
|
|
||||||
{
|
|
||||||
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
|
|
||||||
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
|
|
||||||
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
|
|
||||||
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
|
|
||||||
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
|
|
||||||
};
|
|
||||||
// Chat fade timer: clears chat overlay after N seconds of inactivity.
|
|
||||||
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
|
|
||||||
|
|
||||||
// Branding flash (monetization): a full-frame "made with LlamaCasty!" shown
|
// Branding flash (monetization): a full-frame "made with LlamaCasty!" shown
|
||||||
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
|
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
|
||||||
// BrandFlashEnabled = false. See ai.md "Monetization".
|
// BrandFlashEnabled = false. See ai.md "Monetization".
|
||||||
@@ -79,25 +54,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private bool _brandFlashEnabled = true;
|
private bool _brandFlashEnabled = true;
|
||||||
private bool _brandFlashActive;
|
private bool _brandFlashActive;
|
||||||
|
|
||||||
private const string SupportEmail = "gramps@llamachile.shop";
|
|
||||||
// The creator-hub link targets (About overlay) — public so the hub's
|
|
||||||
// integration test can assert they point at real destinations.
|
|
||||||
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
|
||||||
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
|
|
||||||
public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw";
|
|
||||||
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
|
||||||
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
|
||||||
|
|
||||||
/// <summary>The About hub's Unlock Premium button lights up only once the
|
|
||||||
/// billing URL is set (Polar — polar.sh, pending TASK 10 implementation).</summary>
|
|
||||||
public bool IsPremiumAvailable => PremiumUrl.Length > 0;
|
|
||||||
|
|
||||||
/// <summary>Full in-app licensing/legal text, loaded on demand from the
|
|
||||||
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
|
|
||||||
public string LicensesText { get; private set; } = string.Empty;
|
|
||||||
|
|
||||||
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
|
||||||
|
|
||||||
public SceneElement? SelectedElement
|
public SceneElement? SelectedElement
|
||||||
{
|
{
|
||||||
get => _selectedElement;
|
get => _selectedElement;
|
||||||
@@ -125,12 +81,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
|
|
||||||
/// anywhere — one chat overlay per layout.</summary>
|
|
||||||
public bool CanAddYouTubeChat
|
|
||||||
=> StagedScene != null
|
|
||||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
|
||||||
|
|
||||||
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
||||||
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
||||||
private static SolidColorBrush CreateBrush(string hex)
|
private static SolidColorBrush CreateBrush(string hex)
|
||||||
@@ -141,7 +91,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
&& !el.IsDraggable && !el.IsWebcam
|
&& !el.IsDraggable && !el.IsWebcam
|
||||||
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
||||||
|
|
||||||
public bool ShowChatInactiveMessage => !IsLive;
|
|
||||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
||||||
|
|
||||||
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
||||||
@@ -171,51 +120,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private set => SetProperty(ref _brandFlashActive, value);
|
private set => SetProperty(ref _brandFlashActive, 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>About hub sub-view: the full licensing/legal texts panel
|
|
||||||
/// (in-app scrolling — no Notepad, no browser tab).</summary>
|
|
||||||
public bool IsLicensesOpen
|
|
||||||
{
|
|
||||||
get => _isLicensesOpen;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _isLicensesOpen, value))
|
|
||||||
OnPropertyChanged(nameof(IsHubVisible));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
|
||||||
|
|
||||||
public string OverlayTitle
|
|
||||||
{
|
|
||||||
get => _overlayTitle;
|
|
||||||
private set => SetProperty(ref _overlayTitle, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window geometry pass-through (code-behind owns the WPF specifics).
|
// Window geometry pass-through (code-behind owns the WPF specifics).
|
||||||
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
|
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
|
||||||
=> _layoutStore.LoadWindowState();
|
=> _layoutStore.LoadWindowState();
|
||||||
@@ -223,30 +127,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public void PersistWindowState(double left, double top, double width, double height, string state)
|
public void PersistWindowState(double left, double top, double width, double height, string state)
|
||||||
=> _layoutStore.SaveWindowState(left, top, width, height, state);
|
=> _layoutStore.SaveWindowState(left, top, width, height, state);
|
||||||
|
|
||||||
public string BugReportText
|
|
||||||
{
|
|
||||||
get => _bugReportText;
|
|
||||||
set => SetProperty(ref _bugReportText, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string BugReportEmail
|
|
||||||
{
|
|
||||||
get => _bugReportEmail;
|
|
||||||
set => SetProperty(ref _bugReportEmail, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FeatureRequestText
|
|
||||||
{
|
|
||||||
get => _featureRequestText;
|
|
||||||
set => SetProperty(ref _featureRequestText, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FeatureRequestEmail
|
|
||||||
{
|
|
||||||
get => _featureRequestEmail;
|
|
||||||
set => SetProperty(ref _featureRequestEmail, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
public ICommand AddSceneCommand { get; }
|
public ICommand AddSceneCommand { get; }
|
||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
@@ -254,18 +134,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public ICommand RemoveSourceCommand { get; }
|
public ICommand RemoveSourceCommand { get; }
|
||||||
public ICommand EditElementCommand { get; }
|
public ICommand EditElementCommand { get; }
|
||||||
public ICommand ToggleElementVisibilityCommand { get; }
|
public ICommand ToggleElementVisibilityCommand { 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 OpenMastodonCommand { get; }
|
|
||||||
public ICommand OpenCoffeeCommand { get; }
|
|
||||||
public ICommand OpenPremiumCommand { get; }
|
|
||||||
public ICommand OpenLicensesCommand { get; }
|
|
||||||
public ICommand ActivateLicenseCommand { get; }
|
public ICommand ActivateLicenseCommand { get; }
|
||||||
public ICommand OpenLicenseEntryCommand { get; }
|
public ICommand OpenLicenseEntryCommand { get; }
|
||||||
public ICommand OpenHotkeyConfigCommand { get; }
|
public ICommand OpenHotkeyConfigCommand { get; }
|
||||||
@@ -830,122 +698,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
UpdateActiveBackground();
|
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;
|
|
||||||
if (panel != nameof(IsAboutOpen))
|
|
||||||
IsLicensesOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Shows the in-app licensing panel, loading the full texts from
|
|
||||||
/// the shipped THIRD-PARTY-NOTICES.txt on first open (never the OS viewer).
|
|
||||||
/// Falls back to a "not found" note if the file is missing so the panel
|
|
||||||
/// never hangs on nothing.</summary>
|
|
||||||
private void ShowLicenses()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(LicensesText))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
|
|
||||||
LicensesText = File.Exists(path)
|
|
||||||
? File.ReadAllText(path)
|
|
||||||
: "THIRD-PARTY-NOTICES.txt not found next to the executable.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
LicensesText = $"Could not read THIRD-PARTY-NOTICES.txt: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IsLicensesOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SubmitBug()
|
|
||||||
{
|
|
||||||
var body = string.Join(Environment.NewLine,
|
|
||||||
BugReportText.Trim(),
|
|
||||||
string.Empty,
|
|
||||||
$"LlamaCasty {AppVersionLabel}",
|
|
||||||
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
|
|
||||||
ComposeEmail("[LlamaCasty Bug Report]", body);
|
|
||||||
ShowOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SubmitFeature()
|
|
||||||
{
|
|
||||||
var body = string.Join(Environment.NewLine,
|
|
||||||
FeatureRequestText.Trim(),
|
|
||||||
string.Empty,
|
|
||||||
$"LlamaCasty {AppVersionLabel}",
|
|
||||||
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
|
|
||||||
ComposeEmail("[LlamaCasty 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 void OnChatMessageReceived(ChatMessage message)
|
|
||||||
{
|
|
||||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
ChatMessages.Add(message);
|
|
||||||
if (ChatMessages.Count > 500)
|
|
||||||
ChatMessages.RemoveAt(0);
|
|
||||||
|
|
||||||
// Reset chat fade timer on every message
|
|
||||||
ResetChatFadeTimer();
|
|
||||||
|
|
||||||
StopMockChatPreview();
|
|
||||||
|
|
||||||
// Switch ChatBox previews from mock to real messages
|
|
||||||
if (StagedScene != null)
|
|
||||||
foreach (var chatBox in StagedScene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
|
|
||||||
UpdateChatBoxPreview(chatBox);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ResetChatFadeTimer()
|
|
||||||
{
|
|
||||||
_chatFadeTimer?.Stop();
|
|
||||||
var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
||||||
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
|
|
||||||
if (chatBoxes.Count == 0) return;
|
|
||||||
|
|
||||||
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
|
|
||||||
_chatFadeTimer = new System.Windows.Threading.DispatcherTimer
|
|
||||||
{ Interval = TimeSpan.FromSeconds(delay) };
|
|
||||||
_chatFadeTimer.Tick += (_, _) =>
|
|
||||||
{
|
|
||||||
_chatFadeTimer?.Stop();
|
|
||||||
_chatFadeTimer = null;
|
|
||||||
ChatMessages.Clear();
|
|
||||||
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
||||||
.Where(s => s.Type == SourceType.ChatBox))
|
|
||||||
UpdateChatBoxPreview(chatBox);
|
|
||||||
StartMockChatPreview();
|
|
||||||
};
|
|
||||||
_chatFadeTimer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||||
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
||||||
// images/background by AssetId. A null frame leaves the element transparent.
|
// images/background by AssetId. A null frame leaves the element transparent.
|
||||||
@@ -962,119 +714,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private VideoFrame? RenderChatBox(Source chatBox)
|
|
||||||
{
|
|
||||||
if (ChatMessages.Count == 0) return null;
|
|
||||||
|
|
||||||
var width = (int)Math.Max(1, chatBox.Width);
|
|
||||||
var height = (int)Math.Max(1, chatBox.Height);
|
|
||||||
|
|
||||||
return _chatBoxRenderer.Render(
|
|
||||||
ChatMessages,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
chatBox.ChatFontSize,
|
|
||||||
chatBox.ChatFontFamily,
|
|
||||||
chatBox.ChatFontColor,
|
|
||||||
chatBox.ChatBackgroundColor,
|
|
||||||
chatBox.ChatMaxMessages,
|
|
||||||
chatBox.ChatShowTimestamps,
|
|
||||||
chatBox.ChatShowBadges,
|
|
||||||
chatBox.ChatMemberColor,
|
|
||||||
chatBox.ChatSuperChatColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders the ChatBox preview: real messages when connected, mock when not.
|
|
||||||
/// </summary>
|
|
||||||
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
messages ??= ChatMessages.Count > 0 ? (IReadOnlyList<ChatMessage>)ChatMessages : MockChatMessages;
|
|
||||||
var width = (int)Math.Max(32, chatBox.Width);
|
|
||||||
var height = (int)Math.Max(32, chatBox.Height);
|
|
||||||
|
|
||||||
var frame = _chatBoxRenderer.Render(
|
|
||||||
messages,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
chatBox.ChatFontSize,
|
|
||||||
chatBox.ChatFontFamily,
|
|
||||||
chatBox.ChatFontColor,
|
|
||||||
chatBox.ChatBackgroundColor,
|
|
||||||
chatBox.ChatMaxMessages,
|
|
||||||
chatBox.ChatShowTimestamps,
|
|
||||||
chatBox.ChatShowBadges,
|
|
||||||
chatBox.ChatMemberColor,
|
|
||||||
chatBox.ChatSuperChatColor);
|
|
||||||
|
|
||||||
var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
|
|
||||||
bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
|
||||||
chatBox.VideoImageSource = bitmap;
|
|
||||||
}
|
|
||||||
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private CancellationTokenSource? _mockChatCts;
|
|
||||||
|
|
||||||
private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var n = 0;
|
|
||||||
while (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList());
|
|
||||||
await Task.Delay(1000, ct);
|
|
||||||
n++;
|
|
||||||
if (n == MockChatMessages.Count)
|
|
||||||
{
|
|
||||||
await Task.Delay(3000, ct);
|
|
||||||
n = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartMockChatPreview()
|
|
||||||
{
|
|
||||||
StopMockChatPreview();
|
|
||||||
_mockChatCts = new CancellationTokenSource();
|
|
||||||
var ct = _mockChatCts.Token;
|
|
||||||
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
||||||
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
|
|
||||||
_ = RunMockChatPreviewAsync(chatBox, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StopMockChatPreview()
|
|
||||||
{
|
|
||||||
_mockChatCts?.Cancel();
|
|
||||||
_mockChatCts?.Dispose();
|
|
||||||
_mockChatCts = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Render previews + wire property-change subscriptions for all ChatBox
|
|
||||||
/// sources across every scene. Called after layout load so existing ChatBox
|
|
||||||
/// sources show mock content and respond to config changes.
|
|
||||||
/// </summary>
|
|
||||||
private void UpdateActiveChatBoxPreviews()
|
|
||||||
{
|
|
||||||
foreach (var scene in Scenes)
|
|
||||||
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
|
|
||||||
{
|
|
||||||
chatBox.PropertyChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
|
|
||||||
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
|
|
||||||
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
|
|
||||||
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
|
|
||||||
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
|
|
||||||
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
|
|
||||||
UpdateChatBoxPreview(chatBox);
|
|
||||||
};
|
|
||||||
UpdateChatBoxPreview(chatBox);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
||||||
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
||||||
private CompositorOptions BuildCompositorOptions()
|
private CompositorOptions BuildCompositorOptions()
|
||||||
|
|||||||
+7
-4
@@ -8,7 +8,7 @@ areas' docs in their own rows keeps this index small to read.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Overlays/lifecycle:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), `OpenUrl`/`ComposeEmail`/`SubmitBug`/`SubmitFeature`, window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`/`ShowChatInactiveMessage`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`) — the pump's stream-state consumers live in the Streaming partial. |
|
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox` for chat layers) — the pump's stream-state consumers live in the Streaming partial. |
|
||||||
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
|
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
|
||||||
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
|
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
|
||||||
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
|
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
|
||||||
@@ -18,8 +18,10 @@ areas' docs in their own rows keeps this index small to read.
|
|||||||
| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). |
|
| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). |
|
||||||
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
|
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
|
||||||
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`/`CanToggleOnAir`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`/`ShowLogInButton`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
|
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`/`CanToggleOnAir`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`/`ShowLogInButton`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
|
||||||
|
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` on first open, never the OS viewer); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public, integration-test live) + `PremiumUrl` (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. Ctor command assignments stay in core. |
|
||||||
|
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). `_chatBoxRenderer` field. Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose`) stays in core. |
|
||||||
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
|
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
|
||||||
| **About hub (TASK 11, in `MainViewModel`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
|
| **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
|
||||||
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
||||||
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
|
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
|
||||||
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
|
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
|
||||||
@@ -47,8 +49,9 @@ Progress (Commit N/12 · tag):
|
|||||||
- [x] **Commit 7/12 · `refactor-commit-7`** — **Trax** → `MainViewModel.Trax.cs`
|
- [x] **Commit 7/12 · `refactor-commit-7`** — **Trax** → `MainViewModel.Trax.cs`
|
||||||
- [x] **Commit 8/12 · `refactor-commit-8`** — **Socials** → `MainViewModel.Socials.cs`
|
- [x] **Commit 8/12 · `refactor-commit-8`** — **Socials** → `MainViewModel.Socials.cs`
|
||||||
- [x] **Commit 9/12 · `refactor-commit-9`** — **Streaming** → `MainViewModel.Streaming.cs`
|
- [x] **Commit 9/12 · `refactor-commit-9`** — **Streaming** → `MainViewModel.Streaming.cs`
|
||||||
- [ ] **Commit 10/12** — **Chat** (YouTube chat + mock preview + chatbox render)
|
- [x] **Commit 10/12 · `refactor-commit-10`** — **Chat** → `MainViewModel.Chat.cs`
|
||||||
- [ ] **Commit 11/12** — **Overlays** (settings/bug/feature/about panels + license entry)
|
- [x] **Commit 11/12 · `refactor-commit-11`** — **Overlays** → `MainViewModel.Overlays.cs`
|
||||||
|
- [x] **Phase 1 complete:** core `MainViewModel.cs` split into 10 functional partials (1,890 → 758 lines). Phase 2 (splitting `MainWindow.xaml` into UserControls) not yet started.
|
||||||
|
|
||||||
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
|
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
|
||||||
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
||||||
Reference in New Issue
Block a user