using System.Collections.Generic; using System.Linq; using global::Avalonia; using global::Avalonia.Controls; using global::Avalonia.Input; using global::Avalonia.VisualTree; using LANCommander.Launcher.Controls; using LANCommander.Launcher.ViewModels; using LANCommander.Launcher.ViewModels.Components; using Microsoft.Extensions.DependencyInjection; namespace LANCommander.Launcher.Views; public partial class GameDetailView : UserControl { private bool _pluginTabsAdded; public GameDetailView() { InitializeComponent(); DataContextChanged += (_, _) => AppendPluginTabs(); } /// /// Appends plugin detail sections (via ) to /// the bottom of the left column once a game is bound. A failing extension is skipped so the /// built-in detail content still renders. /// private void AppendPluginTabs() { if (_pluginTabsAdded || DataContext is not GameDetailViewModel detailVm) return; var extensions = App.Services? .GetServices() .OrderBy(c => c.Order) .ToList(); if (extensions == null || extensions.Count == 0) return; _pluginTabsAdded = true; foreach (var extension in extensions) { Control content; try { content = extension.BuildContent(detailVm.Id); } catch { continue; } var header = new TextBlock { Text = extension.Header, FontWeight = global::Avalonia.Media.FontWeight.SemiBold, FontSize = 16, }; var stack = new StackPanel { Spacing = 8 }; stack.Children.Add(header); stack.Children.Add(content); LeftContent.Children.Add(stack); } } /// /// When an element receives focus via directional (gamepad) navigation, /// scroll it into view with some vertical padding so it isn't at the edge. /// private void OnScrollViewerGotFocus(object? sender, GotFocusEventArgs e) { if (e.NavigationMethod == NavigationMethod.Directional && e.Source is Control focused) { focused.BringIntoView(new Rect(0, -80, focused.Bounds.Width, focused.Bounds.Height + 160)); } } /// /// Handle cross-column navigation for gamepad: Down from the bottom of the /// left column redirects to the right-column metadata badges, and Up from /// the metadata badges returns to the left column. /// protected override void OnKeyDown(KeyEventArgs e) { if (!e.Handled) { var focused = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Visual; if (focused != null) { switch (e.Key) { case Key.Down when IsDescendantOf(focused, MediaSection): { var target = FindFirstFocusable(MetadataPanel); if (target != null) { target.Focus(NavigationMethod.Directional); e.Handled = true; return; } break; } case Key.Up when IsDescendantOf(focused, MetadataPanel): { // Go back to the carousel if visible, otherwise the left column var source = MediaSection.IsVisible ? MediaSection : (Visual)LeftContent; var target = FindLastFocusable(source); if (target != null) { target.Focus(NavigationMethod.Directional); e.Handled = true; return; } break; } } } } base.OnKeyDown(e); } private static bool IsDescendantOf(Visual visual, Visual ancestor) { var current = visual; while (current != null) { if (current == ancestor) return true; current = current.GetVisualParent(); } return false; } private static InputElement? FindFirstFocusable(Visual root) { return root.GetVisualDescendants() .OfType() .FirstOrDefault(el => el.Focusable && el.IsEffectivelyVisible && el.IsEffectivelyEnabled); } private static InputElement? FindLastFocusable(Visual root) { return root.GetVisualDescendants() .OfType() .LastOrDefault(el => el.Focusable && el.IsEffectivelyVisible && el.IsEffectivelyEnabled); } private void MediaItem_KeyDown(object? sender, KeyEventArgs e) { if (e.Key is Key.Enter or Key.Space) { OpenMediaLightbox(sender as Panel); e.Handled = true; } } private void MediaItem_Tapped(object? sender, TappedEventArgs e) { OpenMediaLightbox(sender as Panel); } private void OpenMediaLightbox(Panel? panel) { if (panel == null) return; if (panel.DataContext is not GameMediaItemViewModel tappedVm || tappedVm.IsSkeleton) return; if (DataContext is not GameDetailViewModel detailVm) return; // Build lightbox items from all loaded (non-skeleton) media var mediaItems = detailVm.MediaItems; var lightboxItems = new List(); int tappedIndex = 0; for (int i = 0; i < mediaItems.Count; i++) { var m = mediaItems[i]; if (m.IsSkeleton) continue; if (m == tappedVm) tappedIndex = lightboxItems.Count; lightboxItems.Add(new LightboxItem { Type = m.IsVideo ? LightboxItemType.Video : LightboxItemType.Image, Path = m.Path, // The carousel bitmap is a downscaled thumbnail; let the lightbox // load the full-resolution image from Path for fullscreen display. ImageSource = m.IsVideo ? m.ImageSource : null, }); } // For video items, capture timestamp and pause the inline player long videoStartTimeMs = 0; InlineVideoPlayer? inlinePlayer = null; if (tappedVm.IsVideo) { // Find the InlineVideoPlayer inside the tapped panel. It lives inside a // ContentControl's lazily-realized template, so search descendants. var player = panel.GetVisualDescendants().OfType().FirstOrDefault(); if (player != null) { inlinePlayer = player; videoStartTimeMs = player.CurrentTimeMs; player.Pause(); } } var overlay = LightboxOverlay.ShowOverlay(lightboxItems, tappedIndex, videoStartTimeMs); // When the lightbox closes, resume the inline video player if one was paused if (inlinePlayer != null) { var capturedPlayer = inlinePlayer; var resumed = false; overlay.VideoClosed += (_, args) => { // Sync timestamp back if we're still on the same video if (args.Index == tappedIndex) capturedPlayer.ResumeAt(args.TimeMs); else capturedPlayer.ResumeAt(0); resumed = true; }; overlay.Closed += (_, _) => { // If VideoClosed didn't fire (closed on a non-video item), resume anyway if (!resumed) capturedPlayer.ResumeAt(0); }; } } }