Fix Linux LibVLC playback fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 12:02:27 +01:00
commit d9b183c500
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
3 changed files with 220 additions and 14 deletions

View file

@ -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.

View file

@ -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<string, string> GetAudioCaptureDevices()
{
var devices = new Dictionary<string, string>();
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;
}
/// <summary>
/// Crée un objet Media configuré pour le périphérique sélectionné.
/// </summary>
/// <param name="deviceValue">Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux)</param>
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");

View file

@ -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))