LANCommander/LANCommander.Launcher/Views/LightboxOverlay.axaml.cs

379 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using global::Avalonia;
using global::Avalonia.Controls;
using global::Avalonia.Controls.ApplicationLifetimes;
using global::Avalonia.Controls.Primitives;
using global::Avalonia.Data;
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;
using LANCommander.Launcher.ViewModels.Components;
namespace LANCommander.Launcher.Views;
public partial class LightboxOverlay : UserControl
{
public event EventHandler? Closed;
/// <summary>
/// Raised when a video overlay closes. The long is the playback position in ms.
/// The int is the item index that was playing.
/// </summary>
public event EventHandler<(int Index, long TimeMs)>? VideoClosed;
private IReadOnlyList<LightboxItem> _items = Array.Empty<LightboxItem>();
private int _currentIndex;
private bool _closing;
// Video state
private VideoFrameRenderer? _renderer;
private VideoCanvas? _canvas;
private DispatcherTimer? _timer;
private bool _isUpdatingSlider;
private bool _isMuted;
private long _videoStartTimeMs;
private EventHandler<EventArgs>? _endReachedHandler;
public LightboxOverlay()
{
InitializeComponent();
SeekSlider.AddHandler(RangeBase.ValueChangedEvent, SeekSlider_ValueChanged);
}
/// <summary>
/// Opens the lightbox with the given items, starting at the specified index.
/// </summary>
/// <param name="items">All items available for navigation.</param>
/// <param name="startIndex">Index of the item to show first.</param>
/// <param name="videoStartTimeMs">For video items, the starting playback position.</param>
public void Show(IReadOnlyList<LightboxItem> items, int startIndex = 0, long videoStartTimeMs = 0)
{
_items = items;
_currentIndex = Math.Clamp(startIndex, 0, Math.Max(0, items.Count - 1));
_videoStartTimeMs = videoStartTimeMs;
ShowCurrentItem();
Focus();
}
/// <summary>Shows the lightbox as an overlay on the main window.</summary>
public static LightboxOverlay ShowOverlay(IReadOnlyList<LightboxItem> items, int startIndex = 0, long videoStartTimeMs = 0)
{
var overlay = new LightboxOverlay
{
HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
};
var mainWindow = (global::Avalonia.Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
var layer = OverlayLayer.GetOverlayLayer(mainWindow);
if (layer is null)
return overlay;
overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty,
new Binding("Bounds.Width") { Source = layer });
overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty,
new Binding("Bounds.Height") { Source = layer });
layer.Children.Add(overlay);
overlay.Show(items, startIndex, videoStartTimeMs);
return overlay;
}
// ── Navigation ───────────────────────────────────────────────────────
private void Navigate(int delta)
{
if (_items.Count <= 1) return;
StopVideo();
_videoStartTimeMs = 0;
_currentIndex = (_currentIndex + delta + _items.Count) % _items.Count;
ShowCurrentItem();
}
private void ShowCurrentItem()
{
if (_items.Count == 0) return;
var item = _items[_currentIndex];
// Hide all content
ImageBorder.IsVisible = false;
VideoBorder.IsVisible = false;
PdfBorder.IsVisible = false;
TransportControls.IsVisible = false;
LoadingIndicator.IsVisible = false;
// Update navigation
PrevButton.IsVisible = _items.Count > 1;
NextButton.IsVisible = _items.Count > 1;
ItemCounter.Text = _items.Count > 1 ? $"{_currentIndex + 1} / {_items.Count}" : string.Empty;
switch (item.Type)
{
case LightboxItemType.Image:
ShowImage(item);
break;
case LightboxItemType.Video:
ShowVideo(item);
break;
case LightboxItemType.Pdf:
ShowPdf(item);
break;
}
}
// ── Image ────────────────────────────────────────────────────────────
private void ShowImage(LightboxItem item)
{
if (item.ImageSource != null)
{
LightboxImage.Source = item.ImageSource;
}
else if (!string.IsNullOrEmpty(item.Path))
{
try
{
LightboxImage.Source = new Bitmap(item.Path);
}
catch
{
LightboxImage.Source = null;
}
}
ImageBorder.IsVisible = true;
}
// ── Video ────────────────────────────────────────────────────────────
private void ShowVideo(LightboxItem item)
{
LoadingIndicator.IsVisible = true;
VideoBorder.IsVisible = true;
TransportControls.IsVisible = true;
try
{
_renderer = new VideoFrameRenderer(maxWidth: 1920, maxHeight: 1080);
}
catch
{
// LibVLC may not be available (native libraries missing in deployment).
// Fall back to showing the image if one is available, otherwise leave blank.
LoadingIndicator.IsVisible = false;
VideoBorder.IsVisible = false;
TransportControls.IsVisible = false;
if (item.ImageSource != null || !string.IsNullOrEmpty(item.Path))
ShowImage(item);
return;
}
_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");
if (_renderer.Player != null)
_renderer.Player.EndReached += _endReachedHandler;
_renderer.Play(item.Path, muted: false, loop: false, startTimeMs: _videoStartTimeMs);
PlayPauseIcon.Value = "Pause";
_isMuted = false;
VolumeIcon.Value = "SpeakerHigh";
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_timer.Tick += UpdateTransportControls;
_timer.Start();
}
private void StopVideo()
{
if (_timer != null)
{
_timer.Tick -= UpdateTransportControls;
_timer.Stop();
_timer = null;
}
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;
_ = Task.Run(() => renderer.Dispose());
}
_canvas = null;
VideoBorder.Child = null;
}
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}";
}
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";
}
}
private void SeekSlider_ValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (_isUpdatingSlider || _renderer?.Player == null) return;
_renderer.Player.Position = (float)SeekSlider.Value;
}
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";
}
// ── PDF ──────────────────────────────────────────────────────────────
private void ShowPdf(LightboxItem item)
{
PdfViewer.Source = item.Path;
PdfBorder.IsVisible = true;
}
// ── Close / keyboard ─────────────────────────────────────────────────
private void Close_Click(object? sender, RoutedEventArgs e) => CloseOverlay();
private void Backdrop_PointerPressed(object? sender, PointerPressedEventArgs e) => CloseOverlay();
private void Content_PointerPressed(object? sender, PointerPressedEventArgs e) => e.Handled = true;
private void Prev_Click(object? sender, RoutedEventArgs e) => Navigate(-1);
private void Next_Click(object? sender, RoutedEventArgs e) => Navigate(1);
private void OnKeyDown(object? sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
CloseOverlay();
e.Handled = true;
break;
case Key.Space:
if (_items.Count > 0 && _items[_currentIndex].Type == LightboxItemType.Video)
TogglePlayPause();
e.Handled = true;
break;
case Key.Left:
Navigate(-1);
e.Handled = true;
break;
case Key.Right:
Navigate(1);
e.Handled = true;
break;
}
}
private void CloseOverlay()
{
if (_closing) return;
_closing = true;
var videoTimeMs = _renderer?.Player?.Time ?? 0;
var videoIndex = _currentIndex;
var wasVideo = _items.Count > 0 && _items[_currentIndex].Type == LightboxItemType.Video;
StopVideo();
// Clean up PDF
PdfViewer.Source = null;
var layer = OverlayLayer.GetOverlayLayer(this);
if (wasVideo)
VideoClosed?.Invoke(this, (videoIndex, videoTimeMs));
Closed?.Invoke(this, EventArgs.Empty);
layer?.Children.Remove(this);
}
// ── VideoCanvas ──────────────────────────────────────────────────────
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;
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));
}
}
}