fix: webcam identity->device key for output frames; top bar always has a Start; light-pill grouping
Take two (2026-09-01) three confirmed defects, all from creator feedback + the
new resolver logging:
1. NO WEBCAM IN OUTPUT: WebcamSceneConfig.WebcamId carries the identity GUID;
CameraManager keys sessions by DEVICE id — GetLatestFrame(guid) returned null
forever, the compositor silently dropped the layer while the preview (bitmap
path) looked fine. The resolver logging added in 8dcaee0 caught it red-handed.
DeviceKeyForWebcam maps identity->device through the _webcam singleton (identity
mismatch / unknown ids pass through). Test: WebcamOutputKeyTests.
2. START BUTTON VANISHED AFTER STOP: my pills-clear ruling met the old
ShowPrimaryStartButton gate (needed IsConnected or a lit REC pill) — signed-out,
pills-off = blank top bar, no session reachable. Start is now ALWAYS the idle
face; unarmed Start records (local needs no account — no dead-end no-op); the
Sign In button folds into Start's context menu ('Sign in to YouTube', shown while
disconnected) with a dedicated SignInCommand. SessionTeardownTests extended:
stopped session must leave a reachable Start.
3. TOP BAR ORDER (creator spec): [sign light] REC [pill] [sign light] ON-AIR [pill]
— each reality lamp now sits in front of its own intent switch (was: two pills,
then two orphaned dots). Sign labels keep the shared StatusSignText style.
Tests: WebcamOutputKey, SessionTeardown, FramePump, WebcamMenuGate, GlobalHotkey,
BroadcastPullOut — 14/14 across the six classes, clean build 0 warnings.
This commit is contained in:
+17
-21
@@ -24,29 +24,26 @@
|
||||
</TextBlock>
|
||||
</Button>
|
||||
|
||||
<!-- REC / ON-AIR pills (intent) + status signs (reality) -->
|
||||
<!-- Session controls (creator order 2026-09-01): [sign light] LABEL [pill] per
|
||||
output — each reality lamp sits in front of its own intent switch. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- REC pill: local recording needs no sign-in -->
|
||||
<TextBlock Text="REC" Foreground="#e94560" FontWeight="Bold" FontSize="12"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<!-- REC group: local recording needs no sign-in -->
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
|
||||
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
||||
Margin="6,0,16,0"
|
||||
IsChecked="{Binding RecordPillOn}"/>
|
||||
<!-- ON-AIR pill: greyed out until signed in -->
|
||||
<TextBlock Text="ON-AIR" Foreground="#e94560" FontWeight="Bold" FontSize="12"
|
||||
VerticalAlignment="Center" Margin="16,0,6,0"/>
|
||||
<!-- ON-AIR group: greyed out until signed in -->
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||
Fill="{Binding OnAirBrush}"/>
|
||||
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
||||
Margin="6,0,0,0"
|
||||
IsChecked="{Binding OnAirPillOn}"
|
||||
IsEnabled="{Binding CanToggleOnAir}"
|
||||
ToolTip="{Binding AccountStatusLightToolTip}"/>
|
||||
<!-- Status signs: REC dot + ON-AIR dot -->
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
|
||||
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
|
||||
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
|
||||
<!-- ON-AIR sign: gray offline, red public, green private -->
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
|
||||
Fill="{Binding OnAirBrush}"/>
|
||||
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
|
||||
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
|
||||
@@ -89,17 +86,16 @@
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- Sign In button (disconnected + offline) -->
|
||||
<Button Content="Sign In" Style="{StaticResource YtButton}"
|
||||
Margin="0,0,12,0"
|
||||
Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- Primary Start button (offline): dynamic label from the pills -->
|
||||
<!-- Primary Start button: the always-present idle action (sign-in moved
|
||||
into its context menu — a bare bar after stopping was a dead end) -->
|
||||
<Button Content="Start" Style="{StaticResource YtButton}"
|
||||
Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Sign in to YouTube" Command="{Binding SignInCommand}"
|
||||
Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
|
||||
|
||||
@@ -21,7 +21,15 @@ public partial class MainViewModel : ViewModelBase
|
||||
if (IsLive || IsRecording) return;
|
||||
var streaming = OnAirPillOn;
|
||||
var recording = RecordPillOn;
|
||||
if (!streaming && !recording) return;
|
||||
if (!streaming && !recording)
|
||||
{
|
||||
// Nothing armed = "record something" (local recording needs no account,
|
||||
// 2026-09-01): a Start that silently no-ops is the dead end the design
|
||||
// principle forbids. The pill lights so reality and intent agree.
|
||||
RecordPillOn = true;
|
||||
BeginRecordOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!streaming)
|
||||
{
|
||||
|
||||
@@ -60,7 +60,6 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(OnAirBrush));
|
||||
OnPropertyChanged(nameof(IsLivePrivate));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(ShowLogInButton));
|
||||
OnPropertyChanged(nameof(ShowEndStreamButton));
|
||||
OnPropertyChanged(nameof(EndButtonText));
|
||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||
@@ -127,12 +126,12 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The primary button shows when there's something to start, but never
|
||||
/// while a session is running — once recording/live the ONLY control is the End
|
||||
/// button.</summary>
|
||||
public bool ShowPrimaryStartButton =>
|
||||
IsOffline && !IsRecording && !IsLive
|
||||
&& (IsConnected || (RecordPillOn && !OnAirPillOn));
|
||||
/// <summary>The primary button is ALWAYS the idle face of the top bar (2026-09-01):
|
||||
/// stopping a session used to hide every start affordance (pills clear on stop,
|
||||
/// and the old gate needed a sign-in or a lit pill — signed-out creators found a
|
||||
/// blank bar). No dead ends: an unarmed Start records (local needs no account);
|
||||
/// sign-in lives in the Start context menu.</summary>
|
||||
public bool ShowPrimaryStartButton => IsOffline && !IsRecording;
|
||||
|
||||
/// <summary>Either output selected makes the button actionable.</summary>
|
||||
public bool CanStartSession => RecordPillOn || OnAirPillOn;
|
||||
@@ -144,8 +143,6 @@ public partial class MainViewModel : ViewModelBase
|
||||
/// "Stop Recording", while live "End Stream".</summary>
|
||||
public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
|
||||
|
||||
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
|
||||
|
||||
public StreamHealth CurrentHealth
|
||||
{
|
||||
get => _currentHealth;
|
||||
@@ -320,6 +317,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand SignInCommand { get; }
|
||||
public ICommand ChooseRecordFolderCommand { get; }
|
||||
public ICommand ResetRecordFolderCommand { get; }
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
|
||||
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
|
||||
StartStreamCommand = new RelayCommand(_ => StartSession());
|
||||
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => !IsConnected);
|
||||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
|
||||
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
|
||||
ResetRecordFolderCommand = new RelayCommand(_ => ResetRecordFolder());
|
||||
@@ -478,11 +479,24 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
private DateTime _lastWebcamNullLog = DateTime.MinValue;
|
||||
|
||||
/// <summary>Maps a config's identity GUID to the CameraManager session key (the
|
||||
/// DEVICE id). internal = test seam. Take-2 fix: looking up the identity GUID
|
||||
/// against a device-keyed dictionary returned null forever.</summary>
|
||||
internal string DeviceKeyForWebcam(string webcamId) =>
|
||||
_webcam is { } w && w.Id == webcamId && !string.IsNullOrWhiteSpace(w.DeviceId)
|
||||
? w.DeviceId
|
||||
: webcamId;
|
||||
|
||||
private VideoFrame? ResolveOutputFrameCore(SceneElement element)
|
||||
{
|
||||
return element switch
|
||||
{
|
||||
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
|
||||
// CameraManager sessions are keyed by DEVICE id (AcquireAsync(_webcam.DeviceId));
|
||||
// configs carry the identity GUID (WebcamSceneConfig.WebcamId = _webcam.Id).
|
||||
// Looking up by the GUID returned null forever — the webcam was structurally
|
||||
// invisible on the output while the preview (bitmap path) looked fine
|
||||
// (caught by the resolver logging, 2026-09-01 take two).
|
||||
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId)),
|
||||
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
||||
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
|
||||
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
|
||||
|
||||
@@ -56,6 +56,9 @@ public sealed class SessionTeardownTests
|
||||
Assert.False(vm.OnAirPillOn, "both pills clear");
|
||||
Assert.False(vm.ShowEndStreamButton, "no End button once nothing is running");
|
||||
Assert.Equal(ytLive.Models.StreamStatus.Offline, vm.StreamStatus);
|
||||
// No dead ends (2026-09-01): after a stop the top bar must still offer a
|
||||
// reachable Start — pills cleared + signed out used to blank the button.
|
||||
Assert.True(vm.ShowPrimaryStartButton, "a stopped session must leave a Start button");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Take-2 fix (2026-09-01): configs reference the webcam by IDENTITY GUID while
|
||||
/// CameraManager sessions are keyed by DEVICE id — the output resolver looked up the
|
||||
/// wrong dictionary and the camera was structurally invisible on stream/recordings
|
||||
/// while the preview (bitmap path) looked fine. DeviceKeyForWebcam is the mapping;
|
||||
/// this test seeds a layout whose identity differs from its device id and asserts
|
||||
/// the map (plus unknown-id passthrough).
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class WebcamOutputKeyTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public WebcamOutputKeyTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void DeviceKeyForWebcam_Maps_Identity_To_Device()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wckey-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
var window = default(MainWindow);
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
Seed(tempDb);
|
||||
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
|
||||
Assert.Equal("dev-physical-9", vm.DeviceKeyForWebcam("wid-identity-7"));
|
||||
Assert.Equal("unknown-id", vm.DeviceKeyForWebcam("unknown-id"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (window != null) window.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void Seed(string path)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path}");
|
||||
connection.Open();
|
||||
|
||||
var scenes = new (string Id, string Name)[]
|
||||
{
|
||||
("scene-starting", "Starting"),
|
||||
("scene-live", "Live"),
|
||||
("scene-brb", "BRB"),
|
||||
("scene-chat", "Chat"),
|
||||
("scene-ending", "Ending"),
|
||||
};
|
||||
foreach (var (id, name) in scenes)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$name", name);
|
||||
cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var webcam = connection.CreateCommand())
|
||||
{
|
||||
webcam.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ('wid-identity-7', 'dev-physical-9', 'Cam');";
|
||||
webcam.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var config = connection.CreateCommand())
|
||||
{
|
||||
config.CommandText =
|
||||
@"INSERT INTO WebcamSceneConfig
|
||||
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, SortOrder)
|
||||
VALUES ('scene-live', 'wid-identity-7', 1, 100, 100, 480, 270, 1, 0);";
|
||||
config.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user