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
|
### 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
|
* [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans
|
||||||
elevation : publication autonome, raccourci du menu Demarrer,
|
elevation : publication autonome, raccourci du menu Demarrer,
|
||||||
protocole `postit://` pour la connexion et desinstallation via Windows.
|
protocole `postit://` pour la connexion et desinstallation via Windows.
|
||||||
|
|
@ -46,6 +52,16 @@
|
||||||
|
|
||||||
### Fixed
|
### 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
|
* [PostIt.Android] Restauration de la pile de navigation lors de la
|
||||||
recréation de l'activité après suspension : nouvelles pages liées aux
|
recréation de l'activité après suspension : nouvelles pages liées aux
|
||||||
mêmes modèles de vue, sans plus de procès.
|
mêmes modèles de vue, sans plus de procès.
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,105 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using LibVLCSharp.Shared;
|
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;
|
using DirectShowLib;
|
||||||
|
|
||||||
public class CameraStreamer
|
public class CameraStreamer
|
||||||
{
|
{
|
||||||
private readonly LibVLC _libVLC;
|
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)
|
public CameraStreamer(LibVLC libVLC)
|
||||||
{
|
{
|
||||||
_libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC));
|
_libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC));
|
||||||
|
|
@ -32,8 +119,8 @@ public class CameraStreamer
|
||||||
DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice);
|
DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice);
|
||||||
foreach (var device in capDevices)
|
foreach (var device in capDevices)
|
||||||
{
|
{
|
||||||
// Pour Windows, VLC utilise le nom de l'appareil
|
using (device)
|
||||||
devices.Add(device.Name, device.Name);
|
devices.TryAdd(device.Name, device.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
|
@ -57,17 +144,41 @@ public class CameraStreamer
|
||||||
return devices;
|
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>
|
/// <summary>
|
||||||
/// Crée un objet Media configuré pour le périphérique sélectionné.
|
/// Crée un objet Media configuré pour le périphérique sélectionné.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="deviceValue">Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux)</param>
|
/// <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))
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
// Configuration pour DirectShow (Windows)
|
// Configuration pour DirectShow (Windows)
|
||||||
var media = new Media(_libVLC, "dshow://", FromType.FromLocation);
|
var media = new Media(_libVLC, "dshow://", FromType.FromLocation);
|
||||||
media.AddOption($":dshow-vdev={deviceValue}");
|
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)
|
// Options optionnelles (Exemple : Forcer la résolution ou le framerate)
|
||||||
// media.AddOption(":dshow-size=1280x720");
|
// media.AddOption(":dshow-size=1280x720");
|
||||||
|
|
@ -79,7 +190,13 @@ public class CameraStreamer
|
||||||
{
|
{
|
||||||
// Configuration pour V4L2 (Linux)
|
// Configuration pour V4L2 (Linux)
|
||||||
var media = new Media(_libVLC, "v4l2://", FromType.FromLocation);
|
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
|
// Options optionnelles pour Linux
|
||||||
// media.AddOption(":v4l2-width=1280");
|
// media.AddOption(":v4l2-width=1280");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LibVLCSharp.Shared;
|
using LibVLCSharp.Shared;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Services;
|
||||||
|
using Yavsc.Abstract.Streaming;
|
||||||
|
|
||||||
namespace PostIt.Views.Blogs;
|
namespace PostIt.Views.Blogs;
|
||||||
|
|
||||||
|
|
@ -13,6 +16,8 @@ public partial class VideoPage : ContentPage
|
||||||
private LibVLC? _libVlc;
|
private LibVLC? _libVlc;
|
||||||
private MediaPlayer? _player;
|
private MediaPlayer? _player;
|
||||||
private Media? _media;
|
private Media? _media;
|
||||||
|
private PodcastMediaInput? _podcastInput;
|
||||||
|
private bool _externalFallbackTriggered;
|
||||||
|
|
||||||
public VideoPage() => InitializeComponent();
|
public VideoPage() => InitializeComponent();
|
||||||
|
|
||||||
|
|
@ -25,21 +30,33 @@ public partial class VideoPage : ContentPage
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Core.Initialize();
|
Core.Initialize();
|
||||||
_libVlc = new LibVLC();
|
_libVlc = CameraStreamer.CreatePlaybackLibVlc();
|
||||||
_player = new MediaPlayer(_libVlc);
|
_player = new MediaPlayer(_libVlc);
|
||||||
_player.EncounteredError += Player_EncounteredError;
|
_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;
|
Video.MediaPlayer = _player;
|
||||||
PlayButton.IsEnabled = true;
|
PlayButton.IsEnabled = true;
|
||||||
PlaybackStatus.Text = vm.MimeType;
|
PlaybackStatus.Text = vm.MimeType;
|
||||||
if (!_player.Play(_media))
|
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
|
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
|
||||||
or BadImageFormatException or PlatformNotSupportedException)
|
or BadImageFormatException)
|
||||||
{
|
{
|
||||||
ReleasePlayer();
|
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;
|
PlayButton.IsEnabled = false;
|
||||||
Video.MediaPlayer = null;
|
Video.MediaPlayer = null;
|
||||||
|
_podcastInput?.Cancel();
|
||||||
if (_player is not null)
|
if (_player is not null)
|
||||||
{
|
{
|
||||||
_player.EncounteredError -= Player_EncounteredError;
|
_player.EncounteredError -= Player_EncounteredError;
|
||||||
|
|
@ -68,15 +86,64 @@ public partial class VideoPage : ContentPage
|
||||||
}
|
}
|
||||||
_media?.Dispose();
|
_media?.Dispose();
|
||||||
_media = null;
|
_media = null;
|
||||||
|
if (_podcastInput is not null)
|
||||||
|
{
|
||||||
|
_podcastInput.Failed -= PodcastFailed;
|
||||||
|
_podcastInput.Dispose();
|
||||||
|
_podcastInput = null;
|
||||||
|
}
|
||||||
_libVlc?.Dispose();
|
_libVlc?.Dispose();
|
||||||
_libVlc = null;
|
_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)
|
private void Player_EncounteredError(object? sender, EventArgs e)
|
||||||
=> Dispatcher.UIThread.Post(() =>
|
=> Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(sender, _player))
|
if (ReferenceEquals(sender, _player) && !_externalFallbackTriggered)
|
||||||
PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur.";
|
{
|
||||||
|
_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)
|
private void PlayPause_Click(object? sender, RoutedEventArgs e)
|
||||||
|
|
@ -95,6 +162,12 @@ public partial class VideoPage : ContentPage
|
||||||
return;
|
return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (!CanLaunchExternally(vm.Source))
|
||||||
|
{
|
||||||
|
PlaybackStatus.Text = "Ce flux interne ne peut pas être ouvert dans le navigateur.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var topLevel = TopLevel.GetTopLevel(this)
|
var topLevel = TopLevel.GetTopLevel(this)
|
||||||
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
|
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
|
||||||
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
|
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue