feat(podcasts): prepare seekable MP4 replays and overlay controls

Remux completed broadcasts with FFmpeg, retain source segments and account for both formats. Serve authenticated byte ranges and add native Android/Desktop replay controls with auto-hide.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-25 00:48:53 +01:00
commit 1dcbfe159a
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
31 changed files with 1386 additions and 36 deletions

View file

@ -21,7 +21,7 @@ internal static class PlatformBootstrap
};
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
Platform.CreateVideoSurface = player => new AndroidVideoSurface(player);
Platform.CreateVideoSurface = (player, replay) => new AndroidVideoSurface(player, replay);
if (System.OperatingSystem.IsAndroidVersionAtLeast(26))
{
Platform.GetPodcastCaptureDevices = AndroidPodcastCapture.GetDevices;

View file

@ -3,13 +3,20 @@ using Avalonia.Android;
using Avalonia.Controls;
using Avalonia.Platform;
using LibVLCSharp.Shared;
using Android.Content;
using Android.OS;
using Android.Views;
using Android.Widget;
using PostIt.Services;
using VideoView = LibVLCSharp.Platforms.Android.VideoView;
using Button = Android.Widget.Button;
namespace PostIt.Android.Services;
internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHost, IDisposable
internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : NativeControlHost, IDisposable
{
private VideoView? _video;
private ReplayLayout? _layout;
private bool _disposed;
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
@ -18,13 +25,19 @@ internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHos
?? throw new InvalidOperationException("L'activite Android est indisponible.");
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
_video = video;
if (replay && !_disposed)
{
_layout = new ReplayLayout(activity, video, player);
return new AndroidViewControlHandle(_layout);
}
return new AndroidViewControlHandle(video);
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (control is AndroidViewControlHandle { View: VideoView video })
video.MediaPlayer = null;
_layout?.Stop();
_layout = null;
if (_video is not null) _video.MediaPlayer = null;
_video = null;
base.DestroyNativeControlCore(control);
}
@ -32,6 +45,101 @@ internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHos
public void Dispose()
{
_disposed = true;
_layout?.Stop();
if (_video is not null) _video.MediaPlayer = null;
}
private sealed class ReplayLayout : FrameLayout
{
private readonly MediaPlayer _player;
private readonly ReplayControlsState _state = new();
private readonly Handler _handler = new(Looper.MainLooper!);
private readonly Java.Lang.Runnable _tick;
private readonly LinearLayout _bar;
private readonly Button _toggle;
private readonly SeekBar _seek;
private readonly TextView _time;
private bool _stopped;
public ReplayLayout(Context context, VideoView video, MediaPlayer player) : base(context)
{
_player = player;
AddView(video, new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent));
_bar = new LinearLayout(context) { Orientation = Orientation.Horizontal };
_bar.SetGravity(GravityFlags.CenterVertical);
_bar.SetPadding(12, 8, 12, 8);
_bar.SetBackgroundColor(global::Android.Graphics.Color.Argb(210, 20, 20, 20));
_toggle = new Button(context) { Text = "Pause", ContentDescription = "Lecture ou pause du replay" };
_seek = new SeekBar(context) { Max = 10000, ContentDescription = "Position dans le replay" };
_time = new TextView(context);
_time.SetTextColor(global::Android.Graphics.Color.White);
_bar.AddView(_toggle);
_bar.AddView(_seek, new LinearLayout.LayoutParams(0, LayoutParams.WrapContent, 1));
_bar.AddView(_time);
AddView(_bar, new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.WrapContent, GravityFlags.Bottom));
_toggle.Click += Toggle;
_seek.StartTrackingTouch += BeginSeek;
_seek.StopTrackingTouch += EndSeek;
_seek.ProgressChanged += ProgressChanged;
_tick = new Java.Lang.Runnable(Update);
_handler.Post(_tick);
}
public override bool DispatchTouchEvent(MotionEvent? e)
{
_state.Show();
_bar.Visibility = ViewStates.Visible;
return base.DispatchTouchEvent(e);
}
private void Update()
{
if (_stopped) return;
var duration = Math.Max(0, _player.Length);
var position = Math.Max(0, _player.Time);
_bar.Visibility = _state.IsVisible(_player.IsPlaying, _bar.HasFocus) ? ViewStates.Visible : ViewStates.Gone;
_toggle.Text = _player.IsPlaying ? "Pause" : "Lire";
_seek.Enabled = duration > 0 && _player.IsSeekable;
if (!_state.IsInteracting)
_seek.Progress = duration > 0 ? (int)Math.Clamp(position * 10000d / duration, 0, 10000) : 0;
_time.Text = $"{ReplayControlsState.FormatTime(_state.IsInteracting ? (long)(duration * (_seek.Progress / 10000d)) : position)} / {ReplayControlsState.FormatTime(duration)}";
_handler.PostDelayed(_tick, 250);
}
private void Toggle(object? sender, EventArgs e)
{
if (_player.IsPlaying) _player.Pause();
else _player.Play();
_state.Show();
}
private void BeginSeek(object? sender, SeekBar.StartTrackingTouchEventArgs e) => _state.IsInteracting = true;
private void EndSeek(object? sender, SeekBar.StopTrackingTouchEventArgs e)
{
Seek();
_state.IsInteracting = false;
}
private void ProgressChanged(object? sender, SeekBar.ProgressChangedEventArgs e)
{
if (e.FromUser && !_state.IsInteracting) Seek();
}
private void Seek()
{
if (_player.IsSeekable) _player.Time = (long)(_player.Length * (_seek.Progress / 10000d));
_state.Show();
}
public void Stop()
{
if (_stopped) return;
_stopped = true;
_handler.RemoveCallbacks(_tick);
_toggle.Click -= Toggle;
_seek.StartTrackingTouch -= BeginSeek;
_seek.StopTrackingTouch -= EndSeek;
_seek.ProgressChanged -= ProgressChanged;
_tick.Dispose();
_handler.Dispose();
}
}
}

