From 2f5d0c1759d47b3e9f3fe3b302a9cf0999dc6ab5 Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 11 Nov 2026 10:06:54 -0800 Subject: [PATCH] feat(settings): position slide-out notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App Settings now chooses one of six ToastArea positions (left/right × top/center/bottom), defaulting to the existing bottom-right. The setting is persisted per profile and the toast area moves immediately; edge gutters keep bottom notifications above the footer. ToastPositionIntegrationTests covers live repositioning and restart persistence. 617 tests green, clean build with 0 warnings. Reference scan: OBS Notifier supports user-configured notification placement: https://github.com/DmitriySalnikov/OBSNotifier --- Controls/SettingsDrawer.xaml | 16 ++++- Controls/index.md | 2 +- MainWindow.xaml | 6 +- Services/LayoutStore.Settings.cs | 20 ++++++ Services/NotificationService.cs | 4 +- Services/index.md | 2 +- ViewModels/MainViewModel.Settings.cs | 30 +++++++++ ViewModels/MainViewModel.cs | 1 + ViewModels/index.md | 1 + docs/ai/ui-surface.md | 7 ++- ytLive.Tests/ToastPositionIntegrationTests.cs | 61 +++++++++++++++++++ 11 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 ytLive.Tests/ToastPositionIntegrationTests.cs diff --git a/Controls/SettingsDrawer.xaml b/Controls/SettingsDrawer.xaml index 3451b01..2b68291 100644 --- a/Controls/SettingsDrawer.xaml +++ b/Controls/SettingsDrawer.xaml @@ -2,7 +2,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:models="clr-namespace:ytLive.Models" - xmlns:services="clr-namespace:ytLive.Services"> + xmlns:services="clr-namespace:ytLive.Services" + xmlns:notification="clr-namespace:Notification.Core;assembly=Notification.Core"> diff --git a/Controls/index.md b/Controls/index.md index f49e637..3f55c51 100644 --- a/Controls/index.md +++ b/Controls/index.md @@ -13,7 +13,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions. | `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. | | `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border | | `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard | -| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, brand-logo, preview, transition, and staged/all-scene layout-default controls. `BackToAbout_Click` lives here | +| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, slide-out alert placement, brand-logo, preview, transition, and staged/all-scene layout-default controls. `BackToAbout_Click` lives here | | `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above | | `LeftPanel.xaml.cs` | Code-behind for the panel above | diff --git a/MainWindow.xaml b/MainWindow.xaml index 2482785..fdbedac 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -170,10 +170,10 @@ - + + Position="{Binding ToastPosition}" MaxItems="4" + Margin="{Binding ToastAreaMargin}"/> diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index 767eec3..8795bfc 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; +using Notification.Core; using ytLive.Services.Compositor; using ytLive.Models; @@ -6,6 +7,25 @@ namespace ytLive.Services; public partial class LayoutStore : IDisposable { + public NotificationPosition LoadNotificationPosition() + { + var value = GetSetting("Notifications.Position"); + return Enum.TryParse(value, true, out NotificationPosition position) && IsSupportedNotificationPosition(position) + ? position + : NotificationPosition.BottomRight; + } + + public void SaveNotificationPosition(NotificationPosition position) + { + if (!IsSupportedNotificationPosition(position)) position = NotificationPosition.BottomRight; + UpsertSetting("Notifications.Position", position.ToString()); + } + + private static bool IsSupportedNotificationPosition(NotificationPosition position) + => position is NotificationPosition.TopLeft or NotificationPosition.TopRight + or NotificationPosition.CenterLeft or NotificationPosition.CenterRight + or NotificationPosition.BottomLeft or NotificationPosition.BottomRight; + /// The last picked mic source name, or null if never chosen. Restored /// at startup so the sound bar reconnects to the same, already-vetted device. public string? LoadMicSourceName() diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs index d6a91ac..23a5eca 100644 --- a/Services/NotificationService.cs +++ b/Services/NotificationService.cs @@ -8,8 +8,8 @@ using ytLive.Helpers; namespace ytLive.Services; /// -/// Toast notifications rendered inside the main window's ToastArea -/// (bottom-right, above the footer). Info/success auto-dismiss after ~4s, +/// Toast notifications rendered inside the main window's ToastArea, whose +/// position is configurable in App Settings (bottom-right by default). Info/success auto-dismiss after ~4s, /// warnings ~8s, errors stay until dismissed. Calls from any thread are /// marshalled to the captured dispatcher. /// diff --git a/Services/index.md b/Services/index.md index 059ab30..77543ef 100644 --- a/Services/index.md +++ b/Services/index.md @@ -62,7 +62,7 @@ External-facing logic: YouTube API, persistence. See | `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47 + scene ticker:** the `alertTicker: Func?` seam selects the active scene's AlertBox ticker, threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`**; standalone tickers are resolved as per-element dynamic sources. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change | | `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 | | `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread | -| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" | +| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (position configurable in App Settings; bottom-right default); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" | | `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes | | `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels | | `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it | diff --git a/ViewModels/MainViewModel.Settings.cs b/ViewModels/MainViewModel.Settings.cs index 2b68d26..c39f9c3 100644 --- a/ViewModels/MainViewModel.Settings.cs +++ b/ViewModels/MainViewModel.Settings.cs @@ -1,3 +1,6 @@ +using System.Windows; +using Notification.Core; + namespace ytLive.ViewModels; /// @@ -10,6 +13,33 @@ namespace ytLive.ViewModels; public partial class MainViewModel { private bool _settingsDrawerOpen; + private NotificationPosition _toastPosition = NotificationPosition.BottomRight; + + public NotificationPosition ToastPosition + { + get => _toastPosition; + set + { + if (!IsSupportedToastPosition(value) || !SetProperty(ref _toastPosition, value)) return; + _layoutStore.SaveNotificationPosition(value); + OnPropertyChanged(nameof(ToastAreaMargin)); + } + } + + public Thickness ToastAreaMargin => ToastPosition switch + { + NotificationPosition.TopLeft => new Thickness(12, 12, 0, 0), + NotificationPosition.TopRight => new Thickness(0, 12, 12, 0), + NotificationPosition.CenterLeft => new Thickness(12, 0, 0, 0), + NotificationPosition.CenterRight => new Thickness(0, 0, 12, 0), + NotificationPosition.BottomLeft => new Thickness(12, 0, 0, 96), + _ => new Thickness(0, 0, 12, 96), + }; + + private static bool IsSupportedToastPosition(NotificationPosition position) + => position is NotificationPosition.TopLeft or NotificationPosition.TopRight + or NotificationPosition.CenterLeft or NotificationPosition.CenterRight + or NotificationPosition.BottomLeft or NotificationPosition.BottomRight; public bool SettingsDrawerOpen { diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index a024a93..e429343 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -343,6 +343,7 @@ public partial class MainViewModel : ViewModelBase _layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath); _activeLayoutPath = _layoutStore.ActivePath; + _toastPosition = _layoutStore.LoadNotificationPosition(); _recordFolder = _layoutStore.LoadRecordFolder(); // Load saved hotkey bindings (filled with defaults for any unbound actions). diff --git a/ViewModels/index.md b/ViewModels/index.md index 0085544..74b773a 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -20,6 +20,7 @@ areas' docs in their own rows keeps this index small to read. | `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), `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.Settings.cs` | App Settings drawer state + global slide-out toast position (`ToastPosition`), persisted in `LayoutStore.Settings`; `ToastAreaMargin` keeps the six supported positions aligned to the window edges/safe footer gap. | | `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. | | `MainViewModel.Logs.cs` | Non-edit left-panel tab state and bounded current-launch log collections; refreshes the process-local AppLog snapshot while Logs is selected, pins Class 1 before Class 2 until acknowledged, then returns entries to the LIFO timeline; stops its dispatcher timer on shutdown. | | `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 | diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 0b968dd..c365be9 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -159,7 +159,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB **Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer. - **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Entries wrap and use the theme's type colors. Explicit Class 1/2 severity is independent of message type/source: Class 1 sorts above Class 2 in the pinned group; acknowledging unpins the entry but retains it in the chronological list. The staged scene is the preview/edit target; `LiveScene` is the program output. -- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. TASK 37 adds **Restore staged scene** and **Restore all scenes** under Layout defaults; both are disabled while live or recording, and restoring a layout preserves the selected webcam. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer. +- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. TASK 37 adds **Restore staged scene** and **Restore all scenes** under Layout defaults; both are disabled while live or recording, and restoring a layout preserves the selected webcam. App Settings also controls slide-out toast placement (six left/right × top/center/bottom positions; bottom-right default). The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer. - **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its background uses the idle surface color when stopped. Normal ON-AIR stays `Brush.Primary` red while connecting and turns `Brush.Positive` green only after YouTube reports the broadcast `live`; stopping restores the idle color. TEST stays gold for the entire test session. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings. - **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode. - **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`. @@ -204,8 +204,9 @@ extension methods; `MainViewModel` news up the real `NotificationService` at its MIT — notice #10 in `THIRD-PARTY-NOTICES.txt`). Facts that cost a hunt: - The `NotificationArea` XAML control registers itself with the manager on `Loaded`; routing matches the area's `Name` against the request's `AreaName` — our host is `x:Name="ToastArea"` in MainWindow's - root grid (last child = topmost z-order, `Grid.RowSpan=4`, bottom-right above the footer via - `Margin="0,0,12,96"`, outside the preview Viewbox). An unknown AreaName silently drops the toast. + root grid (last child = topmost z-order, `Grid.RowSpan=4`, global App Settings chooses one of six + left/right × top/center/bottom positions; bottom placements leave a 96px footer gap). An unknown + AreaName silently drops the toast. - `NeverExpires()` sets `ExpirationTime = TimeSpan.MaxValue` (NOT null); `NotificationColor.ToHex()` returns `#AARRGGBB` (alpha prefix). - Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping diff --git a/ytLive.Tests/ToastPositionIntegrationTests.cs b/ytLive.Tests/ToastPositionIntegrationTests.cs new file mode 100644 index 0000000..5a01117 --- /dev/null +++ b/ytLive.Tests/ToastPositionIntegrationTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using Microsoft.Data.Sqlite; +using Notification.Core; +using Notification.Wpf.Controls; +using Xunit; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +[Collection("RealApp")] +public sealed class ToastPositionIntegrationTests +{ + private readonly RealAppHost _app; + + public ToastPositionIntegrationTests(RealAppHost app) => _app = app; + + [Fact] + public void GlobalPosition_UpdatesToastArea_AndPersistsAcrossRestart() => _app.Run(() => + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-toast-position-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + MainWindow? window = null; + try + { + window = new MainWindow(); + window.Show(); + var vm = (MainViewModel)window.DataContext; + var area = Assert.IsType(window.FindName("ToastArea")); + + Assert.Equal(NotificationPosition.BottomRight, vm.ToastPosition); + Assert.Equal(NotificationPosition.BottomRight, area.Position); + + vm.ToastPosition = NotificationPosition.CenterLeft; + Assert.Equal(NotificationPosition.CenterLeft, area.Position); + + window.Close(); + window = null; + SqliteConnection.ClearAllPools(); + + window = new MainWindow(); + window.Show(); + var restoredVm = (MainViewModel)window.DataContext; + var restoredArea = Assert.IsType(window.FindName("ToastArea")); + Assert.Equal(NotificationPosition.CenterLeft, restoredVm.ToastPosition); + Assert.Equal(NotificationPosition.CenterLeft, restoredArea.Position); + + var settings = SourceAssert.Text("Controls", "SettingsDrawer.xaml"); + Assert.Contains("Text=\"Slide-out alerts\"", settings); + Assert.Contains("Content=\"Center left\"", settings); + Assert.Contains("Content=\"Bottom right\"", settings); + } + finally + { + window?.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + }); +}