Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcdfcb9cee | |||
| 9baae33916 | |||
| 5eecf4536f |
@@ -0,0 +1,132 @@
|
|||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
private bool _isConnected;
|
||||||
|
private string _accountAvatarUrl = string.Empty;
|
||||||
|
private string _accountDisplayName = string.Empty;
|
||||||
|
|
||||||
|
public bool IsConnected
|
||||||
|
{
|
||||||
|
get => _isConnected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isConnected, value))
|
||||||
|
{
|
||||||
|
// ON-AIR requires a sign-in; dropping the session turns the pill
|
||||||
|
// off so the state can never offer a stream without an account.
|
||||||
|
if (!value) OnAirPillOn = false;
|
||||||
|
OnPropertyChanged(nameof(CanToggleOnAir));
|
||||||
|
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||||
|
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The connected YouTube account's avatar + name — shown in the top
|
||||||
|
/// bar so the creator always sees WHICH account is about to go live.</summary>
|
||||||
|
public string AccountAvatarUrl
|
||||||
|
{
|
||||||
|
get => _accountAvatarUrl;
|
||||||
|
private set => SetProperty(ref _accountAvatarUrl, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AccountDisplayName
|
||||||
|
{
|
||||||
|
get => _accountDisplayName;
|
||||||
|
private set => SetProperty(ref _accountDisplayName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tooltip for the account status light explaining the color.</summary>
|
||||||
|
public string AccountStatusLightToolTip =>
|
||||||
|
IsLive ? "Live on YouTube — the top bar is red to show the stream is on the air."
|
||||||
|
: IsConnected ? "You're signed in to YouTube. The light is green; ON-AIR is available."
|
||||||
|
: "Signed out — the light is red and the ON-AIR pill is greyed out. You can still record locally.";
|
||||||
|
|
||||||
|
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
|
||||||
|
// Best-effort: refresh a near-expiry access token; a session that can no
|
||||||
|
// longer refresh is discarded and the user signs in again.
|
||||||
|
private async Task LoadSavedSessionAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var saved = TokenStore.Load();
|
||||||
|
if (saved == null) return;
|
||||||
|
|
||||||
|
_youtubeAuth.SetSession(saved);
|
||||||
|
if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
||||||
|
{
|
||||||
|
var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
|
||||||
|
if (!refreshed)
|
||||||
|
{
|
||||||
|
TokenStore.Clear();
|
||||||
|
AppLog.Write("Saved session could not be refreshed; signed out");
|
||||||
|
_notifications.Info("Signed out of YouTube",
|
||||||
|
"The saved session expired — sign in again when you next go live.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IsConnected = true;
|
||||||
|
SyncConnectedAccount();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncConnectedAccount()
|
||||||
|
{
|
||||||
|
var channel = _youtubeAuth.CurrentChannel;
|
||||||
|
AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty;
|
||||||
|
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the OAuth flow (browser + loopback callback) on a background thread.
|
||||||
|
// The resulting channel is returned to the caller and persisted via the
|
||||||
|
// auth service's sessionChanged hook.
|
||||||
|
private async Task<YouTubeChannel?> SignInAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
|
||||||
|
if (channel == null)
|
||||||
|
{
|
||||||
|
_notifications.Warning("Sign-in was unsuccessful", "Please try again.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsConnected = true;
|
||||||
|
SyncConnectedAccount();
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_notifications.Error("Sign-in failed", ex.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
|
||||||
|
private async Task SignOutYouTubeAsync()
|
||||||
|
{
|
||||||
|
_youtubeAuth.ClearSession();
|
||||||
|
TokenStore.Clear();
|
||||||
|
IsConnected = false;
|
||||||
|
SyncConnectedAccount();
|
||||||
|
NotifySocialsChanged();
|
||||||
|
AppLog.Write("Socials: signed out of YouTube");
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ChangeAccountAsync()
|
||||||
|
{
|
||||||
|
await SignOutYouTubeAsync();
|
||||||
|
await SignInAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
// TASK 10 — Polar license validation
|
||||||
|
private string _licenseKey = string.Empty;
|
||||||
|
private string _licenseMessage = string.Empty;
|
||||||
|
private bool _isLicenseValidating;
|
||||||
|
private bool _isLicenseEntryOpen;
|
||||||
|
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(14);
|
||||||
|
|
||||||
|
/// <summary>True when the user holds a valid LlamaCasty license (Polar). When
|
||||||
|
/// false, the branding flash runs on every live stream.</summary>
|
||||||
|
private bool _isPremium;
|
||||||
|
public bool IsPremium
|
||||||
|
{
|
||||||
|
get => _isPremium;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (!SetProperty(ref _isPremium, value)) return;
|
||||||
|
// Flip the branding flash when premium state changes.
|
||||||
|
BrandFlashEnabled = !value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK 10 — license key entry
|
||||||
|
public string LicenseKey
|
||||||
|
{
|
||||||
|
get => _licenseKey;
|
||||||
|
set => SetProperty(ref _licenseKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LicenseMessage
|
||||||
|
{
|
||||||
|
get => _licenseMessage;
|
||||||
|
private set => SetProperty(ref _licenseMessage, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLicenseValidating
|
||||||
|
{
|
||||||
|
get => _isLicenseValidating;
|
||||||
|
private set => SetProperty(ref _isLicenseValidating, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLicenseEntryOpen
|
||||||
|
{
|
||||||
|
get => _isLicenseEntryOpen;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isLicenseEntryOpen, value))
|
||||||
|
OnPropertyChanged(nameof(IsHubVisible));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The About hub is visible only when neither the licenses panel
|
||||||
|
/// nor the license entry panel is open.</summary>
|
||||||
|
public bool IsHubVisible => !IsLicensesOpen && !IsLicenseEntryOpen;
|
||||||
|
|
||||||
|
// ─── TASK 10 — Polar license validation ───
|
||||||
|
|
||||||
|
/// <summary>At startup, load the cached license state and re-validate online
|
||||||
|
/// if a key exists. If offline and within the 14-day grace period, the cached
|
||||||
|
/// premium state is trusted. If the grace period is exceeded, premium lapses.</summary>
|
||||||
|
private async Task ValidateLicenseAtStartupAsync()
|
||||||
|
{
|
||||||
|
var savedKey = _layoutStore.LoadLicenseKey();
|
||||||
|
if (string.IsNullOrWhiteSpace(savedKey))
|
||||||
|
{
|
||||||
|
IsPremium = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LicenseKey = savedKey;
|
||||||
|
var validatedAt = _layoutStore.LoadLicenseValidatedAt();
|
||||||
|
var cachedPremium = _layoutStore.LoadIsPremium();
|
||||||
|
|
||||||
|
// If the last validation was within the grace period, trust the cache
|
||||||
|
// while we attempt an online re-validation in the background.
|
||||||
|
if (validatedAt.HasValue && DateTime.UtcNow - validatedAt.Value < OfflineGracePeriod)
|
||||||
|
{
|
||||||
|
IsPremium = cachedPremium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try an online check — if it succeeds, it overrides the cache.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = new PolarLicenseService();
|
||||||
|
var result = await service.ValidateAsync(savedKey);
|
||||||
|
if (result.IsValid)
|
||||||
|
{
|
||||||
|
IsPremium = true;
|
||||||
|
_layoutStore.SaveIsPremium(true);
|
||||||
|
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Key revoked or expired — clear premium.
|
||||||
|
IsPremium = false;
|
||||||
|
_layoutStore.SaveIsPremium(false);
|
||||||
|
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||||
|
AppLog.Write($"License validation failed: {result.Error}");
|
||||||
|
_notifications.Warning("Premium is no longer active",
|
||||||
|
$"{result.Error} — watermark branding returns until the license is renewed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Offline or network error — keep the cached state, log it.
|
||||||
|
AppLog.Write($"License re-validation skipped (offline?): {ex.Message}");
|
||||||
|
if (!validatedAt.HasValue || DateTime.UtcNow - validatedAt.Value >= OfflineGracePeriod)
|
||||||
|
{
|
||||||
|
IsPremium = false;
|
||||||
|
_layoutStore.SaveIsPremium(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Called from the Activate button in the About overlay. Validates
|
||||||
|
/// the entered key, persists it, and flips premium state.</summary>
|
||||||
|
private async Task ActivateLicenseAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(LicenseKey)) return;
|
||||||
|
|
||||||
|
IsLicenseValidating = true;
|
||||||
|
LicenseMessage = "Validating\u2026";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = new PolarLicenseService();
|
||||||
|
var result = await service.ValidateAsync(LicenseKey);
|
||||||
|
if (result.IsValid)
|
||||||
|
{
|
||||||
|
IsPremium = true;
|
||||||
|
_layoutStore.SaveLicenseKey(LicenseKey.Trim());
|
||||||
|
_layoutStore.SaveIsPremium(true);
|
||||||
|
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||||
|
LicenseMessage = "Premium activated! The watermark has been removed.";
|
||||||
|
AppLog.Write("License activated successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IsPremium = false;
|
||||||
|
_layoutStore.SaveLicenseKey(null);
|
||||||
|
_layoutStore.SaveIsPremium(false);
|
||||||
|
LicenseMessage = result.Error ?? "Invalid license key.";
|
||||||
|
AppLog.Write($"License activation failed: {result.Error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LicenseMessage = $"Activation error: {ex.Message}";
|
||||||
|
AppLog.Write($"License activation error: {ex}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLicenseValidating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Windows;
|
||||||
|
using ytLive;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
private Scene? _stagedScene;
|
||||||
|
private Scene? _liveScene;
|
||||||
|
|
||||||
|
// TASK 20 step 2 — hotkey bindings (custom chord per action)
|
||||||
|
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
|
||||||
|
|
||||||
|
public ObservableCollection<Scene> Scenes { get; } = new();
|
||||||
|
|
||||||
|
public Scene? StagedScene
|
||||||
|
{
|
||||||
|
get => _stagedScene;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value != null && value.IsHidden) return;
|
||||||
|
if (SetProperty(ref _stagedScene, value))
|
||||||
|
{
|
||||||
|
// Update IsStaged flags for thumbnail glow
|
||||||
|
foreach (var s in Scenes)
|
||||||
|
s.IsStaged = s == value;
|
||||||
|
|
||||||
|
SelectedElement = null;
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
var background = EnsureBackground(value);
|
||||||
|
if (background != null)
|
||||||
|
StampDefaultBackgroundAsset(background, value.Name);
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
OnPropertyChanged(nameof(CanChangeBackground));
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcam));
|
||||||
|
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||||
|
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||||
|
OnPropertyChanged(nameof(IsCountdownAvailable));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
UpdateBackgroundImage();
|
||||||
|
RefreshSnapshotsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
|
||||||
|
/// or on transition completion. FramePump reads this every tick.</summary>
|
||||||
|
public Scene? LiveScene
|
||||||
|
{
|
||||||
|
get => _liveScene;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _liveScene, value))
|
||||||
|
{
|
||||||
|
foreach (var s in Scenes)
|
||||||
|
s.IsLive = s == value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when offline — edit mode is available. When live/recording,
|
||||||
|
/// all edit operations are locked out (gun safety).</summary>
|
||||||
|
public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
|
||||||
|
|
||||||
|
// ─── Transitions (TASK 19/23) ───
|
||||||
|
|
||||||
|
public TransitionType SelectedTransitionType
|
||||||
|
{
|
||||||
|
get => _selectedTransitionType;
|
||||||
|
set => SetProperty(ref _selectedTransitionType, value);
|
||||||
|
}
|
||||||
|
private TransitionType _selectedTransitionType = TransitionType.Fade;
|
||||||
|
|
||||||
|
public double TransitionDurationMs
|
||||||
|
{
|
||||||
|
get => _transitionDurationMs;
|
||||||
|
set => SetProperty(ref _transitionDurationMs, value);
|
||||||
|
}
|
||||||
|
private double _transitionDurationMs = 300;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a scene thumbnail is clicked. If live, triggers a transition from
|
||||||
|
/// LiveScene to the selected StagedScene. Always sets StagedScene first.
|
||||||
|
/// </summary>
|
||||||
|
public void TransitionToScene(Scene scene)
|
||||||
|
{
|
||||||
|
if (scene.IsHidden || scene == StagedScene) return;
|
||||||
|
StagedScene = scene;
|
||||||
|
if (IsLive)
|
||||||
|
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the current transition type to the next StagedScene change while live.
|
||||||
|
/// Called from XAML when user picks a transition from the dropdown.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyTransitionToLive()
|
||||||
|
{
|
||||||
|
if (IsLive)
|
||||||
|
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
|
||||||
|
/// after the user saves custom bindings from the config dialog.
|
||||||
|
/// </summary>
|
||||||
|
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
|
||||||
|
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
|
||||||
|
|
||||||
|
public void HandleHotkey(Services.HotkeyId id)
|
||||||
|
{
|
||||||
|
switch (id)
|
||||||
|
{
|
||||||
|
case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break;
|
||||||
|
case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break;
|
||||||
|
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
|
||||||
|
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
|
||||||
|
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
|
||||||
|
case Services.HotkeyId.StartStopStream:
|
||||||
|
if (IsLive || IsRecording)
|
||||||
|
{
|
||||||
|
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
|
||||||
|
}
|
||||||
|
else if (StartStreamCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
StartStreamCommand.Execute(null);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break;
|
||||||
|
case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break;
|
||||||
|
case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionToCanonical(string name)
|
||||||
|
{
|
||||||
|
var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name));
|
||||||
|
if (scene != null) TransitionToScene(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenHotkeyConfig()
|
||||||
|
{
|
||||||
|
var vm = new HotkeyConfigViewModel(_layoutStore);
|
||||||
|
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
// Reload bindings from DB (the dialog already saved them).
|
||||||
|
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
||||||
|
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
|
||||||
|
RefreshHotkeyLabels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshHotkeyLabels()
|
||||||
|
{
|
||||||
|
foreach (var scene in Scenes)
|
||||||
|
{
|
||||||
|
if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var hid)
|
||||||
|
&& _hotkeyBindings.TryGetValue(hid, out var chord))
|
||||||
|
scene.HotkeyLabel = chord.ToDisplayString();
|
||||||
|
else if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var defId)
|
||||||
|
&& GlobalHotkeyManager.Defaults.TryGetValue(defId, out var defChord))
|
||||||
|
scene.HotkeyLabel = defChord.ToDisplayString();
|
||||||
|
else
|
||||||
|
scene.HotkeyLabel = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
|
||||||
|
public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
|
||||||
|
|
||||||
|
public bool IsCountdownAvailable => StagedScene != null
|
||||||
|
&& (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
|
||||||
|
|
||||||
|
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
|
||||||
|
/// "+" button may re-add. Work with less, never more.</summary>
|
||||||
|
public IEnumerable<string> MissingScenes
|
||||||
|
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
|
||||||
|
|
||||||
|
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
|
||||||
|
public bool ShowAddScene => MissingScenes.Any();
|
||||||
|
|
||||||
|
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
|
||||||
|
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
|
||||||
|
// the Live screen's Background. Static-art screens never expose them.
|
||||||
|
public bool CanChangeBackground =>
|
||||||
|
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
OnPropertyChanged(nameof(MissingScenes));
|
||||||
|
OnPropertyChanged(nameof(ShowAddScene));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WireScene(Scene scene)
|
||||||
|
{
|
||||||
|
scene.PropertyChanged += OnScenePropertyChanged;
|
||||||
|
scene.Elements.CollectionChanged += OnElementsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnwireScene(Scene scene)
|
||||||
|
{
|
||||||
|
scene.PropertyChanged -= OnScenePropertyChanged;
|
||||||
|
scene.Elements.CollectionChanged -= OnElementsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// A rename can drop a canonical scene in/out of MissingScenes.
|
||||||
|
OnPropertyChanged(nameof(MissingScenes));
|
||||||
|
OnPropertyChanged(nameof(ShowAddScene));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.NewItems != null)
|
||||||
|
foreach (SceneElement element in e.NewItems)
|
||||||
|
element.PropertyChanged += OnElementPropertyChanged;
|
||||||
|
if (e.OldItems != null)
|
||||||
|
foreach (SceneElement element in e.OldItems)
|
||||||
|
element.PropertyChanged -= OnElementPropertyChanged;
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcam));
|
||||||
|
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
||||||
|
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a canonical scene by name — only when it's actually missing. The
|
||||||
|
// background flag follows the policy (Live yes, everyone else no).
|
||||||
|
private void AddScene(string name)
|
||||||
|
{
|
||||||
|
if (!SceneCatalog.IsCanonical(name)) return;
|
||||||
|
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
|
||||||
|
var scene = new Scene
|
||||||
|
{
|
||||||
|
Name = name.Trim(),
|
||||||
|
IsChatScene = SceneCatalog.IsChat(name),
|
||||||
|
HasBackground = SceneCatalog.HasBackground(name),
|
||||||
|
};
|
||||||
|
EnsureBackground(scene);
|
||||||
|
Scenes.Add(scene);
|
||||||
|
StagedScene = scene;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,13 +31,8 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
||||||
private readonly DispatcherTimer _healthPollTimer;
|
private readonly DispatcherTimer _healthPollTimer;
|
||||||
|
|
||||||
private Scene? _stagedScene;
|
|
||||||
private Scene? _liveScene;
|
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
private ImageSource? _activeBackgroundImage;
|
private ImageSource? _activeBackgroundImage;
|
||||||
private bool _isConnected;
|
|
||||||
private string _accountAvatarUrl = string.Empty;
|
|
||||||
private string _accountDisplayName = string.Empty;
|
|
||||||
private double _audioLevel;
|
private double _audioLevel;
|
||||||
private bool _volumeAdjusting;
|
private bool _volumeAdjusting;
|
||||||
private bool _volumeFlash;
|
private bool _volumeFlash;
|
||||||
@@ -173,16 +168,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private bool _brandFlashEnabled = true;
|
private bool _brandFlashEnabled = true;
|
||||||
private bool _brandFlashActive;
|
private bool _brandFlashActive;
|
||||||
|
|
||||||
// TASK 20 step 2 — hotkey bindings (custom chord per action)
|
|
||||||
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
|
|
||||||
|
|
||||||
// TASK 10 — Polar license validation
|
|
||||||
private string _licenseKey = string.Empty;
|
|
||||||
private string _licenseMessage = string.Empty;
|
|
||||||
private bool _isLicenseValidating;
|
|
||||||
private bool _isLicenseEntryOpen;
|
|
||||||
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(14);
|
|
||||||
|
|
||||||
private const string SupportEmail = "gramps@llamachile.shop";
|
private const string SupportEmail = "gramps@llamachile.shop";
|
||||||
// The creator-hub link targets (About overlay) — public so the hub's
|
// The creator-hub link targets (About overlay) — public so the hub's
|
||||||
// integration test can assert they point at real destinations.
|
// integration test can assert they point at real destinations.
|
||||||
@@ -200,64 +185,9 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
|
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
|
||||||
public string LicensesText { get; private set; } = string.Empty;
|
public string LicensesText { get; private set; } = string.Empty;
|
||||||
|
|
||||||
public ObservableCollection<Scene> Scenes { get; } = new();
|
|
||||||
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
||||||
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
||||||
|
|
||||||
public Scene? StagedScene
|
|
||||||
{
|
|
||||||
get => _stagedScene;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (value != null && value.IsHidden) return;
|
|
||||||
if (SetProperty(ref _stagedScene, value))
|
|
||||||
{
|
|
||||||
// Update IsStaged flags for thumbnail glow
|
|
||||||
foreach (var s in Scenes)
|
|
||||||
s.IsStaged = s == value;
|
|
||||||
|
|
||||||
SelectedElement = null;
|
|
||||||
if (value != null)
|
|
||||||
{
|
|
||||||
var background = EnsureBackground(value);
|
|
||||||
if (background != null)
|
|
||||||
StampDefaultBackgroundAsset(background, value.Name);
|
|
||||||
}
|
|
||||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
|
||||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
||||||
OnPropertyChanged(nameof(CanChangeBackground));
|
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
|
||||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
|
||||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
|
||||||
OnPropertyChanged(nameof(IsCountdownAvailable));
|
|
||||||
UpdateActiveBackground();
|
|
||||||
UpdateBackgroundImage();
|
|
||||||
RefreshSnapshotsAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
|
|
||||||
/// or on transition completion. FramePump reads this every tick.</summary>
|
|
||||||
public Scene? LiveScene
|
|
||||||
{
|
|
||||||
get => _liveScene;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _liveScene, value))
|
|
||||||
{
|
|
||||||
foreach (var s in Scenes)
|
|
||||||
s.IsLive = s == value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>True when offline — edit mode is available. When live/recording,
|
|
||||||
/// all edit operations are locked out (gun safety).</summary>
|
|
||||||
public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
|
|
||||||
|
|
||||||
public ImageSource? ActiveBackgroundImage
|
public ImageSource? ActiveBackgroundImage
|
||||||
{
|
{
|
||||||
get => _activeBackgroundImage;
|
get => _activeBackgroundImage;
|
||||||
@@ -330,114 +260,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
=> StagedScene != null
|
=> StagedScene != null
|
||||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
||||||
|
|
||||||
// ─── Transitions (TASK 19/23) ───
|
|
||||||
|
|
||||||
public TransitionType SelectedTransitionType
|
|
||||||
{
|
|
||||||
get => _selectedTransitionType;
|
|
||||||
set => SetProperty(ref _selectedTransitionType, value);
|
|
||||||
}
|
|
||||||
private TransitionType _selectedTransitionType = TransitionType.Fade;
|
|
||||||
|
|
||||||
public double TransitionDurationMs
|
|
||||||
{
|
|
||||||
get => _transitionDurationMs;
|
|
||||||
set => SetProperty(ref _transitionDurationMs, value);
|
|
||||||
}
|
|
||||||
private double _transitionDurationMs = 300;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when a scene thumbnail is clicked. If live, triggers a transition from
|
|
||||||
/// LiveScene to the selected StagedScene. Always sets StagedScene first.
|
|
||||||
/// </summary>
|
|
||||||
public void TransitionToScene(Scene scene)
|
|
||||||
{
|
|
||||||
if (scene.IsHidden || scene == StagedScene) return;
|
|
||||||
StagedScene = scene;
|
|
||||||
if (IsLive)
|
|
||||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apply the current transition type to the next StagedScene change while live.
|
|
||||||
/// Called from XAML when user picks a transition from the dropdown.
|
|
||||||
/// </summary>
|
|
||||||
public void ApplyTransitionToLive()
|
|
||||||
{
|
|
||||||
if (IsLive)
|
|
||||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
|
|
||||||
/// after the user saves custom bindings from the config dialog.
|
|
||||||
/// </summary>
|
|
||||||
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
|
|
||||||
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
|
|
||||||
|
|
||||||
public void HandleHotkey(Services.HotkeyId id)
|
|
||||||
{
|
|
||||||
switch (id)
|
|
||||||
{
|
|
||||||
case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break;
|
|
||||||
case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break;
|
|
||||||
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
|
|
||||||
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
|
|
||||||
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
|
|
||||||
case Services.HotkeyId.StartStopStream:
|
|
||||||
if (IsLive || IsRecording)
|
|
||||||
{
|
|
||||||
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
|
|
||||||
}
|
|
||||||
else if (StartStreamCommand.CanExecute(null))
|
|
||||||
{
|
|
||||||
StartStreamCommand.Execute(null);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break;
|
|
||||||
case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break;
|
|
||||||
case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TransitionToCanonical(string name)
|
|
||||||
{
|
|
||||||
var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name));
|
|
||||||
if (scene != null) TransitionToScene(scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenHotkeyConfig()
|
|
||||||
{
|
|
||||||
var vm = new HotkeyConfigViewModel(_layoutStore);
|
|
||||||
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
|
|
||||||
if (dialog.ShowDialog() == true)
|
|
||||||
{
|
|
||||||
// Reload bindings from DB (the dialog already saved them).
|
|
||||||
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
|
||||||
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
|
|
||||||
RefreshHotkeyLabels();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshHotkeyLabels()
|
|
||||||
{
|
|
||||||
foreach (var scene in Scenes)
|
|
||||||
{
|
|
||||||
if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var hid)
|
|
||||||
&& _hotkeyBindings.TryGetValue(hid, out var chord))
|
|
||||||
scene.HotkeyLabel = chord.ToDisplayString();
|
|
||||||
else if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var defId)
|
|
||||||
&& GlobalHotkeyManager.Defaults.TryGetValue(defId, out var defChord))
|
|
||||||
scene.HotkeyLabel = defChord.ToDisplayString();
|
|
||||||
else
|
|
||||||
scene.HotkeyLabel = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
||||||
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
||||||
@@ -470,20 +292,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||||
public SocialsConfig? Socials => _socials;
|
public SocialsConfig? Socials => _socials;
|
||||||
|
|
||||||
/// <summary>True when the user holds a valid LlamaCasty license (Polar). When
|
|
||||||
/// false, the branding flash runs on every live stream.</summary>
|
|
||||||
private bool _isPremium;
|
|
||||||
public bool IsPremium
|
|
||||||
{
|
|
||||||
get => _isPremium;
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
if (!SetProperty(ref _isPremium, value)) return;
|
|
||||||
// Flip the branding flash when premium state changes.
|
|
||||||
BrandFlashEnabled = !value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
||||||
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
||||||
|
|
||||||
@@ -535,37 +343,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsConnected
|
|
||||||
{
|
|
||||||
get => _isConnected;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _isConnected, value))
|
|
||||||
{
|
|
||||||
// ON-AIR requires a sign-in; dropping the session turns the pill
|
|
||||||
// off so the state can never offer a stream without an account.
|
|
||||||
if (!value) OnAirPillOn = false;
|
|
||||||
OnPropertyChanged(nameof(CanToggleOnAir));
|
|
||||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
|
||||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The connected YouTube account's avatar + name — shown in the top
|
|
||||||
/// bar so the creator always sees WHICH account is about to go live.</summary>
|
|
||||||
public string AccountAvatarUrl
|
|
||||||
{
|
|
||||||
get => _accountAvatarUrl;
|
|
||||||
private set => SetProperty(ref _accountAvatarUrl, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string AccountDisplayName
|
|
||||||
{
|
|
||||||
get => _accountDisplayName;
|
|
||||||
private set => SetProperty(ref _accountDisplayName, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
|
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
|
||||||
// ─── visible; the game bar (meter + volume + mute) appears only while a
|
// ─── visible; the game bar (meter + volume + mute) appears only while a
|
||||||
// ─── full-screen game is producing sound. Both meters preview live. ───
|
// ─── full-screen game is producing sound. Both meters preview live. ───
|
||||||
@@ -1062,32 +839,8 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
|
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
|
||||||
|
|
||||||
/// <summary>Tooltip for the account status light explaining the color.</summary>
|
|
||||||
public string AccountStatusLightToolTip =>
|
|
||||||
IsLive ? "Live on YouTube — the top bar is red to show the stream is on the air."
|
|
||||||
: IsConnected ? "You're signed in to YouTube. The light is green; ON-AIR is available."
|
|
||||||
: "Signed out — the light is red and the ON-AIR pill is greyed out. You can still record locally.";
|
|
||||||
public bool ShowChatInactiveMessage => !IsLive;
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
|
|
||||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
||||||
public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
|
|
||||||
|
|
||||||
public bool IsCountdownAvailable => StagedScene != null
|
|
||||||
&& (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
|
|
||||||
|
|
||||||
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
|
|
||||||
/// "+" button may re-add. Work with less, never more.</summary>
|
|
||||||
public IEnumerable<string> MissingScenes
|
|
||||||
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
|
|
||||||
|
|
||||||
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
|
|
||||||
public bool ShowAddScene => MissingScenes.Any();
|
|
||||||
|
|
||||||
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
|
|
||||||
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
|
|
||||||
// the Live screen's Background. Static-art screens never expose them.
|
|
||||||
public bool CanChangeBackground =>
|
|
||||||
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
|
|
||||||
|
|
||||||
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
||||||
// cadence timer is stopped and any active flash is hidden immediately.
|
// cadence timer is stopped and any active flash is hidden immediately.
|
||||||
@@ -1550,39 +1303,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
set => SetProperty(ref _featureRequestEmail, value);
|
set => SetProperty(ref _featureRequestEmail, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TASK 10 — license key entry
|
|
||||||
public string LicenseKey
|
|
||||||
{
|
|
||||||
get => _licenseKey;
|
|
||||||
set => SetProperty(ref _licenseKey, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string LicenseMessage
|
|
||||||
{
|
|
||||||
get => _licenseMessage;
|
|
||||||
private set => SetProperty(ref _licenseMessage, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsLicenseValidating
|
|
||||||
{
|
|
||||||
get => _isLicenseValidating;
|
|
||||||
private set => SetProperty(ref _isLicenseValidating, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsLicenseEntryOpen
|
|
||||||
{
|
|
||||||
get => _isLicenseEntryOpen;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _isLicenseEntryOpen, value))
|
|
||||||
OnPropertyChanged(nameof(IsHubVisible));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The About hub is visible only when neither the licenses panel
|
|
||||||
/// nor the license entry panel is open.</summary>
|
|
||||||
public bool IsHubVisible => !IsLicensesOpen && !IsLicenseEntryOpen;
|
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
public ICommand AddSceneCommand { get; }
|
public ICommand AddSceneCommand { get; }
|
||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
@@ -1789,147 +1509,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
AppLog.Write("MainViewModel ctor end");
|
AppLog.Write("MainViewModel ctor end");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
|
|
||||||
// Best-effort: refresh a near-expiry access token; a session that can no
|
|
||||||
// longer refresh is discarded and the user signs in again.
|
|
||||||
private async Task LoadSavedSessionAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var saved = TokenStore.Load();
|
|
||||||
if (saved == null) return;
|
|
||||||
|
|
||||||
_youtubeAuth.SetSession(saved);
|
|
||||||
if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
|
||||||
{
|
|
||||||
var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
|
|
||||||
if (!refreshed)
|
|
||||||
{
|
|
||||||
TokenStore.Clear();
|
|
||||||
AppLog.Write("Saved session could not be refreshed; signed out");
|
|
||||||
_notifications.Info("Signed out of YouTube",
|
|
||||||
"The saved session expired — sign in again when you next go live.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IsConnected = true;
|
|
||||||
SyncConnectedAccount();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SyncConnectedAccount()
|
|
||||||
{
|
|
||||||
var channel = _youtubeAuth.CurrentChannel;
|
|
||||||
AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty;
|
|
||||||
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── TASK 10 — Polar license validation ───
|
|
||||||
|
|
||||||
/// <summary>At startup, load the cached license state and re-validate online
|
|
||||||
/// if a key exists. If offline and within the 14-day grace period, the cached
|
|
||||||
/// premium state is trusted. If the grace period is exceeded, premium lapses.</summary>
|
|
||||||
private async Task ValidateLicenseAtStartupAsync()
|
|
||||||
{
|
|
||||||
var savedKey = _layoutStore.LoadLicenseKey();
|
|
||||||
if (string.IsNullOrWhiteSpace(savedKey))
|
|
||||||
{
|
|
||||||
IsPremium = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LicenseKey = savedKey;
|
|
||||||
var validatedAt = _layoutStore.LoadLicenseValidatedAt();
|
|
||||||
var cachedPremium = _layoutStore.LoadIsPremium();
|
|
||||||
|
|
||||||
// If the last validation was within the grace period, trust the cache
|
|
||||||
// while we attempt an online re-validation in the background.
|
|
||||||
if (validatedAt.HasValue && DateTime.UtcNow - validatedAt.Value < OfflineGracePeriod)
|
|
||||||
{
|
|
||||||
IsPremium = cachedPremium;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try an online check — if it succeeds, it overrides the cache.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var service = new PolarLicenseService();
|
|
||||||
var result = await service.ValidateAsync(savedKey);
|
|
||||||
if (result.IsValid)
|
|
||||||
{
|
|
||||||
IsPremium = true;
|
|
||||||
_layoutStore.SaveIsPremium(true);
|
|
||||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Key revoked or expired — clear premium.
|
|
||||||
IsPremium = false;
|
|
||||||
_layoutStore.SaveIsPremium(false);
|
|
||||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
|
||||||
AppLog.Write($"License validation failed: {result.Error}");
|
|
||||||
_notifications.Warning("Premium is no longer active",
|
|
||||||
$"{result.Error} — watermark branding returns until the license is renewed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Offline or network error — keep the cached state, log it.
|
|
||||||
AppLog.Write($"License re-validation skipped (offline?): {ex.Message}");
|
|
||||||
if (!validatedAt.HasValue || DateTime.UtcNow - validatedAt.Value >= OfflineGracePeriod)
|
|
||||||
{
|
|
||||||
IsPremium = false;
|
|
||||||
_layoutStore.SaveIsPremium(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Called from the Activate button in the About overlay. Validates
|
|
||||||
/// the entered key, persists it, and flips premium state.</summary>
|
|
||||||
private async Task ActivateLicenseAsync()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(LicenseKey)) return;
|
|
||||||
|
|
||||||
IsLicenseValidating = true;
|
|
||||||
LicenseMessage = "Validating\u2026";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var service = new PolarLicenseService();
|
|
||||||
var result = await service.ValidateAsync(LicenseKey);
|
|
||||||
if (result.IsValid)
|
|
||||||
{
|
|
||||||
IsPremium = true;
|
|
||||||
_layoutStore.SaveLicenseKey(LicenseKey.Trim());
|
|
||||||
_layoutStore.SaveIsPremium(true);
|
|
||||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
|
||||||
LicenseMessage = "Premium activated! The watermark has been removed.";
|
|
||||||
AppLog.Write("License activated successfully");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
IsPremium = false;
|
|
||||||
_layoutStore.SaveLicenseKey(null);
|
|
||||||
_layoutStore.SaveIsPremium(false);
|
|
||||||
LicenseMessage = result.Error ?? "Invalid license key.";
|
|
||||||
AppLog.Write($"License activation failed: {result.Error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
LicenseMessage = $"Activation error: {ex.Message}";
|
|
||||||
AppLog.Write($"License activation error: {ex}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsLicenseValidating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string DefaultLayoutPath => Path.Combine(
|
private static string DefaultLayoutPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"ytLlive",
|
"ytLlive",
|
||||||
@@ -2427,60 +2006,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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);
|
|
||||||
OnPropertyChanged(nameof(MissingScenes));
|
|
||||||
OnPropertyChanged(nameof(ShowAddScene));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WireScene(Scene scene)
|
|
||||||
{
|
|
||||||
scene.PropertyChanged += OnScenePropertyChanged;
|
|
||||||
scene.Elements.CollectionChanged += OnElementsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UnwireScene(Scene scene)
|
|
||||||
{
|
|
||||||
scene.PropertyChanged -= OnScenePropertyChanged;
|
|
||||||
scene.Elements.CollectionChanged -= OnElementsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
// A rename can drop a canonical scene in/out of MissingScenes.
|
|
||||||
OnPropertyChanged(nameof(MissingScenes));
|
|
||||||
OnPropertyChanged(nameof(ShowAddScene));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.NewItems != null)
|
|
||||||
foreach (SceneElement element in e.NewItems)
|
|
||||||
element.PropertyChanged += OnElementPropertyChanged;
|
|
||||||
if (e.OldItems != null)
|
|
||||||
foreach (SceneElement element in e.OldItems)
|
|
||||||
element.PropertyChanged -= OnElementPropertyChanged;
|
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
|
||||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
|
||||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ScheduleSave()
|
private void ScheduleSave()
|
||||||
{
|
{
|
||||||
if (_isLoading || _saveDebounce == null) return;
|
if (_isLoading || _saveDebounce == null) return;
|
||||||
@@ -2488,23 +2013,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_saveDebounce.Start();
|
_saveDebounce.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds a canonical scene by name — only when it's actually missing. The
|
|
||||||
// background flag follows the policy (Live yes, everyone else no).
|
|
||||||
private void AddScene(string name)
|
|
||||||
{
|
|
||||||
if (!SceneCatalog.IsCanonical(name)) return;
|
|
||||||
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
|
|
||||||
var scene = new Scene
|
|
||||||
{
|
|
||||||
Name = name.Trim(),
|
|
||||||
IsChatScene = SceneCatalog.IsChat(name),
|
|
||||||
HasBackground = SceneCatalog.HasBackground(name),
|
|
||||||
};
|
|
||||||
EnsureBackground(scene);
|
|
||||||
Scenes.Add(scene);
|
|
||||||
StagedScene = scene;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Background model (TASK 25): exactly ONE Background per screen, index 0,
|
// ─── Background model (TASK 25): exactly ONE Background per screen, index 0,
|
||||||
// never removable or re-addable. Static default art on Starting/BRB/Chat/Ending;
|
// never removable or re-addable. Static default art on Starting/BRB/Chat/Ending;
|
||||||
// on Live it's the capture-flavored row (Show Desktop / monitor switch /
|
// on Live it's the capture-flavored row (Show Desktop / monitor switch /
|
||||||
@@ -2997,31 +2505,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs the OAuth flow (browser + loopback callback) on a background thread.
|
|
||||||
// The resulting channel is returned to the caller and persisted via the
|
|
||||||
// auth service's sessionChanged hook.
|
|
||||||
private async Task<YouTubeChannel?> SignInAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
|
|
||||||
if (channel == null)
|
|
||||||
{
|
|
||||||
_notifications.Warning("Sign-in was unsuccessful", "Please try again.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
IsConnected = true;
|
|
||||||
SyncConnectedAccount();
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_notifications.Error("Sign-in failed", ex.Message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnChatMessageReceived(ChatMessage message)
|
private void OnChatMessageReceived(ChatMessage message)
|
||||||
{
|
{
|
||||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||||
@@ -3880,21 +3363,4 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
? SocialBarPosition.Bottom
|
? SocialBarPosition.Bottom
|
||||||
: SocialBarPosition.Top);
|
: SocialBarPosition.Top);
|
||||||
|
|
||||||
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
|
|
||||||
private async Task SignOutYouTubeAsync()
|
|
||||||
{
|
|
||||||
_youtubeAuth.ClearSession();
|
|
||||||
TokenStore.Clear();
|
|
||||||
IsConnected = false;
|
|
||||||
SyncConnectedAccount();
|
|
||||||
NotifySocialsChanged();
|
|
||||||
AppLog.Write("Socials: signed out of YouTube");
|
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ChangeAccountAsync()
|
|
||||||
{
|
|
||||||
await SignOutYouTubeAsync();
|
|
||||||
await SignInAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+21
-3
@@ -18,13 +18,31 @@ for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Hel
|
|||||||
## MainViewModel partial split (in progress)
|
## MainViewModel partial split (in progress)
|
||||||
|
|
||||||
`MainViewModel.cs` is being split into one file per functional area, zero behavior
|
`MainViewModel.cs` is being split into one file per functional area, zero behavior
|
||||||
change, one commit per partial (each commit a roll-back point):
|
change, one commit per partial (each commit a roll-back point). **Commit naming:**
|
||||||
|
titles carry the series id (`Commit N/12`), tags are `refactor-commit-N` — e.g.
|
||||||
|
`git show refactor-commit-1`. Every partial lands with a `ViewModels/index.md`
|
||||||
|
update in the SAME commit.
|
||||||
|
|
||||||
|
Progress (Commit N/12 · tag):
|
||||||
|
- **Commit 1/12 · `refactor-commit-1`: Account landed** → `MainViewModel.Account.cs`
|
||||||
|
(session restore `LoadSavedSessionAsync`, sign-in/out/change account,
|
||||||
|
`IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip`).
|
||||||
|
- **Commit 2/12 · `refactor-commit-2`: License landed** → `MainViewModel.License.cs`
|
||||||
|
(Polar license state: `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/
|
||||||
|
`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`,
|
||||||
|
`ValidateLicenseAtStartupAsync`, `ActivateLicenseAsync`).
|
||||||
|
- **Commit 3/12 · `refactor-commit-3`: Scenes landed** → `MainViewModel.Scenes.cs`
|
||||||
|
(`Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, transitions
|
||||||
|
`SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/
|
||||||
|
`ApplyTransitionToLive`, global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/
|
||||||
|
`RefreshHotkeyLabels`, scene-gating hints, `MissingScenes`/`ShowAddScene`/
|
||||||
|
`CanChangeBackground`, scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`,
|
||||||
|
`AddScene`).
|
||||||
|
|
||||||
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
||||||
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
||||||
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
||||||
- Partials: `Account` (sign-in/session/avatar), `License` (Polar key validation + `IsPremium`),
|
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` (background model + screen
|
||||||
`Scenes` (scene catalog + hotkeys + transitions), `Background` (background model + screen
|
|
||||||
capture + display picker), `Webcam` (webcam identity + per-scene configs + captures),
|
capture + display picker), `Webcam` (webcam identity + per-scene configs + captures),
|
||||||
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
||||||
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
||||||
|
|||||||
Reference in New Issue
Block a user