feat: add authenticated live podcasts and simplify native video playback
All checks were successful
Forgejo Release / release (push) Successful in 8m6s
Dotnet build and test / build (pull_request) Successful in 7m50s
Dotnet build and test / build (push) Successful in 9m29s

Include podcast capture, ingestion and replay, dependency and installer updates, and native video positioning after navigation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 15:22:34 +01:00
commit 4fc91cec3e
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
45 changed files with 2330 additions and 378 deletions

View file

@ -17,16 +17,16 @@
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.2" />
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.2" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="PvRecorder" Version="1.2.13" />
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
@ -36,6 +36,6 @@
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13" />
</ItemGroup>
</Project>
</Project>

View file

@ -0,0 +1,166 @@
using System.Net;
using System.Net.Http;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastNativeCaptureTests
{
public static bool HasFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder()
{
var root = Path.Combine(Path.GetTempPath(), $"postit native's hls {Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
Core.Initialize();
using var vlc = new LibVLC();
using var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
using var player = new MediaPlayer(vlc);
PodcastBroadcaster.ConfigureOutput(media, root);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("LibVLC encoding failed."));
Assert.True(player.Play(media));
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken);
player.Stop();
var playlist = await File.ReadAllTextAsync(Path.Combine(root, "index.m3u8"), TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(playlist);
Assert.True(segments.Count >= 2, playlist);
Assert.Equal(0, segments[0].Sequence);
foreach (var segment in segments)
{
var bytes = await File.ReadAllBytesAsync(Path.Combine(root, segment.FileName), TestContext.Current.CancellationToken);
Assert.True(bytes.Length > 188);
Assert.Equal(0, bytes.Length % 188);
Assert.InRange(segment.DurationSeconds, 0.001, 15);
for (var i = 0; i < bytes.Length; i += 188) Assert.Equal(0x47, bytes[i]);
}
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
public async Task Broadcaster_uploads_final_segment_retries_safely_and_replay_decodes_in_LibVLC()
{
var ct = TestContext.Current.CancellationToken;
await using var transport = new CaptureTransport();
var client = new PodcastApiClient(transport, () => "https://api.example/api/v1/");
await using (var broadcaster = await PodcastBroadcaster.StartAsync(client, "Native test",
vlc => new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!))), ct))
{
await transport.FirstTwo.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
var livePlayback = PlayThroughAsync(client, transport.Podcast.Id, live: true, ct);
await transport.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
await broadcaster.StopAsync().WaitAsync(TimeSpan.FromSeconds(45), ct);
Assert.True(broadcaster.Completion.IsCompletedSuccessfully);
await livePlayback;
}
Assert.Equal(PodcastState.Completed, transport.Podcast.State);
Assert.True(transport.Uploaded.Count >= 3);
Assert.Equal(2, transport.FirstSegmentAttempts);
await PlayThroughAsync(client, transport.Podcast.Id, live: false, ct);
}
private static async Task PlayThroughAsync(PodcastApiClient client, Guid id, bool live, CancellationToken ct)
{
using var vlcPlayer = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib");
using var input = new PodcastMediaInput(client, id, live);
using var replay = new Media(vlcPlayer, input);
replay.AddOption(":demux=ts");
using var player = new MediaPlayer(vlcPlayer);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
input.Failed += message => ended.TrySetException(new InvalidOperationException(message));
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("Replay decode failed."));
Assert.True(player.Play(replay));
try
{
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Video && track.Codec == 0x34363268);
Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Audio && track.Codec == 0x6134706d);
}
finally
{
input.Cancel();
player.Stop();
}
}
private sealed class CaptureTransport : IYavscApiClient
{
private readonly object _sync = new();
public HttpClient Http { get; } = new();
public Dictionary<int, byte[]> Uploaded { get; } = new();
private readonly List<PodcastSegmentResponse> _segments = new();
public TaskCompletionSource FirstTwo { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int FirstSegmentAttempts { get; private set; }
public PodcastResponse Podcast { get; private set; } = new(Guid.NewGuid(), "Test", "alice", "Alice",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
lock (_sync)
{
object result;
if (typeof(T) == typeof(PodcastManifestResponse))
result = new PodcastManifestResponse(Podcast, _segments.ToArray());
else
{
if (path.EndsWith("/complete", StringComparison.Ordinal))
Podcast = Podcast with { State = PodcastState.Completed };
result = Podcast;
}
return Task.FromResult((T)result);
}
}
public async Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
var uri = new Uri(path);
var sequence = int.Parse(uri.Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
var duration = double.Parse(uri.Query["?duration=".Length..], System.Globalization.CultureInfo.InvariantCulture);
using var content = contentFactory();
var bytes = await content.ReadAsByteArrayAsync(ct);
if (sequence >= 2) await ReadStarted.Task.WaitAsync(ct);
lock (_sync)
{
if (Uploaded.TryGetValue(sequence, out var existing))
Assert.Equal(existing, bytes);
else
{
Assert.Equal(_segments.Count, sequence);
Uploaded.Add(sequence, bytes);
_segments.Add(new(sequence, duration, bytes.Length));
Podcast = Podcast with { SegmentCount = _segments.Count, Bytes = Podcast.Bytes + bytes.Length };
}
if (sequence == 0 && ++FirstSegmentAttempts == 1)
throw new HttpRequestException("Acknowledgement lost", null, HttpStatusCode.ServiceUnavailable);
if (Uploaded.Count >= 2) FirstTwo.TrySetResult();
return (T)(object)Podcast;
}
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, body, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
var sequence = int.Parse(Path.GetFileNameWithoutExtension(new Uri(path).AbsolutePath),
System.Globalization.CultureInfo.InvariantCulture);
lock (_sync)
{
ReadStarted.TrySetResult();
return Task.FromResult(Uploaded[sequence]);
}
}
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}

View file

@ -0,0 +1,163 @@
using System.Globalization;
using System.Net.Http;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastTests
{
private static readonly Guid Id = Guid.Parse("9e2f6d47-2b9a-47ae-a870-073980bf2628");
[Fact]
public void Parses_only_complete_ordered_capture_segments_with_invariant_durations()
{
var segments = HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2.5,\nsegment-00000000.ts\n#EXTINF:1.75,\nsegment-00000001.ts\n");
Assert.Equal(2, segments.Count);
Assert.Equal(2.5, segments[0].DurationSeconds);
Assert.Equal(1, segments[1].Sequence);
Assert.Empty(HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2,\n"));
}
[Theory]
[InlineData("#EXTINF:NaN,\nsegment-00000000.ts\n")]
[InlineData("#EXTINF:20,\nsegment-00000000.ts\n")]
[InlineData("#EXTINF:2,\n../escape.ts\n")]
[InlineData("#EXTINF:2,\nhttps://other.test/segment-00000000.ts\n")]
[InlineData("#EXTINF:2,\nsegment-00000000.ts\n#EXTINF:2,\nsegment-00000002.ts\n")]
public void Rejects_invalid_or_nonlocal_capture_playlist(string text)
=> Assert.Throws<FormatException>(() => HlsCapturePlaylist.Parse(text));
[Fact]
public async Task Upload_uses_raw_transport_stream_and_replayable_content_factory()
{
var transport = new RecordingTransport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
var previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
await client.UploadAsync(Id, 0, 2.5, new byte[] { 0x47, 1 }, TestContext.Current.CancellationToken);
Assert.EndsWith($"/api/v1/podcasts/{Id}/segments/0?duration=2.5", transport.Path);
Assert.Equal(HttpMethod.Put, transport.Method);
Assert.NotNull(transport.ContentFactory);
using var first = transport.ContentFactory();
using var retry = transport.ContentFactory();
Assert.Equal("video/mp2t", first.Headers.ContentType?.MediaType);
Assert.Equal(new byte[] { 0x47, 1 }, await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
Assert.Equal(await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken),
await retry.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}
finally { CultureInfo.CurrentCulture = previous; }
}
[Theory]
[InlineData("https://api.example/")]
[InlineData("https://api.example/api/v1/")]
[InlineData("https://api.example/api/v1")]
public async Task Configured_api_url_never_duplicates_the_api_prefix(string baseUrl)
{
var transport = new RecordingTransport();
var client = new PodcastApiClient(transport, () => baseUrl);
await client.GetAsync(Id, TestContext.Current.CancellationToken);
Assert.Equal($"https://api.example/api/v1/podcasts/{Id}", transport.Path);
}
[Fact]
public void Authenticated_playback_reads_segments_in_order_and_reports_end_of_replay()
{
var transport = new RecordingTransport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastMediaInput(client, Id, live: false);
var buffer = Marshal.AllocHGlobal(4);
try
{
Assert.True(input.Open(out var size));
Assert.Equal(ulong.MaxValue, size);
Assert.Equal(2, input.Read(buffer, 4));
Assert.Equal(0x47, Marshal.ReadByte(buffer));
Assert.Equal(0, input.Read(buffer, 4));
Assert.EndsWith($"/{Id}/segments/0.ts", transport.DownloadPath);
}
finally { Marshal.FreeHGlobal(buffer); }
}
[Fact]
public void Playback_errors_are_reported_without_crossing_the_native_callback_boundary()
{
var transport = new RecordingTransport { FailDownload = true };
var client = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastMediaInput(client, Id, live: false);
string? error = null;
input.Failed += message => error = message;
var buffer = Marshal.AllocHGlobal(4);
try
{
Assert.Equal(-1, input.Read(buffer, 4));
Assert.Contains("401", error);
}
finally { Marshal.FreeHGlobal(buffer); }
}
[Fact]
public void Live_playback_joins_three_segments_behind_the_edge_and_cancellation_is_quiet()
{
var transport = new RecordingTransport { SegmentCount = 8 };
var client = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastMediaInput(client, Id, live: true);
string? error = null;
input.Failed += message => error = message;
var buffer = Marshal.AllocHGlobal(4);
try
{
Assert.Equal(2, input.Read(buffer, 4));
Assert.EndsWith($"/{Id}/segments/5.ts", transport.DownloadPath);
input.Cancel();
Assert.Equal(-1, input.Read(buffer, 4));
Assert.Null(error);
}
finally { Marshal.FreeHGlobal(buffer); }
}
private sealed class RecordingTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? Method { get; private set; }
public string? Path { get; private set; }
public string? DownloadPath { get; private set; }
public Func<HttpContent>? ContentFactory { get; private set; }
public bool FailDownload { get; init; }
public int SegmentCount { get; init; } = 1;
private static PodcastResponse Podcast => new(Id, "Live", "alice", "Alice",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Completed, 1, 2, 2);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Method = method;
Path = path;
object response = typeof(T) == typeof(PodcastManifestResponse)
? new PodcastManifestResponse(Podcast with { SegmentCount = SegmentCount },
Enumerable.Range(0, SegmentCount).Select(i => new PodcastSegmentResponse(i, 2, 2)).ToArray()) : Podcast;
return Task.FromResult((T)response);
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
ContentFactory = contentFactory;
return CallAsync<T>(method, path, ct: ct);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, body, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
DownloadPath = path;
if (FailDownload) throw new HttpRequestException("401 Unauthorized");
return Task.FromResult(new byte[] { 0x47, 1 });
}
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}

View file

@ -38,6 +38,7 @@ public static class ServiceCollectionHelpers
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var podcastClient = new PodcastApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -46,6 +47,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<MainView>();
services.AddTransient<BlogsPage>();
services.AddTransient<VideoPage>();
services.AddTransient<PodcastsPage>();
// Models
@ -106,6 +108,8 @@ public static class ServiceCollectionHelpers
services.AddSingleton(resourceUsageClient);
services.AddSingleton(administrationClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(podcastClient);
services.AddTransient<PodcastsPageViewModel>();
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);

View file

@ -12,6 +12,7 @@
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<InternalsVisibleTo Include="PostIt.Tests" />
</ItemGroup>
<ItemGroup>
<Content Include="postit-settings.json">

View file

@ -20,27 +20,14 @@ public class CameraStreamer
"--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();
if (ShouldDisableHardwareAcceleration())
return CreateHeadlessLibVlc();
var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1",
StringComparison.OrdinalIgnoreCase);
@ -50,6 +37,10 @@ public class CameraStreamer
return CreateHeadlessLibVlc();
}
private static bool ShouldDisableHardwareAcceleration()
=> string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_DISABLE_HW"), "1",
StringComparison.OrdinalIgnoreCase);
private static bool IsVaapiUsable()
{
var vaapiDrivers = new[]

View file

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
namespace PostIt.Services;
public sealed record CaptureSegment(int Sequence, double DurationSeconds, string FileName);
public static partial class HlsCapturePlaylist
{
[GeneratedRegex(@"^segment-(\d{8})\.ts$", RegexOptions.CultureInvariant)]
private static partial Regex SegmentName();
public static IReadOnlyList<CaptureSegment> Parse(string playlist)
{
var segments = new List<CaptureSegment>();
double? duration = null;
foreach (var line in playlist.Split('\n'))
{
var text = line.Trim();
if (text.StartsWith("#EXTINF:", StringComparison.Ordinal))
{
if (!double.TryParse(text[8..].Split(',')[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|| !double.IsFinite(value) || value <= 0 || value > 15)
throw new FormatException("Duree du segment LibVLC invalide.");
duration = value;
}
else if (text.Length > 0 && !text.StartsWith('#'))
{
var match = SegmentName().Match(text);
if (!match.Success || duration is null)
throw new FormatException("Index HLS LibVLC invalide.");
var sequence = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
if (segments.Count > 0 && sequence != segments[^1].Sequence + 1)
throw new FormatException("Segments HLS non consecutifs.");
segments.Add(new(sequence, duration.Value, text));
duration = null;
}
}
return segments;
}
}

View file

@ -0,0 +1,231 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using LibVLCSharp.Shared;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Services;
public sealed class PodcastBroadcaster : IAsyncDisposable
{
private readonly PodcastApiClient _api;
private readonly LibVLC _vlc;
private readonly MediaPlayer _player;
private readonly Media _media;
private readonly string _directory;
private readonly CancellationTokenSource _cancellation = new();
private readonly SemaphoreSlim _stopGate = new(1);
private volatile bool _stopped;
private volatile bool _encoderFailed;
private bool _disposed;
public PodcastResponse Podcast { get; private set; }
public Task Completion { get; private set; } = Task.CompletedTask;
public event Action<PodcastResponse>? Progress;
private PodcastBroadcaster(PodcastApiClient api, PodcastResponse podcast,
LibVLC vlc, MediaPlayer player, Media media, string directory)
{
_api = api;
Podcast = podcast;
_vlc = vlc;
_player = player;
_media = media;
_directory = directory;
_player.EncounteredError += OnEncoderError;
}
public static void ConfigureOutput(Media media, string directory)
{
var path = directory.Replace('\\', '/').Replace("\"", "\\\"");
media.AddOption(":sout=#transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=60},vb=1200,fps=30,"
+ "acodec=mp4a,ab=128,channels=2,samplerate=48000}:std{"
+ $"access=livehttp{{seglen=2,delsegs=false,numsegs=0,initial-segment-number=0,index=\"{path}/index.m3u8\","
+ $"index-url=segment-########.ts}},mux=ts{{use-key-frames}},dst=\"{path}/segment-########.ts\"}}");
}
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
string camera, string? microphone, CancellationToken ct)
=> await StartAsync(api, title, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), ct);
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<LibVLC, Media> createMedia, CancellationToken ct)
{
Core.Initialize();
var vlc = CameraStreamer.CreatePreferredLibVlc();
Media? media = null;
MediaPlayer? player = null;
PodcastBroadcaster? broadcaster = null;
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PostIt", "podcast-capture", Guid.NewGuid().ToString("N"));
PodcastResponse? podcast = null;
try
{
Directory.CreateDirectory(directory);
media = createMedia(vlc);
ConfigureOutput(media, directory);
player = new MediaPlayer(vlc);
podcast = await api.CreateAsync(title, ct);
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
ct.ThrowIfCancellationRequested();
broadcaster = new PodcastBroadcaster(api, podcast, vlc, player, media, directory);
if (!player.Play(media))
throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
broadcaster.Completion = broadcaster.UploadAsync(broadcaster._cancellation.Token);
return broadcaster;
}
catch (Exception startupError)
{
if (broadcaster is not null)
{
broadcaster._player.EncounteredError -= broadcaster.OnEncoderError;
broadcaster._cancellation.Dispose();
broadcaster._stopGate.Dispose();
}
player?.Dispose();
media?.Dispose();
vlc.Dispose();
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
if (podcast is not null)
{
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await api.CompleteAsync(podcast.Id, timeout.Token);
}
catch (Exception finishError)
{
throw new AggregateException("Capture impossible ; le direct serveur expirera automatiquement.", startupError, finishError);
}
}
throw;
}
}
public async Task StopAsync()
{
await StopEncoderAsync();
await Completion;
}
private async Task StopEncoderAsync()
{
await _stopGate.WaitAsync();
try
{
if (!_stopped)
{
await Task.Run(_player.Stop);
_stopped = true;
}
}
finally { _stopGate.Release(); }
}
private async Task UploadAsync(CancellationToken ct)
{
var next = 0;
var lastSegment = Stopwatch.StartNew();
try
{
while (true)
{
ct.ThrowIfCancellationRequested();
var finalPass = _stopped;
if (_encoderFailed)
throw new InvalidOperationException("La capture LibVLC a echoue. Verifiez camera, microphone et encodeurs H.264/AAC.");
var pendingBytes = Directory.EnumerateFiles(_directory, "*.ts").Sum(path => new FileInfo(path).Length);
if (pendingBytes > 64L * 1024 * 1024)
throw new IOException("Le reseau est trop lent : la capture depasse 64 Mio de segments en attente.");
var playlist = Path.Combine(_directory, "index.m3u8");
if (File.Exists(playlist))
{
using var reader = new StreamReader(new FileStream(playlist, FileMode.Open,
FileAccess.Read, FileShare.ReadWrite | FileShare.Delete));
var segments = HlsCapturePlaylist.Parse(await reader.ReadToEndAsync(ct));
foreach (var segment in segments.Where(s => s.Sequence >= next))
{
if (segment.Sequence != next)
throw new InvalidDataException("Un segment manque dans la capture.");
var path = Path.Combine(_directory, segment.FileName);
if (new FileInfo(path).Length > 8 * 1024 * 1024)
throw new IOException("Un segment de capture depasse 8 Mio.");
var bytes = await File.ReadAllBytesAsync(path, ct);
var response = await UploadWithRetryAsync(segment, bytes, ct);
Podcast = response;
File.Delete(path);
next++;
lastSegment.Restart();
Progress?.Invoke(response);
}
}
if (finalPass)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
Podcast = await _api.CompleteAsync(Podcast.Id, timeout.Token);
return;
}
if (lastSegment.Elapsed > TimeSpan.FromSeconds(25))
throw new IOException("Aucun segment produit depuis 25 secondes. Verifiez la capture et les encodeurs LibVLC.");
await Task.Delay(400, ct);
}
}
finally
{
// Stop the camera even when uploading or parsing fails.
await StopEncoderAsync();
}
}
private async Task<PodcastResponse> UploadWithRetryAsync(CaptureSegment segment, byte[] bytes, CancellationToken ct)
{
for (var attempt = 0; ; attempt++)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
return await _api.UploadAsync(Podcast.Id, segment.Sequence, segment.DurationSeconds, bytes, timeout.Token);
}
catch (HttpRequestException ex) when (attempt < 2 && (ex.StatusCode is null
or HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)ex.StatusCode >= 500))
{
await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && attempt < 2)
{
await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct);
}
}
}
private void OnEncoderError(object? sender, EventArgs e) => _encoderFailed = true;
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_cancellation.Cancel();
try { await Completion; }
finally
{
_player.EncounteredError -= OnEncoderError;
_player.Dispose();
_media.Dispose();
_vlc.Dispose();
_cancellation.Dispose();
_stopGate.Dispose();
// Keep failed, unacknowledged segments for manual recovery.
if (Completion.IsCompletedSuccessfully && Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
}
}

View file

@ -0,0 +1,78 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using LibVLCSharp.Shared;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Services;
// LibVLC consumes a sequential transport stream; credentials stay in the existing HTTP transport.
public sealed class PodcastMediaInput(PodcastApiClient api, Guid id, bool live) : MediaInput
{
private readonly CancellationTokenSource _cancellation = new();
private byte[] _segment = Array.Empty<byte>();
private int _offset;
private int _sequence = -1;
public event Action<string>? Failed;
public override bool Open(out ulong size)
{
size = ulong.MaxValue;
return true;
}
public override int Read(IntPtr buffer, uint length)
{
if (length == 0) return 0;
try
{
var ct = _cancellation.Token;
ct.ThrowIfCancellationRequested();
while (_offset == _segment.Length)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
var manifest = api.GetAsync(id, timeout.Token).GetAwaiter().GetResult();
if (_sequence < 0)
_sequence = live ? Math.Max(0, manifest.Segments.Count - 3) : 0;
if (_sequence < manifest.Segments.Count)
{
_segment = api.DownloadSegmentAsync(id, _sequence, timeout.Token).GetAwaiter().GetResult();
if (_segment.Length == 0 || _segment.Length != manifest.Segments[_sequence].Bytes)
throw new InvalidOperationException("Segment podcast incomplet.");
_sequence++;
_offset = 0;
break;
}
if (manifest.Podcast.State != PodcastState.Live) return 0;
if (ct.WaitHandle.WaitOne(700)) ct.ThrowIfCancellationRequested();
}
var count = (int)Math.Min(length, (uint)(_segment.Length - _offset));
Marshal.Copy(_segment, _offset, buffer, count);
_offset += count;
return count;
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) { return -1; }
// No managed exception may escape a native LibVLC callback.
catch (Exception ex)
{
Failed?.Invoke($"Lecture du podcast interrompue : {ex.Message}");
return -1;
}
}
public override bool Seek(ulong offset) => false;
public override void Close() { }
public void Cancel() => _cancellation.Cancel();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_cancellation.Cancel();
_cancellation.Dispose();
}
base.Dispose(disposing);
}
}

