Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1006a04ab | |||
| c41ff7fd0a |
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using ytLive;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
private SocialsConfig? _socials;
|
||||||
|
private VideoFrame? _socialBarFrame;
|
||||||
|
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
||||||
|
|
||||||
|
public ICommand OpenSocialDialogCommand { get; }
|
||||||
|
|
||||||
|
// ─── Social bar (global resource, shown on every scene) ───
|
||||||
|
|
||||||
|
/// <summary>True when the global socials config has at least one validated entry.</summary>
|
||||||
|
public bool HasSocials => _socials != null && _socials.Entries.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The bar is on the stream when it's enabled and has entries.</summary>
|
||||||
|
public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
|
||||||
|
|
||||||
|
/// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
|
||||||
|
public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
|
||||||
|
|
||||||
|
/// <summary>Green glow on the bar while it's on the stream.</summary>
|
||||||
|
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
|
||||||
|
|
||||||
|
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||||
|
public SocialsConfig? Socials => _socials;
|
||||||
|
|
||||||
|
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
||||||
|
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
|
||||||
|
/// fediverse entry that doesn't have one, so the bar shows the instance's real
|
||||||
|
/// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
|
||||||
|
/// leaves the glyph untouched. Returns handle → software for what was resolved —
|
||||||
|
/// the caller applies + persists (the app hops to the dispatcher; a test applies
|
||||||
|
/// directly).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
|
||||||
|
SocialsConfig socials,
|
||||||
|
ISocialValidator validator,
|
||||||
|
Action<string>? log = null)
|
||||||
|
{
|
||||||
|
var resolved = new Dictionary<string, string>();
|
||||||
|
if (socials == null || validator == null) return resolved;
|
||||||
|
foreach (var entry in socials.Entries)
|
||||||
|
{
|
||||||
|
if (entry.Service != SocialService.Fediverse
|
||||||
|
|| !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
|
||||||
|
continue;
|
||||||
|
if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
|
||||||
|
continue;
|
||||||
|
var software = await validator.ResolveFediverseSoftwareAsync(
|
||||||
|
domain, System.Threading.CancellationToken.None);
|
||||||
|
if (string.IsNullOrWhiteSpace(software)) continue;
|
||||||
|
resolved[entry.Handle] = software;
|
||||||
|
log?.Invoke($"Socials heal: {entry.Handle} → {software}");
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
|
||||||
|
private void HealFediverseSoftwareInBackground()
|
||||||
|
{
|
||||||
|
var socials = _socials;
|
||||||
|
if (socials == null) return;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolved = await HealFediverseSoftwareAsync(
|
||||||
|
socials, _socialValidator, m => AppLog.Write(m));
|
||||||
|
if (resolved.Count == 0) return;
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var current = _socials;
|
||||||
|
if (current == null) return;
|
||||||
|
var applied = false;
|
||||||
|
foreach (var entry in current.Entries)
|
||||||
|
{
|
||||||
|
if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
|
||||||
|
if (entry.FediverseSoftware == software) continue;
|
||||||
|
entry.FediverseSoftware = software;
|
||||||
|
applied = true;
|
||||||
|
}
|
||||||
|
if (applied) NotifySocialsChanged(); // re-renders the bar + saves
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"Socials heal failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-rasterizes the social bar strip the output compositor overlays. UI thread
|
||||||
|
/// only (WPF rendering); the resulting frame is immutable, so the frame pump may
|
||||||
|
/// read it from its own thread. Null when the bar is off or empty.
|
||||||
|
/// </summary>
|
||||||
|
private void RenderSocialBarFrame()
|
||||||
|
{
|
||||||
|
_socialBarFrame = _socials != null && _socials.BarEnabled
|
||||||
|
? SocialBarRenderer.Render(_socials.Entries)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
|
||||||
|
/// validation). On save the working copy replaces <see cref="_socials"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void OpenSocialDialog()
|
||||||
|
{
|
||||||
|
var dialog = new SocialsDialogViewModel(
|
||||||
|
_socialValidator,
|
||||||
|
SignInAsync,
|
||||||
|
SignOutYouTubeAsync,
|
||||||
|
_youtubeAuth.CurrentChannel,
|
||||||
|
_socials);
|
||||||
|
var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
|
||||||
|
if (window.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
_socials = dialog.BuildSocialsConfig();
|
||||||
|
NotifySocialsChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifySocialsChanged()
|
||||||
|
{
|
||||||
|
RenderSocialBarFrame();
|
||||||
|
OnPropertyChanged(nameof(HasSocials));
|
||||||
|
OnPropertyChanged(nameof(SocialBarVisible));
|
||||||
|
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
|
||||||
|
public void SetSocialBarPosition(SocialBarPosition position)
|
||||||
|
{
|
||||||
|
if (_socials == null || _socials.BarPosition == position) return;
|
||||||
|
_socials.BarPosition = position;
|
||||||
|
OnPropertyChanged(nameof(SocialBarTop));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
|
||||||
|
public void ToggleSocialBarPosition()
|
||||||
|
=> SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
|
||||||
|
? SocialBarPosition.Bottom
|
||||||
|
: SocialBarPosition.Top);
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.Services.Encoder;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
|
||||||
|
private readonly DispatcherTimer _liveTimer;
|
||||||
|
private readonly DispatcherTimer _healthPollTimer;
|
||||||
|
|
||||||
|
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||||
|
private StreamHealth _currentHealth = new();
|
||||||
|
private string _streamTitle = string.Empty;
|
||||||
|
private string _streamDescription = string.Empty;
|
||||||
|
private string _streamVisibility = "Private";
|
||||||
|
private string? _currentBroadcastId;
|
||||||
|
private string? _reusableStreamUrl;
|
||||||
|
private ReusableStream? _reusableStream;
|
||||||
|
private string? _healthIssueBanner;
|
||||||
|
private string _healthIssueBackground = "Transparent";
|
||||||
|
private string _windowTitle = "LlamaCasty";
|
||||||
|
private string _topBarBackground = "#16213e";
|
||||||
|
private string _previewGlowBrush = "Transparent";
|
||||||
|
private Thickness _previewGlowThickness = new(0);
|
||||||
|
private string _liveElapsedText = "00:00:00";
|
||||||
|
private double _recDotPulse = 1.0;
|
||||||
|
private TimeSpan _liveElapsed;
|
||||||
|
|
||||||
|
// ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are
|
||||||
|
// ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in.
|
||||||
|
private bool _recordPillOn;
|
||||||
|
private bool _onAirPillOn;
|
||||||
|
private bool _isRecording;
|
||||||
|
private string? _recordFolder;
|
||||||
|
|
||||||
|
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
|
||||||
|
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
|
||||||
|
|
||||||
|
private string? _activeRecordPath;
|
||||||
|
private DateTime _recordStartTime;
|
||||||
|
private TimeSpan _recordLength;
|
||||||
|
|
||||||
|
private readonly FramePump _framePump;
|
||||||
|
|
||||||
|
public StreamStatus StreamStatus
|
||||||
|
{
|
||||||
|
get => _streamStatus;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _streamStatus, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsOffline));
|
||||||
|
OnPropertyChanged(nameof(IsLive));
|
||||||
|
OnPropertyChanged(nameof(IsEditMode));
|
||||||
|
OnPropertyChanged(nameof(RecDotBrush));
|
||||||
|
OnPropertyChanged(nameof(RecTextBrush));
|
||||||
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
|
OnPropertyChanged(nameof(OnAirBrush));
|
||||||
|
OnPropertyChanged(nameof(IsLivePrivate));
|
||||||
|
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||||
|
OnPropertyChanged(nameof(ShowLogInButton));
|
||||||
|
OnPropertyChanged(nameof(ShowEndStreamButton));
|
||||||
|
OnPropertyChanged(nameof(EndButtonText));
|
||||||
|
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||||
|
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||||
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||||
|
UpdateLiveVisuals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||||
|
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||||
|
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
||||||
|
public string RecDotBrush => !IsRecording ? "#555555" : "#e94560";
|
||||||
|
public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
|
||||||
|
public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
|
||||||
|
public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560";
|
||||||
|
|
||||||
|
// ─── REC / ON-AIR pills (TASK 18) ───
|
||||||
|
public bool RecordPillOn
|
||||||
|
{
|
||||||
|
get => _recordPillOn;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetProperty(ref _recordPillOn, value)) return;
|
||||||
|
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||||
|
OnPropertyChanged(nameof(CanStartSession));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool OnAirPillOn
|
||||||
|
{
|
||||||
|
get => _onAirPillOn;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value && !CanToggleOnAir) return; // needs a sign-in
|
||||||
|
if (!SetProperty(ref _onAirPillOn, value)) return;
|
||||||
|
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||||
|
OnPropertyChanged(nameof(CanStartSession));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The ON-AIR pill (and thus live streaming) needs a YouTube sign-in.</summary>
|
||||||
|
public bool CanToggleOnAir => IsConnected;
|
||||||
|
|
||||||
|
/// <summary>Whether the session is actively recording — the REC status light
|
||||||
|
/// turns green only when this is true (the pill is intent, this is reality).</summary>
|
||||||
|
public bool IsRecording
|
||||||
|
{
|
||||||
|
get => _isRecording;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (!SetProperty(ref _isRecording, value)) return;
|
||||||
|
OnPropertyChanged(nameof(RecDotBrush));
|
||||||
|
OnPropertyChanged(nameof(RecTextBrush));
|
||||||
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
|
OnPropertyChanged(nameof(IsEditMode));
|
||||||
|
OnPropertyChanged(nameof(ShowEndStreamButton));
|
||||||
|
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||||
|
OnPropertyChanged(nameof(EndButtonText));
|
||||||
|
UpdateSessionTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>Either output selected makes the button actionable.</summary>
|
||||||
|
public bool CanStartSession => RecordPillOn || OnAirPillOn;
|
||||||
|
|
||||||
|
/// <summary>The End button shows while recording OR live.</summary>
|
||||||
|
public bool ShowEndStreamButton => IsLive || IsRecording;
|
||||||
|
|
||||||
|
/// <summary>The one button whose label changes: while recording it reads
|
||||||
|
/// "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;
|
||||||
|
set => SetProperty(ref _currentHealth, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Report-by-exception YouTube health (TASK 5 item 3): non-null only
|
||||||
|
/// while a warning/error configuration issue exists — the banner's text.
|
||||||
|
/// good/ok/noData keep it null so nothing renders.</summary>
|
||||||
|
public string? HealthIssueBanner
|
||||||
|
{
|
||||||
|
get => _healthIssueBanner;
|
||||||
|
private set => SetProperty(ref _healthIssueBanner, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Banner background: app red (#8f1f1f) for an error issue, amber
|
||||||
|
/// (#b8860b) for a warning. Transparent when no banner is shown.</summary>
|
||||||
|
public string HealthIssueBackground
|
||||||
|
{
|
||||||
|
get => _healthIssueBackground;
|
||||||
|
private set => SetProperty(ref _healthIssueBackground, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StreamTitle
|
||||||
|
{
|
||||||
|
get => _streamTitle;
|
||||||
|
set => SetProperty(ref _streamTitle, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StreamDescription
|
||||||
|
{
|
||||||
|
get => _streamDescription;
|
||||||
|
set => SetProperty(ref _streamDescription, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StreamVisibility
|
||||||
|
{
|
||||||
|
get => _streamVisibility;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _streamVisibility, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(RecDotBrush));
|
||||||
|
OnPropertyChanged(nameof(RecTextBrush));
|
||||||
|
OnPropertyChanged(nameof(OnAirBrush));
|
||||||
|
OnPropertyChanged(nameof(IsLivePrivate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string WindowTitle
|
||||||
|
{
|
||||||
|
get => _windowTitle;
|
||||||
|
set => SetProperty(ref _windowTitle, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TopBarBackground
|
||||||
|
{
|
||||||
|
get => _topBarBackground;
|
||||||
|
set => SetProperty(ref _topBarBackground, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PreviewGlowBrush
|
||||||
|
{
|
||||||
|
get => _previewGlowBrush;
|
||||||
|
set => SetProperty(ref _previewGlowBrush, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Thickness PreviewGlowThickness
|
||||||
|
{
|
||||||
|
get => _previewGlowThickness;
|
||||||
|
set => SetProperty(ref _previewGlowThickness, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LiveElapsedText
|
||||||
|
{
|
||||||
|
get => _liveElapsedText;
|
||||||
|
set => SetProperty(ref _liveElapsedText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ───
|
||||||
|
|
||||||
|
/// <summary>The broadcast id of the current/last session; null until Go Live
|
||||||
|
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).</summary>
|
||||||
|
public string? CurrentBroadcastId => _currentBroadcastId;
|
||||||
|
|
||||||
|
public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
|
||||||
|
|
||||||
|
// ─── Resolution quality dropdown (bottom bar) ───
|
||||||
|
// Tiers offered to the creator before going live. First = default. The
|
||||||
|
// dropdown is disabled while live because YouTube stream resolution is
|
||||||
|
// immutable after stream creation (see TASKS.md compliance notes).
|
||||||
|
|
||||||
|
// The composition master frame. Every tier is an output rect + target
|
||||||
|
// resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
|
||||||
|
// use the full frame; the vertical tier uses a centered 9:16 window and
|
||||||
|
// the preview dims the cropped strips (semi-crop).
|
||||||
|
private const double MasterFrameWidth = 1920;
|
||||||
|
private const double MasterFrameHeight = 1080;
|
||||||
|
|
||||||
|
public QualityOption[] QualityOptions { get; } =
|
||||||
|
{
|
||||||
|
new("1080p60", 60, 8.0, 1920, 1080),
|
||||||
|
new("1080p30", 30, 8.0, 1920, 1080),
|
||||||
|
new("720p60", 60, 6.0, 1280, 720),
|
||||||
|
new("720p30", 30, 6.0, 1280, 720),
|
||||||
|
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
|
||||||
|
};
|
||||||
|
|
||||||
|
public string ResolutionHelp { get; } =
|
||||||
|
"Pick the highest resolution your upload bandwidth can comfortably sustain. " +
|
||||||
|
"Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
|
||||||
|
"then choose a tier your upload comfortably exceeds. 1080p60 is the default.";
|
||||||
|
|
||||||
|
private QualityOption _selectedQuality;
|
||||||
|
public QualityOption SelectedQuality
|
||||||
|
{
|
||||||
|
get => _selectedQuality;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedQuality, value))
|
||||||
|
ApplyStreamQuality(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output frame of the selected tier over the 1920x1080 master. The strips
|
||||||
|
// outside it are dimmed in the preview so the cropped area stays visible.
|
||||||
|
public double OutputRectX { get; private set; }
|
||||||
|
public double OutputRectY { get; private set; }
|
||||||
|
public double OutputRectWidth { get; private set; }
|
||||||
|
public double OutputRectHeight { get; private set; }
|
||||||
|
public bool IsOutputCropped { get; private set; }
|
||||||
|
public string ResolutionBadgeText { get; private set; } = string.Empty;
|
||||||
|
public ObservableCollection<Rect> DimRects { get; } = new();
|
||||||
|
|
||||||
|
private void ApplyStreamQuality(QualityOption quality)
|
||||||
|
{
|
||||||
|
var health = CurrentHealth;
|
||||||
|
health.CurrentBitrate = quality.Bitrate;
|
||||||
|
health.FPS = quality.Fps;
|
||||||
|
OnPropertyChanged(nameof(CurrentHealth));
|
||||||
|
|
||||||
|
var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
|
||||||
|
var rectW = quality.Width * scale;
|
||||||
|
var rectH = quality.Height * scale;
|
||||||
|
var rectX = (MasterFrameWidth - rectW) / 2;
|
||||||
|
var rectY = (MasterFrameHeight - rectH) / 2;
|
||||||
|
|
||||||
|
OutputRectX = rectX;
|
||||||
|
OutputRectY = rectY;
|
||||||
|
OutputRectWidth = rectW;
|
||||||
|
OutputRectHeight = rectH;
|
||||||
|
|
||||||
|
DimRects.Clear();
|
||||||
|
if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
|
||||||
|
if (rectX + rectW < MasterFrameWidth)
|
||||||
|
DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
|
||||||
|
if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
|
||||||
|
if (rectY + rectH < MasterFrameHeight)
|
||||||
|
DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
|
||||||
|
|
||||||
|
IsOutputCropped = DimRects.Count > 0;
|
||||||
|
ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(OutputRectX));
|
||||||
|
OnPropertyChanged(nameof(OutputRectY));
|
||||||
|
OnPropertyChanged(nameof(OutputRectWidth));
|
||||||
|
OnPropertyChanged(nameof(OutputRectHeight));
|
||||||
|
OnPropertyChanged(nameof(IsOutputCropped));
|
||||||
|
OnPropertyChanged(nameof(ResolutionBadgeText));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand StartStreamCommand { get; }
|
||||||
|
public ICommand EndStreamCommand { get; }
|
||||||
|
public ICommand ChooseRecordFolderCommand { get; }
|
||||||
|
public ICommand ResetRecordFolderCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Entry point for the primary Start button (and the start/stop hotkey):
|
||||||
|
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
|
||||||
|
private void StartSession()
|
||||||
|
{
|
||||||
|
if (IsLive || IsRecording) return;
|
||||||
|
var streaming = OnAirPillOn;
|
||||||
|
var recording = RecordPillOn;
|
||||||
|
if (!streaming && !recording) return;
|
||||||
|
|
||||||
|
if (!streaming)
|
||||||
|
{
|
||||||
|
BeginRecordOnly();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BeginGoLive(recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
|
||||||
|
/// is local and works signed-out. Generates the auto-named file and starts the
|
||||||
|
/// pipeline immediately.</summary>
|
||||||
|
private async void BeginRecordOnly()
|
||||||
|
{
|
||||||
|
var path = StartRecordFile();
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
_notifications.Error("Couldn't record", "No record output path could be created.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recordStartTime = DateTime.Now;
|
||||||
|
_liveElapsed = TimeSpan.Zero;
|
||||||
|
_recordLength = TimeSpan.Zero;
|
||||||
|
ResetHealth(StreamStatus.Offline);
|
||||||
|
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
||||||
|
IsRecording = true;
|
||||||
|
await _framePump.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginGoLive(bool alsoRecord)
|
||||||
|
{
|
||||||
|
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
||||||
|
{
|
||||||
|
StreamTitle = BroadcastForm.Title,
|
||||||
|
StreamDescription = BroadcastForm.Description,
|
||||||
|
};
|
||||||
|
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
||||||
|
if (window.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
StreamTitle = dialog.StreamTitle;
|
||||||
|
StreamDescription = dialog.StreamDescription;
|
||||||
|
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
|
||||||
|
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
|
||||||
|
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
|
||||||
|
? "LlamaCasty"
|
||||||
|
: $"{dialog.StreamTitle} — LlamaCasty";
|
||||||
|
StreamStatus = StreamStatus.Streaming;
|
||||||
|
ResetHealth(StreamStatus.Streaming);
|
||||||
|
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
||||||
|
_liveElapsed = TimeSpan.Zero;
|
||||||
|
_recordLength = TimeSpan.Zero;
|
||||||
|
|
||||||
|
var recordStarted = false;
|
||||||
|
if (alsoRecord)
|
||||||
|
{
|
||||||
|
if (StartRecordFile() == null)
|
||||||
|
{
|
||||||
|
_notifications.Error("Couldn't record",
|
||||||
|
"No record output path could be created — streaming continues without recording.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_recordStartTime = DateTime.Now;
|
||||||
|
recordStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IsRecording = recordStarted;
|
||||||
|
|
||||||
|
_ = PrepareAndStartLiveAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
|
||||||
|
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
|
||||||
|
private string? StartRecordFile()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var folder = _recordFolder ?? DefaultRecordFolder();
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
|
||||||
|
var path = Path.Combine(folder, name);
|
||||||
|
_activeRecordPath = path;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "Recording: failed to prepare output path");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
|
||||||
|
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
|
||||||
|
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
|
||||||
|
/// before the pump starts — hence the ordering here. Failures surface as an
|
||||||
|
/// error health state, never a crash.</summary>
|
||||||
|
private async Task PrepareAndStartLiveAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
|
||||||
|
if (stream == null)
|
||||||
|
{
|
||||||
|
AppLog.Write("Reusable stream unavailable; check the OAuth session");
|
||||||
|
_notifications.Error("Couldn't go live",
|
||||||
|
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
|
||||||
|
StreamStatus = StreamStatus.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_reusableStream = stream;
|
||||||
|
_reusableStreamUrl = stream.RtmpUrl;
|
||||||
|
_layoutStore.SaveReusableStream(stream);
|
||||||
|
|
||||||
|
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
|
||||||
|
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
|
||||||
|
if (_currentBroadcastId == null)
|
||||||
|
{
|
||||||
|
AppLog.Write("Broadcast creation failed; check the OAuth session");
|
||||||
|
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
|
||||||
|
StreamStatus = StreamStatus.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
||||||
|
OnPropertyChanged(nameof(CurrentBroadcastId));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
|
||||||
|
{
|
||||||
|
BroadcastForm.IsDrawerOpen = true;
|
||||||
|
_notifications.Warning("Complete your broadcast details",
|
||||||
|
"Your title and description are empty — fill them in from the Text panel on the right.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
|
||||||
|
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
|
||||||
|
// the stream continues without on-panel chat.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
|
||||||
|
if (liveChatId != null)
|
||||||
|
{
|
||||||
|
_youtubeChat.Start(liveChatId);
|
||||||
|
AppLog.Write($"Chat polling started: {liveChatId}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception chatEx)
|
||||||
|
{
|
||||||
|
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"Go-live preparation failed: {ex.Message}");
|
||||||
|
_notifications.Error("Couldn't go live", ex.Message);
|
||||||
|
StreamStatus = StreamStatus.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||||
|
|
||||||
|
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
|
||||||
|
// live. Report-by-exception — a banner appears only on warning/error.
|
||||||
|
_healthPollTimer.Start();
|
||||||
|
_ = PollHealthAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
|
||||||
|
|
||||||
|
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
|
||||||
|
/// health state + the report-by-exception banner. Never throws — a poll
|
||||||
|
/// failure logs and leaves the last state untouched.</summary>
|
||||||
|
private async Task PollHealthAsync()
|
||||||
|
{
|
||||||
|
if (_reusableStream == null || !IsLive) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
|
||||||
|
if (health == null)
|
||||||
|
{
|
||||||
|
AppLog.Write("Stream health poll failed; check the OAuth session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CurrentHealth.HealthStatus = health.HealthStatus;
|
||||||
|
CurrentHealth.HealthMessage = health.HealthStatus;
|
||||||
|
CurrentHealth.ConfigurationIssues.Clear();
|
||||||
|
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
|
||||||
|
OnPropertyChanged(nameof(CurrentHealth));
|
||||||
|
ApplyHealthIssue(health.ConfigurationIssues);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"Stream health poll failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report-by-exception: good/ok/noData (or info-only issues) clear the banner;
|
||||||
|
// the first warning/error issue raises it, colored by the worst severity.
|
||||||
|
private void ApplyHealthIssue(List<StreamConfigurationIssue> issues)
|
||||||
|
{
|
||||||
|
var report = StreamHealthReporter.BannerFor(issues);
|
||||||
|
if (report.Text == null)
|
||||||
|
{
|
||||||
|
HealthIssueBanner = null;
|
||||||
|
HealthIssueBackground = "Transparent";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b";
|
||||||
|
HealthIssueBanner = report.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void StopStream()
|
||||||
|
{
|
||||||
|
var wasLive = IsLive;
|
||||||
|
var wasRecording = IsRecording;
|
||||||
|
|
||||||
|
StreamStatus = StreamStatus.Offline;
|
||||||
|
IsRecording = false;
|
||||||
|
WindowTitle = "LlamaCasty";
|
||||||
|
ResetHealth(StreamStatus.Offline);
|
||||||
|
// Stop chat polling first (TASK 9.4) — no more messages arriving while
|
||||||
|
// we're tearing down the stream.
|
||||||
|
_youtubeChat.Stop();
|
||||||
|
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
|
||||||
|
// then stop the pump (stdin EOF) — both inputs end and the encoder
|
||||||
|
// finalizes the output. Audio capture itself is always-on (preview
|
||||||
|
// monitoring); only the live/report pipe, the pump, and the session stop here.
|
||||||
|
_audioMixer.StopLive();
|
||||||
|
await _framePump.StopAsync();
|
||||||
|
_currentBroadcastId = null;
|
||||||
|
OnPropertyChanged(nameof(CurrentBroadcastId));
|
||||||
|
|
||||||
|
if (wasRecording) await FinalizeRecordingAsync();
|
||||||
|
|
||||||
|
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
|
||||||
|
// the session/token — the creator signs out deliberately via the
|
||||||
|
// Logout / Change Account commands.
|
||||||
|
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
|
||||||
|
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
|
||||||
|
/// name collisions. Runs only after the pump has fully closed the file.</summary>
|
||||||
|
private async Task FinalizeRecordingAsync()
|
||||||
|
{
|
||||||
|
var startPath = _activeRecordPath;
|
||||||
|
_activeRecordPath = null;
|
||||||
|
if (string.IsNullOrEmpty(startPath)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_framePump.IsRunning) await Task.Delay(50);
|
||||||
|
|
||||||
|
if (!File.Exists(startPath))
|
||||||
|
{
|
||||||
|
AppLog.Write($"Recording: output not found for rename: {startPath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recordLength = _liveElapsed;
|
||||||
|
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
|
||||||
|
var dir = Path.GetDirectoryName(startPath)!;
|
||||||
|
|
||||||
|
// Manual rename modal: give the creator a chance to name the recording;
|
||||||
|
// null = keep the auto name (or the default recorded above).
|
||||||
|
string stem = autoStem;
|
||||||
|
var dialog = new RenameRecordingDialog(autoStem);
|
||||||
|
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
|
||||||
|
stem = dialog.ResultFileName;
|
||||||
|
|
||||||
|
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
|
||||||
|
File.Move(startPath, finalPath);
|
||||||
|
AppLog.Write($"Recording saved: {finalPath}");
|
||||||
|
_notifications.Info("Recording saved",
|
||||||
|
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "Recording: rename-on-stop failed");
|
||||||
|
_notifications.Error("Recording saved",
|
||||||
|
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UniquePath(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) return path;
|
||||||
|
var dir = Path.GetDirectoryName(path)!;
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
for (var i = 2; ; i++)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
|
||||||
|
if (!File.Exists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChooseRecordFolder()
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||||
|
{
|
||||||
|
Title = "Choose a folder for local recordings",
|
||||||
|
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
_recordFolder = dialog.FolderName;
|
||||||
|
_layoutStore.SaveRecordFolder(_recordFolder);
|
||||||
|
OnPropertyChanged(nameof(RecordFolderDisplay));
|
||||||
|
_notifications.Info("Record folder set", _recordFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetRecordFolder()
|
||||||
|
{
|
||||||
|
_recordFolder = null;
|
||||||
|
_layoutStore.SaveRecordFolder(null);
|
||||||
|
OnPropertyChanged(nameof(RecordFolderDisplay));
|
||||||
|
_notifications.Info("Record folder reset", RecordFolderDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Falls back to the user's Downloads folder when no preference is set;
|
||||||
|
/// returns MyVideos if Downloads doesn't exist.</summary>
|
||||||
|
internal static string DefaultRecordFolder()
|
||||||
|
{
|
||||||
|
var downloads = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||||
|
return Directory.Exists(downloads)
|
||||||
|
? downloads
|
||||||
|
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full encoder options for the current tier, or null when no RTMP URL is
|
||||||
|
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
||||||
|
private EncoderOptions? BuildEncoderOptions()
|
||||||
|
{
|
||||||
|
var url = _rtmpUrlProvider();
|
||||||
|
var quality = SelectedQuality;
|
||||||
|
return new EncoderOptions
|
||||||
|
{
|
||||||
|
RtmpUrl = url ?? string.Empty,
|
||||||
|
StreamEnabled = OnAirPillOn,
|
||||||
|
RecordEnabled = RecordPillOn,
|
||||||
|
RecordPath = _activeRecordPath,
|
||||||
|
Width = quality.Width,
|
||||||
|
Height = quality.Height,
|
||||||
|
Fps = quality.Fps,
|
||||||
|
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFramePumpFailed(object? sender, string message)
|
||||||
|
{
|
||||||
|
AppLog.Write($"Frame pump failed: {message}");
|
||||||
|
_notifications.Error("The stream pipeline stopped", message);
|
||||||
|
if (IsLive) StreamStatus = StreamStatus.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
|
||||||
|
// duration) lands in the bottom bar. The stderr loop raises on a background
|
||||||
|
// thread — marshal to the UI thread like the audio level handlers.
|
||||||
|
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
|
||||||
|
{
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||||
|
dispatcher.BeginInvoke(() => ApplyHealth(health));
|
||||||
|
else
|
||||||
|
ApplyHealth(health);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyHealth(StreamHealth health)
|
||||||
|
{
|
||||||
|
CurrentHealth.Status = health.Status;
|
||||||
|
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
|
||||||
|
CurrentHealth.FPS = health.FPS;
|
||||||
|
CurrentHealth.DroppedFrames = health.DroppedFrames;
|
||||||
|
CurrentHealth.StreamDuration = health.StreamDuration;
|
||||||
|
CurrentHealth.LastError = health.LastError;
|
||||||
|
CurrentHealth.HealthMessage = health.HealthMessage;
|
||||||
|
OnPropertyChanged(nameof(CurrentHealth));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
|
||||||
|
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
|
||||||
|
// on the tier's targets — ApplyStreamQuality sets them on pick).
|
||||||
|
private void ResetHealth(StreamStatus status)
|
||||||
|
{
|
||||||
|
CurrentHealth.Status = status;
|
||||||
|
CurrentHealth.DroppedFrames = 0;
|
||||||
|
CurrentHealth.StreamDuration = TimeSpan.Zero;
|
||||||
|
CurrentHealth.LastError = null;
|
||||||
|
CurrentHealth.HealthStatus = null;
|
||||||
|
CurrentHealth.HealthMessage = null;
|
||||||
|
CurrentHealth.ConfigurationIssues.Clear();
|
||||||
|
HealthIssueBanner = null;
|
||||||
|
HealthIssueBackground = "Transparent";
|
||||||
|
OnPropertyChanged(nameof(CurrentHealth));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateLiveVisuals()
|
||||||
|
{
|
||||||
|
var live = IsLive;
|
||||||
|
TopBarBackground = live ? "#e94560" : "#16213e";
|
||||||
|
PreviewGlowBrush = live ? "#e94560" : "Transparent";
|
||||||
|
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
|
||||||
|
|
||||||
|
if (live)
|
||||||
|
{
|
||||||
|
if (BrandFlashEnabled) StartBrandFlashTimer();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_healthPollTimer.Stop();
|
||||||
|
_brandFlashTimer.Stop();
|
||||||
|
_brandFlashOffTimer.Stop();
|
||||||
|
BrandFlashActive = false;
|
||||||
|
}
|
||||||
|
UpdateSessionTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
|
||||||
|
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
|
||||||
|
private void UpdateSessionTimer()
|
||||||
|
{
|
||||||
|
var active = IsLive || IsRecording;
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
_recDotPulse = 1.0;
|
||||||
|
_liveTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_liveTimer.Stop();
|
||||||
|
LiveElapsedText = "00:00:00";
|
||||||
|
_recDotPulse = 1.0;
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
|
OnPropertyChanged(nameof(LiveElapsedText));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLiveTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||||
|
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||||
|
_recordLength = _liveElapsed;
|
||||||
|
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
||||||
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,43 +26,9 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private readonly YouTubeAuthService _youtubeAuth;
|
private readonly YouTubeAuthService _youtubeAuth;
|
||||||
private readonly YouTubeStreamService _youtubeStream;
|
private readonly YouTubeStreamService _youtubeStream;
|
||||||
private readonly YouTubeChatService _youtubeChat;
|
private readonly YouTubeChatService _youtubeChat;
|
||||||
private readonly DispatcherTimer _liveTimer;
|
|
||||||
private readonly DispatcherTimer _healthPollTimer;
|
|
||||||
|
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
|
|
||||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
|
||||||
private StreamHealth _currentHealth = new();
|
|
||||||
private string _streamTitle = string.Empty;
|
|
||||||
private string _streamDescription = string.Empty;
|
|
||||||
private string _streamVisibility = "Private";
|
|
||||||
private string? _currentBroadcastId;
|
|
||||||
private string? _reusableStreamUrl;
|
|
||||||
private ReusableStream? _reusableStream;
|
|
||||||
private string? _healthIssueBanner;
|
|
||||||
private string _healthIssueBackground = "Transparent";
|
|
||||||
private string _windowTitle = "LlamaCasty";
|
|
||||||
private string _topBarBackground = "#16213e";
|
|
||||||
private string _previewGlowBrush = "Transparent";
|
|
||||||
private Thickness _previewGlowThickness = new(0);
|
|
||||||
private string _liveElapsedText = "00:00:00";
|
|
||||||
private double _recDotPulse = 1.0;
|
|
||||||
private TimeSpan _liveElapsed;
|
|
||||||
|
|
||||||
// ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are
|
|
||||||
// ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in.
|
|
||||||
private bool _recordPillOn;
|
|
||||||
private bool _onAirPillOn;
|
|
||||||
private bool _isRecording;
|
|
||||||
private string? _recordFolder;
|
|
||||||
|
|
||||||
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
|
|
||||||
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
|
|
||||||
|
|
||||||
private string? _activeRecordPath;
|
|
||||||
private DateTime _recordStartTime;
|
|
||||||
private TimeSpan _recordLength;
|
|
||||||
|
|
||||||
private bool _isSettingsOpen;
|
private bool _isSettingsOpen;
|
||||||
private bool _isBugOpen;
|
private bool _isBugOpen;
|
||||||
private bool _isFeatureOpen;
|
private bool _isFeatureOpen;
|
||||||
@@ -83,14 +49,10 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// news up the real service; the VM is built from XAML so no DI seam exists.
|
// news up the real service; the VM is built from XAML so no DI seam exists.
|
||||||
private readonly INotificationService _notifications = new NotificationService();
|
private readonly INotificationService _notifications = new NotificationService();
|
||||||
|
|
||||||
private SocialsConfig? _socials;
|
|
||||||
private VideoFrame? _socialBarFrame;
|
|
||||||
|
|
||||||
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
||||||
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
||||||
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
|
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
|
||||||
private readonly Func<string?> _rtmpUrlProvider;
|
private readonly Func<string?> _rtmpUrlProvider;
|
||||||
private readonly FramePump _framePump;
|
|
||||||
private readonly TransitionService _transition = new();
|
private readonly TransitionService _transition = new();
|
||||||
|
|
||||||
private readonly ChatBoxRenderer _chatBoxRenderer;
|
private readonly ChatBoxRenderer _chatBoxRenderer;
|
||||||
@@ -169,138 +131,16 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
=> StagedScene != null
|
=> StagedScene != null
|
||||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
||||||
|
|
||||||
// ─── Social bar (global resource, shown on every scene) ───
|
|
||||||
|
|
||||||
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
||||||
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
||||||
private static SolidColorBrush CreateBrush(string hex)
|
private static SolidColorBrush CreateBrush(string hex)
|
||||||
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
|
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
|
||||||
|
|
||||||
/// <summary>True when the global socials config has at least one validated entry.</summary>
|
|
||||||
public bool HasSocials => _socials != null && _socials.Entries.Count > 0;
|
|
||||||
|
|
||||||
/// <summary>The bar is on the stream when it's enabled and has entries.</summary>
|
|
||||||
public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
|
|
||||||
|
|
||||||
/// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
|
|
||||||
public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
|
|
||||||
|
|
||||||
/// <summary>Green glow on the bar while it's on the stream.</summary>
|
|
||||||
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
|
|
||||||
|
|
||||||
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
|
||||||
public SocialsConfig? Socials => _socials;
|
|
||||||
|
|
||||||
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
|
||||||
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
|
||||||
|
|
||||||
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
|
||||||
|
|
||||||
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
|
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
|
||||||
public bool IsElementsPanelEmpty => SelectedElement is { } el
|
public bool IsElementsPanelEmpty => SelectedElement is { } el
|
||||||
&& !el.IsDraggable && !el.IsWebcam
|
&& !el.IsDraggable && !el.IsWebcam
|
||||||
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
||||||
|
|
||||||
public StreamStatus StreamStatus
|
|
||||||
{
|
|
||||||
get => _streamStatus;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _streamStatus, value))
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(IsOffline));
|
|
||||||
OnPropertyChanged(nameof(IsLive));
|
|
||||||
OnPropertyChanged(nameof(IsEditMode));
|
|
||||||
OnPropertyChanged(nameof(RecDotBrush));
|
|
||||||
OnPropertyChanged(nameof(RecTextBrush));
|
|
||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
|
||||||
OnPropertyChanged(nameof(OnAirBrush));
|
|
||||||
OnPropertyChanged(nameof(IsLivePrivate));
|
|
||||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
|
||||||
OnPropertyChanged(nameof(ShowLogInButton));
|
|
||||||
OnPropertyChanged(nameof(ShowEndStreamButton));
|
|
||||||
OnPropertyChanged(nameof(EndButtonText));
|
|
||||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
|
||||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
|
||||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
|
||||||
UpdateLiveVisuals();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
|
||||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
|
||||||
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
|
||||||
public string RecDotBrush => !IsRecording ? "#555555" : "#e94560";
|
|
||||||
public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
|
|
||||||
public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
|
|
||||||
public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560";
|
|
||||||
|
|
||||||
// ─── REC / ON-AIR pills (TASK 18) ───
|
|
||||||
public bool RecordPillOn
|
|
||||||
{
|
|
||||||
get => _recordPillOn;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (!SetProperty(ref _recordPillOn, value)) return;
|
|
||||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
|
||||||
OnPropertyChanged(nameof(CanStartSession));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool OnAirPillOn
|
|
||||||
{
|
|
||||||
get => _onAirPillOn;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (value && !CanToggleOnAir) return; // needs a sign-in
|
|
||||||
if (!SetProperty(ref _onAirPillOn, value)) return;
|
|
||||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
|
||||||
OnPropertyChanged(nameof(CanStartSession));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The ON-AIR pill (and thus live streaming) needs a YouTube sign-in.</summary>
|
|
||||||
public bool CanToggleOnAir => IsConnected;
|
|
||||||
|
|
||||||
/// <summary>Whether the session is actively recording — the REC status light
|
|
||||||
/// turns green only when this is true (the pill is intent, this is reality).</summary>
|
|
||||||
public bool IsRecording
|
|
||||||
{
|
|
||||||
get => _isRecording;
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
if (!SetProperty(ref _isRecording, value)) return;
|
|
||||||
OnPropertyChanged(nameof(RecDotBrush));
|
|
||||||
OnPropertyChanged(nameof(RecTextBrush));
|
|
||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
|
||||||
OnPropertyChanged(nameof(IsEditMode));
|
|
||||||
OnPropertyChanged(nameof(ShowEndStreamButton));
|
|
||||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
|
||||||
OnPropertyChanged(nameof(EndButtonText));
|
|
||||||
UpdateSessionTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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>Either output selected makes the button actionable.</summary>
|
|
||||||
public bool CanStartSession => RecordPillOn || OnAirPillOn;
|
|
||||||
|
|
||||||
/// <summary>The End button shows while recording OR live.</summary>
|
|
||||||
public bool ShowEndStreamButton => IsLive || IsRecording;
|
|
||||||
|
|
||||||
/// <summary>The one button whose label changes: while recording it reads
|
|
||||||
/// "Stop Recording", while live "End Stream".</summary>
|
|
||||||
public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
|
|
||||||
|
|
||||||
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
|
|
||||||
|
|
||||||
public bool ShowChatInactiveMessage => !IsLive;
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
||||||
|
|
||||||
@@ -331,86 +171,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private set => SetProperty(ref _brandFlashActive, value);
|
private set => SetProperty(ref _brandFlashActive, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamHealth CurrentHealth
|
|
||||||
{
|
|
||||||
get => _currentHealth;
|
|
||||||
set => SetProperty(ref _currentHealth, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Report-by-exception YouTube health (TASK 5 item 3): non-null only
|
|
||||||
/// while a warning/error configuration issue exists — the banner's text.
|
|
||||||
/// good/ok/noData keep it null so nothing renders.</summary>
|
|
||||||
public string? HealthIssueBanner
|
|
||||||
{
|
|
||||||
get => _healthIssueBanner;
|
|
||||||
private set => SetProperty(ref _healthIssueBanner, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Banner background: app red (#8f1f1f) for an error issue, amber
|
|
||||||
/// (#b8860b) for a warning. Transparent when no banner is shown.</summary>
|
|
||||||
public string HealthIssueBackground
|
|
||||||
{
|
|
||||||
get => _healthIssueBackground;
|
|
||||||
private set => SetProperty(ref _healthIssueBackground, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string StreamTitle
|
|
||||||
{
|
|
||||||
get => _streamTitle;
|
|
||||||
set => SetProperty(ref _streamTitle, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string StreamDescription
|
|
||||||
{
|
|
||||||
get => _streamDescription;
|
|
||||||
set => SetProperty(ref _streamDescription, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string StreamVisibility
|
|
||||||
{
|
|
||||||
get => _streamVisibility;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _streamVisibility, value))
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(RecDotBrush));
|
|
||||||
OnPropertyChanged(nameof(RecTextBrush));
|
|
||||||
OnPropertyChanged(nameof(OnAirBrush));
|
|
||||||
OnPropertyChanged(nameof(IsLivePrivate));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string WindowTitle
|
|
||||||
{
|
|
||||||
get => _windowTitle;
|
|
||||||
set => SetProperty(ref _windowTitle, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string TopBarBackground
|
|
||||||
{
|
|
||||||
get => _topBarBackground;
|
|
||||||
set => SetProperty(ref _topBarBackground, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string PreviewGlowBrush
|
|
||||||
{
|
|
||||||
get => _previewGlowBrush;
|
|
||||||
set => SetProperty(ref _previewGlowBrush, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Thickness PreviewGlowThickness
|
|
||||||
{
|
|
||||||
get => _previewGlowThickness;
|
|
||||||
set => SetProperty(ref _previewGlowThickness, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string LiveElapsedText
|
|
||||||
{
|
|
||||||
get => _liveElapsedText;
|
|
||||||
set => SetProperty(ref _liveElapsedText, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overlay panels
|
// Overlay panels
|
||||||
public bool IsSettingsOpen
|
public bool IsSettingsOpen
|
||||||
{
|
{
|
||||||
@@ -456,14 +216,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private set => SetProperty(ref _overlayTitle, value);
|
private set => SetProperty(ref _overlayTitle, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ───
|
|
||||||
|
|
||||||
/// <summary>The broadcast id of the current/last session; null until Go Live
|
|
||||||
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).</summary>
|
|
||||||
public string? CurrentBroadcastId => _currentBroadcastId;
|
|
||||||
|
|
||||||
public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
|
|
||||||
|
|
||||||
// Window geometry pass-through (code-behind owns the WPF specifics).
|
// Window geometry pass-through (code-behind owns the WPF specifics).
|
||||||
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
|
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
|
||||||
=> _layoutStore.LoadWindowState();
|
=> _layoutStore.LoadWindowState();
|
||||||
@@ -471,90 +223,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public void PersistWindowState(double left, double top, double width, double height, string state)
|
public void PersistWindowState(double left, double top, double width, double height, string state)
|
||||||
=> _layoutStore.SaveWindowState(left, top, width, height, state);
|
=> _layoutStore.SaveWindowState(left, top, width, height, state);
|
||||||
|
|
||||||
// ─── Resolution quality dropdown (bottom bar) ───
|
|
||||||
// Tiers offered to the creator before going live. First = default. The
|
|
||||||
// dropdown is disabled while live because YouTube stream resolution is
|
|
||||||
// immutable after stream creation (see TASKS.md compliance notes).
|
|
||||||
|
|
||||||
// The composition master frame. Every tier is an output rect + target
|
|
||||||
// resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
|
|
||||||
// use the full frame; the vertical tier uses a centered 9:16 window and
|
|
||||||
// the preview dims the cropped strips (semi-crop).
|
|
||||||
private const double MasterFrameWidth = 1920;
|
|
||||||
private const double MasterFrameHeight = 1080;
|
|
||||||
|
|
||||||
public QualityOption[] QualityOptions { get; } =
|
|
||||||
{
|
|
||||||
new("1080p60", 60, 8.0, 1920, 1080),
|
|
||||||
new("1080p30", 30, 8.0, 1920, 1080),
|
|
||||||
new("720p60", 60, 6.0, 1280, 720),
|
|
||||||
new("720p30", 30, 6.0, 1280, 720),
|
|
||||||
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
|
|
||||||
};
|
|
||||||
|
|
||||||
public string ResolutionHelp { get; } =
|
|
||||||
"Pick the highest resolution your upload bandwidth can comfortably sustain. " +
|
|
||||||
"Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
|
|
||||||
"then choose a tier your upload comfortably exceeds. 1080p60 is the default.";
|
|
||||||
|
|
||||||
private QualityOption _selectedQuality;
|
|
||||||
public QualityOption SelectedQuality
|
|
||||||
{
|
|
||||||
get => _selectedQuality;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _selectedQuality, value))
|
|
||||||
ApplyStreamQuality(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output frame of the selected tier over the 1920x1080 master. The strips
|
|
||||||
// outside it are dimmed in the preview so the cropped area stays visible.
|
|
||||||
public double OutputRectX { get; private set; }
|
|
||||||
public double OutputRectY { get; private set; }
|
|
||||||
public double OutputRectWidth { get; private set; }
|
|
||||||
public double OutputRectHeight { get; private set; }
|
|
||||||
public bool IsOutputCropped { get; private set; }
|
|
||||||
public string ResolutionBadgeText { get; private set; } = string.Empty;
|
|
||||||
public ObservableCollection<Rect> DimRects { get; } = new();
|
|
||||||
|
|
||||||
private void ApplyStreamQuality(QualityOption quality)
|
|
||||||
{
|
|
||||||
var health = CurrentHealth;
|
|
||||||
health.CurrentBitrate = quality.Bitrate;
|
|
||||||
health.FPS = quality.Fps;
|
|
||||||
OnPropertyChanged(nameof(CurrentHealth));
|
|
||||||
|
|
||||||
var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
|
|
||||||
var rectW = quality.Width * scale;
|
|
||||||
var rectH = quality.Height * scale;
|
|
||||||
var rectX = (MasterFrameWidth - rectW) / 2;
|
|
||||||
var rectY = (MasterFrameHeight - rectH) / 2;
|
|
||||||
|
|
||||||
OutputRectX = rectX;
|
|
||||||
OutputRectY = rectY;
|
|
||||||
OutputRectWidth = rectW;
|
|
||||||
OutputRectHeight = rectH;
|
|
||||||
|
|
||||||
DimRects.Clear();
|
|
||||||
if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
|
|
||||||
if (rectX + rectW < MasterFrameWidth)
|
|
||||||
DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
|
|
||||||
if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
|
|
||||||
if (rectY + rectH < MasterFrameHeight)
|
|
||||||
DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
|
|
||||||
|
|
||||||
IsOutputCropped = DimRects.Count > 0;
|
|
||||||
ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(OutputRectX));
|
|
||||||
OnPropertyChanged(nameof(OutputRectY));
|
|
||||||
OnPropertyChanged(nameof(OutputRectWidth));
|
|
||||||
OnPropertyChanged(nameof(OutputRectHeight));
|
|
||||||
OnPropertyChanged(nameof(IsOutputCropped));
|
|
||||||
OnPropertyChanged(nameof(ResolutionBadgeText));
|
|
||||||
}
|
|
||||||
|
|
||||||
public string BugReportText
|
public string BugReportText
|
||||||
{
|
{
|
||||||
get => _bugReportText;
|
get => _bugReportText;
|
||||||
@@ -586,11 +254,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public ICommand RemoveSourceCommand { get; }
|
public ICommand RemoveSourceCommand { get; }
|
||||||
public ICommand EditElementCommand { get; }
|
public ICommand EditElementCommand { get; }
|
||||||
public ICommand ToggleElementVisibilityCommand { get; }
|
public ICommand ToggleElementVisibilityCommand { get; }
|
||||||
public ICommand OpenSocialDialogCommand { get; }
|
|
||||||
public ICommand StartStreamCommand { get; }
|
|
||||||
public ICommand EndStreamCommand { get; }
|
|
||||||
public ICommand ChooseRecordFolderCommand { get; }
|
|
||||||
public ICommand ResetRecordFolderCommand { get; }
|
|
||||||
public ICommand OpenSettingsCommand { get; }
|
public ICommand OpenSettingsCommand { get; }
|
||||||
public ICommand OpenBugCommand { get; }
|
public ICommand OpenBugCommand { get; }
|
||||||
public ICommand OpenFeatureCommand { get; }
|
public ICommand OpenFeatureCommand { get; }
|
||||||
@@ -1283,349 +946,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_chatFadeTimer.Start();
|
_chatFadeTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Entry point for the primary Start button (and the start/stop hotkey):
|
|
||||||
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
|
|
||||||
private void StartSession()
|
|
||||||
{
|
|
||||||
if (IsLive || IsRecording) return;
|
|
||||||
var streaming = OnAirPillOn;
|
|
||||||
var recording = RecordPillOn;
|
|
||||||
if (!streaming && !recording) return;
|
|
||||||
|
|
||||||
if (!streaming)
|
|
||||||
{
|
|
||||||
BeginRecordOnly();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BeginGoLive(recording);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
|
|
||||||
/// is local and works signed-out. Generates the auto-named file and starts the
|
|
||||||
/// pipeline immediately.</summary>
|
|
||||||
private async void BeginRecordOnly()
|
|
||||||
{
|
|
||||||
var path = StartRecordFile();
|
|
||||||
if (path == null)
|
|
||||||
{
|
|
||||||
_notifications.Error("Couldn't record", "No record output path could be created.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_recordStartTime = DateTime.Now;
|
|
||||||
_liveElapsed = TimeSpan.Zero;
|
|
||||||
_recordLength = TimeSpan.Zero;
|
|
||||||
ResetHealth(StreamStatus.Offline);
|
|
||||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
|
||||||
IsRecording = true;
|
|
||||||
await _framePump.StartAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BeginGoLive(bool alsoRecord)
|
|
||||||
{
|
|
||||||
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
|
||||||
{
|
|
||||||
StreamTitle = BroadcastForm.Title,
|
|
||||||
StreamDescription = BroadcastForm.Description,
|
|
||||||
};
|
|
||||||
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
|
|
||||||
if (window.ShowDialog() == true)
|
|
||||||
{
|
|
||||||
StreamTitle = dialog.StreamTitle;
|
|
||||||
StreamDescription = dialog.StreamDescription;
|
|
||||||
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
|
|
||||||
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
|
|
||||||
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
|
|
||||||
? "LlamaCasty"
|
|
||||||
: $"{dialog.StreamTitle} — LlamaCasty";
|
|
||||||
StreamStatus = StreamStatus.Streaming;
|
|
||||||
ResetHealth(StreamStatus.Streaming);
|
|
||||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
|
||||||
_liveElapsed = TimeSpan.Zero;
|
|
||||||
_recordLength = TimeSpan.Zero;
|
|
||||||
|
|
||||||
var recordStarted = false;
|
|
||||||
if (alsoRecord)
|
|
||||||
{
|
|
||||||
if (StartRecordFile() == null)
|
|
||||||
{
|
|
||||||
_notifications.Error("Couldn't record",
|
|
||||||
"No record output path could be created — streaming continues without recording.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_recordStartTime = DateTime.Now;
|
|
||||||
recordStarted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IsRecording = recordStarted;
|
|
||||||
|
|
||||||
_ = PrepareAndStartLiveAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
|
|
||||||
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
|
|
||||||
private string? StartRecordFile()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var folder = _recordFolder ?? DefaultRecordFolder();
|
|
||||||
Directory.CreateDirectory(folder);
|
|
||||||
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
|
|
||||||
var path = Path.Combine(folder, name);
|
|
||||||
_activeRecordPath = path;
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write(ex, "Recording: failed to prepare output path");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
|
|
||||||
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
|
|
||||||
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
|
|
||||||
/// before the pump starts — hence the ordering here. Failures surface as an
|
|
||||||
/// error health state, never a crash.</summary>
|
|
||||||
private async Task PrepareAndStartLiveAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
|
|
||||||
if (stream == null)
|
|
||||||
{
|
|
||||||
AppLog.Write("Reusable stream unavailable; check the OAuth session");
|
|
||||||
_notifications.Error("Couldn't go live",
|
|
||||||
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
|
|
||||||
StreamStatus = StreamStatus.Error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_reusableStream = stream;
|
|
||||||
_reusableStreamUrl = stream.RtmpUrl;
|
|
||||||
_layoutStore.SaveReusableStream(stream);
|
|
||||||
|
|
||||||
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
|
|
||||||
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
|
|
||||||
if (_currentBroadcastId == null)
|
|
||||||
{
|
|
||||||
AppLog.Write("Broadcast creation failed; check the OAuth session");
|
|
||||||
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
|
|
||||||
StreamStatus = StreamStatus.Error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
|
||||||
OnPropertyChanged(nameof(CurrentBroadcastId));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
|
|
||||||
{
|
|
||||||
BroadcastForm.IsDrawerOpen = true;
|
|
||||||
_notifications.Warning("Complete your broadcast details",
|
|
||||||
"Your title and description are empty — fill them in from the Text panel on the right.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
|
|
||||||
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
|
|
||||||
// the stream continues without on-panel chat.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
|
|
||||||
if (liveChatId != null)
|
|
||||||
{
|
|
||||||
_youtubeChat.Start(liveChatId);
|
|
||||||
AppLog.Write($"Chat polling started: {liveChatId}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception chatEx)
|
|
||||||
{
|
|
||||||
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write($"Go-live preparation failed: {ex.Message}");
|
|
||||||
_notifications.Error("Couldn't go live", ex.Message);
|
|
||||||
StreamStatus = StreamStatus.Error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
|
||||||
|
|
||||||
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
|
|
||||||
// live. Report-by-exception — a banner appears only on warning/error.
|
|
||||||
_healthPollTimer.Start();
|
|
||||||
_ = PollHealthAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
|
|
||||||
|
|
||||||
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
|
|
||||||
/// health state + the report-by-exception banner. Never throws — a poll
|
|
||||||
/// failure logs and leaves the last state untouched.</summary>
|
|
||||||
private async Task PollHealthAsync()
|
|
||||||
{
|
|
||||||
if (_reusableStream == null || !IsLive) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
|
|
||||||
if (health == null)
|
|
||||||
{
|
|
||||||
AppLog.Write("Stream health poll failed; check the OAuth session");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CurrentHealth.HealthStatus = health.HealthStatus;
|
|
||||||
CurrentHealth.HealthMessage = health.HealthStatus;
|
|
||||||
CurrentHealth.ConfigurationIssues.Clear();
|
|
||||||
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
|
|
||||||
OnPropertyChanged(nameof(CurrentHealth));
|
|
||||||
ApplyHealthIssue(health.ConfigurationIssues);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write($"Stream health poll failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report-by-exception: good/ok/noData (or info-only issues) clear the banner;
|
|
||||||
// the first warning/error issue raises it, colored by the worst severity.
|
|
||||||
private void ApplyHealthIssue(List<StreamConfigurationIssue> issues)
|
|
||||||
{
|
|
||||||
var report = StreamHealthReporter.BannerFor(issues);
|
|
||||||
if (report.Text == null)
|
|
||||||
{
|
|
||||||
HealthIssueBanner = null;
|
|
||||||
HealthIssueBackground = "Transparent";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b";
|
|
||||||
HealthIssueBanner = report.Text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void StopStream()
|
|
||||||
{
|
|
||||||
var wasLive = IsLive;
|
|
||||||
var wasRecording = IsRecording;
|
|
||||||
|
|
||||||
StreamStatus = StreamStatus.Offline;
|
|
||||||
IsRecording = false;
|
|
||||||
WindowTitle = "LlamaCasty";
|
|
||||||
ResetHealth(StreamStatus.Offline);
|
|
||||||
// Stop chat polling first (TASK 9.4) — no more messages arriving while
|
|
||||||
// we're tearing down the stream.
|
|
||||||
_youtubeChat.Stop();
|
|
||||||
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
|
|
||||||
// then stop the pump (stdin EOF) — both inputs end and the encoder
|
|
||||||
// finalizes the output. Audio capture itself is always-on (preview
|
|
||||||
// monitoring); only the live/report pipe, the pump, and the session stop here.
|
|
||||||
_audioMixer.StopLive();
|
|
||||||
await _framePump.StopAsync();
|
|
||||||
_currentBroadcastId = null;
|
|
||||||
OnPropertyChanged(nameof(CurrentBroadcastId));
|
|
||||||
|
|
||||||
if (wasRecording) await FinalizeRecordingAsync();
|
|
||||||
|
|
||||||
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
|
|
||||||
// the session/token — the creator signs out deliberately via the
|
|
||||||
// Logout / Change Account commands.
|
|
||||||
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
|
|
||||||
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
|
|
||||||
/// name collisions. Runs only after the pump has fully closed the file.</summary>
|
|
||||||
private async Task FinalizeRecordingAsync()
|
|
||||||
{
|
|
||||||
var startPath = _activeRecordPath;
|
|
||||||
_activeRecordPath = null;
|
|
||||||
if (string.IsNullOrEmpty(startPath)) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (_framePump.IsRunning) await Task.Delay(50);
|
|
||||||
|
|
||||||
if (!File.Exists(startPath))
|
|
||||||
{
|
|
||||||
AppLog.Write($"Recording: output not found for rename: {startPath}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_recordLength = _liveElapsed;
|
|
||||||
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
|
|
||||||
var dir = Path.GetDirectoryName(startPath)!;
|
|
||||||
|
|
||||||
// Manual rename modal: give the creator a chance to name the recording;
|
|
||||||
// null = keep the auto name (or the default recorded above).
|
|
||||||
string stem = autoStem;
|
|
||||||
var dialog = new RenameRecordingDialog(autoStem);
|
|
||||||
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
|
|
||||||
stem = dialog.ResultFileName;
|
|
||||||
|
|
||||||
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
|
|
||||||
File.Move(startPath, finalPath);
|
|
||||||
AppLog.Write($"Recording saved: {finalPath}");
|
|
||||||
_notifications.Info("Recording saved",
|
|
||||||
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write(ex, "Recording: rename-on-stop failed");
|
|
||||||
_notifications.Error("Recording saved",
|
|
||||||
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string UniquePath(string path)
|
|
||||||
{
|
|
||||||
if (!File.Exists(path)) return path;
|
|
||||||
var dir = Path.GetDirectoryName(path)!;
|
|
||||||
var name = Path.GetFileNameWithoutExtension(path);
|
|
||||||
var ext = Path.GetExtension(path);
|
|
||||||
for (var i = 2; ; i++)
|
|
||||||
{
|
|
||||||
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
|
|
||||||
if (!File.Exists(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ChooseRecordFolder()
|
|
||||||
{
|
|
||||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
|
||||||
{
|
|
||||||
Title = "Choose a folder for local recordings",
|
|
||||||
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
|
|
||||||
};
|
|
||||||
if (dialog.ShowDialog() == true)
|
|
||||||
{
|
|
||||||
_recordFolder = dialog.FolderName;
|
|
||||||
_layoutStore.SaveRecordFolder(_recordFolder);
|
|
||||||
OnPropertyChanged(nameof(RecordFolderDisplay));
|
|
||||||
_notifications.Info("Record folder set", _recordFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ResetRecordFolder()
|
|
||||||
{
|
|
||||||
_recordFolder = null;
|
|
||||||
_layoutStore.SaveRecordFolder(null);
|
|
||||||
OnPropertyChanged(nameof(RecordFolderDisplay));
|
|
||||||
_notifications.Info("Record folder reset", RecordFolderDisplay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Falls back to the user's Downloads folder when no preference is set;
|
|
||||||
/// returns MyVideos if Downloads doesn't exist.</summary>
|
|
||||||
internal static string DefaultRecordFolder()
|
|
||||||
{
|
|
||||||
var downloads = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
|
||||||
return Directory.Exists(downloads)
|
|
||||||
? downloads
|
|
||||||
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||||
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
||||||
// images/background by AssetId. A null frame leaves the element transparent.
|
// images/background by AssetId. A null frame leaves the element transparent.
|
||||||
@@ -1771,114 +1091,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full encoder options for the current tier, or null when no RTMP URL is
|
|
||||||
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
|
||||||
private EncoderOptions? BuildEncoderOptions()
|
|
||||||
{
|
|
||||||
var url = _rtmpUrlProvider();
|
|
||||||
var quality = SelectedQuality;
|
|
||||||
return new EncoderOptions
|
|
||||||
{
|
|
||||||
RtmpUrl = url ?? string.Empty,
|
|
||||||
StreamEnabled = OnAirPillOn,
|
|
||||||
RecordEnabled = RecordPillOn,
|
|
||||||
RecordPath = _activeRecordPath,
|
|
||||||
Width = quality.Width,
|
|
||||||
Height = quality.Height,
|
|
||||||
Fps = quality.Fps,
|
|
||||||
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnFramePumpFailed(object? sender, string message)
|
|
||||||
{
|
|
||||||
AppLog.Write($"Frame pump failed: {message}");
|
|
||||||
_notifications.Error("The stream pipeline stopped", message);
|
|
||||||
if (IsLive) StreamStatus = StreamStatus.Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
|
|
||||||
// duration) lands in the bottom bar. The stderr loop raises on a background
|
|
||||||
// thread — marshal to the UI thread like the audio level handlers.
|
|
||||||
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
|
|
||||||
{
|
|
||||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
|
||||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
|
||||||
dispatcher.BeginInvoke(() => ApplyHealth(health));
|
|
||||||
else
|
|
||||||
ApplyHealth(health);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyHealth(StreamHealth health)
|
|
||||||
{
|
|
||||||
CurrentHealth.Status = health.Status;
|
|
||||||
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
|
|
||||||
CurrentHealth.FPS = health.FPS;
|
|
||||||
CurrentHealth.DroppedFrames = health.DroppedFrames;
|
|
||||||
CurrentHealth.StreamDuration = health.StreamDuration;
|
|
||||||
CurrentHealth.LastError = health.LastError;
|
|
||||||
CurrentHealth.HealthMessage = health.HealthMessage;
|
|
||||||
OnPropertyChanged(nameof(CurrentHealth));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
|
|
||||||
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
|
|
||||||
// on the tier's targets — ApplyStreamQuality sets them on pick).
|
|
||||||
private void ResetHealth(StreamStatus status)
|
|
||||||
{
|
|
||||||
CurrentHealth.Status = status;
|
|
||||||
CurrentHealth.DroppedFrames = 0;
|
|
||||||
CurrentHealth.StreamDuration = TimeSpan.Zero;
|
|
||||||
CurrentHealth.LastError = null;
|
|
||||||
CurrentHealth.HealthStatus = null;
|
|
||||||
CurrentHealth.HealthMessage = null;
|
|
||||||
CurrentHealth.ConfigurationIssues.Clear();
|
|
||||||
HealthIssueBanner = null;
|
|
||||||
HealthIssueBackground = "Transparent";
|
|
||||||
OnPropertyChanged(nameof(CurrentHealth));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateLiveVisuals()
|
|
||||||
{
|
|
||||||
var live = IsLive;
|
|
||||||
TopBarBackground = live ? "#e94560" : "#16213e";
|
|
||||||
PreviewGlowBrush = live ? "#e94560" : "Transparent";
|
|
||||||
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
|
|
||||||
|
|
||||||
if (live)
|
|
||||||
{
|
|
||||||
if (BrandFlashEnabled) StartBrandFlashTimer();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_healthPollTimer.Stop();
|
|
||||||
_brandFlashTimer.Stop();
|
|
||||||
_brandFlashOffTimer.Stop();
|
|
||||||
BrandFlashActive = false;
|
|
||||||
}
|
|
||||||
UpdateSessionTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
|
|
||||||
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
|
|
||||||
private void UpdateSessionTimer()
|
|
||||||
{
|
|
||||||
var active = IsLive || IsRecording;
|
|
||||||
if (active)
|
|
||||||
{
|
|
||||||
_recDotPulse = 1.0;
|
|
||||||
_liveTimer.Start();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_liveTimer.Stop();
|
|
||||||
LiveElapsedText = "00:00:00";
|
|
||||||
_recDotPulse = 1.0;
|
|
||||||
}
|
|
||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
|
||||||
OnPropertyChanged(nameof(LiveElapsedText));
|
|
||||||
}
|
|
||||||
|
|
||||||
// First flash shortly after go-live, then every 300s (the cadence resets on
|
// First flash shortly after go-live, then every 300s (the cadence resets on
|
||||||
// the first tick so the interval is 5s only for that one shot).
|
// the first tick so the interval is 5s only for that one shot).
|
||||||
private void StartBrandFlashTimer()
|
private void StartBrandFlashTimer()
|
||||||
@@ -1904,136 +1116,4 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_brandFlashOffTimer.Start();
|
_brandFlashOffTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLiveTimerTick(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
|
||||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
|
||||||
_recordLength = _liveElapsed;
|
|
||||||
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
|
||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Social bar ───
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
|
|
||||||
/// fediverse entry that doesn't have one, so the bar shows the instance's real
|
|
||||||
/// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
|
|
||||||
/// leaves the glyph untouched. Returns handle → software for what was resolved —
|
|
||||||
/// the caller applies + persists (the app hops to the dispatcher; a test applies
|
|
||||||
/// directly).
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
|
|
||||||
SocialsConfig socials,
|
|
||||||
ISocialValidator validator,
|
|
||||||
Action<string>? log = null)
|
|
||||||
{
|
|
||||||
var resolved = new Dictionary<string, string>();
|
|
||||||
if (socials == null || validator == null) return resolved;
|
|
||||||
foreach (var entry in socials.Entries)
|
|
||||||
{
|
|
||||||
if (entry.Service != SocialService.Fediverse
|
|
||||||
|| !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
|
|
||||||
continue;
|
|
||||||
if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
|
|
||||||
continue;
|
|
||||||
var software = await validator.ResolveFediverseSoftwareAsync(
|
|
||||||
domain, System.Threading.CancellationToken.None);
|
|
||||||
if (string.IsNullOrWhiteSpace(software)) continue;
|
|
||||||
resolved[entry.Handle] = software;
|
|
||||||
log?.Invoke($"Socials heal: {entry.Handle} → {software}");
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
|
|
||||||
private void HealFediverseSoftwareInBackground()
|
|
||||||
{
|
|
||||||
var socials = _socials;
|
|
||||||
if (socials == null) return;
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var resolved = await HealFediverseSoftwareAsync(
|
|
||||||
socials, _socialValidator, m => AppLog.Write(m));
|
|
||||||
if (resolved.Count == 0) return;
|
|
||||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
var current = _socials;
|
|
||||||
if (current == null) return;
|
|
||||||
var applied = false;
|
|
||||||
foreach (var entry in current.Entries)
|
|
||||||
{
|
|
||||||
if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
|
|
||||||
if (entry.FediverseSoftware == software) continue;
|
|
||||||
entry.FediverseSoftware = software;
|
|
||||||
applied = true;
|
|
||||||
}
|
|
||||||
if (applied) NotifySocialsChanged(); // re-renders the bar + saves
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write($"Socials heal failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Re-rasterizes the social bar strip the output compositor overlays. UI thread
|
|
||||||
/// only (WPF rendering); the resulting frame is immutable, so the frame pump may
|
|
||||||
/// read it from its own thread. Null when the bar is off or empty.
|
|
||||||
/// </summary>
|
|
||||||
private void RenderSocialBarFrame()
|
|
||||||
{
|
|
||||||
_socialBarFrame = _socials != null && _socials.BarEnabled
|
|
||||||
? SocialBarRenderer.Render(_socials.Entries)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
|
|
||||||
/// validation). On save the working copy replaces <see cref="_socials"/>.
|
|
||||||
/// </summary>
|
|
||||||
private void OpenSocialDialog()
|
|
||||||
{
|
|
||||||
var dialog = new SocialsDialogViewModel(
|
|
||||||
_socialValidator,
|
|
||||||
SignInAsync,
|
|
||||||
SignOutYouTubeAsync,
|
|
||||||
_youtubeAuth.CurrentChannel,
|
|
||||||
_socials);
|
|
||||||
var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
|
|
||||||
if (window.ShowDialog() == true)
|
|
||||||
{
|
|
||||||
_socials = dialog.BuildSocialsConfig();
|
|
||||||
NotifySocialsChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NotifySocialsChanged()
|
|
||||||
{
|
|
||||||
RenderSocialBarFrame();
|
|
||||||
OnPropertyChanged(nameof(HasSocials));
|
|
||||||
OnPropertyChanged(nameof(SocialBarVisible));
|
|
||||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
|
|
||||||
public void SetSocialBarPosition(SocialBarPosition position)
|
|
||||||
{
|
|
||||||
if (_socials == null || _socials.BarPosition == position) return;
|
|
||||||
_socials.BarPosition = position;
|
|
||||||
OnPropertyChanged(nameof(SocialBarTop));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
|
|
||||||
public void ToggleSocialBarPosition()
|
|
||||||
=> SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
|
|
||||||
? SocialBarPosition.Bottom
|
|
||||||
: SocialBarPosition.Top);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-63
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user