View file

@ -0,0 +1,199 @@
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using Avalonia.Controls;
using Avalonia.Headless;
using PostIt.Services;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastReplayTests
{
private static readonly Guid Id = Guid.NewGuid();
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
public static bool HasNativeFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_REPLAY"));
[Fact(Skip = "Set POSTIT_TEST_REPLAY to a local MP4 (8+ seconds) and install LibVLC.", SkipUnless = nameof(HasNativeFixture))]
public async Task LibVLC_decodes_authenticated_MP4_ranges_and_seeks_in_both_directions()
{
using var transport = new RangeTransport
{
Bytes = await File.ReadAllBytesAsync(Environment.GetEnvironmentVariable("POSTIT_TEST_REPLAY")!, _ct)
};
var api = new PodcastApiClient(transport, () => "https://api.example/");
Core.Initialize();
using var vlc = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib");
using var input = new PodcastReplayMediaInput(api, Id, transport.Bytes.Length);
using var media = new Media(vlc, input);
using var player = new MediaPlayer(vlc);
string? failure = null;
input.Failed += message => failure = message;
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new IOException("LibVLC MP4 decoding failed."));
Assert.True(player.Play(media));
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
while (player.Length <= 0 || !player.IsSeekable || player.Time <= 0)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Length, 8000, 30000);
var forward = player.Length / 2;
player.Time = forward;
while (player.Time < forward - 300)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Time, forward - 300, forward + 1000);
player.Time = 1000;
while (player.Time > 2500)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Time, 500, 2500);
await ended.Task.WaitAsync(timeout.Token);
Assert.Null(failure);
Assert.Contains(media.Tracks, t => t.TrackType == TrackType.Video && t.Codec == 0x34363268);
Assert.Contains(media.Tracks, t => t.TrackType == TrackType.Audio && t.Codec == 0x6134706d);
}
finally
{
input.Cancel();
player.Stop();
}
}
[Fact]
public Task Desktop_overlay_has_real_position_pause_and_duration_controls()
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastReplayTests).Assembly).Dispatch(() =>
{
var overlay = new PostIt.Controls.ReplayControls();
var window = new Window { Content = overlay, Width = 640, Height = 360 };
try
{
window.Show();
Assert.NotNull(overlay.FindControl<Slider>("PositionSlider"));
Assert.NotNull(overlay.FindControl<Button>("ToggleButton"));
Assert.Equal("0:00 / 0:00", overlay.FindControl<TextBlock>("TimeLabel")?.Text);
}
finally { window.Close(); }
}, _ct);
[Fact]
public async Task Ranges_use_authenticated_factory_with_bounded_exact_content_and_no_token_in_URL()
{
await using var transport = new RangeTransport();
var api = new PodcastApiClient(transport, () => "https://api.example/");
Assert.Equal(transport.Bytes.Skip(12).Take(3), await api.DownloadReplayRangeAsync(Id, 12, 3, transport.Bytes.Length, _ct));
Assert.Equal("bytes=12-14", transport.Range);
Assert.Equal($"https://api.example/api/v1/podcasts/{Id}/replay.mp4", transport.Url);
Assert.Equal(transport.Bytes.Skip(transport.Bytes.Length - 2),
await api.DownloadReplayRangeAsync(Id, transport.Bytes.Length - 2, 256 * 1024, transport.Bytes.Length, _ct));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
api.DownloadReplayRangeAsync(Id, 0, 256 * 1024 + 1, transport.Bytes.Length, _ct));
}
[Theory]
[InlineData("full")]
[InlineData("range")]
[InlineData("size")]
[InlineData("extra")]
public async Task Untrusted_or_unbounded_responses_fail_instead_of_playing_wrong_bytes(string failure)
{
await using var transport = new RangeTransport { Failure = failure };
var api = new PodcastApiClient(transport, () => "https://api.example/");
await Assert.ThrowsAsync<InvalidDataException>(() => api.DownloadReplayRangeAsync(Id, 0, 8, transport.Bytes.Length, _ct));
}
[Fact]
public void Native_input_supports_backward_and_forward_seek_EOF_and_cancellation()
{
using var transport = new RangeTransport();
var api = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastReplayMediaInput(api, Id, transport.Bytes.Length);
Assert.True(input.CanSeek);
var buffer = Marshal.AllocHGlobal(16);
try
{
Assert.True(input.Open(out var size));
Assert.Equal((ulong)transport.Bytes.Length, size);
Assert.True(input.CanSeek);
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(0, Marshal.ReadByte(buffer));
Assert.True(input.Seek(300000));
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(transport.Bytes[300000], Marshal.ReadByte(buffer));
Assert.True(input.Seek(2));
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(2, Marshal.ReadByte(buffer));
Assert.True(input.Seek(size - 1));
Assert.Equal(1, input.Read(buffer, 16));
Assert.Equal(0, input.Read(buffer, 16));
Assert.False(input.Seek(size + 1));
string? error = null;
input.Failed += message => error = message;
input.Cancel();
Assert.Equal(-1, input.Read(buffer, 16));
Assert.Null(error);
}
finally { Marshal.FreeHGlobal(buffer); }
}
[Fact]
public void Controls_hide_at_three_seconds_but_not_during_drag_pause_or_keyboard_focus()
{
var clock = new Clock();
var controls = new ReplayControlsState(clock);
Assert.True(controls.IsVisible(true, false));
clock.Now += TimeSpan.FromMilliseconds(2999);
Assert.True(controls.IsVisible(true, false));
clock.Now += TimeSpan.FromMilliseconds(1);
Assert.False(controls.IsVisible(true, false));
Assert.True(controls.IsVisible(false, false));
Assert.True(controls.IsVisible(true, true));
controls.IsInteracting = true;
Assert.True(controls.IsVisible(true, false));
controls.IsInteracting = false;
controls.Show();
Assert.True(controls.IsVisible(true, false));
Assert.Equal("0:00", ReplayControlsState.FormatTime(-1));
Assert.Equal("1:02", ReplayControlsState.FormatTime(62000));
Assert.Equal("1:01:02", ReplayControlsState.FormatTime(3662000));
}
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class RangeTransport : IYavscApiClient, IDisposable
{
public byte[] Bytes { get; init; } = Enumerable.Range(0, 600000).Select(i => (byte)i).ToArray();
public string? Failure { get; init; }
public string? Range { get; private set; }
public string? Url { get; private set; }
public HttpClient Http { get; } = new();
public Task<HttpResponseMessage> SendAuthenticatedAsync(Func<HttpRequestMessage> factory, CancellationToken ct = default)
{
using var request = factory();
Range = request.Headers.Range?.ToString();
Url = request.RequestUri!.AbsoluteUri;
var item = request.Headers.Range!.Ranges.Single();
var from = item.From!.Value;
var to = item.To!.Value;
var response = new HttpResponseMessage(Failure == "full" ? HttpStatusCode.OK : HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(Bytes[(int)from..(int)(to + 1 + (Failure == "extra" ? 1 : 0))])
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(
Failure == "range" ? from + 1 : from, to, Failure == "size" ? Bytes.Length + 1 : Bytes.Length);
return Task.FromResult(response);
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> factory, CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> factory, CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
public void Dispose() => Http.Dispose();
}
}

View file

@ -17,6 +17,38 @@ namespace PostIt.Tests;
/// </summary>
public class YavscApiClientTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Authenticated_streaming_refreshes_tokens_and_recreates_range_requests(bool expired)
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(forceFirstRequest: !expired);
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
if (expired) ExpireCachedAccessToken(tokensPath);
await using var client = new YavscApiClient(settings, new TokenStore(tokensPath));
var requests = new List<HttpRequestMessage>();
using var response = await client.SendAuthenticatedAsync(() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, apiServer.BaseUrl + "/posts");
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(10, 20);
requests.Add(request);
return request;
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(expired ? 1 : 2, requests.Count);
Assert.All(requests, request => Assert.Equal("bytes=10-20", request.Headers.Range?.ToString()));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.NotEmpty(apiServer.SeenBearers);
}
finally { File.Delete(tokensPath); }
}
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{

View file

@ -0,0 +1,17 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Controls.ReplayControls"
Background="Transparent">
<Border x:Name="ControlBar" Background="#CC151515" Padding="12"
VerticalAlignment="Bottom">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<Button x:Name="ToggleButton" Content="Pause" Click="Toggle_Click"
AutomationProperties.Name="Lecture ou pause du replay" />
<Slider x:Name="PositionSlider" Grid.Column="1" Minimum="0" Maximum="1"
ValueChanged="Position_Changed" VerticalAlignment="Center"
AutomationProperties.Name="Position dans le replay" />
<TextBlock x:Name="TimeLabel" Grid.Column="2" Foreground="White"
VerticalAlignment="Center" Text="0:00 / 0:00" />
</Grid>
</Border>
</UserControl>

View file

@ -0,0 +1,92 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Controls;
public partial class ReplayControls : UserControl
{
private readonly ReplayControlsState _state = new();
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(250) };
private MediaPlayer? _player;
private bool _updating;
public ReplayControls()
{
InitializeComponent();
_timer.Tick += Update;
PointerMoved += (_, _) => _state.Show();
PointerPressed += (_, _) => _state.Show();
KeyDown += (_, _) => _state.Show();
PositionSlider.AddHandler(PointerPressedEvent, BeginSeek, RoutingStrategies.Tunnel);
PositionSlider.AddHandler(PointerReleasedEvent, EndSeek, RoutingStrategies.Bubble, handledEventsToo: true);
PositionSlider.PointerCaptureLost += (_, _) =>
{
if (_state.IsInteracting) CommitSeek();
};
}
public ReplayControls(MediaPlayer player) : this() => _player = player;
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_state.Show();
_timer.Start();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
base.OnDetachedFromVisualTree(e);
}
private void Update(object? sender, EventArgs e)
{
if (_player is not { } player) return;
var duration = Math.Max(0, player.Length);
var position = Math.Max(0, player.Time);
ControlBar.IsVisible = _state.IsVisible(player.IsPlaying, ControlBar.IsKeyboardFocusWithin);
ToggleButton.Content = player.IsPlaying ? "Pause" : "Lire";
PositionSlider.IsEnabled = duration > 0 && player.IsSeekable;
_updating = true;
PositionSlider.Maximum = Math.Max(1, duration);
if (!_state.IsInteracting) PositionSlider.Value = Math.Min(position, duration);
_updating = false;
TimeLabel.Text = $"{ReplayControlsState.FormatTime(_state.IsInteracting ? (long)PositionSlider.Value : position)} / {ReplayControlsState.FormatTime(duration)}";
}
private void BeginSeek(object? sender, PointerPressedEventArgs e)
{
_state.IsInteracting = true;
_state.Show();
}
private void EndSeek(object? sender, PointerReleasedEventArgs e) => CommitSeek();
private void CommitSeek()
{
if (_player is { IsSeekable: true } player) player.Time = (long)PositionSlider.Value;
_state.IsInteracting = false;
_state.Show();
}
private void Position_Changed(object? sender, RangeBaseValueChangedEventArgs e)
{
if (!_updating && !_state.IsInteracting) CommitSeek();
}
private void Toggle_Click(object? sender, RoutedEventArgs e)
{
if (_player is not { } player) return;
if (player.IsPlaying) player.Pause();
else player.Play();
_state.Show();
}
}

