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 { /// /// Raised when the overlay is closed. /// The long argument is the playback position in milliseconds at /// the moment of closing, so the caller can sync it back to the carousel. /// public event EventHandler? Closed; private VideoFrameRenderer? _renderer; private VideoCanvas? _canvas; private DispatcherTimer? _timer; private bool _isUpdatingSlider; private bool _isMuted; private bool _closing; private EventHandler? _endReachedHandler; public VideoPlayerOverlay() { InitializeComponent(); SeekSlider.AddHandler(RangeBase.ValueChangedEvent, SeekSlider_ValueChanged); } /// Local file path of the video. /// /// Position (in milliseconds) to seek to once playback starts. /// Typically the carousel player's current position. /// 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 ────────────────────────────────────────────────────── /// /// Lightweight control that draws a /// directly via , guaranteeing /// that every render pass reads the latest pixel data. /// 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)); } } }