Fix Linux LibVLC playback fallback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
f63b04d25b
commit
d9b183c500
3 changed files with 220 additions and 14 deletions
16
CHANGELOG.md
16
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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue