LANCommander/LANCommander.Launcher/Views/VideoPlayerOverlay.axaml.cs
Pat Hartl b183e4b18c Remove old launcher, rename Avalonia launcher
Also adds ARM builds for Linux + Windows launcher
2026-06-06 05:49:24 -05:00

228 lines
8.1 KiB
C#

using System;
using System.Globalization;
using System.Threading.Tasks;
using global::Avalonia;
using global::Avalonia.Controls;
using global::Avalonia.Controls.Primitives;
using global::Avalonia.Input;
using global::Avalonia.Interactivity;
using global::Avalonia.Media;
using global::Avalonia.Media.Imaging;
using global::Avalonia.Threading;
using LANCommander.Launcher.Helpers;
namespace LANCommander.Launcher.Views;
public partial class VideoPlayerOverlay : UserControl
{
/// <summary>
/// Raised when the overlay is closed.
/// The <c>long</c> argument is the playback position in milliseconds at
/// the moment of closing, so the caller can sync it back to the carousel.
/// </summary>
public event EventHandler<long>? Closed;
private VideoFrameRenderer? _renderer;
private VideoCanvas? _canvas;
private DispatcherTimer? _timer;
private bool _isUpdatingSlider;
private bool _isMuted;
private bool _closing;
private EventHandler<EventArgs>? _endReachedHandler;
public VideoPlayerOverlay()
{
InitializeComponent();
SeekSlider.AddHandler(RangeBase.ValueChangedEvent, SeekSlider_ValueChanged);
}
/// <param name="path">Local file path of the video.</param>
/// <param name="startTimeMs">
/// Position (in milliseconds) to seek to once playback starts.
/// Typically the carousel player's current position.
/// </param>
public void PlayVideo(string path, long startTimeMs = 0)
{
_renderer = new VideoFrameRenderer(maxWidth: 1920, maxHeight: 1080);
// Use a direct-rendering control instead of Image so bitmap
// updates are always picked up on every render pass.
_canvas = new VideoCanvas();
VideoBorder.Child = _canvas;
_renderer.BitmapReady += () =>
{
_canvas.Bitmap = _renderer?.Bitmap;
_canvas.InvalidateVisual();
};
_renderer.FrameReady += () =>
{
if (LoadingIndicator.IsVisible)
LoadingIndicator.IsVisible = false;
_canvas?.InvalidateVisual();
};
_endReachedHandler = (_, _) =>
Dispatcher.UIThread.Post(() => PlayPauseIcon.Value = "Play");
_renderer.Player!.EndReached += _endReachedHandler;
// Use VLC's :start-time option so decoding begins at the right
// position immediately, avoiding a seek-after-play black flash.
_renderer.Play(path, muted: false, loop: false, startTimeMs: startTimeMs);
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_timer.Tick += UpdateTransportControls;
_timer.Start();
Focus();
}
// ── Transport updates ────────────────────────────────────────────────
private void UpdateTransportControls(object? sender, EventArgs e)
{
if (_renderer?.Player is not { } player) return;
_isUpdatingSlider = true;
SeekSlider.Value = player.Position;
_isUpdatingSlider = false;
var current = TimeSpan.FromMilliseconds(Math.Max(0, player.Time));
var total = TimeSpan.FromMilliseconds(Math.Max(0, player.Length));
TimeLabel.Text = $"{current:m\\:ss} / {total:m\\:ss}";
}
// ── Play / Pause ─────────────────────────────────────────────────────
private void PlayPause_Click(object? sender, RoutedEventArgs e) => TogglePlayPause();
private void TogglePlayPause()
{
if (_renderer?.Player is not { } player) return;
if (player.IsPlaying)
{
player.Pause();
PlayPauseIcon.Value = "Play";
}
else
{
player.Play();
PlayPauseIcon.Value = "Pause";
}
}
// ── Seek ─────────────────────────────────────────────────────────────
private void SeekSlider_ValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (_isUpdatingSlider || _renderer?.Player == null) return;
_renderer.Player.Position = (float)SeekSlider.Value;
}
// ── Volume ───────────────────────────────────────────────────────────
private void Volume_Click(object? sender, RoutedEventArgs e)
{
if (_renderer?.Player == null) return;
_isMuted = !_isMuted;
_renderer.Player.Volume = _isMuted ? 0 : 100;
VolumeIcon.Value = _isMuted ? "SpeakerSlash" : "SpeakerHigh";
}
// ── Close / keyboard ─────────────────────────────────────────────────
private void Close_Click(object? sender, RoutedEventArgs e) => CloseOverlay();
private void Backdrop_PointerPressed(object? sender, PointerPressedEventArgs e) => CloseOverlay();
private void Video_PointerPressed(object? sender, PointerPressedEventArgs e) => e.Handled = true;
private void OnKeyDown(object? sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
CloseOverlay();
e.Handled = true;
break;
case Key.Space:
TogglePlayPause();
e.Handled = true;
break;
}
}
private void CloseOverlay()
{
if (_closing) return;
_closing = true;
if (_timer != null)
{
_timer.Tick -= UpdateTransportControls;
_timer.Stop();
_timer = null;
}
var timeMs = _renderer?.Player?.Time ?? 0;
if (_renderer != null)
{
if (_endReachedHandler != null && _renderer.Player != null)
_renderer.Player.EndReached -= _endReachedHandler;
_endReachedHandler = null;
_renderer.FrameReady = null;
_renderer.BitmapReady = null;
var renderer = _renderer;
_renderer = null;
// Dispose on a background thread — MediaPlayer.Stop() is a
// blocking call that waits for all VLC threads to finish. Running
// it on the UI thread deadlocks if a VLC callback is contending
// for the WriteableBitmap lock held by the render thread.
_ = Task.Run(() => renderer.Dispose());
}
var layer = OverlayLayer.GetOverlayLayer(this);
Closed?.Invoke(this, timeMs);
layer?.Children.Remove(this);
}
// ── VideoCanvas ──────────────────────────────────────────────────────
/// <summary>
/// Lightweight control that draws a <see cref="WriteableBitmap"/>
/// directly via <see cref="DrawingContext.DrawImage"/>, guaranteeing
/// that every render pass reads the latest pixel data.
/// </summary>
private sealed class VideoCanvas : Control
{
public WriteableBitmap? Bitmap { get; set; }
protected override Size MeasureOverride(Size availableSize) => availableSize;
public override void Render(DrawingContext context)
{
if (Bitmap is not { } bmp) return;
var srcW = (double)bmp.PixelSize.Width;
var srcH = (double)bmp.PixelSize.Height;
var dstW = Bounds.Width;
var dstH = Bounds.Height;
if (srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0) return;
// Uniform stretch — fit within bounds, centred.
var scale = Math.Min(dstW / srcW, dstH / srcH);
var w = srcW * scale;
var h = srcH * scale;
context.DrawImage(
bmp,
new Rect(0, 0, srcW, srcH),
new Rect((dstW - w) / 2, (dstH - h) / 2, w, h));
}
}
}