diff --git a/ViewModels/MainViewModel.Account.cs b/ViewModels/MainViewModel.Account.cs new file mode 100644 index 0000000..35d2e10 --- /dev/null +++ b/ViewModels/MainViewModel.Account.cs @@ -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)); + } + } + } + + /// The connected YouTube account's avatar + name — shown in the top + /// bar so the creator always sees WHICH account is about to go live. + public string AccountAvatarUrl + { + get => _accountAvatarUrl; + private set => SetProperty(ref _accountAvatarUrl, value); + } + + public string AccountDisplayName + { + get => _accountDisplayName; + private set => SetProperty(ref _accountDisplayName, value); + } + + /// Tooltip for the account status light explaining the color. + 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 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; + } + } + + /// Signs out of YouTube (the delete-the-YouTube-slot action in the dialog). + 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(); + } +} \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index bb57eae..cd0ba3c 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -35,9 +35,6 @@ public partial class MainViewModel : ViewModelBase private Scene? _liveScene; private SceneElement? _selectedElement; private ImageSource? _activeBackgroundImage; - private bool _isConnected; - private string _accountAvatarUrl = string.Empty; - private string _accountDisplayName = string.Empty; private double _audioLevel; private bool _volumeAdjusting; private bool _volumeFlash; @@ -535,37 +532,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)); - } - } - } - - /// The connected YouTube account's avatar + name — shown in the top - /// bar so the creator always sees WHICH account is about to go live. - 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 // ─── visible; the game bar (meter + volume + mute) appears only while a // ─── full-screen game is producing sound. Both meters preview live. ─── @@ -1062,11 +1028,6 @@ public partial class MainViewModel : ViewModelBase public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording; - /// Tooltip for the account status light explaining the color. - 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 ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null; @@ -1789,46 +1750,6 @@ public partial class MainViewModel : ViewModelBase 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 ─── /// At startup, load the cached license state and re-validate online @@ -2997,31 +2918,6 @@ public partial class MainViewModel : ViewModelBase 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 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) { System.Windows.Application.Current.Dispatcher.Invoke(() => @@ -3880,21 +3776,4 @@ public partial class MainViewModel : ViewModelBase ? SocialBarPosition.Bottom : SocialBarPosition.Top); - /// Signs out of YouTube (the delete-the-YouTube-slot action in the dialog). - 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(); - } -} diff --git a/ViewModels/index.md b/ViewModels/index.md index 7b024a5..5f9408e 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -18,12 +18,20 @@ for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Hel ## MainViewModel partial split (in progress) `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`). - Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`, `SubmitBug`/`SubmitFeature`, window-geometry pass-through. -- Partials: `Account` (sign-in/session/avatar), `License` (Polar key validation + `IsPremium`), +- Partials: `Account` ✔ (Commit 1), `License` (Polar key validation + `IsPremium`), `Scenes` (scene catalog + hotkeys + transitions), `Background` (background model + screen capture + display picker), `Webcam` (webcam identity + per-scene configs + captures), `Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),