View file

@ -540,8 +540,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
response = await sendOnce().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
using (response)
{
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
}
public async Task LogoutAsync()

View file

@ -43,6 +43,7 @@ public class ViewLocator : IDataTemplate
{
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
PodcastsPageViewModel => services.GetRequiredService<PodcastsPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),

View file

@ -1,4 +1,6 @@
using System;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
@ -6,7 +8,10 @@ public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelB
{
public Uri Source { get; } = source;
public string MimeType { get; } = mimeType;
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
public PodcastApiClient? PodcastClient { get; init; }
public PodcastResponse? Podcast { get; init; }
public bool CanOpenExternal => Podcast is null;
public string Title => Podcast?.Title ?? Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => true; protected set { } }
}

View file

@ -74,10 +74,19 @@ public class HomePageViewModel : ViewModelBase
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
OpenPodcasts = new AsyncRelayCommand(async () =>
{
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
var vm = app.ServiceProvider?.GetRequiredService<PodcastsPageViewModel>()
?? throw new InvalidOperationException("Page podcasts indisponible.");
await app.PushPageAsync(vm);
});
}
public IAsyncRelayCommand OpenChat { get; }
public IAsyncRelayCommand OpenPodcasts { get; }
public bool SupportsPodcasts => OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { get; }

View file

