OAuth session persists via DPAPI; sign-in lives in the Start Stream dialog; add xUnit test project (6 passing)
This commit is contained in:
@@ -1,13 +1,31 @@
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public class GoLiveViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||
private string _streamTitle = string.Empty;
|
||||
private string _streamDescription = string.Empty;
|
||||
private string _visibility = "Public";
|
||||
private bool _isSignedIn;
|
||||
private string _accountDisplayName = string.Empty;
|
||||
private string _accountAvatarUrl = string.Empty;
|
||||
private bool _isBusy;
|
||||
|
||||
public GoLiveViewModel(Func<Task<YouTubeChannel?>> signInProvider, YouTubeChannel? account)
|
||||
{
|
||||
_signInProvider = signInProvider;
|
||||
|
||||
SignInCommand = new RelayCommand(_ => _ = SignInAsync());
|
||||
StartCommand = new RelayCommand(_ => StartRequested?.Invoke(), _ => IsSignedIn && !IsBusy);
|
||||
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke(), _ => !IsBusy);
|
||||
|
||||
if (account != null)
|
||||
ApplyAccount(account);
|
||||
}
|
||||
|
||||
public string StreamTitle
|
||||
{
|
||||
@@ -29,15 +47,65 @@ public class GoLiveViewModel : ViewModelBase
|
||||
|
||||
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
||||
|
||||
public bool IsSignedIn
|
||||
{
|
||||
get => _isSignedIn;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isSignedIn, value))
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
public string AccountDisplayName
|
||||
{
|
||||
get => _accountDisplayName;
|
||||
private set => SetProperty(ref _accountDisplayName, value);
|
||||
}
|
||||
|
||||
public string AccountAvatarUrl
|
||||
{
|
||||
get => _accountAvatarUrl;
|
||||
private set => SetProperty(ref _accountAvatarUrl, value);
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value))
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand SignInCommand { get; }
|
||||
public ICommand StartCommand { get; }
|
||||
public ICommand CancelCommand { get; }
|
||||
|
||||
public GoLiveViewModel()
|
||||
{
|
||||
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
|
||||
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||
}
|
||||
|
||||
public event Action? StartRequested;
|
||||
public event Action? CancelRequested;
|
||||
|
||||
private async Task SignInAsync()
|
||||
{
|
||||
if (IsBusy) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var channel = await _signInProvider();
|
||||
if (channel != null)
|
||||
ApplyAccount(channel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAccount(YouTubeChannel channel)
|
||||
{
|
||||
AccountDisplayName = channel.DisplayName;
|
||||
AccountAvatarUrl = channel.ProfileImageUrl;
|
||||
IsSignedIn = true;
|
||||
}
|
||||
}
|
||||
|
||||
+42
-11
@@ -126,18 +126,14 @@ public class MainViewModel : ViewModelBase
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isConnected, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsNotConnected));
|
||||
OnPropertyChanged(nameof(ShowStartStream));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||
public bool LiveIndicatorVisible => IsLive;
|
||||
public bool IsNotConnected => !IsConnected;
|
||||
public bool ShowStartStream => IsConnected && IsOffline;
|
||||
public bool ShowStartStream => IsOffline;
|
||||
public bool ShowChatInactiveMessage => !IsLive;
|
||||
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
|
||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
|
||||
@@ -406,7 +402,6 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand AddSourceCommand { get; }
|
||||
public ICommand AddImageCommand { get; }
|
||||
public ICommand RemoveSourceCommand { get; }
|
||||
public ICommand ConnectCommand { get; }
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand OpenSettingsCommand { get; }
|
||||
@@ -424,7 +419,8 @@ public class MainViewModel : ViewModelBase
|
||||
public MainViewModel()
|
||||
{
|
||||
AppLog.Write("MainViewModel ctor begin");
|
||||
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret);
|
||||
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
|
||||
sessionChanged: ch => TokenStore.Save(ch));
|
||||
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
|
||||
_youtubeChat = new YouTubeChatService(_youtubeAuth);
|
||||
AppLog.Write("MainViewModel ctor: services created");
|
||||
@@ -462,7 +458,6 @@ public class MainViewModel : ViewModelBase
|
||||
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
|
||||
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
|
||||
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
|
||||
ConnectCommand = new RelayCommand(_ => _ = ConnectAsync());
|
||||
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
|
||||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
|
||||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||
@@ -472,9 +467,40 @@ public class MainViewModel : ViewModelBase
|
||||
_layoutStore = new LayoutStore(DefaultLayoutPath);
|
||||
_activeLayoutPath = _layoutStore.ActivePath;
|
||||
LoadLayout();
|
||||
_ = LoadSavedSessionAsync();
|
||||
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");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
IsConnected = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DefaultLayoutPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
@@ -870,7 +896,10 @@ public class MainViewModel : ViewModelBase
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
// 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
|
||||
{
|
||||
@@ -879,15 +908,17 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
IsConnected = true;
|
||||
return channel;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,7 +934,7 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
private void BeginGoLive()
|
||||
{
|
||||
var dialog = new GoLiveViewModel
|
||||
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
||||
{
|
||||
StreamTitle = DefaultStreamTitle,
|
||||
StreamDescription = DefaultStreamDescription,
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`) |
|
||||
| `GoLiveViewModel.cs` | Start Stream dialog: title/description/visibility, start/cancel requests |
|
||||
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog |
|
||||
| `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/visibility, start/cancel requests |
|
||||
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
||||
|
||||
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
||||
|
||||
Reference in New Issue
Block a user