feat(ui): route locked tiers through About

Keep non-owner resolution choices visible but muted. Selecting one preserves the free tier and opens About; external navigation is only the explicit llamacasty.com link. Ref: https://streamlabs.com/desktop
This commit is contained in:
2026-11-02 09:41:38 -08:00
parent ff5e8d94ae
commit 6208cb9409
16 changed files with 149 additions and 84 deletions
+9 -3
View File
@@ -25,12 +25,11 @@ public partial class MainViewModel : ViewModelBase
// 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 PremiumUrl = "https://llamacasty.com";
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>
/// <summary>The About hub's optional product-information link.</summary>
public bool IsPremiumAvailable => PremiumUrl.Length > 0;
/// <summary>Full in-app licensing/legal text, loaded on demand from the
@@ -176,6 +175,13 @@ public partial class MainViewModel : ViewModelBase
IsLicensesOpen = false;
}
private void ShowAbout()
{
IsLicensesOpen = false;
IsLicenseEntryOpen = false;
ShowOverlay(nameof(IsAboutOpen), "About");
}
/// <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
+7
View File
@@ -432,6 +432,13 @@ public partial class MainViewModel : ViewModelBase
get => _selectedQuality;
set
{
if (!HasProOutput && value.RequiresPro)
{
OnPropertyChanged(nameof(SelectedQuality));
OpenAboutCommand.Execute(null);
return;
}
if (SetProperty(ref _selectedQuality, value))
ApplyStreamQuality(value);
}
+2 -2
View File
@@ -199,7 +199,7 @@ public partial class MainViewModel : ViewModelBase
_youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created");
_selectedQuality = QualityOptions[0];
_selectedQuality = OutputProfile.Resolve(QualityOptions[0], QualityOptions, HasProOutput);
ApplyStreamQuality(_selectedQuality);
_youtubeChat.MessageReceived += OnChatMessageReceived;
@@ -274,7 +274,7 @@ public partial class MainViewModel : ViewModelBase
TestAlertCommand = new RelayCommand(_ => TestAlert());
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
OpenAboutCommand = new RelayCommand(_ => ShowAbout());
CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
+2 -2
View File
@@ -19,7 +19,7 @@ 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.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:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, 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; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
| `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.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), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. 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, then feed **both** component layers), 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`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.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 |
| **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. |
@@ -75,4 +75,4 @@ commit, tag `refactor-commit-N`, no per-commit push).
- [x] **Commit F · `refactor-commit-F`** — **Recording (context/concern)** → new **`MainViewModel.Recording.cs`** (121 lines). The encoder (`Services/Encoder/FfmpegEncoder.cs`) and frame pump (`Services/Encoder/FramePump.cs`) were ALREADY extracted services — the planned "extract FFmpegEncoder/StreamHealthMonitor/FramePump" was a no-op premise. The honest functional seam was the local recording-output concern: `StartRecordFile`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder` + fields `_recordFolder`/`RecordFolderDisplay`/`_activeRecordPath`/`_recordStartTime`/`_recordLength` moved out of the live-stream orchestration. `MainViewModel.Streaming.Operations.cs` 475 → **377**; `MainViewModel.Streaming.cs` 328 → **323**. Same partial class, MVVM glue untouched.
- [x] **Commit G · `refactor-commit-G`** — **ChatOverlayLayer (true component)** → new **`Services/ChatOverlayLayer.cs`** (199 lines). The first REAL decomposition of the 4,105-line god-object: `MainViewModel.Chat.cs` 194 → **44** (thin delegation). The layer owns all chat behavior — `Messages` buffer, `ChatBoxRenderer`, fade timer, mock-preview timer, per-source preview renders, live-output `RenderFrame`. The VM keeps only the binding surface: `ChatMessages` (delegates to `_chatLayer.Messages`, so XAML bind + LeftPanel `CollectionChanged` hold), `CanAddYouTubeChat`/`ShowChatInactiveMessage` (computed + `OnPropertyChanged` from VM setters), and thin forwards. Scenes are handed in as args (no `Func` seam) so the layer stays scene-graph-free. Zero behavior change; 246 pass / 2 known.
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.