@ -0,0 +1,276 @@
using System;
using System.Collections.ObjectModel;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public sealed record CaptureDevice(string Name, string Value);
public partial class PodcastsPageViewModel : ViewModelBase
{
private readonly PodcastApiClient _client;
private readonly YavscApiClient _api;
private readonly SessionStatusViewModel _session;
private CancellationTokenSource _pageCancellation = new();
private PodcastBroadcaster? _broadcaster;
private string? _ownerId;
private string _title = "";
private string _status = "";
private bool _isBroadcasting;
private bool _busy;
private CaptureDevice? _camera;
private CaptureDevice? _microphone;
private PodcastResponse? _selected;
public ObservableCollection<PodcastResponse> Podcasts { get; } = new();
public ObservableCollection<CaptureDevice> Cameras { get; } = new();
public ObservableCollection<CaptureDevice> Microphones { get; } = new();
public bool IsDesktop => OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
public string Status { get => _status; private set => SetProperty(ref _status, value); }
public bool IsBroadcasting
{
get => _isBroadcasting;
private set { if (SetProperty(ref _isBroadcasting, value)) UpdateCommands(); }
}
public bool IsBusy
{
get => _busy;
private set { if (SetProperty(ref _busy, value)) UpdateCommands(); }
}
public CaptureDevice? SelectedCamera
{
get => _camera;
set { if (SetProperty(ref _camera, value)) UpdateCommands(); }
}
public CaptureDevice? SelectedMicrophone
{
get => _microphone;
set { if (SetProperty(ref _microphone, value)) UpdateCommands(); }
}
public PodcastResponse? SelectedPodcast
{
get => _selected;
set { if (SetProperty(ref _selected, value)) UpdateCommands(); }
}
public IAsyncRelayCommand RefreshCommand { get; }
public IRelayCommand DetectDevicesCommand { get; }
public IAsyncRelayCommand StartCommand { get; }
public IAsyncRelayCommand StopCommand { get; }
public IAsyncRelayCommand PlayCommand { get; }
public IAsyncRelayCommand FinishCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => !IsBroadcasting; protected set { } }
public PodcastsPageViewModel(PodcastApiClient client, YavscApiClient api, SessionStatusViewModel session)
{
_client = client;
_api = api;
_session = session;
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
DetectDevicesCommand = new RelayCommand(DetectDevices, () => IsDesktop && !IsBroadcasting && !IsBusy);
StartCommand = new AsyncRelayCommand(StartAsync,
() => IsDesktop && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
FinishCommand = new AsyncRelayCommand(FinishAsync,
() => SelectedPodcast is { State: not PodcastState.Completed } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
() => SelectedPodcast is { State: not PodcastState.Live } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
}
private void UpdateCommands()
{
RefreshCommand.NotifyCanExecuteChanged();
DetectDevicesCommand.NotifyCanExecuteChanged();
StartCommand.NotifyCanExecuteChanged();
StopCommand.NotifyCanExecuteChanged();
PlayCommand.NotifyCanExecuteChanged();
FinishCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(CanNavigatePrevious));
}
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var profileUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), "account/me");
_ownerId = (await _api.CallAsync<Me>(HttpMethod.Get, profileUrl.AbsoluteUri)).UserId;
var podcasts = await _client.ListAsync();
Podcasts.Clear();
foreach (var podcast in podcasts) Podcasts.Add(podcast);
Status = "Directs et replays reserves aux utilisateurs connectes.";
}
catch (Exception ex) { Status = $"Chargement impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
public async Task ActivateAsync()
{
if (_pageCancellation.IsCancellationRequested)
{
_pageCancellation.Dispose();
_pageCancellation = new();
}
await RefreshAsync();
}
public async Task DeactivateAsync()
{
_pageCancellation.Cancel();
if (_broadcaster is not null) await StopAsync();
}
private void DetectDevices()
{
try
{
Core.Initialize();
using var vlc = CameraStreamer.CreatePreferredLibVlc();
var capture = new CameraStreamer(vlc);
Cameras.Clear();
foreach (var item in capture.GetCaptureDevices()) Cameras.Add(new(item.Key, item.Value));
Microphones.Clear();
Microphones.Add(new("Sans microphone", ""));
foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value));
SelectedCamera = Cameras.Count == 0 ? null : Cameras[0];
SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0];
Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer.";
}
catch (Exception ex) { Status = $"Detection impossible : {ex.Message}. Verifiez l'installation de LibVLC."; }
}
private async Task StartAsync()
{
IsBusy = true;
try
{
if (SelectedCamera is null) throw new InvalidOperationException("Choisissez une camera.");
_broadcaster = await PodcastBroadcaster.StartAsync(_client, Title, SelectedCamera.Value,
string.IsNullOrWhiteSpace(SelectedMicrophone?.Value) ? null : SelectedMicrophone.Value, _pageCancellation.Token);
_broadcaster.Progress += OnProgress;
_session.LogoutCompleted += OnLogout;
IsBroadcasting = true;
Status = "Capture en cours ; attente du premier segment HLS.";
_ = ObserveAsync(_broadcaster);
}
catch (Exception ex) { Status = $"Demarrage impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
private void OnProgress(PodcastResponse podcast)
=> Dispatcher.UIThread.Post(() =>
{
if (_broadcaster?.Podcast.Id == podcast.Id)
Status = $"En direct : {podcast.SegmentCount} segments, {podcast.DurationSeconds:0} s envoyees.";
});
private async Task ObserveAsync(PodcastBroadcaster broadcaster)
{
try
{
await broadcaster.Completion;
Status = broadcaster.Podcast.SegmentCount > 0
? "Direct termine. Le replay est disponible."
: "Direct termine sans segment recu : aucun replay a lire.";
}
catch (Exception ex)
{
Status = $"Diffusion interrompue : {ex.Message}. Les segments recus restent disponibles ; les segments non envoyes sont conserves localement.";
}
finally
{
broadcaster.Progress -= OnProgress;
_session.LogoutCompleted -= OnLogout;
try { await broadcaster.DisposeAsync(); }
catch (Exception ex) { Status += $" Nettoyage : {ex.Message}"; }
_broadcaster = null;
IsBroadcasting = false;
}
}
public async Task StopAsync()
{
IsBusy = true;
try
{
if (_broadcaster is not null) await _broadcaster.StopAsync();
}
catch (Exception ex) { Status = $"Arret du direct : {ex.Message}"; }
finally { IsBusy = false; }
}
private async void OnLogout()
{
// Releasing capture is required even after the HTTP credentials have been cleared.
await StopAsync();
}
private async Task PlayAsync()
{
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
var manifestUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), $"podcasts/{podcast.Id}/index.m3u8");
await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl")
{
PodcastClient = _client, Podcast = podcast
});
}
catch (Exception ex) { Status = $"Lecture impossible : {ex.Message}"; }
}
private async Task FinishAsync()
{
IsBusy = true;
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
await _client.CompleteAsync(podcast.Id);
await RefreshAsync();
}
catch (Exception ex) { Status = $"Finalisation impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
private async Task DeleteAsync()
{
IsBusy = true;
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
await _client.DeleteAsync(podcast.Id);
SelectedPodcast = null;
await RefreshAsync();
}
catch (Exception ex) { Status = $"Suppression impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
}

View file

@ -7,12 +7,12 @@
x:DataType="vm:VideoPageViewModel">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
<Border Grid.Row="1" Background="Black">
<Border Grid.Row="1" Background="Black" BorderThickness="1" BorderBrush="Green">
<vlc:VideoView x:Name="Video" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" IsVisible="{Binding CanOpenExternal}" />
</StackPanel>
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
</Grid>

View file

@ -1,5 +1,4 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@ -15,9 +14,7 @@ public partial class VideoPage : ContentPage
{
private LibVLC? _libVlc;
private MediaPlayer? _player;
private Media? _media;
private PodcastMediaInput? _podcastInput;
private bool _externalFallbackTriggered;
public VideoPage() => InitializeComponent();
@ -25,38 +22,42 @@ public partial class VideoPage : ContentPage
{
base.OnNavigatedTo(e);
ReleasePlayer();
if (DataContext is not VideoPageViewModel vm)
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
return;
try
{
Core.Initialize();
_libVlc = CameraStreamer.CreatePlaybackLibVlc();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
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);
using var media = _podcastInput is null
? new Media(_libVlc, vm.Source)
: new Media(_libVlc, _podcastInput);
if (_podcastInput is not null)
media.AddOption(":demux=ts");
Video.MediaPlayer = _player;
var player = _player;
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
Dispatcher.UIThread.Post(() =>
{
if (ReferenceEquals(_player, player))
Video.TryUpdateNativeControlPosition();
}, DispatcherPriority.Loaded);
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
if (!_player.Play(_media))
if (!_player.Play(media))
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)
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
{
ReleasePlayer();
_externalFallbackTriggered = true;
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Ouverture du navigateur pour afficher le média.";
_ = OpenExternalFallbackAsync(vm.Source);
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
}
}
@ -84,8 +85,6 @@ public partial class VideoPage : ContentPage
_player.Dispose();
_player = null;
}
_media?.Dispose();
_media = null;
if (_podcastInput is not null)
{
_podcastInput.Failed -= PodcastFailed;
@ -100,55 +99,27 @@ public partial class VideoPage : ContentPage
private static bool CanLaunchExternally(Uri source)
=> source is { IsAbsoluteUri: true }
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(source.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|| string.Equals(source.Scheme, "https", 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.";
}
}
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase)
&& !source.AbsolutePath.Contains("/api/", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase);
private void Player_EncounteredError(object? sender, EventArgs e)
=> Dispatcher.UIThread.Post(() =>
{
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…";
}
if (!ReferenceEquals(sender, _player) || _player is null)
return;
PlaybackStatus.Text = _podcastInput is null
? "Impossible de lire cette vidéo."
: "Impossible de lire le podcast.";
});
private void PlayPause_Click(object? sender, RoutedEventArgs e)
{
if (_player is null || _media is null)
if (_player is null)
return;
if (_player.IsPlaying)
_player.Pause();

View file

@ -57,6 +57,11 @@
Command="{Binding OpenChat}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Podcasts : directs et replays"
Command="{Binding OpenPodcasts}"
HorizontalAlignment="Center"
IsVisible="{Binding SupportsPodcasts}"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Open Blog Interface"
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"

View file

@ -0,0 +1,55 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
x:Class="PostIt.Views.PodcastsPage"
x:DataType="vm:PodcastsPageViewModel"
Header="Podcasts">
<ScrollViewer>
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
<TextBlock Text="Lecture et emission reservees aux utilisateurs connectes. Quitter cette page arrete la capture." TextWrapping="Wrap" />
<StackPanel Spacing="8" IsVisible="{Binding IsDesktop}" IsEnabled="{Binding !IsBroadcasting}">
<TextBox Text="{Binding Title}" PlaceholderText="Titre du direct" MaxLength="160" />
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox ItemsSource="{Binding Microphones}" SelectedItem="{Binding SelectedMicrophone}" PlaceholderText="Microphone">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
</StackPanel>
<Button Content="Arreter et publier le replay" Command="{Binding StopCommand}" />
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Button Content="Actualiser les directs et replays" Command="{Binding RefreshCommand}" />
<ListBox ItemsSource="{Binding Podcasts}" SelectedItem="{Binding SelectedPodcast}" MinHeight="160" MaxHeight="420"
SelectionChanged="PodcastSelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="streaming:PodcastResponse">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="{Binding OwnerName}" />
<TextBlock Text="{Binding State}" />
<TextBlock Text="{Binding DurationSeconds, StringFormat='{}{0:0} s'}" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<WrapPanel>
<Button Content="Regarder le direct / replay" Command="{Binding PlayCommand}" Margin="0,0,8,8" />
<Button Content="Terminer mon direct selectionne" Command="{Binding FinishCommand}" Margin="0,0,8,8" />
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox x:Name="ConfirmDelete" Content="Confirmer la suppression du replay selectionne" />
<Button Content="Supprimer mon replay" Command="{Binding DeleteCommand}" IsEnabled="{Binding #ConfirmDelete.IsChecked}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,25 @@
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class PodcastsPage : ContentPage
{
public PodcastsPage() => InitializeComponent();
private void PodcastSelectionChanged(object? sender, SelectionChangedEventArgs e) => ConfirmDelete.IsChecked = false;
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (DataContext is PodcastsPageViewModel vm && !vm.IsBroadcasting)
_ = vm.ActivateAsync();
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
{
if (DataContext is PodcastsPageViewModel vm)
_ = vm.DeactivateAsync();
base.OnNavigatedFrom(e);
}
}