From e725cb4fef8e4ef29f27179f05631d231efe838f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Thu, 15 Oct 2026 19:43:33 +0300 Subject: [PATCH 1/4] Feature: Play audio and video in the preview pane with libmpv Renders frames with the libmpv software render API into a XAML image, so playback works on the native Wayland host as well as X11 without native window embedding. libmpv is optional; without it the metadata preview stays. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1-sol (OpenAI Codex) --- src/Files.App/Strings/en-US/Resources.resw | 13 + .../FilePreviews/LinuxMediaPreview.xaml | 39 ++ .../FilePreviews/LinuxMediaPreview.xaml.cs | 219 ++++++++ .../UserControls/InfoPaneViewModel.cs | 9 +- .../Previews/MediaMetadataPreviewViewModel.cs | 3 +- src/Files.App/Views/Shells/BaseShellPage.cs | 7 + .../Media/MpvMediaPlayer.cs | 478 ++++++++++++++++++ src/Files.Platform.Linux/Native/MpvNative.cs | 79 +++ .../Previews/MpvMediaPlayerTests.cs | 225 +++++++++ 9 files changed, 1067 insertions(+), 5 deletions(-) create mode 100644 src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml create mode 100644 src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml.cs create mode 100644 src/Files.Platform.Linux/Media/MpvMediaPlayer.cs create mode 100644 src/Files.Platform.Linux/Native/MpvNative.cs create mode 100644 tests/Files.Platform.Tests/Previews/MpvMediaPlayerTests.cs diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index b5396cfc74c8..cced72f02c60 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -5135,4 +5135,17 @@ Requires a restart. Uses the focused compositor output scale (niri, sway or Hyprland), KDE display scale, or GNOME text scale when no display or toolkit scale is configured. Scale changes require a restart. May double-scale on some setups. + + Seek + + + Volume + + + This file could not be played. + + + {0} / {1} + Playback elapsed time ({0}) and total duration ({1}). + diff --git a/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml b/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml new file mode 100644 index 000000000000..d8ce950353d4 --- /dev/null +++ b/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml.cs b/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml.cs new file mode 100644 index 000000000000..182dcec1112b --- /dev/null +++ b/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml.cs @@ -0,0 +1,219 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if DESKTOP +using Files.App.ViewModels.Previews; +using Files.Platform.Linux.Media; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media.Imaging; +using System.Globalization; +using System.Runtime.InteropServices.WindowsRuntime; + +namespace Files.App.UserControls.FilePreviews +{ + public sealed partial class LinuxMediaPreview : UserControl + { + private readonly IUserSettingsService settings = Ioc.Default.GetRequiredService(); + private readonly DispatcherQueue windowDispatcher; + private readonly object frameGate = new(); + private readonly SemaphoreSlim commandGate = new(1, 1); + private MpvMediaPlayer? player; + private MpvFrame? latestFrame; + private WriteableBitmap? bitmap; + private bool uiPending; + private bool updatingControls = true; + private bool commandBusy; + private bool failed; + private int unloaded; + private XamlRoot? previewRoot; + + public MediaMetadataPreviewViewModel ViewModel { get; } + + public LinuxMediaPreview(MediaMetadataPreviewViewModel viewModel) + { + ViewModel = viewModel; + windowDispatcher = MainWindow.Instance.DispatcherQueue; + InitializeComponent(); + VolumeSlider.Value = Math.Clamp(settings.InfoPaneSettingsService.MediaVolume, 0, 1); + PlaybackTime.Text = FormatPlaybackTime(TimeSpan.Zero, TimeSpan.Zero); + updatingControls = false; + Loaded += (_, _) => + { + previewRoot = XamlRoot; + if (previewRoot is not null) previewRoot.Changed += Root_Changed; + UpdateTargetSize(); + }; + Unloaded += Preview_Unloaded; + } + + public async void TogglePlayback() + { + if (Volatile.Read(ref unloaded) != 0 || commandBusy || failed) return; + commandBusy = true; + try + { + if (player is null) + { + // Construction is cheap; native initialization and parsing start only on explicit Play. + var created = new MpvMediaPlayer { Volume = VolumeSlider.Value }; + player = created; + created.FrameRendered += Player_FrameRendered; + created.StateChanged += Player_StateChanged; + UpdateTargetSize(); + var path = ViewModel.Item.GetRequiredPath(); + await RunCommandAsync(() => { created.Open(path); created.Play(); }); + } + else + { + var current = player; + await RunCommandAsync(current.TogglePause); + } + } + catch (Exception) { ShowFailure(); } + finally { commandBusy = false; } + } + + private async Task RunCommandAsync(Action command) + { + await commandGate.WaitAsync(); + try + { + if (Volatile.Read(ref unloaded) == 0) await Task.Run(command); + } + finally { commandGate.Release(); } + } + + private void Play_Click(object sender, RoutedEventArgs e) => TogglePlayback(); + + private void Player_StateChanged(object? sender, EventArgs e) => QueueUiUpdate(); + + private void Player_FrameRendered(MpvFrame frame) + { + lock (frameGate) + { + if (Volatile.Read(ref unloaded) != 0) return; + // Each frame owns its pixels. Replacing this reference drops obsolete frames safely. + latestFrame = frame; + } + QueueUiUpdate(); + } + + private void QueueUiUpdate() + { + lock (frameGate) + { + if (Volatile.Read(ref unloaded) != 0 || uiPending) return; + uiPending = true; + if (!windowDispatcher.TryEnqueue(UpdateUi)) uiPending = false; + } + } + + private void UpdateUi() + { + MpvFrame? frame; + lock (frameGate) + { + uiPending = false; + frame = latestFrame; + latestFrame = null; + } + if (Volatile.Read(ref unloaded) != 0 || failed || player is not { } current) return; + if (current.Error is not null) { ShowFailure(); return; } + try + { + if (frame is not null) + { + if (bitmap is null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height) + { + bitmap = new WriteableBitmap(frame.Width, frame.Height); + Video.Source = bitmap; + } + using (var stream = bitmap.PixelBuffer.AsStream()) stream.Write(frame.Pixels.Span); + bitmap.Invalidate(); + Video.Visibility = Visibility.Visible; + Cover.Visibility = Visibility.Collapsed; + } + updatingControls = true; + var duration = current.Duration.TotalSeconds; + SeekSlider.Maximum = Math.Max(1, duration); + SeekSlider.IsEnabled = duration > 0; + SeekSlider.Value = Math.Clamp(current.Position.TotalSeconds, 0, SeekSlider.Maximum); + PlaybackTime.Text = FormatPlaybackTime(current.Position, current.Duration); + var label = (current.IsPaused ? Strings.Play : Strings.Key_Pause).GetLocalizedResource(); + PlayIcon.Glyph = current.IsPaused ? "\uE768" : "\uE769"; + AutomationProperties.SetName(PlayButton, label); + ToolTipService.SetToolTip(PlayButton, label); + } + catch (Exception) { ShowFailure(); } + finally { updatingControls = false; } + } + + private async void Seek_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + if (updatingControls || Volatile.Read(ref unloaded) != 0 || player is not { } current) return; + try { await RunCommandAsync(() => current.Seek(TimeSpan.FromSeconds(e.NewValue))); } + catch (Exception) { ShowFailure(); } + } + + private async void Volume_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + if (updatingControls || Volatile.Read(ref unloaded) != 0) return; + settings.InfoPaneSettingsService.MediaVolume = e.NewValue; + if (failed) return; + if (player is not { } current) return; + try { await RunCommandAsync(() => current.Volume = e.NewValue); } + catch (Exception) { ShowFailure(); } + } + + private static string FormatPlaybackTime(TimeSpan position, TimeSpan duration) => string.Format(CultureInfo.CurrentCulture, + Strings.LinuxMediaTimeFormat.GetLocalizedResource(), FormatTime(position), FormatTime(duration)); + + private static string FormatTime(TimeSpan time) => time.ToString(time.TotalHours >= 1 ? @"h\:mm\:ss" : @"m\:ss", CultureInfo.CurrentCulture); + + private void VideoHost_SizeChanged(object sender, SizeChangedEventArgs e) => UpdateTargetSize(); + + private void Root_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateTargetSize(); + + private void UpdateTargetSize() + { + var scale = XamlRoot?.RasterizationScale ?? 1; + player?.SetTargetSize((int)Math.Clamp(VideoHost.ActualWidth * scale, 1, 1920), + (int)Math.Clamp(VideoHost.ActualHeight * scale, 1, 1080)); + } + + private void ShowFailure() + { + if (Volatile.Read(ref unloaded) != 0) return; + failed = true; + Video.Visibility = Visibility.Collapsed; + Video.Source = null; + bitmap = null; + Cover.Visibility = Visibility.Visible; + PlaybackError.Visibility = Visibility.Visible; + PlayIcon.Glyph = "\uE768"; + PlayButton.IsEnabled = SeekSlider.IsEnabled = false; + player?.Dispose(); + } + + private void Preview_Unloaded(object sender, RoutedEventArgs e) + { + Interlocked.Exchange(ref unloaded, 1); + if (previewRoot is not null) previewRoot.Changed -= Root_Changed; + if (player is { } current) + { + current.FrameRendered -= Player_FrameRendered; + current.StateChanged -= Player_StateChanged; + current.Dispose(); + } + lock (frameGate) latestFrame = null; + Video.Source = null; + bitmap = null; + ViewModel.PreviewControlBase_Unloaded(sender, e); + } + } +} +#endif diff --git a/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs b/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs index 8ddeceeb5aaf..c9d82340a2db 100644 --- a/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs @@ -314,15 +314,18 @@ private async Task LoadPreviewControlAsync(CancellationToken token, bool downloa (FileExtensionHelpers.IsAudioFile(ext) || FileExtensionHelpers.IsVideoFile(ext))) { #if DESKTOP - if (!NativePreviewSupport.IsAvailable) + if (!Files.Platform.Linux.Media.MpvMediaPlayer.IsAvailable && !NativePreviewSupport.IsAvailable) return await NativePreviewFallback.CreateMediaAsync(item, token); - // LINUX-TODO(media): metadata only; playback needs libvlc (see MediaMetadataPreviewViewModel) + // Optional libmpv playback starts only when the user presses Play. var model = new MediaMetadataPreviewViewModel(item); using var registration = token.Register(model.LoadCancelledTokenSource.Cancel); await model.LoadAsync(); + token.ThrowIfCancellationRequested(); - return new BasicPreview(model); + return Files.Platform.Linux.Media.MpvMediaPlayer.IsAvailable + ? new LinuxMediaPreview(model) + : new BasicPreview(model); #else var model = new MediaPreviewViewModel(item); await model.LoadAsync(); diff --git a/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs index 3a165cd26127..097403267cc0 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs @@ -10,9 +10,8 @@ namespace Files.App.ViewModels.Previews { /// - /// Audio/video preview on Linux: embedded cover art (or the thumbnail) plus tag metadata. Nothing is played. + /// Audio/video cover art (or the thumbnail) and tag metadata. Optional libmpv playback is created by the preview control on explicit Play. /// - // LINUX-TODO(media): playback needs MediaPlayerElement with Uno.UI.MediaPlayer.Skia.X11 and a system libvlc; until that is verified the preview is metadata only. public sealed partial class MediaMetadataPreviewViewModel : BasePreviewModel { private const int MaxCoverBytes = 8 * 1024 * 1024; diff --git a/src/Files.App/Views/Shells/BaseShellPage.cs b/src/Files.App/Views/Shells/BaseShellPage.cs index 6e7c9a224636..b371a1847edf 100644 --- a/src/Files.App/Views/Shells/BaseShellPage.cs +++ b/src/Files.App/Views/Shells/BaseShellPage.cs @@ -407,6 +407,13 @@ protected void ShellPage_PreviewKeyDown(object sender, KeyRoutedEventArgs args) mediaPreviewContent.ViewModel.TogglePlayback(); args.Handled = true; } +#if DESKTOP + else if (Ioc.Default.GetRequiredService().PreviewPaneContent is UserControls.FilePreviews.LinuxMediaPreview linuxMediaPreview) + { + linuxMediaPreview.TogglePlayback(); + args.Handled = true; + } +#endif break; } } diff --git a/src/Files.Platform.Linux/Media/MpvMediaPlayer.cs b/src/Files.Platform.Linux/Media/MpvMediaPlayer.cs new file mode 100644 index 000000000000..11de0fe33aa2 --- /dev/null +++ b/src/Files.Platform.Linux/Media/MpvMediaPlayer.cs @@ -0,0 +1,478 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Native; +using System; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Linux.Media +{ + /// A frame owns its immutable BGRA pixels; it remains valid after the callback and player disposal. + public sealed class MpvFrame + { + public int Width { get; } + public int Height { get; } + public int Stride { get; } + public ReadOnlyMemory Pixels { get; } + + internal MpvFrame(int width, int height, byte[] pixels) + { + Width = width; + Height = height; + Stride = width * 4; + Pixels = pixels; + } + } + + /// + /// Optional in-process software playback. Open/commands may block and should be called off the UI thread. + /// Events run on background threads. Dispose schedules teardown; Disposal completes after native resources are freed. + /// + public sealed unsafe class MpvMediaPlayer : IDisposable + { + private static readonly Lazy available = new(ProbeAvailability); + private static readonly object localeGate = new(); + + // Fail closed if any option is unavailable: never silently omit a security restriction. + internal static ReadOnlySpan<(string Name, string Value)> Options => new (string, string)[] + { + ("vo", "libmpv"), ("config", "no"), ("load-scripts", "no"), ("ytdl", "no"), + ("osc", "no"), ("input-default-bindings", "no"), ("input-vo-keyboard", "no"), + ("terminal", "no"), ("hwdec", "no"), ("keep-open", "yes"), ("pause", "yes"), + ("idle", "yes"), ("audio-display", "embedded-first"), ("access-references", "no"), + ("load-unsafe-playlists", "no"), ("autoload-files", "no"), ("sub-auto", "no"), + ("audio-file-auto", "no"), ("cover-art-auto", "no"), ("resume-playback", "no"), + ("save-position-on-quit", "no"), ("autocreate-playlist", "no"), ("prefetch-playlist", "no"), + ("ordered-chapters", "no"), + // Without an audio device, audio-only files would otherwise fail to load. + ("audio-fallback-to-null", "yes"), + // Bypass mpv's playlist/EDL/directory demuxers. Restrict nested libavformat IO as well. + ("demuxer", "lavf"), + // Only the media containers used by the pane; exclude HLS, DASH, concat and other reference formats. + ("demuxer-lavf-o", "protocol_whitelist=[file],format_whitelist=[mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,asf,mp3,aac,flac,ogg,wav]"), + ("stream-lavf-o", "protocol_whitelist=[file]") + }; + + private readonly object nativeGate = new(); + private readonly object stateGate = new(); + private readonly AutoResetEvent renderSignal = new(false); + private readonly TaskCompletionSource disposal = new(TaskCreationOptions.RunContinuationsAsynchronously); + private nint handle; + private nint renderContext; + private GCHandle callbackHandle; + private Thread? eventThread; + private Thread? renderThread; + private int disposed; + private int validated; + private int redraw; + private int targetWidth = 640; + private int targetHeight = 360; + private string? requestedPath; + private bool playRequested; + private double volume = 1; + private double position; + private double duration; + private bool paused = true; + private bool ended; + private int videoWidth; + private int videoHeight; + private string? error; + + public static bool IsAvailable => available.Value; + public Task Disposal => disposal.Task; + public event EventHandler? StateChanged; + /// Runs on the render thread; handlers must not call synchronous playback commands. + public event Action? FrameRendered; + public TimeSpan Position { get { lock (stateGate) return ToTime(position); } } + public TimeSpan Duration { get { lock (stateGate) return ToTime(duration); } } + public bool IsPaused { get { lock (stateGate) return paused; } } + public bool IsEnded { get { lock (stateGate) return ended; } } + public int VideoWidth { get { lock (stateGate) return videoWidth; } } + public int VideoHeight { get { lock (stateGate) return videoHeight; } } + public bool HasVideo { get { lock (stateGate) return videoWidth > 0 && videoHeight > 0; } } + /// Diagnostic only; UI consumers should show a localized playback failure message. + public string? Error { get { lock (stateGate) return error; } } + + public double Volume + { + get { lock (nativeGate) return volume; } + set + { + lock (nativeGate) + { + ThrowIfDisposed(); + volume = double.IsFinite(value) ? Math.Clamp(value, 0, 1) : 0; + if (handle != 0) SetDouble("volume", volume * 100); + } + } + } + + public void Open(string absolutePath) + { + if (!Path.IsPathFullyQualified(absolutePath) || absolutePath.Contains('\0')) + throw new ArgumentException("An absolute local path is required.", nameof(absolutePath)); + lock (nativeGate) + { + ThrowIfDisposed(); + if (requestedPath is not null) throw new InvalidOperationException("Use a new player for each file."); + requestedPath = Path.GetFullPath(absolutePath); + try + { + Initialize(); + ThrowIfDisposed(); + Command("loadfile", requestedPath, "replace"); + } + catch (Exception ex) + { + Fail(ex.Message); + Dispose(); + throw; + } + } + } + + public void Play() + { + lock (nativeGate) + { + ThrowIfDisposed(); + playRequested = true; + if (Volatile.Read(ref validated) == 0) return; + if (IsEnded) Command("seek", "0", "absolute"); + SetFlag("pause", false); + } + } + + public void Pause() + { + lock (nativeGate) + { + ThrowIfDisposed(); + playRequested = false; + if (handle != 0) SetFlag("pause", true); + } + } + + public void TogglePause() + { + lock (nativeGate) + { + if (Volatile.Read(ref validated) == 0 ? playRequested : !IsPaused) Pause(); + else Play(); + } + } + + public void Seek(TimeSpan time) + { + lock (nativeGate) + { + ThrowIfDisposed(); + if (Volatile.Read(ref validated) != 0) + Command("seek", Math.Max(0, time.TotalSeconds).ToString("R", CultureInfo.InvariantCulture), "absolute+keyframes"); + } + } + + public void SetTargetSize(int widthPx, int heightPx) + { + lock (stateGate) + { + if (Volatile.Read(ref disposed) != 0) return; + targetWidth = Math.Clamp(widthPx, 1, 1920); + targetHeight = Math.Clamp(heightPx, 1, 1080); + Interlocked.Exchange(ref redraw, 1); + // The signal is disposed under this lock after both worker threads have exited. + renderSignal.Set(); + } + } + + internal static (int Width, int Height) FitSize(int width, int height, int videoWidth, int videoHeight) + { + width = Math.Clamp(width, 1, 1920); + height = Math.Clamp(height, 1, 1080); + if (videoWidth <= 0 || videoHeight <= 0) return (width, height); + var scale = Math.Min((double)width / videoWidth, (double)height / videoHeight); + return (Math.Clamp((int)Math.Round(videoWidth * scale), 1, width), + Math.Clamp((int)Math.Round(videoHeight * scale), 1, height)); + } + + private static bool ProbeAvailability() + { + try + { + if (!NativeLibrary.TryLoad("libmpv.so.2", out var library)) return false; + try { return (MpvNative.mpv_client_api_version() >> 16) == 2; } + finally { NativeLibrary.Free(library); } + } + catch { return false; } + } + + private void Initialize() + { + if (!IsAvailable) throw new NotSupportedException("libmpv client API 2 is unavailable."); + lock (localeGate) + { + // LC_NUMERIC is process-wide. mpv requires C for its entire lifetime. + if (MpvNative.SetLocale(1, "C") == 0) throw new InvalidOperationException("Cannot set LC_NUMERIC."); + handle = MpvNative.mpv_create(); + } + if (handle == 0) throw new InvalidOperationException("Cannot create mpv."); + foreach (var (name, value) in Options) Check(MpvNative.mpv_set_option_string(handle, name, value)); + Check(MpvNative.mpv_initialize(handle)); + SetDouble("volume", volume * 100); + string[] properties = ["time-pos", "duration", "pause", "eof-reached", "dwidth", "dheight"]; + for (var i = 0; i < properties.Length; i++) + Check(MpvNative.mpv_observe_property(handle, (ulong)i + 1, properties[i], + i is 2 or 3 ? MpvNative.FormatFlag : MpvNative.FormatDouble)); + + // Create before loadfile; subsequent render calls and free belong exclusively to the render thread. + fixed (byte* api = "sw\0"u8) + { + MpvNative.RenderParam* parameters = stackalloc MpvNative.RenderParam[] { new(1, (nint)api), new(0, 0) }; + Check(MpvNative.mpv_render_context_create(out renderContext, handle, parameters)); + } + callbackHandle = GCHandle.Alloc(this); + MpvNative.mpv_render_context_set_update_callback(renderContext, &RenderUpdate, GCHandle.ToIntPtr(callbackHandle)); + renderThread = new Thread(RenderLoop) { IsBackground = true, Name = "Media preview render" }; + eventThread = new Thread(EventLoop) { IsBackground = true, Name = "Media preview events" }; + renderThread.Start(); + eventThread.Start(); + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void RenderUpdate(nint context) + { + // Native teardown quiesces callbacks before the handle and signal are freed. + var player = (MpvMediaPlayer)GCHandle.FromIntPtr(context).Target!; + if (Volatile.Read(ref player.disposed) == 0) player.renderSignal.Set(); + } + + private void EventLoop() + { + try + { + while (Volatile.Read(ref disposed) == 0) + { + var evt = MpvNative.mpv_wait_event(handle, -1); + if (Volatile.Read(ref disposed) != 0) break; + switch (evt->Id) + { + case 8: // MPV_EVENT_FILE_LOADED + ValidateLoadedFile(); + break; + case 7: // MPV_EVENT_END_FILE: first fields are reason and error. + if (evt->Data != 0 && (*(int*)evt->Data is 4 or 5)) + RejectFile("Media load failed or redirected to a playlist."); + break; + case 22: // MPV_EVENT_PROPERTY_CHANGE + UpdateProperty(evt->UserData, (MpvNative.EventProperty*)evt->Data); + break; + case 1: // MPV_EVENT_SHUTDOWN + return; + } + } + } + catch (Exception ex) + { + Fail(ex.Message); + Dispose(); + } + } + + private void ValidateLoadedFile() + { + lock (nativeGate) + { + if (Volatile.Read(ref disposed) != 0) return; + double count = 0; + nint path = 0; + try + { + Check(MpvNative.mpv_get_property(handle, "playlist-count", MpvNative.FormatDouble, &count)); + Check(MpvNative.mpv_get_property(handle, "path", MpvNative.FormatString, &path)); + if (count != 1 || !string.Equals(Marshal.PtrToStringUTF8(path), requestedPath, StringComparison.Ordinal)) + { + RejectFile("Media load expanded a playlist or changed the requested path."); + return; + } + Volatile.Write(ref validated, 1); + Interlocked.Exchange(ref redraw, 1); + renderSignal.Set(); + if (playRequested) SetFlag("pause", false); + } + finally { if (path != 0) MpvNative.mpv_free(path); } + } + } + + private void UpdateProperty(ulong id, MpvNative.EventProperty* property) + { + if (property == null || property->Data == 0) return; + lock (stateGate) + { + var value = property->Format == MpvNative.FormatDouble ? *(double*)property->Data : 0; + if (!double.IsFinite(value)) value = 0; + var flag = property->Format == MpvNative.FormatFlag && *(int*)property->Data != 0; + switch (id) + { + case 1: position = value; break; + case 2: duration = value; break; + case 3: paused = flag; break; + case 4: ended = flag; break; + case 5: videoWidth = (int)Math.Clamp(value, 0, int.MaxValue); break; + case 6: videoHeight = (int)Math.Clamp(value, 0, int.MaxValue); break; + } + } + if (id is 5 or 6) + { + Interlocked.Exchange(ref redraw, 1); + renderSignal.Set(); + } + NotifyState(); + } + + private void RenderLoop() + { + try + { + while (Volatile.Read(ref disposed) == 0) + { + renderSignal.WaitOne(); + if (Volatile.Read(ref disposed) != 0) break; + var flags = MpvNative.mpv_render_context_update(renderContext); + var force = Interlocked.Exchange(ref redraw, 0) != 0; + if ((flags & 1) == 0 && !force) continue; + // Service requested frames even before FILE_LOADED validation, so the core can progress. + // Publishing waits for validation and display dimensions in RenderFrame. + if ((flags & 1) == 0 && !HasVideo) continue; + int width, height; + lock (stateGate) (width, height) = FitSize(targetWidth, targetHeight, videoWidth, videoHeight); + RenderFrame(width, height); + } + } + catch (Exception ex) + { + Fail(ex.Message); + Dispose(); + } + finally + { + MpvNative.mpv_render_context_set_update_callback(renderContext, null, 0); + MpvNative.mpv_render_context_free(renderContext); + renderContext = 0; + } + } + + private void RenderFrame(int width, int height) + { + var length = checked(width * height * 4); + var buffer = NativeMemory.AlignedAlloc((nuint)((length + 63) & ~63), 64); + if (buffer == null) throw new OutOfMemoryException(); + NativeMemory.Clear(buffer, (nuint)length); + try + { + int* size = stackalloc int[] { width, height }; + nuint stride = (nuint)width * 4; + fixed (byte* format = "bgr0\0"u8) + { + MpvNative.RenderParam* parameters = stackalloc MpvNative.RenderParam[] + { + new(17, (nint)size), new(18, (nint)format), new(19, (nint)(&stride)), + new(20, (nint)buffer), new(0, 0) + }; + Check(MpvNative.mpv_render_context_render(renderContext, parameters)); + } + if (Volatile.Read(ref disposed) != 0 || Volatile.Read(ref validated) == 0 || !HasVideo || Error is not null) return; + var pixels = new byte[length]; + Marshal.Copy((nint)buffer, pixels, 0, length); + for (var i = 3; i < length; i += 4) pixels[i] = 0xFF; + FrameRendered?.Invoke(new MpvFrame(width, height, pixels)); + } + finally { NativeMemory.AlignedFree(buffer); } + } + + private void RejectFile(string message) + { + lock (nativeGate) + { + Volatile.Write(ref validated, 0); + if (Volatile.Read(ref disposed) == 0) Command("stop"); + } + Fail(message); + } + + private void Fail(string message) + { + lock (stateGate) { error = message; paused = true; } + NotifyState(); + } + + private void NotifyState() + { + if (Volatile.Read(ref disposed) == 0) StateChanged?.Invoke(this, EventArgs.Empty); + } + + private static TimeSpan ToTime(double seconds) => TimeSpan.FromSeconds(Math.Clamp(seconds, 0, TimeSpan.MaxValue.TotalSeconds - 1)); + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) != 0, this); + private static void Check(int result) + { + if (result < 0) throw new InvalidOperationException(Marshal.PtrToStringUTF8(MpvNative.mpv_error_string(result))); + } + private void SetFlag(string name, bool value) + { + int flag = value ? 1 : 0; + Check(MpvNative.mpv_set_property(handle, name, MpvNative.FormatFlag, &flag)); + } + private void SetDouble(string name, double value) => Check(MpvNative.mpv_set_property(handle, name, MpvNative.FormatDouble, &value)); + + private void Command(params string[] args) + { + byte** pointers = stackalloc byte*[args.Length + 1]; + new Span(pointers, args.Length + 1).Clear(); + try + { + for (var i = 0; i < args.Length; i++) pointers[i] = (byte*)Marshal.StringToCoTaskMemUTF8(args[i]); + Check(MpvNative.mpv_command(handle, pointers)); + } + finally + { + for (var i = 0; i < args.Length; i++) Marshal.FreeCoTaskMem((nint)pointers[i]); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) return; + _ = Task.Run(Teardown); + } + + private void Teardown() + { + try + { + lock (nativeGate) + { + renderSignal.Set(); + if (handle != 0) MpvNative.mpv_wakeup(handle); + } + // No nativeGate while joining: the event thread may still be finishing a command. + if (renderThread?.IsAlive == true) renderThread.Join(); + if (eventThread?.IsAlive == true) eventThread.Join(); + lock (nativeGate) + { + // Also handles failure before the render thread started. + if (renderContext != 0) MpvNative.mpv_render_context_free(renderContext); + if (handle != 0) MpvNative.mpv_terminate_destroy(handle); + renderContext = handle = 0; + if (callbackHandle.IsAllocated) callbackHandle.Free(); + } + lock (stateGate) renderSignal.Dispose(); + disposal.TrySetResult(); + } + catch (Exception ex) { disposal.TrySetException(ex); } + } + } +} diff --git a/src/Files.Platform.Linux/Native/MpvNative.cs b/src/Files.Platform.Linux/Native/MpvNative.cs new file mode 100644 index 000000000000..f85e2c8619df --- /dev/null +++ b/src/Files.Platform.Linux/Native/MpvNative.cs @@ -0,0 +1,79 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.Runtime.InteropServices; + +namespace Files.Platform.Linux.Native +{ + internal static unsafe partial class MpvNative + { + internal const int FormatString = 1; + internal const int FormatFlag = 3; + internal const int FormatDouble = 5; + + [LibraryImport("libc", EntryPoint = "setlocale", StringMarshalling = StringMarshalling.Utf8)] + internal static partial nint SetLocale(int category, string locale); + + [LibraryImport("libmpv.so.2")] + internal static partial nuint mpv_client_api_version(); + [LibraryImport("libmpv.so.2")] + internal static partial nint mpv_create(); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_initialize(nint handle); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_set_option_string(nint handle, string name, string value); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_command(nint handle, byte** args); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_set_property(nint handle, string name, int format, void* data); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_get_property(nint handle, string name, int format, void* data); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_observe_property(nint handle, ulong userData, string name, int format); + [LibraryImport("libmpv.so.2")] + internal static partial Event* mpv_wait_event(nint handle, double timeout); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_wakeup(nint handle); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_terminate_destroy(nint handle); + [LibraryImport("libmpv.so.2")] + internal static partial nint mpv_error_string(int error); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_free(nint data); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_render_context_create(out nint context, nint handle, RenderParam* parameters); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_render_context_set_update_callback(nint context, delegate* unmanaged[Cdecl] callback, nint userData); + [LibraryImport("libmpv.so.2")] + internal static partial ulong mpv_render_context_update(nint context); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_render_context_render(nint context, RenderParam* parameters); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_render_context_free(nint context); + + [StructLayout(LayoutKind.Sequential)] + internal struct Event + { + internal int Id; + internal int Error; + internal ulong UserData; + internal nint Data; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct EventProperty + { + internal nint Name; + internal int Format; + internal nint Data; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct RenderParam + { + internal int Type; + internal nint Data; + internal RenderParam(int type, nint data) => (Type, Data) = (type, data); + } + } +} diff --git a/tests/Files.Platform.Tests/Previews/MpvMediaPlayerTests.cs b/tests/Files.Platform.Tests/Previews/MpvMediaPlayerTests.cs new file mode 100644 index 000000000000..b089a68e10b8 --- /dev/null +++ b/tests/Files.Platform.Tests/Previews/MpvMediaPlayerTests.cs @@ -0,0 +1,225 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Media; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Tests.Previews +{ + [TestClass] + public sealed class MpvMediaPlayerTests + { + [TestMethod] + [DataRow(640, 360, 640, 480, 480, 360)] + [DataRow(5000, 5000, 3840, 2160, 1920, 1080)] + [DataRow(1920, 1080, 1080, 1920, 608, 1080)] + [DataRow(0, -1, 64, 48, 1, 1)] + [DataRow(640, 360, 0, 0, 640, 360)] + [DataRow(int.MaxValue, int.MaxValue, int.MaxValue, 1, 1920, 1)] + public void FitsAspectRatioWithinBoundedTarget(int width, int height, int videoWidth, int videoHeight, int expectedWidth, int expectedHeight) + { + Assert.AreEqual((expectedWidth, expectedHeight), MpvMediaPlayer.FitSize(width, height, videoWidth, videoHeight)); + } + + [TestMethod] + public void OptionsDisableExternalInputsAndPlaylistDemuxers() + { + var options = MpvMediaPlayer.Options.ToArray().ToDictionary(p => p.Name, p => p.Value); + foreach (var name in new[] { "config", "load-scripts", "ytdl", "access-references", "load-unsafe-playlists", "autoload-files", "sub-auto", "audio-file-auto", "cover-art-auto", "autocreate-playlist", "prefetch-playlist", "ordered-chapters" }) + Assert.AreEqual("no", options[name], name); + Assert.AreEqual("lavf", options["demuxer"]); + StringAssert.StartsWith(options["demuxer-lavf-o"], "protocol_whitelist=[file],format_whitelist=["); + foreach (var format in new[] { "hls", "dash", "concat", "sdp" }) + Assert.IsFalse(options["demuxer-lavf-o"].Contains(format, StringComparison.Ordinal)); + Assert.AreEqual("protocol_whitelist=[file]", options["stream-lavf-o"]); + Assert.AreEqual("libmpv", options["vo"]); + Assert.AreEqual("no", options["hwdec"]); + Assert.AreEqual("yes", options["pause"]); + } + + [TestMethod] + public async Task RejectsRelativePathsWithoutLoadingNativeCode() + { + using var player = new MpvMediaPlayer(); + Assert.ThrowsExactly(() => player.Open("relative.mp4")); + player.Dispose(); + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + } + + [TestMethod] + public async Task RendersOpaqueOwnedFramesAndResizesWhilePaused() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + var path = await CreateVideo(root); + player.SetTargetSize(128, 96); + var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resized = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + player.FrameRendered += frame => + { + if (frame.Width == 128 && frame.Height == 96) first.TrySetResult(frame); + if (frame.Width == 80 && frame.Height == 60) resized.TrySetResult(frame); + }; + player.Open(path); + var frame = await first.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.IsNull(player.Error); + Assert.IsTrue(player.IsPaused); + Assert.IsTrue(player.HasVideo); + Assert.AreEqual(64, player.VideoWidth); + Assert.AreEqual(48, player.VideoHeight); + Assert.AreEqual(frame.Width * 4, frame.Stride); + AssertOpaque(frame); + Assert.IsTrue(frame.Pixels.ToArray().Where((_, i) => i % 4 != 3).Distinct().Count() > 5, "Expected a decoded test pattern."); + var retained = frame.Pixels.ToArray(); + player.SetTargetSize(80, 60); + AssertOpaque(await resized.Task.WaitAsync(TimeSpan.FromSeconds(10))); + player.Play(); + await WaitUntil(() => player.IsEnded, player); + Assert.IsTrue(player.Duration.TotalSeconds > 0); + player.Seek(TimeSpan.Zero); + player.Pause(); + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + CollectionAssert.AreEqual(retained, frame.Pixels.ToArray()); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task DisposeDuringPlaybackAndDoubleDisposeComplete() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + var path = await CreateVideo(root); + player.Open(path); + player.Play(); // Also exercises a play request made before FILE_LOADED. + await WaitUntil(() => player.Position > TimeSpan.Zero, player); + var stopwatch = Stopwatch.StartNew(); + player.Dispose(); + Assert.IsLessThan(TimeSpan.FromSeconds(1), stopwatch.Elapsed); + await Task.WhenAll(Task.Run(player.Dispose), Task.Run(player.Dispose)); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.ThrowsExactly(player.Play); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task DisposeFromFrameCallbackCompletes() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + var path = await CreateVideo(root); + var rendered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + player.FrameRendered += frame => + { + player.Dispose(); + rendered.TrySetResult(frame); + }; + player.Open(path); + var frame = await rendered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + AssertOpaque(frame); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + [TestMethod] + [DataRow("#EXTM3U\nhttp://127.0.0.1:9/should-not-open.mp4\n")] + [DataRow("[playlist]\nNumberOfEntries=1\nFile1=http://127.0.0.1:9/should-not-open.mp4\n")] + [DataRow("#EXTM3U\n#EXT-X-TARGETDURATION:1\n#EXTINF:1,\nhttp://127.0.0.1:9/should-not-open.ts\n#EXT-X-ENDLIST\n")] + [DataRow("#EXTM3U\n#EXT-X-TARGETDURATION:1\n#EXTINF:1,\ntest video.mp4\n#EXT-X-ENDLIST\n")] + [DataRow("ffconcat version 1.0\nfile 'test video.mp4'\n")] + public async Task DisguisedPlaylistFailsWithoutPublishingFrames(string content) + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + if (content.Contains("test video.mp4", StringComparison.Ordinal)) await CreateVideo(root); + var path = Path.Combine(root, "playlist.mp4"); + await File.WriteAllTextAsync(path, content); + var frames = 0; + player.FrameRendered += _ => Interlocked.Increment(ref frames); + player.Open(path); + player.Play(); + await WaitUntil(() => player.Error is not null, player, allowFailure: true); + Assert.IsNotNull(player.Error); + Assert.AreEqual(0, Volatile.Read(ref frames)); + Assert.IsTrue(player.IsPaused); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + private static void RequireIntegration() + { + if (!MpvMediaPlayer.IsAvailable || !File.Exists("/usr/bin/ffmpeg")) + Assert.Inconclusive("Media integration requires libmpv client API 2 and ffmpeg."); + } + + private static async Task CreateVideo(string root) + { + var path = Path.Combine(root, "test video.mp4"); + using var process = Process.Start(new ProcessStartInfo("/usr/bin/ffmpeg", + ["-nostdin", "-loglevel", "error", "-y", "-filter_threads", "1", "-f", "lavfi", "-i", "testsrc=size=64x48:rate=10:duration=1", "-c:v", "mpeg4", "-threads", "1", "-pix_fmt", "yuv420p", path]) { UseShellExecute = false })!; + try { await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(15)); } + finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } } + Assert.AreEqual(0, process.ExitCode, "ffmpeg test video generation failed."); + return path; + } + + private static void AssertOpaque(MpvFrame frame) + { + Assert.AreEqual(frame.Stride * frame.Height, frame.Pixels.Length); + for (var i = 3; i < frame.Pixels.Length; i += 4) Assert.AreEqual((byte)255, frame.Pixels.Span[i]); + } + + private static async Task WaitUntil(Func condition, MpvMediaPlayer player, bool allowFailure = false) + { + var deadline = Stopwatch.StartNew(); + while (!condition()) + { + if (!allowFailure) Assert.IsNull(player.Error); + if (deadline.Elapsed > TimeSpan.FromSeconds(15)) Assert.Fail("Timed out waiting for media state: " + player.Error); + await Task.Delay(20); + } + } + } +} From 24371d478d1873c900ab6df53b2d38e15df316a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Thu, 15 Oct 2026 20:58:03 +0300 Subject: [PATCH 2/4] Feature: Render HTML previews as a snapshot with WebKitGTK A short-lived helper process (the app started with a hidden switch) renders the document with an offscreen WebKitGTK 4.1 view and returns a PNG, so HTML previews work on the native Wayland host and on X11 without embedding a native window. Scripts, navigation and network access are blocked; without WebKitGTK the source text fallback stays. Also documents the preview design and lists mpv and webkit2gtk-4.1 as optional AUR dependencies. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1-sol (OpenAI Codex) --- docs/linux-port/wayland-previews.md | 51 ++++ packaging/linux/aur/linuxfiles-bin/PKGBUILD | 4 +- packaging/linux/aur/linuxfiles/PKGBUILD | 2 +- src/Files.App/Platforms/Desktop/Program.cs | 1 + .../FilePreviews/LinuxHtmlPreview.xaml | 19 ++ .../FilePreviews/LinuxHtmlPreview.xaml.cs | 143 ++++++++++ .../UserControls/InfoPaneViewModel.cs | 7 +- .../Native/WebKitGtkNative.cs | 135 ++++++++++ .../Previews/HtmlSnapshotHelper.cs | 252 ++++++++++++++++++ .../Previews/HtmlSnapshotRenderer.cs | 139 ++++++++++ .../Files.Platform.Tests.csproj | 1 + .../Previews/HtmlSnapshotTests.cs | 216 +++++++++++++++ tests/Files.Platform.Tests/Program.cs | 17 ++ 13 files changed, 984 insertions(+), 3 deletions(-) create mode 100644 docs/linux-port/wayland-previews.md create mode 100644 src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml create mode 100644 src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml.cs create mode 100644 src/Files.Platform.Linux/Native/WebKitGtkNative.cs create mode 100644 src/Files.Platform.Linux/Previews/HtmlSnapshotHelper.cs create mode 100644 src/Files.Platform.Linux/Previews/HtmlSnapshotRenderer.cs create mode 100644 tests/Files.Platform.Tests/Previews/HtmlSnapshotTests.cs create mode 100644 tests/Files.Platform.Tests/Program.cs diff --git a/docs/linux-port/wayland-previews.md b/docs/linux-port/wayland-previews.md new file mode 100644 index 000000000000..3051a641c499 --- /dev/null +++ b/docs/linux-port/wayland-previews.md @@ -0,0 +1,51 @@ +# Media and HTML previews without native window embedding + +Status: media playback and rendered HTML previews are implemented for the preview pane. The release notes page still opens the browser. This note records why, how, and what a fully interactive HTML view would cost. + +## What the X11 host actually did + +`NativePreviewSupport.IsAvailable` (true on the X11 host) suggested that X11 embeds native previews. It does not in LinuxFiles: + +- `MediaPlayerElement` needs the `Uno.WinUI.MediaPlayer.Skia.X11` add-in (libVLC) and `WebView2` needs `Uno.WinUI.WebView.Skia.X11` (WebKitGTK 4.1, embedded as an X11 child window). LinuxFiles references neither package. `LibVLCSharp.dll` only ships because `Uno.WinUI.Runtime.Skia.X11` depends on it. +- On the desktop target the info pane never creates `MediaPreview` or `HtmlPreview`. Media showed cover art and tags only, and HTML showed its source as highlighted code on X11 or as plain text through `NativePreviewFallback` on Wayland. +- `ReleaseNotesPage` does instantiate `WebView2` on X11, but without the add-in there is no native web view behind it. + +So the Wayland work did not need to match an existing X11 implementation. The new previews draw through Skia, so they work the same way on both hosts. + +## Media: libmpv software rendering (implemented) + +- `Files.Platform.Linux/Media/MpvMediaPlayer.cs` loads `libmpv.so.2` at runtime (`LibraryImport`, no `DirectPInvoke`), so mpv is an optional dependency. Without it, the previous metadata preview is shown. +- Frames come from the libmpv render API with `MPV_RENDER_API_TYPE_SW` (`bgr0`, forced opaque) on a dedicated render thread. They are sized to the pane in device pixels (at most 1920x1080) and copied into a `WriteableBitmap`. UI updates are coalesced, so a slow UI thread drops frames instead of queuing them. mpv handles audio output itself. +- Nothing is decoded when a file is selected. mpv is created on the first Play (button or Ctrl+Space), so browsing stays cheap and untrusted media is only parsed when the user asks for it. +- Hardening: user configuration, scripts, ytdl, and auto-loaded external files are disabled. Only the lavf demuxer runs, limited to `protocol_whitelist=[file]` and a container whitelist (no HLS, DASH, or concat). A file that expands into a playlist or changes path after loading is rejected, so a text playlist renamed to `.mp4` never fetches URLs. `audio-fallback-to-null` lets audio-only files play when no audio device is available. +- Cost: software scaling and conversion use CPU in proportion to the pane size, which is small. `hwdec` stays off for robustness. A future OpenGL render path (see the subsurface section) could hand frames to Skia as GPU textures. + +## HTML: offscreen WebKitGTK snapshot in a helper process (implemented) + +- `Files --linuxfiles-html-snapshot ` is handled first in `Main`. It initialises GTK 3 and WebKitGTK 4.1 in a short-lived child process, loads the file in a `GtkOffscreenWindow`, takes a full-document snapshot, and writes a PNG to stdout. The app shows the PNG in a scroll viewer at the pane width. +- The work runs out of process because `gtk_init` changes the process locale and needs its own main loop, and a WebKit crash must not take down the file manager. WebKit also starts its own sandboxed web process. +- Hardening: JavaScript, plugins, WebGL, and media are off, and navigation, new windows, and downloads are refused. The data manager is ephemeral. +- No network: the bounded document (up to 8 MiB) is loaded with `webkit_web_view_load_html`, with a Content-Security-Policy `` prepended and the file's own URI as the base. The policy allows only `file:` and `data:` images, styles, and fonts. `` ignores CSP and was verified to open a TCP connection, so the `LinkPreconnect`, `LinkPreconnectEarlyHints`, `LinkPrefetch`, and `SpeculationRulesPrefetch` WebKit features are turned off. This needs WebKitGTK 2.42 or later. On older versions the helper refuses to render and the text fallback is shown. +- Limits: 10 s in the helper, 15 s in the parent, a 64 MiB PNG cap, and a height cap of 8192 device pixels. HiDPI uses the WebKit page zoom (1x or 2x) rather than `GDK_SCALE`, because GTK 3 only honours `GDK_SCALE` on X11. The helper is killed when the selection changes. +- What users see: the preview is a static picture. Links do not respond, `:hover` and scripted content are not shown, very long pages are cut off at the height limit, and documents that are neither UTF-8 nor BOM-marked Unicode fall back to source text. "Open with default app" stays available. +- Optional dependency: `webkit2gtk-4.1` (and GTK 3). When it is missing, or rendering fails, the plain text fallback is shown as before. + +## Packaging + +- AUR: `mpv` and `webkit2gtk-4.1` are listed as optional dependencies. +- Nix: neither library is in the flake's `runtimeLibs` yet, because WebKitGTK would add a large closure. Both previews fall back on NixOS until the flake adds them (perhaps behind an option). +- Flatpak: not covered yet. + +## Release notes (not changed) + +`ReleaseNotesPage` points at the upstream Files blog (`files.community/blog/posts/v…?minimal`), which describes the Windows app and needs network access and interactive links. That makes it a poor fit for a snapshot. On Wayland it keeps opening the page in the default browser. A better fix is to render the LinuxFiles GitHub release body (Markdown) with the existing Markdig preview renderer, which needs a fetch and caching design of its own. + +## Interactive HTML on Wayland: options and cost + +A live, scrollable, clickable web view on the native Wayland host would need one of these: + +1. **Native element hosting via `wl_subsurface` in the Uno Wayland host.** The host would create a subsurface per native element, keep its position and clip in sync with layout (including scrolling, transforms, and DPI changes), and hand the toolkit a surface to draw into. libmpv could render into it through its OpenGL render API on an EGL window surface. WebKitGTK cannot: GTK 3 and GTK 4 widgets only render into their own toplevel `GdkSurface`, not into a foreign `wl_surface`. Airspace problems apply as on X11, because flyouts, menus, and tooltips over the element would be hidden. Estimated cost: a new Uno host subsystem (about 1.5k lines plus tests), upstream review, and it still would not help HTML. +2. **WPE WebKit with a custom view backend (`libwpe` + `wpebackend-fdo`, or the WPE platform API).** WPE exports frames as EGLImages, dma-bufs, or SHM buffers that can be drawn into Skia, and it accepts injected pointer, keyboard, and scroll events. This is the right route to a real web view on Wayland. Cost: new native dependencies that most desktop distributions do not install by default, GL and EGL interop with Uno's Skia context (or an SHM copy), input translation, and lifecycle and threading work. Estimated at 2 to 3k lines across an Uno add-in, `Uno.UI.WebView.Skia.Wayland` (`INativeWebView`), and its tests. This is the recommended follow-up if interactive web content becomes a requirement. It would also give LinuxFiles a working `WebView2` on Wayland for the release notes page. +3. **WebKitGTK offscreen with input forwarding.** Extend the snapshot helper into a long-lived process that streams damaged regions over shared memory and receives input events. This avoids new dependencies but means reimplementing a compositor protocol between the two processes, with high latency and complexity. Not recommended over WPE. + +For read-only previews, the static snapshot gives most of the value at a small fraction of these costs. diff --git a/packaging/linux/aur/linuxfiles-bin/PKGBUILD b/packaging/linux/aur/linuxfiles-bin/PKGBUILD index d9bb514da159..6e681c372f97 100644 --- a/packaging/linux/aur/linuxfiles-bin/PKGBUILD +++ b/packaging/linux/aur/linuxfiles-bin/PKGBUILD @@ -18,7 +18,9 @@ optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'udisks2: mount and eject drives' 'libsecret: saved network credentials (Secret Service provider)' - 'gnome-disk-utility: format and manage drives') + 'gnome-disk-utility: format and manage drives' + 'mpv: audio and video playback in the preview pane' + 'webkit2gtk-4.1: rendered HTML previews') provides=("$_pkgname") conflicts=("$_pkgname") options=('!strip' '!debug') # stripping breaks the self-contained .NET binaries diff --git a/packaging/linux/aur/linuxfiles/PKGBUILD b/packaging/linux/aur/linuxfiles/PKGBUILD index 9f2a2d4b9174..095110104f05 100644 --- a/packaging/linux/aur/linuxfiles/PKGBUILD +++ b/packaging/linux/aur/linuxfiles/PKGBUILD @@ -11,7 +11,7 @@ license=('MIT') # Self-contained publish bundles the runtime; native libs below are loaded by Skia/Uno at runtime. depends=('fontconfig' 'freetype2' 'libx11' 'libxcursor' 'libxrandr' 'libxi' 'libxext' 'wayland' 'libxkbcommon' 'libglvnd' 'mesa' 'glib2' 'hicolor-icon-theme') makedepends=('dotnet-sdk>=10.0' 'git' 'clang' 'zlib' 'python') -optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'gnome-disk-utility: format and manage drives') +optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'gnome-disk-utility: format and manage drives' 'mpv: audio and video playback in the preview pane' 'webkit2gtk-4.1: rendered HTML previews') options=('!strip') # .NET single-file/self-contained binaries break when stripped # Generated from build/uno-wayland-packages.sha256; templates require complete pins. _uno_version=6.7.135-wayland.6 diff --git a/src/Files.App/Platforms/Desktop/Program.cs b/src/Files.App/Platforms/Desktop/Program.cs index 68ab8842bb90..d2fdcf03eb65 100644 --- a/src/Files.App/Platforms/Desktop/Program.cs +++ b/src/Files.App/Platforms/Desktop/Program.cs @@ -39,6 +39,7 @@ internal sealed class Program [STAThread] public static int Main(string[] args) { + if (Files.Platform.Linux.Previews.HtmlSnapshotHelper.TryRunHelper(args, out var helperExitCode)) return helperExitCode; try { using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.root-environment"); diff --git a/src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml b/src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml new file mode 100644 index 000000000000..9f3abb103433 --- /dev/null +++ b/src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + +