Scene strip tile artwork + on-air indicator (creator ruling 2026-10-10). Top-bar tiles now carry their own art: each canonical scene's tile is a per-scene CRT-styled image stored in the internal DB (Scene.ThumbnailAssetId - additive migration, seeded at heal from the creator's five Downloads JPGs via SceneThumbnailSeedPaths), and the preview pane keeps rendering the scene's ORIGINAL background - never the tiles. One shared on-air image (Gemini 4zajyl...jpg) replaces the staged scene's tile and follows the scene swap ('the on-air screen jumps around the top bar as the user swaps scenes'): stored as an Asset BLOB tracked by the OnAirImageAssetId Setting (EnsureOnAirImage, same contract as the alert clip) with MainViewModel.OnAirSeedPath override; RefreshSnapshotsAsync shows it for the staged scene, otherwise thumbnail-or-background art, and a staged Live with no on-air art still falls back to the green placeholder (minis never render live captures). Removed the 6px #22c55e rectangle in PreviewPane's backdrop ('green outline around the preview screen' that surfaced when a scene had no background) - the outer PreviewGlow red border is the real live signal. Doubled the scene-tile labels (FontSize 10 -> 20). Fixed a real bug this surfaced: Save()'s Asset pruner only kept Source/alert-clip rows, so the first save would have DELETED the seeded tile + on-air assets - the keep-set now includes Scene.ThumbnailAssetId and the OnAirImageAssetId Setting. New Settings/Load/Save migrations + SceneThumbnailTests (4 tests: seeded tile in strip, preview keeps original bg, Live-staged tile, no-seed fallback, on-air swap + persistence). 427 pass, 0 build warnings. (Unit: strip tiles + on-air)
This commit is contained in:
@@ -69,7 +69,6 @@
|
||||
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
|
||||
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
|
||||
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
|
||||
<Rectangle Stroke="#22c55e" StrokeThickness="6"/>
|
||||
</Grid>
|
||||
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
|
||||
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
Stretch="UniformToFill"
|
||||
RenderOptions.BitmapScalingMode="LowQuality"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="10"
|
||||
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="20"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,2,0,0"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
|
||||
+50
-48
@@ -1,65 +1,71 @@
|
||||
# HANDOFF — 2026-10-10 (scene-order F-key fix + desktop-lock fix; both LOCAL, uncommitted)
|
||||
# HANDOFF — 2026-10-10 (strip tile artwork + on-air tile unit; uncommitted)
|
||||
|
||||
## At a glance
|
||||
- **Branch** main (no feature branch). Pushed through `5685c5d`; 18 commits local, **not pushed**
|
||||
- **Branch** main (no feature branch). Pushed through `5685c5d`; 19 commits local, **not pushed**
|
||||
(push at the creator's call).
|
||||
- Desktop-lock fix committed as `63bf844`; **scene-order fix below is uncommitted**.
|
||||
- Scene-order unit committed `06f88f4`; desktop-lock committed `63bf844`. The **strip tile
|
||||
artwork unit below is uncommitted**.
|
||||
|
||||
## Branch
|
||||
- main (no feature branch). Pushed through `5685c5d` (TRAX toggle); everything after is local,
|
||||
**not pushed** (push at the creator's call).
|
||||
|
||||
## New work unit (this handoff) — READY TO VERIFY, do not commit yet
|
||||
- **Fix** for the scene-strip / F-key ordering report — `ViewModels/MainViewModel.Scenes.cs` new
|
||||
`ReorderScenesToCanonical()` (canonical F1-F5 order at load, renamed scenes after; next save
|
||||
re-stamps SortOrder) called from `MainViewModel.cs:LoadLayout`; `ytLive.Tests/SceneOrderTests.cs`
|
||||
(new, seeds a Chat-before-BRB DB and asserts order + F1-F5 labels + persisted SortOrder).
|
||||
See `bugs.md` entry 2026-10-10. Scope-locked files: `ViewModels/MainViewModel.Scenes.cs`,
|
||||
`ViewModels/MainViewModel.cs` (1 call line), `ytLive.Tests/SceneOrderTests.cs`, `bugs.md`,
|
||||
`HANDOFF.md`.
|
||||
- Full suite **423 passed / 0 failed / 0 warnings**. Root cause was the creator's own DB (Chat
|
||||
SortOrder 2, BRB 3); the fix heals it on next launch regardless of DB history.
|
||||
- **To commit later (this unit only):** the files listed above. Then the working tree is back to
|
||||
just the creator's pinned-chat unit (NOT mine to commit).
|
||||
## New work unit (this handoff) — VERIFIED, ready to commit
|
||||
Strip tile artwork + on-air indicator (creator ruling 2026-10-10). Three sub-parts:
|
||||
|
||||
## Desktop-lock unit (COMMITTED 63bf844 — desktop "lock" fix)
|
||||
- `ShowActivated = !FullscreenGameRunningNow` + `FullScreenDetectorOverride` seam + `DesktopLockTests`
|
||||
(3 tests). Committed 2026-10-10. Creator still to verify in-game (launch while an
|
||||
exclusive-fullscreen game owns the foreground — the game must stay live).
|
||||
1. **Per-scene CRT tile art** — each canonical scene's top-bar tile is its OWN image stored in the
|
||||
internal DB (`Scene.ThumbnailAssetId`, additive migration, seeded at heal from the creator's five
|
||||
Downloads JPGs via `SceneThumbnailSeedPaths`). The preview pane keeps rendering the scene's original
|
||||
background — never the tile. `Save()` Asset prune now keeps `Scene.ThumbnailAssetId` rows
|
||||
(this was a real bug: the first save would have deleted the seeded tiles).
|
||||
2. **On-air image** — one shared image (`Gemini_Generated_Image_4zajyl4zajyl4zaj.jpg`) that REPLACES the
|
||||
staged scene's tile ("the on-air screen jumps around the top bar as the creator swaps scenes").
|
||||
Stored as an Asset BLOB tracked by the `OnAirImageAssetId` Setting (`EnsureOnAirImage`, same contract
|
||||
as the alert clip; prune keep-set extended). Seed path override: `MainViewModel.OnAirSeedPath`.
|
||||
`RefreshSnapshotsAsync`: staged scene → on-air art; unstaged → own thumbnail or background art;
|
||||
staged Live without on-air art → green placeholder (unchanged invariant: minis never render captures).
|
||||
3. **Preview + labels polish** — removed the 6px `#22c55e` rectangle in `BackdropLayer`
|
||||
(`Controls/PreviewPane.xaml`) — the ONLY live signal is the outer `PreviewGlow` red border
|
||||
(`MainViewModel.Streaming.cs`, transparent otherwise); the redundant rect was only visible when a
|
||||
scene had no background ("green outline around the preview screen" the creator saw). Scene-tile
|
||||
labels doubled: `Controls/SceneThumbnailStrip.xaml` FontSize 10 → 20.
|
||||
|
||||
- Files: `Models/Scene.cs`, `Services/LayoutStore.{Migrations,Load,Save,Settings}.cs`,
|
||||
`ViewModels/MainViewModel.Background.cs`, `Controls/PreviewPane.xaml` (rect removed),
|
||||
`Controls/SceneThumbnailStrip.xaml` (label), `ytLive.Tests/SceneThumbnailTests.cs` (4 tests).
|
||||
- Full suite **427 passed / 0 failed / 0 warnings** (one run flaked on the known
|
||||
`RealMouseDrag_…PersistsTheReorder` env test — needs an idle desktop — and passed on re-run;
|
||||
nothing in this unit touches layer reorder). Existing 3 tile tests stub seed paths + `OnAirSeedPath`
|
||||
so the suite is hermetic; the new 4th test covers the on-air swap + persistence.
|
||||
- **To commit later (this unit only):** the files above. Then the working tree is back to just the
|
||||
creator's pinned-chat unit (NOT mine to commit).
|
||||
|
||||
## Desktop-lock unit (COMMITTED 63bf844)
|
||||
- `ShowActivated = !FullscreenGameRunningNow` + `FullScreenDetectorOverride` seam + `DesktopLockTests`.
|
||||
Creator still to verify in-game (launch while an exclusive-fullscreen game owns the foreground).
|
||||
|
||||
## Scene-order unit (COMMITTED 06f88f4)
|
||||
- `ReorderScenesToCanonical()` + `SceneOrderTests`. The creator's DB (Chat SortOrder 2, BRB 3) heals to
|
||||
canonical F1-F5 order at load; next save re-stamps SortOrder.
|
||||
|
||||
## Dirty files — current reality (2026-10-10)
|
||||
- **NOT mine — do NOT commit:** the creator's in-flight "pinned chat" unit (`Models/YouTube.cs`,
|
||||
`ViewModels/MainViewModel.Chat.cs`, untracked `Helpers/RelayCommandGeneric.cs`, and **its hunks in
|
||||
`Controls/LeftPanel.xaml`**), plus the creator's untracked `Assets/llamacasty*` art.
|
||||
- ⚠️ Pre-commit (scene-order unit): `scripts/verify.sh` runs `scripts/scope-check.sh` against ALL
|
||||
working-tree changes — the not-mine pinned-chat + art files WILL flag. Pass them as declared-known.
|
||||
|
||||
## What just happened — TASK 51 quality pass (committed 2026-10-09)
|
||||
1. **Jitter fix (`3057069`) — `MaskSmoother`**: per-session blend 0.75×new + 0.25×previous at the
|
||||
ONE worker (`CameraManager.RunEffectWorker` → `session.Smoother.Smooth(TrySegment(source))`).
|
||||
State on `CameraSession`, NOT the segmenter (shared app-wide, contract-pinned to raw output). Null
|
||||
resets history; first frame passes through; never aliases caller's array; ping-pong buffers (no
|
||||
per-frame LOH alloc). Weights: OBS obs-backgroundremoval 0.85 default, Jitsi 0.7/0.3 best
|
||||
(0.5/0.5 smears). Test `Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames`: x=6 → first
|
||||
255 / smoothed 58 / raw-unsmoothed 0. ⚠️ Gotcha: worker guards a re-queued identical frame
|
||||
reference — pump frame2 as a NEW instance.
|
||||
2. **Blur "heavy" fix (this commit) — quadratic radius**: `BlurRadius(strength)` = 1 + 5·s²,
|
||||
`MaxBlurRadius` 12→6 small px (≈ 24 full px max). Default 0.5 = 2 small px (was 6 under the old
|
||||
linear curve) — ~3× lighter spread. Rationale: StreamFX defaults Box blur Size to 5, author stops
|
||||
downscaled blurs ~8px. Test `Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the
|
||||
mapping and step-edge behaviour (reach 9px default vs 26px max). Point-source probes vanish below
|
||||
the 0.5 rounding (3 box passes dilute a spike) — use a step edge, not a spike, to measure blur.
|
||||
- ⚠️ Pre-commit: `scripts/verify.sh` runs `scripts/scope-check.sh` against ALL working-tree changes —
|
||||
the not-mine pinned-chat + art files WILL flag. Pass them as declared-known.
|
||||
|
||||
## Next step
|
||||
- Creator launches the app: the scene strip must show Starting/Live/BRB/Chat/Ending in order with
|
||||
F1-F5 correct (the creator's DB had Chat before BRB; the load-time reorder heals it and the next
|
||||
save re-stamps SortOrder). Also still to verify when convenient: the desktop-lock fix while an
|
||||
exclusive-fullscreen game owns the foreground. Then I can commit the scene-order unit.
|
||||
- Commit this unit (files listed above; gate already green at 427/427, 0 warnings). Then the creator
|
||||
launches the app on the real DB: the five CRT tiles seed from `C:\Users\gramp\Downloads\Gemini_…`,
|
||||
the staged scene's tile shows the on-air image, the preview shows the scene's real background with no
|
||||
green frame, and the tile labels are legible (FontSize 20). Push is still at the creator's call.
|
||||
|
||||
## Tests
|
||||
- Full suite **423 passed / 0 failed / 0 skipped / 0 warnings** (2026-10-10; includes the new
|
||||
`SceneOrderTests`). Known env flakes remain `Mix_HonorsProviderGains…` (fails while system audio
|
||||
plays) and `RealMouseDrag…` (needs an idle desktop).
|
||||
- Full suite **427 passed / 0 failed / 0 skipped / 0 warnings** (2026-10-10). Known env flakes:
|
||||
`Mix_HonorsProviderGains…` (fails while system audio plays), `RealMouseDrag…` (needs an idle desktop),
|
||||
`Round_Clip_Corner…` (grab/geometry under active mouse). The verify gate whitelists only the latter two —
|
||||
`RealMouseDrag…` failing mid-session needs one re-run when the desktop idles.
|
||||
|
||||
## Also still open
|
||||
- **Vertical-format regression (STALE, not fixed):** 1080p60 vertical selection no longer shrinks the
|
||||
@@ -68,9 +74,5 @@
|
||||
`MainViewModel.Streaming.cs` `ApplyStreamQuality` from `SelectedQuality` (binding + ctor only,
|
||||
`new("Vertical 1080p60", 60, 8.0, 1080, 1920)`), `DimRects` in `MainViewModel.cs:91-94`. Root cause
|
||||
unknown — paused.
|
||||
- **Vertical recording file closed:** the recording itself is 1080×1920 60fps correctly; the
|
||||
"whitespace" was the app's preview UI, not the file. Audio loudness (`ebur128`) for the private video
|
||||
was never run and the thread is closed unless the user reopens it.
|
||||
- Blur-heaviness = fixed (above). Yellow-border regression = stale thread.
|
||||
- ⛔ Sudo password rotation pending (user; then update both password lines in `CREDENTIALS.md`).
|
||||
- TASK 56 secret scan before `gramps/LlamaCasty` goes public. TASK 55 disk hygiene queued.
|
||||
@@ -72,6 +72,14 @@ public class Scene : INotifyPropertyChanged
|
||||
|
||||
public bool IsChatScene { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Asset-table id of this scene's top-strip tile artwork. The tile is a
|
||||
/// separate image from the scene's background: the preview pane keeps
|
||||
/// rendering the scene's original background, the strip shows this instead.
|
||||
/// Null = the strip falls back to the background art (creator ruling 2026-10-10).
|
||||
/// </summary>
|
||||
public string? ThumbnailAssetId { get; set; }
|
||||
|
||||
private BitmapSource? _snapshot;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,7 +16,7 @@ public partial class LayoutStore : IDisposable
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
@@ -28,6 +28,7 @@ public partial class LayoutStore : IDisposable
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
HasBackground = reader.GetInt32(4) != 0,
|
||||
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||
ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ public partial class LayoutStore : IDisposable
|
||||
MigrateWebcamConfigTable();
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSceneThumbnailColumn();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
@@ -527,6 +528,27 @@ public partial class LayoutStore : IDisposable
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 2026-10-10: Scene gains ThumbnailAssetId (the top-strip tile artwork, stored
|
||||
// as an Asset row like every image). Additive, no version bump — old DBs just
|
||||
// get a NULL column and the strip falls back to the scene's background art.
|
||||
private void MigrateSceneThumbnailColumn()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("ThumbnailAssetId")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Scene ADD COLUMN ThumbnailAssetId TEXT;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
|
||||
// BarJustify column stays for back-compat but is no longer read or written.
|
||||
private void MigrateSocialsTable()
|
||||
|
||||
@@ -54,8 +54,8 @@ public partial class LayoutStore : IDisposable
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -64,6 +64,7 @@ public partial class LayoutStore : IDisposable
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
|
||||
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||
var thumbnailP = cmd.Parameters.Add("$thumbnail", SqliteType.Text);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
@@ -75,6 +76,7 @@ public partial class LayoutStore : IDisposable
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
backgroundP.Value = scene.HasBackground ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
@@ -324,9 +326,13 @@ public partial class LayoutStore : IDisposable
|
||||
DELETE FROM Asset WHERE Id NOT IN (
|
||||
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
|
||||
UNION
|
||||
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
|
||||
);
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
|
||||
@@ -326,4 +326,26 @@ public partial class LayoutStore : IDisposable
|
||||
|
||||
/// <summary>The stored default clip's Asset id, or null when never stamped.</summary>
|
||||
public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey);
|
||||
|
||||
internal const string OnAirImageKey = "OnAirImageAssetId";
|
||||
|
||||
/// <summary>The strip tile that labels the scene currently PREVIEWED (creator
|
||||
/// ruling 2026-10-10 — the "on-air background screen"). One shared image, not
|
||||
/// per-scene: whichever scene is staged shows it in the top bar, so it "jumps
|
||||
/// around" as the creator swaps scenes. Lives as a BLOB in the internal Asset
|
||||
/// table, tracked by this Setting so the Save pruner keeps it alive — the same
|
||||
/// contract as the alert default clip. <c>UpsertAsset</c> is hash-keyed, so
|
||||
/// re-stamping the same bytes returns the same id.</summary>
|
||||
public string EnsureOnAirImage(byte[] data)
|
||||
{
|
||||
var saved = GetSetting(OnAirImageKey);
|
||||
if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null)
|
||||
return saved;
|
||||
var id = UpsertAsset(data, 0, 0);
|
||||
UpsertSetting(OnAirImageKey, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
|
||||
public string? GetOnAirImageId() => GetSetting(OnAirImageKey);
|
||||
}
|
||||
|
||||
@@ -144,19 +144,34 @@ public partial class MainViewModel
|
||||
{
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
// Minis never render live captures. While the Live screen is staged
|
||||
// (being previewed) its mini shows the green placeholder; unstaged,
|
||||
// it shows the stored default art. Real-time surface = center only.
|
||||
var stagedLive = ReferenceEquals(scene, StagedScene)
|
||||
// The scene currently shown in the preview labels its top-bar tile with
|
||||
// the on-air image (creator ruling 2026-10-10) — it jumps around the
|
||||
// strip as the creator swaps scenes. Every other tile keeps its own
|
||||
// artwork (ThumbnailAssetId) or falls back to the background art; minis
|
||||
// never render live captures: a staged Live without a stamped on-air art
|
||||
// still falls back to the green placeholder. Real-time surface = center only.
|
||||
var isStaged = ReferenceEquals(scene, StagedScene);
|
||||
var img = isStaged && OnAirImage != null
|
||||
? OnAirImage
|
||||
: SceneThumbnailImage(scene);
|
||||
if (img == null)
|
||||
{
|
||||
var stagedLive = isStaged
|
||||
&& SceneCatalog.Is(scene.Name, SceneCatalog.Live);
|
||||
var img = stagedLive ? null : LoadBackgroundImage(scene);
|
||||
img = stagedLive ? null : LoadBackgroundImage(scene);
|
||||
}
|
||||
if (img != null)
|
||||
scene.Snapshot = img;
|
||||
else if (scene.Snapshot == null || stagedLive)
|
||||
else if (scene.Snapshot == null || (isStaged && OnAirImage == null))
|
||||
scene.Snapshot = CreatePlaceholderSnapshot(scene.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static System.Windows.Media.Imaging.BitmapSource? SceneThumbnailImage(Scene scene)
|
||||
=> string.IsNullOrWhiteSpace(scene.ThumbnailAssetId)
|
||||
? null
|
||||
: ImageCache.Get(scene.ThumbnailAssetId);
|
||||
|
||||
|
||||
// Full-screen game on its monitor, else null (static bg fallback — no
|
||||
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
|
||||
@@ -426,9 +441,82 @@ public partial class MainViewModel
|
||||
if (background != null)
|
||||
StampDefaultBackgroundAsset(background, scene.Name);
|
||||
}
|
||||
SeedSceneThumbnails();
|
||||
SeedOnAirImage();
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
}
|
||||
|
||||
// ─── Scene strip tile artwork (creator ruling 2026-10-10) ───
|
||||
// Each canonical scene's tile in the top strip shows its own image, stored as
|
||||
// an Asset row like any image. The five files are the creator's CRT-styled
|
||||
// renders (each scene's original background seen through a CRT screen). They
|
||||
// are seeded into the layout's Asset table once per scene; the preview pane
|
||||
// keeps rendering each scene's ORIGINAL background — never these tiles.
|
||||
internal static readonly Dictionary<string, string> SceneThumbnailSeedPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[SceneCatalog.Starting] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_uvq3i3uvq3i3uvq3.jpg",
|
||||
[SceneCatalog.Live] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_y27sgqy27sgqy27s.jpg",
|
||||
[SceneCatalog.Brb] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_lxcq0klxcq0klxcq.jpg",
|
||||
[SceneCatalog.Chat] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_nqosdenqosdenqos.jpg",
|
||||
[SceneCatalog.Ending] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_8je5n68je5n68je5.jpg",
|
||||
};
|
||||
|
||||
private void SeedSceneThumbnails()
|
||||
{
|
||||
var imported = false;
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scene.ThumbnailAssetId)) continue;
|
||||
if (!SceneThumbnailSeedPaths.TryGetValue(scene.Name, out var path)) continue;
|
||||
if (!System.IO.File.Exists(path)) continue;
|
||||
try
|
||||
{
|
||||
// UpsertAsset is SHA256-keyed, so a re-seed never duplicates rows
|
||||
// and returns the same id. Only the Scene mapping is new state.
|
||||
var assetId = AddAsset(System.IO.File.ReadAllBytes(path));
|
||||
if (assetId == null) continue;
|
||||
scene.ThumbnailAssetId = assetId;
|
||||
imported = true;
|
||||
}
|
||||
catch { /* the strip keeps the scene's background-art fallback */ }
|
||||
}
|
||||
if (!imported) return;
|
||||
RefreshSnapshotsAsync();
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
// ─── On-air strip tile (creator ruling 2026-10-10) ───
|
||||
// A single shared image ("on-air background screen") labels whichever scene is
|
||||
// CURRENTLY PREVIEWED in the top bar — it replaces that scene's tile and jumps
|
||||
// around as the creator swaps scenes. Stored as an Asset row like any image,
|
||||
// tracked by the OnAirImageAssetId setting so the Save pruner keeps it alive.
|
||||
internal static string OnAirSeedPath = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_4zajyl4zajyl4zaj.jpg";
|
||||
|
||||
private System.Windows.Media.Imaging.BitmapImage? OnAirImage;
|
||||
|
||||
private void SeedOnAirImage()
|
||||
{
|
||||
var saved = _layoutStore.GetOnAirImageId();
|
||||
if (!string.IsNullOrWhiteSpace(saved))
|
||||
{
|
||||
var cached = ImageCache.Get(saved);
|
||||
if (cached != null)
|
||||
{
|
||||
OnAirImage = cached;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(OnAirSeedPath) || !System.IO.File.Exists(OnAirSeedPath)) return;
|
||||
try
|
||||
{
|
||||
var id = _layoutStore.EnsureOnAirImage(System.IO.File.ReadAllBytes(OnAirSeedPath));
|
||||
OnAirImage = ImageCache.Get(id);
|
||||
RefreshSnapshotsAsync();
|
||||
}
|
||||
catch { /* the strip keeps per-scene tiles when the on-air art can't import */ }
|
||||
}
|
||||
|
||||
/// <summary>Points a background row at its scene's shipped default art when it
|
||||
/// carries none. A custom image (Browse…) always wins and is never overwritten.</summary>
|
||||
private void StampDefaultBackgroundAsset(Source background, string sceneName)
|
||||
|
||||
@@ -299,7 +299,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
|
||||
|
||||
**Thumbnails:** `Scene.Snapshot` property. `SceneCompositor.Render()` at 320×180 → `WriteableBitmap`. Only staged scene has live backdrop render; other 4 = static snapshots.
|
||||
|
||||
**Thumbnails:** `Scene.Snapshot` property. `RefreshSnapshotsAsync` fills minis from each scene's background art (`LoadBackgroundImage`); **minis never render live captures** — while the Live screen is staged its mini shows the green placeholder, unstaged it shows `live-background.jpg` (real-time surface = center monitor only; user decision 2026-08-23).
|
||||
**Thumbnails:** `Scene.Snapshot` property. Fill = per-scene tile artwork first — each canonical scene's `ThumbnailAssetId` (an Asset-table image, the creator's CRT-styled renders seeded at heal into the internal DB), else background art; **minis never render live captures**. The scene currently staged shows the shared **on-air image** (`OnAirImageAssetId` setting → Asset row, seed path `MainViewModel.OnAirSeedPath`) in its tile and it follows the scene swap — "the on-air screen jumps around the top bar" (user ruling 2026-10-10). The Live screen's tile only shows the green placeholder when it is staged and no on-air art is stamped. The strip labels are FontSize 20 (user ruling 2026-10-10); border = `#e94560` when staged, `#22c55e` when live. The preview's own live signal is the outer `PreviewGlow` red border — NOT the 6px green `#22c55e` rectangle that previously sat in the BackdropLayer (removed 2026-10-10).
|
||||
|
||||
### Global hotkeys (TASK 20 — step 1 shipped 2026-08-24)
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Bugs
|
||||
|
||||
## Scene strip tile + on-air artwork assets deleted on the next Save (FIXED 2026-10-10)
|
||||
The scene tiles and the shared on-air image are stored as Asset-table BLOBs (per-scene
|
||||
`Scene.ThumbnailAssetId` + the `OnAirImageAssetId` Setting). `LayoutStore.Save()` runs an Asset
|
||||
pruner that only keeps rows referenced by `Source`, the alert clip setting, and — after the fix —
|
||||
the two new references: it `DELETE FROM Asset WHERE Id NOT IN (SELECT ThumbnailAssetId FROM Scene …,
|
||||
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId')`. Without that, the very first save after
|
||||
seeding would have wiped the freshly-imported tile art, and every tile would have silently fallen
|
||||
back to the scene's background until the next launch re-seeded it.
|
||||
|
||||
## Scene strip / F-keys out of sequence (FIXED 2026-10-10)
|
||||
Report: "the ordering of the 5 screens and their corresponding F-key is out of sequence." The strip
|
||||
renders `Scenes ORDER BY SortOrder` but each tab's F-key label is name-derived, so the two can drift
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scene strip tile artwork (creator ruling 2026-10-10). Each canonical scene's
|
||||
/// top-strip tile is its own image, stored as an Asset row and seeded from the
|
||||
/// creator's CRT-styled renders. The preview pane keeps rendering each scene's
|
||||
/// ORIGINAL background — the tiles never appear as preview backgrounds.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class SceneThumbnailTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public SceneThumbnailTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Strip_Shows_The_Seeded_Tile_And_The_Preview_Keeps_The_Original_Background()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
EnvGuard? env = null;
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out var file);
|
||||
env.SeedPaths[SceneCatalog.Starting] = file.TilePath;
|
||||
env.SeedPaths[SceneCatalog.Live] = MissingPath();
|
||||
env.Apply();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
var starting = SceneByName(vm, SceneCatalog.Starting);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(starting.ThumbnailAssetId));
|
||||
|
||||
// The strip tile is the seeded file (identity + dims prove it).
|
||||
var tile = ImageCache.Get(starting.ThumbnailAssetId);
|
||||
Assert.NotNull(tile);
|
||||
Assert.Same(tile, starting.Snapshot);
|
||||
Assert.Equal(320, starting.Snapshot!.PixelWidth);
|
||||
Assert.Equal(180, starting.Snapshot.PixelHeight);
|
||||
|
||||
// The preview pane renders the scene's ORIGINAL background — never
|
||||
// the tile artwork.
|
||||
var originalBackground = ImageCache.Get(env.BackgroundIds[SceneCatalog.Starting]);
|
||||
Assert.NotNull(originalBackground);
|
||||
Assert.Same(originalBackground, vm.BackgroundImage);
|
||||
Assert.Same(originalBackground, vm.ActiveBackgroundImage);
|
||||
Assert.Same(originalBackground, MainViewModel.LoadBackgroundImage(starting));
|
||||
Assert.NotSame(tile, vm.BackgroundImage);
|
||||
|
||||
// A scene without its own tile keeps its background art as the strip.
|
||||
var ending = SceneByName(vm, SceneCatalog.Ending);
|
||||
Assert.Null(ending.ThumbnailAssetId);
|
||||
Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Ending]), ending.Snapshot);
|
||||
|
||||
// The mapping and the tile asset survive a save + reload (the
|
||||
// Save-time Asset prune must keep Scene.ThumbnailAssetId rows), and
|
||||
// a second load re-seeds nothing new (UpsertAsset is SHA256-keyed).
|
||||
vm.SaveLayoutNow();
|
||||
using (var reloaded = new LayoutStore(env.DbPath))
|
||||
{
|
||||
var reloadedStarting = reloaded.Load().Single(s => s.Name == SceneCatalog.Starting);
|
||||
Assert.Equal(starting.ThumbnailAssetId, reloadedStarting.ThumbnailAssetId);
|
||||
Assert.NotNull(reloaded.GetAssetBytes(starting.ThumbnailAssetId));
|
||||
Assert.Equal(1, CountAssetRows(env.DbPath, starting.ThumbnailAssetId));
|
||||
}
|
||||
|
||||
var secondVm = new MainViewModel();
|
||||
Assert.Equal(starting.ThumbnailAssetId,
|
||||
SceneByName(secondVm, SceneCatalog.Starting).ThumbnailAssetId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
env?.Restore();
|
||||
env?.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_Staged_Mini_Shows_Its_Tile_Not_The_Capture_Placeholder()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
EnvGuard? env = null;
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out var file);
|
||||
env.SeedPaths[SceneCatalog.Live] = file.TilePath;
|
||||
env.Apply();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
var live = SceneByName(vm, SceneCatalog.Live);
|
||||
Assert.False(string.IsNullOrWhiteSpace(live.ThumbnailAssetId));
|
||||
|
||||
vm.StagedScene = live;
|
||||
// Minis never render captures; a seeded Live tile shows instead of
|
||||
// the green placeholder, while the preview still shows the background.
|
||||
Assert.Same(ImageCache.Get(live.ThumbnailAssetId), live.Snapshot);
|
||||
Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Live]), vm.BackgroundImage);
|
||||
}
|
||||
finally
|
||||
{
|
||||
env?.Restore();
|
||||
env?.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Staged_Scene_Tile_Shows_The_On_Air_Art_And_It_Follows_The_Scene_Swap()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
EnvGuard? env = null;
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out var file);
|
||||
MainViewModel.OnAirSeedPath = file.OnAirPath;
|
||||
env.Apply();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
var starting = SceneByName(vm, SceneCatalog.Starting);
|
||||
var live = SceneByName(vm, SceneCatalog.Live);
|
||||
|
||||
var onAir = ImageCache.Get(Services.LayoutStore.Instance!.GetOnAirImageId()!);
|
||||
Assert.NotNull(onAir);
|
||||
|
||||
// The staged scene's tile (Starting at launch) shows the on-air art.
|
||||
Assert.Same(onAir, starting.Snapshot);
|
||||
// Unstaged scenes keep their own art, never the on-air image.
|
||||
Assert.NotSame(onAir, live.Snapshot);
|
||||
|
||||
// Swapping scenes moves the on-air tile: staging Live swaps it across,
|
||||
// and Starting's tile returns to its own background art (no thumbnail).
|
||||
vm.StagedScene = live;
|
||||
Assert.Same(onAir, live.Snapshot);
|
||||
Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Starting]), starting.Snapshot);
|
||||
|
||||
// The preview pane still renders the scene's ORIGINAL background —
|
||||
// the on-air art never leaks into the preview.
|
||||
Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Live]), vm.BackgroundImage);
|
||||
|
||||
// Persistence: the on-air asset survives a save + reload (the Save
|
||||
// pruner must keep the OnAirImageAssetId setting's row), and a fresh
|
||||
// VM re-stamps it from the DB instead of re-importing.
|
||||
vm.SaveLayoutNow();
|
||||
using (var reloaded = new LayoutStore(env.DbPath))
|
||||
{
|
||||
var id = reloaded.GetOnAirImageId();
|
||||
Assert.NotNull(id);
|
||||
Assert.NotNull(reloaded.GetAssetBytes(id));
|
||||
}
|
||||
var secondVm = new MainViewModel();
|
||||
Assert.Same(onAir, SceneByName(secondVm, SceneCatalog.Starting).Snapshot);
|
||||
}
|
||||
finally
|
||||
{
|
||||
env?.Restore();
|
||||
env?.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_Seed_File_Means_No_Thumbnail_And_Background_Art_Fallback()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
EnvGuard? env = null;
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out _);
|
||||
env.SeedPaths.Clear();
|
||||
env.Apply();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
foreach (var scene in vm.Scenes)
|
||||
{
|
||||
Assert.Null(scene.ThumbnailAssetId);
|
||||
Assert.Same(ImageCache.Get(env.BackgroundIds[scene.Name]), scene.Snapshot);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
env?.Restore();
|
||||
env?.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Fixture plumbing ───
|
||||
|
||||
private static Scene SceneByName(MainViewModel vm, string name)
|
||||
=> vm.Scenes.Single(s => s.Name == name);
|
||||
|
||||
private static int CountAssetRows(string dbPath, string assetId)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={dbPath}");
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Asset WHERE Id = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", assetId);
|
||||
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
/// <summary>Builds a temp DB whose every scene has a REAL background asset row.
|
||||
/// One throwaway VM creates the canonical scenes + heals their Backgrounds
|
||||
/// (the pack:// default-art stamp silently fails under the test host, so the
|
||||
/// contract is tested with an explicit background — the tile must never
|
||||
/// replace it). Seed paths start all-missing; the caller picks which scenes
|
||||
/// get a tile.</summary>
|
||||
private static EnvGuard ArrangedDb(out Files files)
|
||||
{
|
||||
files = new Files();
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"ytLive-scenethumb-{Guid.NewGuid():N}.db");
|
||||
using (new LayoutStore(dbPath)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
var env = new EnvGuard(dbPath, files);
|
||||
try
|
||||
{
|
||||
env.Apply();
|
||||
var builder = new MainViewModel();
|
||||
var backgroundIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var i = 0;
|
||||
foreach (var scene in builder.Scenes)
|
||||
{
|
||||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
Assert.NotNull(bg);
|
||||
var bytes = files.NewBackgroundBytes(i++);
|
||||
var id = LayoutStore.Instance!.UpsertAsset(bytes, 320, 180);
|
||||
Assert.NotNull(id);
|
||||
bg!.AssetId = id;
|
||||
backgroundIds[scene.Name] = id;
|
||||
}
|
||||
builder.SaveLayoutNow();
|
||||
env.BackgroundIds = backgroundIds;
|
||||
return env;
|
||||
}
|
||||
catch
|
||||
{
|
||||
env.Restore();
|
||||
env.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record EnvGuard : IDisposable
|
||||
{
|
||||
public string DbPath { get; }
|
||||
public Files Files { get; }
|
||||
public Dictionary<string, string> BackgroundIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, string> SeedPaths { get; }
|
||||
private readonly Dictionary<string, string> _originalPaths;
|
||||
private readonly string _originalOnAirPath;
|
||||
|
||||
public EnvGuard(string dbPath, Files files)
|
||||
{
|
||||
DbPath = dbPath;
|
||||
Files = files;
|
||||
_originalPaths = new Dictionary<string, string>(MainViewModel.SceneThumbnailSeedPaths, StringComparer.OrdinalIgnoreCase);
|
||||
_originalOnAirPath = MainViewModel.OnAirSeedPath;
|
||||
SeedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
MainViewModel.LayoutPathOverride = dbPath;
|
||||
MainViewModel.OnAirSeedPath = MissingPath();
|
||||
foreach (var path in _originalPaths)
|
||||
SeedPaths[path.Key] = MissingPath();
|
||||
}
|
||||
|
||||
/// <summary>Writes the tests' chosen SeedPaths into the live map; any
|
||||
/// canonical scene not overridden stays pointing at a missing file.</summary>
|
||||
public void Apply()
|
||||
{
|
||||
MainViewModel.SceneThumbnailSeedPaths.Clear();
|
||||
foreach (var (key, value) in SeedPaths)
|
||||
MainViewModel.SceneThumbnailSeedPaths[key] = value;
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
MainViewModel.SceneThumbnailSeedPaths.Clear();
|
||||
foreach (var (key, value) in _originalPaths)
|
||||
MainViewModel.SceneThumbnailSeedPaths[key] = value;
|
||||
MainViewModel.OnAirSeedPath = _originalOnAirPath;
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Files.Dispose();
|
||||
try { File.Delete(DbPath); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Files : IDisposable
|
||||
{
|
||||
public string TilePath { get; }
|
||||
public string OnAirPath { get; }
|
||||
private readonly List<string> _paths = new();
|
||||
|
||||
public Files()
|
||||
{
|
||||
TilePath = NewImage(320, 180, Brushes.Teal, Brushes.Red);
|
||||
OnAirPath = NewImage(320, 180, Brushes.SlateBlue, Brushes.White);
|
||||
}
|
||||
|
||||
/// <summary>Distinct per-scene solid-color "original background".</summary>
|
||||
public byte[] NewBackgroundBytes(int index)
|
||||
{
|
||||
var colors = new[] { Brushes.ForestGreen, Brushes.OrangeRed, Brushes.DodgerBlue, Brushes.DarkOrchid, Brushes.SaddleBrown };
|
||||
var path = NewImage(320, 180, colors[index % colors.Length], Brushes.Black);
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
private string NewImage(int w, int h, Brush fill, Brush accent)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLive-scenethumb-img-{Guid.NewGuid():N}.png");
|
||||
var dv = new DrawingVisual();
|
||||
using (var dc = dv.RenderOpen())
|
||||
{
|
||||
dc.DrawRectangle(fill, null, new Rect(0, 0, w, h));
|
||||
dc.DrawRectangle(accent, null, new Rect(24, 24, 64, 64));
|
||||
}
|
||||
var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
|
||||
rtb.Render(dv);
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(rtb));
|
||||
using var fs = File.OpenWrite(path);
|
||||
encoder.Save(fs);
|
||||
_paths.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var path in _paths)
|
||||
try { File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static string MissingPath()
|
||||
=> Path.Combine(Path.GetTempPath(), $"ytLive-no-such-{Guid.NewGuid():N}.png");
|
||||
}
|
||||
Reference in New Issue
Block a user