View file

@ -17,7 +17,7 @@ public static class Platform
public static Func<string, string?, string, CancellationToken, Task<IPodcastCapture>>? CreatePodcastCaptureAsync { get; set; }
/// <summary>Platform-specific video surface inside the shared video page.</summary>
public static Func<MediaPlayer, Control>? CreateVideoSurface { get; set; }
public static Func<MediaPlayer, bool, Control>? CreateVideoSurface { get; set; }
public static event Action? PlaybackSuspending;
public static event Action? PlaybackResuming;

View file

@ -0,0 +1,90 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using LibVLCSharp.Shared;
using Yavsc.Api.Client;
namespace PostIt.Services;
public sealed class PodcastReplayMediaInput : MediaInput
{
private readonly PodcastApiClient _api;
private readonly Guid _id;
private readonly long _length;
private readonly CancellationTokenSource _cancellation = new();
private byte[] _buffer = [];
private long _bufferStart;
private long _position;
public event Action<string>? Failed;
public PodcastReplayMediaInput(PodcastApiClient api, Guid id, long length)
{
_api = api;
_id = id;
_length = length;
CanSeek = true;
}
public override bool Open(out ulong size)
{
if (_length <= 0)
{
size = 0;
Failed?.Invoke("Le MP4 du replay est vide.");
return false;
}
_position = 0;
_buffer = [];
size = (ulong)_length;
return true;
}
public override int Read(IntPtr buffer, uint lengthRequested)
{
if (lengthRequested == 0) return 0;
try
{
_cancellation.Token.ThrowIfCancellationRequested();
if (_position == _length) return 0;
if (_position < _bufferStart || _position >= _bufferStart + _buffer.Length)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_cancellation.Token);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
_buffer = _api.DownloadReplayRangeAsync(_id, _position, 256 * 1024, _length, timeout.Token)
.GetAwaiter().GetResult();
_bufferStart = _position;
}
var offset = checked((int)(_position - _bufferStart));
var count = (int)Math.Min(lengthRequested, (uint)(_buffer.Length - offset));
Marshal.Copy(_buffer, offset, buffer, count);
_position += count;
return count;
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) { return -1; }
catch (Exception ex)
{
Failed?.Invoke($"Lecture du replay interrompue : {ex.Message}");
return -1;
}
}
public override bool Seek(ulong offset)
{
if (offset > (ulong)_length || _cancellation.IsCancellationRequested) return false;
_position = (long)offset;
return true;
}
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

