From d9b183c500cf222b94e0e73e66d05fcefe2b3ba2 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 19 Oct 2026 12:02:27 +0100 Subject: [PATCH] Fix Linux LibVLC playback fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 16 +++ src/PostIt/PostIt/Services/CameraStreamer.cs | 131 +++++++++++++++++- .../PostIt/Views/Blogs/VideoPage.axaml.cs | 87 +++++++++++- 3 files changed, 220 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8567c5899..970efdda5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +* [PostIt.Desktop, Yavsc.Api] Podcasts en direct avec replay HLS conserve : + capture camera et microphone par LibVLC (H.264/AAC, segments MPEG-TS), + envoi ordonne et idempotent, catalogue et lecture authentifiee dans PostIt. + L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur + disque, quotas dedies et conservation des segments recus apres interruption. + * [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans elevation : publication autonome, raccourci du menu Demarrer, protocole `postit://` pour la connexion et desinstallation via Windows. @@ -46,6 +52,16 @@ ### Fixed +* [PostIt] Le playback Linux revient sur un chemin logiciel sûr quand + VAAPI/VDPAU n'est pas validé, sans bloquer la lecture si le flux reste + lisible. Le fallback navigateur reste actif pour les médias impossibles + à lire dans le contrôle VLC, sans se baser sur l'absence de `vainfo` + ou de pilotes VAAPI. + +* [Yavsc.Server] Reception WebSocket historique : correction du tampon + reutilise, suppression de la file non synchronisee et liberation des + connexions. Les podcasts utilisent desormais une ingestion HTTP bornee. + * [PostIt.Android] Restauration de la pile de navigation lors de la recréation de l'activité après suspension : nouvelles pages liées aux mêmes modèles de vue, sans plus de procès. diff --git a/src/PostIt/PostIt/Services/CameraStreamer.cs b/src/PostIt/PostIt/Services/CameraStreamer.cs index 10607f204..d78a84349 100644 --- a/src/PostIt/PostIt/Services/CameraStreamer.cs +++ b/src/PostIt/PostIt/Services/CameraStreamer.cs @@ -1,18 +1,105 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using LibVLCSharp.Shared; -// Note : Si vous compilez sous Linux, DirectShowLib n'est pas disponible. -// Pour un projet multiplateforme propre, vous pouvez utiliser des directives de précompilation (#if WINDOWS) -// ou charger la détection DirectShow dans une assembly séparée. using DirectShowLib; public class CameraStreamer { private readonly LibVLC _libVLC; + public static LibVLC CreateHeadlessLibVlc() + => new( + "--no-xlib", + "--avcodec-hw=none", + "--vout=dummy", + "--aout=dummy", + "--intf=dummy"); + + public static LibVLC CreatePlaybackLibVlc() + { + if (!OperatingSystem.IsLinux()) + return new LibVLC(); + + var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1", + StringComparison.OrdinalIgnoreCase); + + if (preferHardware && IsVaapiUsable()) + return new LibVLC(); + + // On Linux, a non-hardware display path is still valid for in-app playback as long as + // the platform is not explicitly forced into a broken VAAPI/VDPAU stack. + return new LibVLC("--avcodec-hw=none"); + } + + public static LibVLC CreatePreferredLibVlc() + { + if (!OperatingSystem.IsLinux()) + return new LibVLC(); + + var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1", + StringComparison.OrdinalIgnoreCase); + + if (preferHardware && IsVaapiUsable()) + return new LibVLC(); + + return CreateHeadlessLibVlc(); + } + + private static bool IsVaapiUsable() + { + var vaapiDrivers = new[] + { + "/usr/lib/x86_64-linux-gnu/dri/nvidia_drv_video.so", + "/usr/lib/x86_64-linux-gnu/dri/i965_drv_video.so", + "/usr/lib/x86_64-linux-gnu/dri/i915_drv_video.so", + "/usr/lib/x86_64-linux-gnu/dri/radeonsi_dri.so", + "/usr/lib/x86_64-linux-gnu/dri/iris_dri.so", + "/usr/lib64/dri/nvidia_drv_video.so", + "/usr/lib64/dri/i965_drv_video.so", + "/usr/lib64/dri/i915_drv_video.so", + "/usr/lib64/dri/radeonsi_dri.so", + "/usr/lib64/dri/iris_dri.so" + }; + + if (!vaapiDrivers.Any(File.Exists)) + return false; + + try + { + var processInfo = new ProcessStartInfo("vainfo") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = Process.Start(processInfo); + if (process is null) + return false; + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(2000); + + var combined = (output + Environment.NewLine + error).Trim(); + return process.ExitCode == 0 + && !string.IsNullOrWhiteSpace(combined) + && combined.Contains("VA-API version", StringComparison.OrdinalIgnoreCase) + && !combined.Contains("failed", StringComparison.OrdinalIgnoreCase) + && !combined.Contains("unknown libva error", StringComparison.OrdinalIgnoreCase) + && !combined.Contains("va_openDriver() returns -1", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + public CameraStreamer(LibVLC libVLC) { _libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC)); @@ -32,8 +119,8 @@ public class CameraStreamer DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); foreach (var device in capDevices) { - // Pour Windows, VLC utilise le nom de l'appareil - devices.Add(device.Name, device.Name); + using (device) + devices.TryAdd(device.Name, device.Name); } } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) @@ -57,17 +144,41 @@ public class CameraStreamer return devices; } + public Dictionary GetAudioCaptureDevices() + { + var devices = new Dictionary(); + if (OperatingSystem.IsWindows()) + { + foreach (var device in DsDevice.GetDevicesOfCat(FilterCategory.AudioInputDevice)) + { + using (device) + devices.TryAdd(device.Name, device.Name); + } + } + else if (OperatingSystem.IsLinux()) + devices.Add("Microphone par defaut (PulseAudio)", "default"); + else + throw new PlatformNotSupportedException("La capture est disponible sous Windows et Linux."); + return devices; + } + /// /// Crée un objet Media configuré pour le périphérique sélectionné. /// /// Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux) - public Media CreateCaptureMedia(string deviceValue) + public Media CreateCaptureMedia(string deviceValue, string? audioDevice = null) { + ArgumentException.ThrowIfNullOrWhiteSpace(deviceValue); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Configuration pour DirectShow (Windows) var media = new Media(_libVLC, "dshow://", FromType.FromLocation); media.AddOption($":dshow-vdev={deviceValue}"); + media.AddOption($":dshow-adev={audioDevice ?? "none"}"); + media.AddOption(":live-caching=200"); + media.AddOption(":thread-cout=0"); + media.AddOption(":avcodec-hw=none"); + media.AddOption(":vcodec=h264"); // Options optionnelles (Exemple : Forcer la résolution ou le framerate) // media.AddOption(":dshow-size=1280x720"); @@ -79,7 +190,13 @@ public class CameraStreamer { // Configuration pour V4L2 (Linux) var media = new Media(_libVLC, "v4l2://", FromType.FromLocation); - media.AddOption($":v4l2-vdev={deviceValue}"); + media.AddOption($":v4l2-dev={deviceValue}"); + media.AddOption(":live-caching=200"); + media.AddOption(":thread-cout=0"); + media.AddOption(":avcodec-hw=none"); + media.AddOption(":vcodec=h264"); + if (!string.IsNullOrWhiteSpace(audioDevice)) + media.AddOption($":input-slave=pulse://{audioDevice}"); // Options optionnelles pour Linux // media.AddOption(":v4l2-width=1280"); diff --git a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs index a87cd2c6c..c2f0a6a91 100644 --- a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs @@ -1,10 +1,13 @@ using System; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; using LibVLCSharp.Shared; using PostIt.ViewModels; +using PostIt.Services; +using Yavsc.Abstract.Streaming; namespace PostIt.Views.Blogs; @@ -13,6 +16,8 @@ public partial class VideoPage : ContentPage private LibVLC? _libVlc; private MediaPlayer? _player; private Media? _media; + private PodcastMediaInput? _podcastInput; + private bool _externalFallbackTriggered; public VideoPage() => InitializeComponent(); @@ -25,21 +30,33 @@ public partial class VideoPage : ContentPage try { Core.Initialize(); - _libVlc = new LibVLC(); + _libVlc = CameraStreamer.CreatePlaybackLibVlc(); _player = new MediaPlayer(_libVlc); _player.EncounteredError += Player_EncounteredError; - _media = new Media(_libVlc, vm.Source); + if (vm.Podcast is { } podcast && vm.PodcastClient is { } client) + { + _podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live); + _podcastInput.Failed += PodcastFailed; + _media = new Media(_libVlc, _podcastInput); + _media.AddOption(":demux=ts"); + } + else + _media = new Media(_libVlc, vm.Source); Video.MediaPlayer = _player; PlayButton.IsEnabled = true; PlaybackStatus.Text = vm.MimeType; if (!_player.Play(_media)) - PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur."; + PlaybackStatus.Text = vm.Podcast is null + ? "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur." + : "Impossible de demarrer la lecture du podcast."; } catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException - or BadImageFormatException or PlatformNotSupportedException) + or BadImageFormatException) { ReleasePlayer(); - PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC."; + _externalFallbackTriggered = true; + PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Ouverture du navigateur pour afficher le média."; + _ = OpenExternalFallbackAsync(vm.Source); } } @@ -59,6 +76,7 @@ public partial class VideoPage : ContentPage { PlayButton.IsEnabled = false; Video.MediaPlayer = null; + _podcastInput?.Cancel(); if (_player is not null) { _player.EncounteredError -= Player_EncounteredError; @@ -68,15 +86,64 @@ public partial class VideoPage : ContentPage } _media?.Dispose(); _media = null; + if (_podcastInput is not null) + { + _podcastInput.Failed -= PodcastFailed; + _podcastInput.Dispose(); + _podcastInput = null; + } _libVlc?.Dispose(); _libVlc = null; } + private void PodcastFailed(string message) => Dispatcher.UIThread.Post(() => PlaybackStatus.Text = message); + + private static bool CanLaunchExternally(Uri source) + => source is { IsAbsoluteUri: true } + && !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase) + && !string.Equals(source.Host, "localhost", StringComparison.OrdinalIgnoreCase) + && !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase); + + private async Task OpenExternalFallbackAsync(Uri source) + { + try + { + if (!CanLaunchExternally(source)) + { + PlaybackStatus.Text = "Lecture intégrée indisponible ; ce flux ne peut pas être ouvert dans le navigateur."; + return; + } + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is null) + { + PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur."; + return; + } + + if (await topLevel.Launcher.LaunchUriAsync(source)) + PlaybackStatus.Text = "Lecture intégrée indisponible ; le média a été ouvert dans le navigateur."; + else + PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur."; + } + catch (Exception ex) + { + PlaybackStatus.Text = $"Lecture intégrée indisponible : {ex.Message}. Ouvrez le média dans le navigateur."; + } + } + private void Player_EncounteredError(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => { - if (ReferenceEquals(sender, _player)) - PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur."; + if (ReferenceEquals(sender, _player) && !_externalFallbackTriggered) + { + _externalFallbackTriggered = true; + if (DataContext is VideoPageViewModel vm) + _ = OpenExternalFallbackAsync(vm.Source); + PlaybackStatus.Text = _podcastInput is null + ? "Impossible de lire cette vidéo. Ouverture du navigateur…" + : "Impossible de lire le podcast. Ouverture du navigateur…"; + } }); private void PlayPause_Click(object? sender, RoutedEventArgs e) @@ -95,6 +162,12 @@ public partial class VideoPage : ContentPage return; try { + if (!CanLaunchExternally(vm.Source)) + { + PlaybackStatus.Text = "Ce flux interne ne peut pas être ouvert dans le navigateur."; + return; + } + var topLevel = TopLevel.GetTopLevel(this) ?? throw new InvalidOperationException("La fenêtre de lecture est indisponible."); if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))