Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c41ff7fd0a | |||
| 535a50192c |
@@ -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,132 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services.Audio;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
|
||||||
|
// the default device so it rides the loopback into the stream. The dot is
|
||||||
|
// red (no track) / yellow (loaded, stopped) / green (playing).
|
||||||
|
private readonly MusicPlayer _musicPlayer = new();
|
||||||
|
private Music? _music;
|
||||||
|
private bool _musicPlaying;
|
||||||
|
|
||||||
|
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
|
||||||
|
/// <summary>True while the TRAX background track is playing.</summary>
|
||||||
|
public bool IsMusicPlaying
|
||||||
|
{
|
||||||
|
get => _musicPlaying;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _musicPlaying, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(TraxDotBrush));
|
||||||
|
OnPropertyChanged(nameof(TraxToolTip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? _traxError;
|
||||||
|
|
||||||
|
/// <summary>Error message from the last TRAX load/playback failure; null = no error.</summary>
|
||||||
|
public string? TraxError
|
||||||
|
{
|
||||||
|
get => _traxError;
|
||||||
|
private set => SetProperty(ref _traxError, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>TRAX status dot: green = playing, gray = idle or no track.</summary>
|
||||||
|
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
|
||||||
|
? BarOnBrush
|
||||||
|
: TraxIdleBrush;
|
||||||
|
|
||||||
|
public string TraxToolTip => _musicPlayer.TrackName != null
|
||||||
|
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
||||||
|
: "No track — left-click or right-click to choose background music";
|
||||||
|
|
||||||
|
/// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
|
||||||
|
public void TraxLeftClick()
|
||||||
|
{
|
||||||
|
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
||||||
|
PickTraxTrack();
|
||||||
|
else if (IsMusicPlaying)
|
||||||
|
PauseTrax();
|
||||||
|
else
|
||||||
|
PlayTrax();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
|
||||||
|
public void TraxRightClick() => PickTraxTrack();
|
||||||
|
|
||||||
|
private void PickTraxTrack()
|
||||||
|
{
|
||||||
|
var dialog = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Choose background music",
|
||||||
|
Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
|
||||||
|
LoadTrax(play: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadTrax(bool play)
|
||||||
|
{
|
||||||
|
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_musicPlayer.Load(_music.TrackPath);
|
||||||
|
UpdateTraxLocalGain();
|
||||||
|
_music.IsEnabled = true;
|
||||||
|
TraxError = null;
|
||||||
|
if (play)
|
||||||
|
_musicPlayer.Play();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
|
||||||
|
TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}";
|
||||||
|
}
|
||||||
|
RefreshTraxUi();
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlayTrax() => _musicPlayer.Play();
|
||||||
|
|
||||||
|
private void PauseTrax() => _musicPlayer.Pause();
|
||||||
|
|
||||||
|
private void OnTraxPlaybackEnded(Exception? ex)
|
||||||
|
{
|
||||||
|
if (ex != null)
|
||||||
|
{
|
||||||
|
AppLog.Write($"TRAX: playback error: {ex.Message}");
|
||||||
|
TraxError = $"Playback error: {ex.Message}";
|
||||||
|
}
|
||||||
|
RefreshTraxUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshTraxUi()
|
||||||
|
{
|
||||||
|
IsMusicPlaying = _musicPlayer.IsPlaying;
|
||||||
|
OnPropertyChanged(nameof(TraxDotBrush));
|
||||||
|
OnPropertyChanged(nameof(TraxToolTip));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors the desktop/game audio bar's volume and mute onto the
|
||||||
|
/// music so the creator hears the controls work in the headphones (the
|
||||||
|
/// track rides the loopback channel, so it must duck the same way locally).
|
||||||
|
/// Called whenever the game volume/mute change and on TRAX load.</summary>
|
||||||
|
private void UpdateTraxLocalGain()
|
||||||
|
{
|
||||||
|
_musicPlayer.LocalGain = GameMuted ? 0f : 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
|
|
||||||
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
|
|
||||||
// the default device so it rides the loopback into the stream. The dot is
|
|
||||||
// red (no track) / yellow (loaded, stopped) / green (playing).
|
|
||||||
private readonly MusicPlayer _musicPlayer = new();
|
|
||||||
private Music? _music;
|
|
||||||
private bool _musicPlaying;
|
|
||||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||||
private StreamHealth _currentHealth = new();
|
private StreamHealth _currentHealth = new();
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
@@ -89,9 +83,6 @@ 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.
|
||||||
@@ -175,33 +166,11 @@ 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
|
||||||
@@ -234,121 +203,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
|
|
||||||
/// <summary>True while the TRAX background track is playing.</summary>
|
|
||||||
public bool IsMusicPlaying
|
|
||||||
{
|
|
||||||
get => _musicPlaying;
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _musicPlaying, value))
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(TraxDotBrush));
|
|
||||||
OnPropertyChanged(nameof(TraxToolTip));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _traxError;
|
|
||||||
|
|
||||||
/// <summary>Error message from the last TRAX load/playback failure; null = no error.</summary>
|
|
||||||
public string? TraxError
|
|
||||||
{
|
|
||||||
get => _traxError;
|
|
||||||
private set => SetProperty(ref _traxError, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>TRAX status dot: green = playing, gray = idle or no track.</summary>
|
|
||||||
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
|
|
||||||
? BarOnBrush
|
|
||||||
: TraxIdleBrush;
|
|
||||||
|
|
||||||
public string TraxToolTip => _musicPlayer.TrackName != null
|
|
||||||
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
|
||||||
: "No track — left-click or right-click to choose background music";
|
|
||||||
|
|
||||||
/// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
|
|
||||||
public void TraxLeftClick()
|
|
||||||
{
|
|
||||||
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
|
||||||
PickTraxTrack();
|
|
||||||
else if (IsMusicPlaying)
|
|
||||||
PauseTrax();
|
|
||||||
else
|
|
||||||
PlayTrax();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
|
|
||||||
public void TraxRightClick() => PickTraxTrack();
|
|
||||||
|
|
||||||
private void PickTraxTrack()
|
|
||||||
{
|
|
||||||
var dialog = new OpenFileDialog
|
|
||||||
{
|
|
||||||
Title = "Choose background music",
|
|
||||||
Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
|
|
||||||
};
|
|
||||||
if (dialog.ShowDialog() != true)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
|
|
||||||
LoadTrax(play: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadTrax(bool play)
|
|
||||||
{
|
|
||||||
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_musicPlayer.Load(_music.TrackPath);
|
|
||||||
UpdateTraxLocalGain();
|
|
||||||
_music.IsEnabled = true;
|
|
||||||
TraxError = null;
|
|
||||||
if (play)
|
|
||||||
_musicPlayer.Play();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
|
|
||||||
TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}";
|
|
||||||
}
|
|
||||||
RefreshTraxUi();
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PlayTrax() => _musicPlayer.Play();
|
|
||||||
|
|
||||||
private void PauseTrax() => _musicPlayer.Pause();
|
|
||||||
|
|
||||||
private void OnTraxPlaybackEnded(Exception? ex)
|
|
||||||
{
|
|
||||||
if (ex != null)
|
|
||||||
{
|
|
||||||
AppLog.Write($"TRAX: playback error: {ex.Message}");
|
|
||||||
TraxError = $"Playback error: {ex.Message}";
|
|
||||||
}
|
|
||||||
RefreshTraxUi();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshTraxUi()
|
|
||||||
{
|
|
||||||
IsMusicPlaying = _musicPlayer.IsPlaying;
|
|
||||||
OnPropertyChanged(nameof(TraxDotBrush));
|
|
||||||
OnPropertyChanged(nameof(TraxToolTip));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Mirrors the desktop/game audio bar's volume and mute onto the
|
|
||||||
/// music so the creator hears the controls work in the headphones (the
|
|
||||||
/// track rides the loopback channel, so it must duck the same way locally).
|
|
||||||
/// Called whenever the game volume/mute change and on TRAX load.</summary>
|
|
||||||
private void UpdateTraxLocalGain()
|
|
||||||
{
|
|
||||||
_musicPlayer.LocalGain = GameMuted ? 0f : 1f;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||||
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -707,7 +561,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 StartStreamCommand { get; }
|
||||||
public ICommand EndStreamCommand { get; }
|
public ICommand EndStreamCommand { get; }
|
||||||
public ICommand ChooseRecordFolderCommand { get; }
|
public ICommand ChooseRecordFolderCommand { get; }
|
||||||
@@ -2034,127 +1887,4 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
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);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-57
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user