@ -0,0 +1,20 @@
using System;
namespace PostIt.Services;
public sealed class ReplayControlsState(TimeProvider? clock = null)
{
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
private DateTimeOffset _lastInteraction = (clock ?? TimeProvider.System).GetUtcNow();
public bool IsInteracting { get; set; }
public void Show() => _lastInteraction = _clock.GetUtcNow();
public bool IsVisible(bool playing, bool focused)
=> !playing || focused || IsInteracting || _clock.GetUtcNow() - _lastInteraction < TimeSpan.FromSeconds(3);
public static string FormatTime(long milliseconds)
{
var time = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds));
return time.TotalHours >= 1 ? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
: $"{(int)time.TotalMinutes}:{time.Seconds:00}";
}
}

View file

@ -519,6 +519,14 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
HttpMethod method,
string path,
CancellationToken ct = default)
{
using var response = await SendAuthenticatedAsync(() => new HttpRequestMessage(method, path), ct)
.ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public virtual async Task<HttpResponseMessage> SendAuthenticatedAsync(
Func<HttpRequestMessage> requestFactory, CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
@ -527,8 +535,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
using var request = requestFactory();
return await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
@ -540,10 +548,15 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
response = await sendOnce().ConfigureAwait(false);
}
using (response)
try
{
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
return response;
}
catch
{
response.Dispose();
throw;
}
}

View file

@ -79,6 +79,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
public IAsyncRelayCommand PlayCommand { get; }
public IAsyncRelayCommand FinishCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public IAsyncRelayCommand RetryReplayCommand { get; }
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => !IsBroadcasting; protected set { } }
@ -100,6 +101,9 @@ public partial class PodcastsPageViewModel : ViewModelBase
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
() => SelectedPodcast is { State: not PodcastState.Live } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
RetryReplayCommand = new AsyncRelayCommand(RetryReplayAsync,
() => SelectedPodcast is { State: PodcastState.Completed, Replay.State: PodcastReplayState.Failed } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
}
private void UpdateCommands()
@ -111,6 +115,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
PlayCommand.NotifyCanExecuteChanged();
FinishCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
RetryReplayCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(CanNavigatePrevious));
}
@ -287,4 +292,17 @@ public partial class PodcastsPageViewModel : ViewModelBase
catch (Exception ex) { Status = $"Suppression impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
private async Task RetryReplayAsync()
{
IsBusy = true;
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
await _client.RetryReplayAsync(podcast.Id);
await RefreshAsync();
}
catch (Exception ex) { Status = $"Relance du MP4 impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
}

View file

@ -1,4 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@ -15,6 +17,8 @@ public partial class VideoPage : ContentPage
private LibVLC? _libVlc;
private MediaPlayer? _player;
private PodcastMediaInput? _podcastInput;
private PodcastReplayMediaInput? _replayInput;
private CancellationTokenSource? _initialization;
public VideoPage() => InitializeComponent();
@ -26,28 +30,62 @@ public partial class VideoPage : ContentPage
InitializePlayer();
}
private void InitializePlayer()
private async void InitializePlayer()
{
ReleasePlayer();
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
return;
var cancellation = new CancellationTokenSource();
_initialization = cancellation;
var ct = cancellation.Token;
try
{
var replay = false;
var podcast = vm.Podcast;
if (podcast is not null && vm.PodcastClient is { } replayClient)
{
while (true)
{
var manifest = await replayClient.GetAsync(podcast.Id, ct);
ct.ThrowIfCancellationRequested();
podcast = manifest.Podcast;
if (podcast.State != PodcastState.Completed) break;
PlaybackStatus.Text = "Preparation du replay MP4...";
if (manifest.Podcast.Replay is { State: PodcastReplayState.Ready, Bytes: > 0 } ready)
{
_replayInput = new PodcastReplayMediaInput(replayClient, podcast.Id, ready.Bytes);
_replayInput.Failed += PodcastFailed;
replay = true;
break;
}
if (manifest.Podcast.Replay is { State: PodcastReplayState.Failed } failed)
{
PlaybackStatus.Text = failed.Error ?? "Preparation du MP4 en echec.";
return;
}
await Task.Delay(1000, ct);
}
}
ct.ThrowIfCancellationRequested();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
if (vm.Podcast is { } podcast && vm.PodcastClient is { } client)
if (!replay && podcast is { } retained && vm.PodcastClient is { } client)
{
_podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live);
_podcastInput = new PodcastMediaInput(client, retained.Id, retained.State == PodcastState.Live);
_podcastInput.Failed += PodcastFailed;
}
using var media = _podcastInput is null
? new Media(_libVlc, vm.Source)
: new Media(_libVlc, _podcastInput);
using var media = _replayInput is not null ? new Media(_libVlc, _replayInput)
: _podcastInput is not null ? new Media(_libVlc, _podcastInput)
: new Media(_libVlc, vm.Source);
if (_podcastInput is not null)
media.AddOption(":demux=ts");
var surface = Platform.CreateVideoSurface?.Invoke(_player)
?? new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
var surface = Platform.CreateVideoSurface?.Invoke(_player, replay)
?? new LibVLCSharp.Avalonia.VideoView
{
MediaPlayer = _player,
Content = replay ? new PostIt.Controls.ReplayControls(_player) : null
};
VideoSurface.Content = surface;
var player = _player;
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
@ -57,14 +95,16 @@ public partial class VideoPage : ContentPage
desktopVideo.TryUpdateNativeControlPosition();
}, DispatcherPriority.Loaded);
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
PlaybackStatus.Text = replay ? "video/mp4" : vm.MimeType;
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 DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception ex)
{
if (!ReferenceEquals(_initialization, cancellation)) return;
ReleasePlayer();
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
}
@ -88,8 +128,12 @@ public partial class VideoPage : ContentPage
private void ReleasePlayer()
{
_initialization?.Cancel();
_initialization?.Dispose();
_initialization = null;
PlayButton.IsEnabled = false;
_podcastInput?.Cancel();
_replayInput?.Cancel();
if (VideoSurface.Content is LibVLCSharp.Avalonia.VideoView desktopVideo)
desktopVideo.MediaPlayer = null;
if (VideoSurface.Content is IDisposable nativeSurface)
@ -110,6 +154,12 @@ public partial class VideoPage : ContentPage
}
_libVlc?.Dispose();
_libVlc = null;
if (_replayInput is not null)
{
_replayInput.Failed -= PodcastFailed;
_replayInput.Dispose();
_replayInput = null;
}
}
private void SuspendPlayback()

View file

@ -38,7 +38,9 @@
<TextBlock Text="{Binding OwnerName}" />
<TextBlock Text="{Binding State}" />
<TextBlock Text="{Binding DurationSeconds, StringFormat='{}{0:0} s'}" />
<TextBlock Text="{Binding Replay.State, StringFormat='MP4 : {0}'}" />
</StackPanel>
<TextBlock Text="{Binding Replay.Error}" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
@ -46,6 +48,7 @@
<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" />
<Button Content="Relancer la preparation MP4" Command="{Binding RetryReplayCommand}" Margin="0,0,8,8" />
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox x:Name="ConfirmDelete" Content="Confirmer la suppression du replay selectionne" />