feat: add authenticated live podcasts and simplify native video playback
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:
parent
d9b183c500
commit
4fc91cec3e
45 changed files with 2330 additions and 378 deletions
|
|
@ -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>
|
||||
|
|
|
|||
166
src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
Normal file
166
src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
163
src/PostIt/PostIt.Tests/PodcastTests.cs
Normal file
163
src/PostIt/PostIt.Tests/PodcastTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<InternalsVisibleTo Include="PostIt.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="postit-settings.json">
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
43
src/PostIt/PostIt/Services/HlsCapturePlaylist.cs
Normal file
43
src/PostIt/PostIt/Services/HlsCapturePlaylist.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
231
src/PostIt/PostIt/Services/PodcastBroadcaster.cs
Normal file
231
src/PostIt/PostIt/Services/PodcastBroadcaster.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/PostIt/PostIt/Services/PodcastMediaInput.cs
Normal file
78
src/PostIt/PostIt/Services/PodcastMediaInput.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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 { } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
276
src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
Normal file
276
src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
55
src/PostIt/PostIt/Views/PodcastsPage.axaml
Normal file
55
src/PostIt/PostIt/Views/PodcastsPage.axaml
Normal 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>
|
||||
25
src/PostIt/PostIt/Views/PodcastsPage.axaml.cs
Normal file
25
src/PostIt/PostIt/Views/PodcastsPage.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue