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:
parent
ed75f06fb2
commit
1dcbfe159a
31 changed files with 1386 additions and 36 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
src/PostIt/PostIt.Tests/PodcastReplayTests.cs
Normal file
199
src/PostIt/PostIt.Tests/PodcastReplayTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
17
src/PostIt/PostIt/Controls/ReplayControls.axaml
Normal file
17
src/PostIt/PostIt/Controls/ReplayControls.axaml
Normal 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>
|
||||
92
src/PostIt/PostIt/Controls/ReplayControls.axaml.cs
Normal file
92
src/PostIt/PostIt/Controls/ReplayControls.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
90
src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs
Normal file
90
src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/PostIt/PostIt/Services/ReplayControlsState.cs
Normal file
20
src/PostIt/PostIt/Services/ReplayControlsState.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue