feat(android): share podcast playback and foreground broadcasting
Some checks failed
Forgejo Release / release (push) Has been cancelled
Dotnet build and test / build (push) Successful in 21m51s

Add native Camera2 capture, MPEG-TS segmentation and foreground service with notification stop. Reuse shared authenticated uploads and replay finalization, add capture regression tests and document platform limitations. Update Avalonia to 12.1.4.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 22:18:22 +01:00
commit ad92744a94
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
24 changed files with 1495 additions and 70 deletions

View file

@ -1,7 +1,26 @@
# Changelog
## [1.0.8-rc28] - unstable
## [1.0.8-rc29] - unstable
### Added
* [PostIt.Android] Lecture des podcasts directs et replays dans la page
video partagee avec le desktop : surface LibVLC native Android et moteur
embarque, transport authentifie commun et controles uniques. Liberation
du lecteur en arriere-plan ; reouverture du flux au retour dans l'application.
* [PostIt.Android] Emission depuis la camera et le microphone Android
(Android 8 minimum), encodage H.264/AAC natif et segmentation MPEG-TS
sur images cles. Interface, envoi authentifie, retries et finalisation
du replay partages avec le desktop. Service de premier plan avec
notification d'arret et maintien de la capture en arriere-plan.
### Changed
* [PostIt] Mise a jour d'Avalonia vers 12.1.4.
## [1.0.8-rc28] - unstable
### Added

View file

@ -140,6 +140,87 @@ reads a replay from its beginning; seeking is not implemented. Native
camera/microphone permissions, drivers and codecs must still be verified
on each target Windows/Linux machine.
### Android podcasts
The Android APK bundles LibVLC and uses a native Android video surface inside
the same Avalonia video page as desktop. Catalogue, playback controls and
authenticated segment downloads are shared; no external browser, public media
URL or camera/microphone permissions are needed to watch a podcast.
Leaving the app stops playback and cancels downloads. Returning recreates the
player: a replay starts from the beginning, and a live stream rejoins near the
live edge. Seeking and background audio are not supported.
Broadcasting requires **Android 8 (API 26) or newer**. In the shared podcast
page, detect devices, select front/rear camera and optionally the microphone,
then start the live broadcast while PostIt is visible. Camera permission,
microphone permission when selected, and notification permission on Android 13+
must be granted. A permission denial is reported in the page without starting
capture or creating a server broadcast. Permissions are requested before the
server session is created so a long permission dialog cannot expire it.
Watching does not request these permissions.
Android uses Camera2 and its native MediaRecorder H.264/AAC encoder, up to 480p at 30 fps,
not the desktop LibVLC camera inputs or software encoders. MPEG-TS is segmented
at IDR frames, with timestamps used for segment durations. Closed segments
enter the same ordered upload/retry/quota/replay pipeline as desktop.
Devices without a supported camera/encoder report a startup error.
A camera/microphone foreground service and a partial wake lock maintain the
broadcast when PostIt is backgrounded or the screen locks. Its ongoing
notification returns to the app and offers **Arreter et publier**. Stopping
flushes the final segment before completing the replay. Leaving the podcast
page or logging out still stops capture. Android's explicit service stop,
process termination or application restart does not automatically restart a
camera; segments already received by the server remain available under the
interrupted-replay rules above. Device power policies can impose additional
restrictions and must be tested on the intended physical devices.
**Native library limitation:** bundled `VideoLAN.LibVLC.Android` 3.6.5 emits
`XA0141`: its native libraries are not aligned for 16 KiB memory pages.
Playback on 16 KiB-page devices and store distribution requiring this alignment
are not validated/supported by this package choice. A compatible native LibVLC
release is required before claiming that support; a successful APK build or
a test on a 4 KiB-page emulator does not establish it.
Build with the Android workload and SDK configured:
```bash
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj
```
On an Android device, verify replay video/audio, live playback, pause/resume,
rotation, Back navigation and returning after backgrounding the app. Also
verify an expired session or interrupted network reports a playback error.
For broadcasting, test front/rear cameras, microphone enabled/disabled,
permission denial, screen lock, background capture, notification stop and the
final replay. No automatic retry of a denied permission or a killed camera
service is performed.
The Debug APK includes a local native-capture test activity (excluded from
Release); it does not create a server podcast. On a dedicated emulator, grant
camera, audio and notification permissions, then run:
```bash
adb shell am start -W -f 0x18000000 -n fr.pschneider.postit/fr.pschneider.postit.PodcastCaptureTestActivity
# Wait about 20 seconds for foreground/background recording and final flush.
adb shell run-as fr.pschneider.postit cat files/podcast-capture-test-result.txt
# Repeat with the second camera, no audio, and the notification stop action:
adb shell am start -W -f 0x18000000 -n fr.pschneider.postit/fr.pschneider.postit.PodcastCaptureTestActivity --es microphone false --es camera front --es notificationStop true
```
The result reports the segment count, the background progress and the private
recording directory under `files/podcast-capture-tests`. Recordings remain
there for local decoder inspection and can be removed after the test.
The implementation was checked on an Android 16 x64 emulator with 4 KiB
pages: camera/microphone capture produced five independently decodable
H.264/AAC segments across foreground/background operation; the second camera
without audio produced six decodable H.264 segments, including screen-off
operation and stop through the notification action. The foreground service
was gone after both tests. These local tests do not replace an authenticated
Android-to-server broadcast/replay test or validation on physical devices.
## Resources
System resources needed to install.

View file

@ -4,7 +4,7 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.3</AvaloniaVersionBase>
<AvaloniaVersionBase>12.1.4</AvaloniaVersionBase>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
@ -13,11 +13,13 @@
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.4" />
<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.2" />
<PackageVersion Include="LibVLCSharp" Version="3.10.2" />
<PackageVersion Include="VideoLAN.LibVLC.Android" Version="3.6.5" />
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />

View file

@ -25,6 +25,7 @@ public class MainActivity : AvaloniaMainActivity
/// The current MainActivity instance.
/// </summary>
public static MainActivity? Current { get; private set; }
internal static bool IsForeground { get; private set; }
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
{
@ -36,8 +37,8 @@ public class MainActivity : AvaloniaMainActivity
EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest);
EmojiCompat.Init(config);
PlatformBootstrap.InitPlatform();
base.OnCreate(savedInstanceState);
Current = this;
base.OnCreate(savedInstanceState);
}
/// <summary>
/// Receives the deep-link Intent fired by the system browser after the
@ -60,9 +61,34 @@ public class MainActivity : AvaloniaMainActivity
}
public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults)
protected override void OnStop()
{
if (PostIt.Android.Services.AndroidCurrentLocationProvider
PostIt.Services.Platform.SuspendPlayback();
base.OnStop();
}
protected override void OnResume()
{
base.OnResume();
IsForeground = true;
}
protected override void OnPause()
{
IsForeground = false;
base.OnPause();
}
protected override void OnRestart()
{
base.OnRestart();
PostIt.Services.Platform.ResumePlayback();
}
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
{
if (PostIt.Android.Services.PodcastPermissions.HandleResult(requestCode, permissions, grantResults)
|| PostIt.Android.Services.AndroidCurrentLocationProvider
.HandlePermissionResult(requestCode, grantResults))
{
return;

View file

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

View file

@ -21,12 +21,17 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" />
<PackageReference Include="LibVLCSharp" />
<PackageReference Include="VideoLAN.LibVLC.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
<PackageReference Include="Xamarin.AndroidX.Browser" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<Compile Include="..\PostIt.Tests\AndroidPodcastCaptureTestActivity.cs" Link="Tests\AndroidPodcastCaptureTestActivity.cs" />
</ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
extension's injected HotReload agent (references Microsoft.Maui.*).
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after

View file

@ -1,6 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application
android:label="PostIt"
android:icon="@drawable/Icon"

View file

@ -0,0 +1,309 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Android.Content;
using Android.Hardware.Camera2;
using Android.Hardware.Camera2.Params;
using Android.Media;
using Android.OS;
using Android.Views;
using Microsoft.Win32.SafeHandles;
using PostIt.Services;
namespace PostIt.Android.Services;
[SupportedOSPlatform("android26.0")]
internal sealed class AndroidPodcastCapture : IPodcastCapture
{
private readonly PodcastCaptureService _service;
private readonly SemaphoreSlim _stopGate = new(1);
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Handler _handler = new(Looper.MainLooper!);
private CameraDevice? _camera;
private CameraCaptureSession? _session;
private MediaRecorder? _recorder;
private Surface? _surface;
private ParcelFileDescriptor? _readPipe;
private ParcelFileDescriptor? _writePipe;
private Task _pump = Task.CompletedTask;
private Exception? _failure;
private bool _stopped;
private bool _stopRequested;
private bool _started;
private bool _disposed;
public Task Completion => _completion.Task;
private AndroidPodcastCapture(PodcastCaptureService service) => _service = service;
private static CameraManager GetManager(Context context)
=> context.GetSystemService(Context.CameraService) as CameraManager
?? throw new InvalidOperationException("Service camera Android indisponible.");
public static PodcastCaptureDevices GetDevices()
{
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
var manager = GetManager(activity);
var cameras = new List<CaptureDevice>();
foreach (var id in manager.GetCameraIdList())
{
using var characteristics = manager.GetCameraCharacteristics(id);
using var facing = characteristics.Get(CameraCharacteristics.LensFacing) as Java.Lang.Integer;
cameras.Add(new(facing?.IntValue() == (int)LensFacing.Front ? "Camera avant" : "Camera arriere", id));
}
return new(cameras, [new("Sans microphone", ""), new("Microphone Android", "microphone")]);
}
public static async Task RequestPermissionsAsync(string? microphone, CancellationToken ct)
{
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
await PodcastPermissions.EnsureAsync(activity, microphone is not null, ct);
if (!MainActivity.IsForeground)
throw new InvalidOperationException("Revenez dans PostIt pour demarrer la camera.");
}
public static async Task<IPodcastCapture> StartAsync(string camera, string? microphone, string directory, CancellationToken ct)
{
await RequestPermissionsAsync(microphone, ct);
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
var service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct);
var capture = new AndroidPodcastCapture(service);
service.Capture = capture;
try
{
ct.ThrowIfCancellationRequested();
await capture.StartNativeAsync(camera, microphone is not null, directory, ct);
return capture;
}
catch
{
await capture.DisposeAsync();
throw;
}
}
private async Task StartNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
{
await _stopGate.WaitAsync(ct);
try { await ConfigureNativeAsync(cameraId, microphone, directory, ct); }
finally { _stopGate.Release(); }
}
private async Task ConfigureNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
{
var manager = GetManager(_service);
using var characteristics = manager.GetCameraCharacteristics(cameraId);
using var formats = characteristics.Get(CameraCharacteristics.ScalerStreamConfigurationMap) as StreamConfigurationMap
?? throw new InvalidOperationException("Formats camera indisponibles.");
var sizes = formats.GetOutputSizes(Java.Lang.Class.FromType(typeof(MediaRecorder)))
?? throw new InvalidOperationException("Aucun format video disponible.");
var size = sizes.Where(s => s.Width <= 640 && s.Height <= 480)
.OrderByDescending(s => s.Width * s.Height).FirstOrDefault()
?? throw new InvalidOperationException("La camera ne propose aucun format jusqu'a 480p.");
#pragma warning disable CS0618
_recorder = OperatingSystem.IsAndroidVersionAtLeast(31)
? new MediaRecorder(_service) : new MediaRecorder();
#pragma warning restore CS0618
_recorder.Error += OnRecorderError;
if (microphone) _recorder.SetAudioSource(AudioSource.Mic);
_recorder.SetVideoSource(VideoSource.Surface);
_recorder.SetOutputFormat(OutputFormat.Mpeg2Ts);
_recorder.SetVideoSize(size.Width, size.Height);
_recorder.SetVideoFrameRate(30);
_recorder.SetVideoEncodingBitRate(1200000);
_recorder.SetVideoEncoder(VideoEncoder.H264);
if (microphone)
{
_recorder.SetAudioSamplingRate(48000);
_recorder.SetAudioChannels(1);
_recorder.SetAudioEncodingBitRate(96000);
_recorder.SetAudioEncoder(AudioEncoder.Aac);
}
var pipe = ParcelFileDescriptor.CreatePipe()
?? throw new IOException("Creation du canal MPEG-TS impossible.");
_readPipe = pipe[0];
_writePipe = pipe[1];
_recorder.SetOutputFile(_writePipe.FileDescriptor);
await Task.Run(_recorder.Prepare, ct);
ct.ThrowIfCancellationRequested();
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
_surface = _recorder.Surface ?? throw new InvalidOperationException("Surface encodeur indisponible.");
var opened = new TaskCompletionSource<CameraDevice>(TaskCreationOptions.RunContinuationsAsynchronously);
manager.OpenCamera(cameraId, new DeviceCallback(this, opened), _handler);
_camera = await opened.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
var configured = new TaskCompletionSource<CameraCaptureSession>(TaskCreationOptions.RunContinuationsAsynchronously);
#pragma warning disable CS0618, CA1422
_camera.CreateCaptureSession(new List<Surface> { _surface }, new SessionCallback(this, configured), _handler);
#pragma warning restore CS0618, CA1422
_session = await configured.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
ct.ThrowIfCancellationRequested();
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
using var request = _camera.CreateCaptureRequest(CameraTemplate.Record);
request.AddTarget(_surface);
using var built = request.Build();
_session.SetRepeatingRequest(built, null, _handler);
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
{
using (handle)
using (stream)
{
try
{
await new TransportStreamCapture(directory).RunAsync(stream, CancellationToken.None);
if (_failure is not null) throw new IOException("Capture Android interrompue.", _failure);
if (!_stopped) throw new IOException("Le flux de capture Android s'est ferme prematurement.");
_completion.TrySetResult();
}
catch (Exception ex)
{
_completion.TrySetException(ex);
// Drain until MediaRecorder stops, so its writer cannot block Stop().
await stream.CopyToAsync(System.IO.Stream.Null);
}
}
});
await Task.Run(_recorder.Start, ct);
_started = true;
_writePipe.Close();
_writePipe.Dispose();
_writePipe = null;
}
private sealed class DeviceCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraDevice> opened)
: CameraDevice.StateCallback
{
public override void OnOpened(CameraDevice camera)
{
if (capture._stopRequested || opened.Task.IsCompleted)
{
camera.Close();
opened.TrySetException(new IOException("Ouverture camera annulee."));
}
else
{
capture._camera = camera;
opened.TrySetResult(camera);
}
}
public override void OnDisconnected(CameraDevice camera) => Fail(camera, "Camera Android deconnectee.");
public override void OnError(CameraDevice camera, CameraError error) => Fail(camera, $"Erreur camera Android : {error}.");
private void Fail(CameraDevice camera, string message)
{
camera.Close();
var error = new IOException(message);
opened.TrySetException(error);
capture.Fail(error);
}
}
private sealed class SessionCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraCaptureSession> configured)
: CameraCaptureSession.StateCallback
{
public override void OnConfigured(CameraCaptureSession session)
{
if (capture._stopRequested || configured.Task.IsCompleted)
{
session.Close();
configured.TrySetException(new IOException("Configuration camera annulee."));
}
else
{
capture._session = session;
configured.TrySetResult(session);
}
}
public override void OnConfigureFailed(CameraCaptureSession session)
{
session.Close();
var error = new IOException("Configuration de la capture Android impossible.");
configured.TrySetException(error);
capture.Fail(error);
}
}
private void OnRecorderError(object? sender, MediaRecorder.ErrorEventArgs e)
=> Fail(new IOException($"Erreur encodeur Android : {e.What}/{e.Extra}."));
private void Fail(Exception error)
{
if (_stopped) return;
_failure = error;
_completion.TrySetException(error);
RequestStop();
}
internal async void RequestStop()
{
try { await StopAsync(); }
catch (Exception ex) { global::Android.Util.Log.Error("PostIt.Podcast", ex.ToString()); }
}
public async Task StopAsync()
{
_stopRequested = true;
await _stopGate.WaitAsync();
try
{
if (_stopped) return;
_stopped = true;
await Task.Run(StopNative);
await _pump;
}
finally { _stopGate.Release(); }
}
private void StopNative()
{
try { if (_started) _recorder?.Stop(); }
catch (Java.Lang.RuntimeException ex)
{
_failure = ex;
_completion.TrySetException(new IOException("L'encodeur Android n'a pas pu finaliser la capture.", ex));
}
finally
{
_started = false;
_session?.Close();
_session?.Dispose();
_session = null;
_camera?.Close();
_camera?.Dispose();
_camera = null;
if (_recorder is not null)
{
_recorder.Error -= OnRecorderError;
_recorder.Release();
_recorder.Dispose();
_recorder = null;
}
_surface?.Dispose();
_surface = null;
_writePipe?.Close();
_writePipe?.Dispose();
_writePipe = null;
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await StopAsync(); }
finally
{
_readPipe?.Dispose();
_readPipe = null;
_service.Capture = null;
_service.StopSelf();
_handler.Dispose();
_stopGate.Dispose();
}
}
}

View file

@ -0,0 +1,37 @@
using System;
using Avalonia.Android;
using Avalonia.Controls;
using Avalonia.Platform;
using LibVLCSharp.Shared;
using VideoView = LibVLCSharp.Platforms.Android.VideoView;
namespace PostIt.Android.Services;
internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHost, IDisposable
{
private VideoView? _video;
private bool _disposed;
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
var activity = MainActivity.Current
?? throw new InvalidOperationException("L'activite Android est indisponible.");
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
_video = video;
return new AndroidViewControlHandle(video);
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (control is AndroidViewControlHandle { View: VideoView video })
video.MediaPlayer = null;
_video = null;
base.DestroyNativeControlCore(control);
}
public void Dispose()
{
_disposed = true;
if (_video is not null) _video.MediaPlayer = null;
}
}

View file

@ -0,0 +1,109 @@
using System;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.App;
namespace PostIt.Android.Services;
[Service(Exported = false, ForegroundServiceType = ForegroundService.TypeCamera | ForegroundService.TypeMicrophone)]
[SupportedOSPlatform("android26.0")]
public sealed class PodcastCaptureService : Service
{
private const string Channel = "podcast-broadcast";
internal const string StopAction = "fr.pschneider.postit.STOP_PODCAST";
private const int NotificationId = 4044;
private static TaskCompletionSource<PodcastCaptureService>? _pending;
private PowerManager.WakeLock? _wakeLock;
internal AndroidPodcastCapture? Capture { get; set; }
public static async Task<PodcastCaptureService> StartAsync(Activity activity, bool microphone, CancellationToken ct)
{
if (_pending is not null) throw new InvalidOperationException("Un direct Android est deja en cours.");
var pending = new TaskCompletionSource<PodcastCaptureService>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending = pending;
try
{
var intent = new Intent(activity, typeof(PodcastCaptureService)).PutExtra("microphone", microphone);
activity.StartForegroundService(intent);
return await pending.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
}
catch
{
activity.StopService(new Intent(activity, typeof(PodcastCaptureService)));
_pending = null;
throw;
}
}
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
{
if (intent?.Action == StopAction)
{
if (Capture is { } capture) capture.RequestStop();
else
{
_pending?.TrySetException(new System.OperationCanceledException("Demarrage du direct annule depuis la notification."));
StopSelf();
}
return StartCommandResult.NotSticky;
}
try
{
var manager = GetSystemService(NotificationService) as NotificationManager
?? throw new InvalidOperationException("Notifications Android indisponibles.");
manager.CreateNotificationChannel(new NotificationChannel(Channel, "Diffusion podcast", NotificationImportance.Low));
var launch = PackageManager?.GetLaunchIntentForPackage(PackageName!)
?? throw new InvalidOperationException("Application Android indisponible.");
var open = PendingIntent.GetActivity(this, 0, launch, PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
var stop = PendingIntent.GetService(this, 1,
new Intent(this, typeof(PodcastCaptureService)).SetAction(StopAction),
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
var builder = new NotificationCompat.Builder(this, Channel);
builder.SetSmallIcon(global::Android.Resource.Drawable.IcMenuCamera);
builder.SetContentTitle("Podcast en direct");
builder.SetContentText("Camera active. Toucher pour revenir au direct.");
builder.SetContentIntent(open);
builder.SetOngoing(true);
builder.AddAction(global::Android.Resource.Drawable.IcMediaPause, "Arreter et publier", stop);
var notification = builder.Build();
var type = (ForegroundService)0;
if (OperatingSystem.IsAndroidVersionAtLeast(30))
{
type = ForegroundService.TypeCamera;
if (intent?.GetBooleanExtra("microphone", false) == true) type |= ForegroundService.TypeMicrophone;
}
ServiceCompat.StartForeground(this, NotificationId, notification, (int)type);
var power = GetSystemService(PowerService) as PowerManager
?? throw new InvalidOperationException("Gestion d'alimentation Android indisponible.");
_wakeLock = power.NewWakeLock(WakeLockFlags.Partial, "PostIt:Podcast")
?? throw new InvalidOperationException("Le verrou d'alimentation Android est indisponible.");
_wakeLock.Acquire();
_pending?.TrySetResult(this);
}
catch (Exception ex)
{
_pending?.TrySetException(ex);
StopSelf();
}
return StartCommandResult.NotSticky;
}
public override IBinder? OnBind(Intent? intent) => null;
public override void OnDestroy()
{
_pending?.TrySetException(new InvalidOperationException("Le service de diffusion Android a ete arrete."));
Capture?.RequestStop();
Capture = null;
if (_wakeLock?.IsHeld == true) _wakeLock.Release();
_wakeLock?.Dispose();
_wakeLock = null;
_pending = null;
base.OnDestroy();
}
}

View file

@ -0,0 +1,47 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Android;
using Android.App;
using Android.Content.PM;
using AndroidX.Core.App;
using AndroidX.Core.Content;
namespace PostIt.Android.Services;
internal static class PodcastPermissions
{
private const int RequestCode = 4043;
private static TaskCompletionSource<bool>? _pending;
private static string[] _requested = [];
public static async Task EnsureAsync(Activity activity, bool microphone, CancellationToken ct)
{
var requested = new List<string> { Manifest.Permission.Camera };
if (microphone) requested.Add(Manifest.Permission.RecordAudio);
if (OperatingSystem.IsAndroidVersionAtLeast(33)) requested.Add(Manifest.Permission.PostNotifications);
var permissions = requested.ToArray();
if (permissions.All(p => ContextCompat.CheckSelfPermission(activity, p) == Permission.Granted)) return;
if (_pending is not null) throw new InvalidOperationException("Une demande de permissions est deja en cours.");
_requested = permissions;
var pending = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending = pending;
ActivityCompat.RequestPermissions(activity, permissions, RequestCode);
if (!await pending.Task.WaitAsync(ct))
throw new UnauthorizedAccessException("Autorisez la camera, les notifications et, si selectionne, le microphone pour diffuser.");
}
public static bool HandleResult(int requestCode, string[]? permissions, Permission[]? results)
{
if (requestCode != RequestCode) return false;
var granted = permissions is not null && results is not null && permissions.Length == results.Length
&& _requested.All(p => Array.IndexOf(permissions, p) is var index && index >= 0
&& results[index] == Permission.Granted);
var pending = _pending;
_pending = null;
pending?.TrySetResult(granted);
return true;
}
}

View file

@ -0,0 +1,100 @@
#if ANDROID
using System;
using System.IO;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.OS;
using Avalonia.Threading;
using PostIt.Android;
using PostIt.Android.Services;
using PostIt.Services;
namespace PostIt.Tests;
/// <summary>Debug-only local encoder test; never connects to the podcast API.</summary>
[Activity(Name = "fr.pschneider.postit.PodcastCaptureTestActivity", Exported = true)]
[SupportedOSPlatform("android26.0")]
public sealed class AndroidPodcastCaptureTestActivity : Activity
{
private bool _microphone = true;
private bool _notificationStop;
private int _cameraIndex;
protected override async void OnCreate(Bundle? state)
{
base.OnCreate(state);
_microphone = Intent?.GetStringExtra("microphone") != "false";
_notificationStop = Intent?.GetStringExtra("notificationStop") == "true";
_cameraIndex = Intent?.GetStringExtra("camera") == "front" ? 1 : 0;
var resultPath = Path.Combine(FilesDir!.AbsolutePath, "podcast-capture-test-result.txt");
File.WriteAllText(resultPath, "RUNNING");
var result = new Bundle();
try
{
await RunAsync(result);
File.WriteAllText(resultPath, $"PASS\nsegments={result.GetInt("segments")}\ndirectory={result.GetString("directory")}\nbackground={result.GetString("background")}");
}
catch (Exception ex)
{
File.WriteAllText(resultPath, $"FAIL\n{ex}");
}
finally { Finish(); }
}
private async Task RunAsync(Bundle result)
{
if (!OperatingSystem.IsAndroidVersionAtLeast(26))
throw new PlatformNotSupportedException("Android 8 required.");
var context = this;
var intent = new Intent(context, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask);
StartActivity(intent);
for (var i = 0; i < 100 && !MainActivity.IsForeground; i++) await Task.Delay(100);
await Task.Delay(1000);
var directory = Path.Combine(context.FilesDir!.AbsolutePath, "podcast-capture-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var devices = AndroidPodcastCapture.GetDevices();
if (devices.Cameras.Count == 0) throw new InvalidOperationException("No emulator camera.");
IPodcastCapture? capture = null;
try
{
capture = await Dispatcher.UIThread.InvokeAsync(() => AndroidPodcastCapture.StartAsync(
devices.Cameras[Math.Min(_cameraIndex, devices.Cameras.Count - 1)].Value,
_microphone ? "microphone" : null, directory, CancellationToken.None));
await Task.Delay(3000);
var before = SegmentCount(directory);
context.StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask));
await Task.Delay(9000);
var after = SegmentCount(directory);
if (capture.Completion.IsFaulted) await capture.Completion;
if (after <= before) throw new InvalidOperationException($"Capture did not continue in background: {before} -> {after} segments.");
if (_notificationStop)
{
using var stop = PendingIntent.GetService(context, 1,
new Intent(context, typeof(PodcastCaptureService)).SetAction(PodcastCaptureService.StopAction),
PendingIntentFlags.NoCreate | PendingIntentFlags.Immutable)
?? throw new InvalidOperationException("Notification stop action missing.");
stop.Send();
}
else await capture.StopAsync().WaitAsync(TimeSpan.FromSeconds(15));
await capture.Completion.WaitAsync(TimeSpan.FromSeconds(15));
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8")));
if (segments.Count < 3) throw new InvalidOperationException("Insufficient capture segments.");
result.PutInt("segments", segments.Count);
result.PutString("directory", directory);
result.PutString("background", $"segments {before} -> {after}");
}
finally
{
if (capture is not null) await capture.DisposeAsync();
}
}
private static int SegmentCount(string directory)
{
var path = Path.Combine(directory, "index.m3u8");
return File.Exists(path) ? HlsCapturePlaylist.Parse(File.ReadAllText(path)).Count : 0;
}
}
#endif

View file

@ -464,11 +464,31 @@ public class BlogReaderTests
}
[AvaloniaFact]
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
public void Video_viewmodel_resolves_to_the_shared_page_with_a_platform_video_surface_slot()
{
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
Assert.NotNull(page.FindControl<ContentControl>("VideoSurface"));
Assert.NotNull(page.FindControl<Button>("PlayButton"));
}
[AvaloniaFact]
public async Task Video_page_observes_platform_lifecycle_only_while_navigated()
{
var nav = new NavigationPage();
await nav.PushAsync(new ContentPage());
var page = new VideoPage();
await nav.PushAsync(page);
var status = page.FindControl<TextBlock>("PlaybackStatus")!;
Platform.SuspendPlayback();
Assert.Equal("Lecture suspendue.", status.Text);
Assert.False(page.FindControl<Button>("PlayButton")!.IsEnabled);
Platform.ResumePlayback();
Assert.Null(page.FindControl<ContentControl>("VideoSurface")!.Content);
await nav.PopAsync();
status.Text = "No longer active";
Platform.SuspendPlayback();
Assert.Equal("No longer active", status.Text);
}
[Fact]

View file

@ -0,0 +1,175 @@
using System.Net;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastBroadcasterTests
{
[AvaloniaFact]
public async Task Shared_page_uses_platform_devices_and_requests_permissions_before_creating_server_broadcast()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-podcast-{Guid.NewGuid():N}.json");
var devices = Platform.GetPodcastCaptureDevices;
var permissions = Platform.RequestPodcastPermissionsAsync;
var factory = Platform.CreatePodcastCaptureAsync;
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(new Settings(), store);
await using var transport = new Transport();
try
{
Platform.GetPodcastCaptureDevices = () => new(
[new("Front", "front"), new("Rear", "rear")],
[new("None", ""), new("Android microphone", "microphone")]);
Platform.RequestPodcastPermissionsAsync = (_, _) =>
Task.FromException(new UnauthorizedAccessException("Camera denied"));
Platform.CreatePodcastCaptureAsync = (_, _, _, _) =>
throw new InvalidOperationException("Capture must not start after permission denial.");
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
var vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
{
Title = "Android live"
};
Assert.True(vm.CanBroadcast);
vm.DetectDevicesCommand.Execute(null);
Assert.Equal(2, vm.Cameras.Count);
Assert.Equal("front", vm.SelectedCamera?.Value);
Assert.Equal("microphone", vm.SelectedMicrophone?.Value);
Assert.True(vm.StartCommand.CanExecute(null));
await vm.StartCommand.ExecuteAsync(null);
Assert.Contains("Camera denied", vm.Status);
Assert.Equal(0, transport.CreateCount);
Assert.False(vm.IsBusy);
Assert.False(vm.IsBroadcasting);
}
finally
{
Platform.GetPodcastCaptureDevices = devices;
Platform.RequestPodcastPermissionsAsync = permissions;
Platform.CreatePodcastCaptureAsync = factory;
File.Delete(tokenPath);
}
}
[Fact]
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
{
await using var transport = new Transport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
FakeCapture? capture = null;
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
(directory, _) =>
{
capture = new FakeCapture(directory);
return Task.FromResult<IPodcastCapture>(capture);
}, TestContext.Current.CancellationToken);
await transport.FirstUpload.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await capture!.StopAsync();
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Assert.Equal(new[] { 0, 1 }, transport.Uploads);
Assert.Equal(new[] { "upload:0", "upload:1", "complete" }, transport.Events);
Assert.True(capture.Stopped);
}
[Fact]
public async Task Native_failure_stops_capture_and_preserves_unacknowledged_segments()
{
await using var transport = new Transport { FailUpload = true };
var client = new PodcastApiClient(transport, () => "https://api.example/");
FakeCapture? capture = null;
var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
(directory, _) =>
{
capture = new FakeCapture(directory);
return Task.FromResult<IPodcastCapture>(capture);
}, TestContext.Current.CancellationToken);
try
{
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.Completion);
Assert.True(capture!.Stopped);
Assert.True(File.Exists(Path.Combine(capture.Directory, "segment-00000000.ts")));
Assert.DoesNotContain("complete", transport.Events);
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.DisposeAsync().AsTask());
Assert.True(capture.Disposed);
}
finally
{
if (capture is not null) Directory.Delete(capture.Directory, recursive: true);
}
}
[Fact]
public async Task Startup_failure_finalizes_empty_server_broadcast()
{
await using var transport = new Transport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => PodcastBroadcaster.StartAsync(client, "Denied",
(_, _) => Task.FromException<IPodcastCapture>(new UnauthorizedAccessException("Camera denied")),
TestContext.Current.CancellationToken));
Assert.Equal(new[] { "complete" }, transport.Events);
}
private sealed class FakeCapture : IPodcastCapture
{
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public string Directory { get; }
public bool Stopped { get; private set; }
public bool Disposed { get; private set; }
public Task Completion => _completion.Task;
public FakeCapture(string directory)
{
Directory = directory;
File.WriteAllBytes(Path.Combine(directory, "segment-00000000.ts"), [0x47, 1]);
File.WriteAllText(Path.Combine(directory, "index.m3u8"), "#EXTM3U\n#EXTINF:2,\nsegment-00000000.ts\n");
}
public Task StopAsync()
{
if (Stopped) return Task.CompletedTask;
File.WriteAllBytes(Path.Combine(Directory, "segment-00000001.ts"), [0x47, 2]);
File.AppendAllText(Path.Combine(Directory, "index.m3u8"), "#EXTINF:0.5,\nsegment-00000001.ts\n");
Stopped = true;
_completion.TrySetResult();
return Task.CompletedTask;
}
public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
}
private sealed class Transport : IYavscApiClient
{
private readonly PodcastResponse _podcast = new(Guid.NewGuid(), "Test", "alice", "Alice",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
public HttpClient Http { get; } = new();
public List<int> Uploads { get; } = [];
public List<string> Events { get; } = [];
public bool FailUpload { get; init; }
public int CreateCount { get; private set; }
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
else CreateCount++;
return Task.FromResult((T)(object)_podcast);
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
if (FailUpload) throw new HttpRequestException("Denied", null, HttpStatusCode.Forbidden);
var sequence = int.Parse(new Uri(path).Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
Uploads.Add(sequence);
Events.Add($"upload:{sequence}");
FirstUpload.TrySetResult();
return Task.FromResult((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)
=> throw new NotSupportedException();
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}

View file

@ -0,0 +1,146 @@
using PostIt.Services;
namespace PostIt.Tests;
public sealed class TransportStreamCaptureTests
{
[Fact]
public async Task Segments_on_IDR_and_flushes_final_segment_with_real_PTS_durations()
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream();
input.Write(Pat());
input.Write(Pmt());
for (var i = 0; i <= 120; i++) input.Write(Video(i * 3000L, i % 60 == 0));
input.Position = 0;
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
Assert.Equal(3, segments.Count);
Assert.Equal(2, segments[0].DurationSeconds);
Assert.Equal(2, segments[1].DurationSeconds);
Assert.InRange(segments[2].DurationSeconds, 0.033, 0.034);
foreach (var segment in segments)
{
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
Assert.Equal(0, bytes.Length % 188);
Assert.Equal(Pat(), bytes[..188]);
Assert.Equal(Pmt(), bytes[188..376]);
Assert.Contains((byte)0x65, bytes[376..]);
}
}
finally { Directory.Delete(directory, recursive: true); }
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Rejects_corrupt_or_truncated_transport_without_publishing_a_segment(bool truncated)
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream(new byte[truncated ? 187 : 188]);
await Assert.ThrowsAsync<InvalidDataException>(() =>
new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken));
Assert.False(File.Exists(Path.Combine(directory, "index.m3u8")));
}
finally { Directory.Delete(directory, recursive: true); }
}
[Fact]
public async Task Preserves_out_of_band_H264_configuration_at_every_segment_start()
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream();
input.Write(Pat());
input.Write(Pmt());
var configuration = Video(0, false);
configuration[22] = 0x67;
new byte[] { 0, 0, 0, 1, 0x68, 0x80 }.CopyTo(configuration, 25);
input.Write(configuration);
for (var i = 0; i <= 120; i++) input.Write(Video(90000 + i * 3000L, i % 60 == 0));
input.Position = 0;
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
Assert.Equal(3, segments.Count);
foreach (var segment in segments)
{
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
Assert.Equal(0x67, bytes[376 + 22]);
Assert.Equal(0x68, bytes[376 + 29]);
Assert.Equal(0x65, bytes[564 + 22]);
Assert.Equal((bytes[564 + 3] - 1) & 0x0f, bytes[376 + 3] & 0x0f);
Assert.Equal(bytes[(564 + 13)..(564 + 18)], bytes[(376 + 13)..(376 + 18)]);
}
}
finally { Directory.Delete(directory, recursive: true); }
}
[Fact]
public async Task Handles_33_bit_PTS_wrap_without_negative_duration()
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream();
input.Write(Pat());
input.Write(Pmt());
for (var i = 0; i <= 120; i++)
input.Write(Video(((1L << 33) - 90000 + i * 3000L) & ((1L << 33) - 1), i % 60 == 0));
input.Position = 0;
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
Assert.Equal(3, segments.Count);
Assert.Equal(2, segments[0].DurationSeconds);
}
finally { Directory.Delete(directory, recursive: true); }
}
private static byte[] Packet(int pid)
{
var packet = Enumerable.Repeat((byte)0xff, 188).ToArray();
packet[0] = 0x47;
packet[1] = (byte)(0x40 | (pid >> 8));
packet[2] = (byte)pid;
packet[3] = 0x10;
return packet;
}
private static byte[] Pat()
{
var packet = Packet(0);
packet[4] = 0;
new byte[] { 0, 0xb0, 13, 0, 1, 0xc1, 0, 0, 0, 1, 0xe1, 0 }.CopyTo(packet, 5);
return packet;
}
private static byte[] Pmt()
{
var packet = Packet(256);
packet[4] = 0;
new byte[] { 2, 0xb0, 18, 0, 1, 0xc1, 0, 0, 0xe1, 1, 0xf0, 0,
0x1b, 0xe1, 1, 0xf0, 0 }.CopyTo(packet, 5);
return packet;
}
private static byte[] Video(long pts, bool idr)
{
var packet = Packet(257);
new byte[] { 0, 0, 1, 0xe0, 0, 0, 0x84, 0x80, 5 }.CopyTo(packet, 4);
packet[13] = (byte)(0x21 | ((pts >> 29) & 0xe));
packet[14] = (byte)(pts >> 22);
packet[15] = (byte)(1 | ((pts >> 14) & 0xfe));
packet[16] = (byte)(pts >> 7);
packet[17] = (byte)(1 | ((pts << 1) & 0xfe));
new byte[] { 0, 0, 0, 1, idr ? (byte)0x65 : (byte)0x41, 0x88 }.CopyTo(packet, 18);
return packet;
}
}

View file

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace PostIt.Services;
public sealed record CaptureDevice(string Name, string Value);
public sealed record PodcastCaptureDevices(
IReadOnlyList<CaptureDevice> Cameras, IReadOnlyList<CaptureDevice> Microphones);
/// <summary>A running encoder publishing closed segments and an atomic index.m3u8.</summary>
public interface IPodcastCapture : IAsyncDisposable
{
Task Completion { get; }
Task StopAsync();
}

View file

@ -0,0 +1,62 @@
using System;
using System.Threading.Tasks;
using LibVLCSharp.Shared;
namespace PostIt.Services;
internal sealed class LibVlcPodcastCapture : IPodcastCapture
{
private readonly LibVLC _vlc;
private readonly Media _media;
private readonly MediaPlayer _player;
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool _stopped;
public Task Completion => _completion.Task;
public LibVlcPodcastCapture(Func<LibVLC, Media> createMedia, string directory)
{
Core.Initialize();
_vlc = CameraStreamer.CreatePreferredLibVlc();
try
{
_media = createMedia(_vlc);
PodcastBroadcaster.ConfigureOutput(_media, directory);
_player = new MediaPlayer(_vlc);
_player.EncounteredError += OnError;
if (!_player.Play(_media))
throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
}
catch
{
_player?.Dispose();
_media?.Dispose();
_vlc.Dispose();
throw;
}
}
private void OnError(object? sender, EventArgs e)
=> _completion.TrySetException(new InvalidOperationException(
"La capture LibVLC a echoue. Verifiez camera, microphone et encodeurs H.264/AAC."));
public async Task StopAsync()
{
if (_stopped) return;
_stopped = true;
await Task.Run(_player.Stop);
_completion.TrySetResult();
}
public async ValueTask DisposeAsync()
{
try { await StopAsync(); }
finally
{
_player.EncounteredError -= OnError;
_player.Dispose();
_media.Dispose();
_vlc.Dispose();
}
}
}

View file

@ -2,19 +2,28 @@ using System;
using IdentityModel.OidcClient.Browser;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using LibVLCSharp.Shared;
namespace PostIt.Services;
/// <summary>
/// Per-platform access to native integration points used by the OIDC
/// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does
/// not reference any UI framework; platform projects (PostIt.Android,
/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so
/// the shared OIDC login path can drive a native browser without taking
/// a hard dependency on any specific UI toolkit.
/// Native integration points populated by platform projects at startup.
/// </summary>
public static class Platform
{
public static Func<PodcastCaptureDevices>? GetPodcastCaptureDevices { get; set; }
public static Func<string?, CancellationToken, Task>? RequestPodcastPermissionsAsync { get; set; }
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 event Action? PlaybackSuspending;
public static event Action? PlaybackResuming;
public static void SuspendPlayback() => PlaybackSuspending?.Invoke();
public static void ResumePlayback() => PlaybackResuming?.Invoke();
/// <summary>
/// Default redirect URI for the running platform. The shared
/// default uses a custom URI scheme (<c>postit://callback</c>)

View file

@ -16,14 +16,11 @@ 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 IPodcastCapture _capture;
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; }
@ -31,15 +28,12 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
public event Action<PodcastResponse>? Progress;
private PodcastBroadcaster(PodcastApiClient api, PodcastResponse podcast,
LibVLC vlc, MediaPlayer player, Media media, string directory)
IPodcastCapture capture, string directory)
{
_api = api;
Podcast = podcast;
_vlc = vlc;
_player = player;
_media = media;
_capture = capture;
_directory = directory;
_player.EncounteredError += OnEncoderError;
}
public static void ConfigureOutput(Media media, string directory)
@ -57,11 +51,13 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<LibVLC, Media> createMedia, CancellationToken ct)
=> await StartAsync(api, title,
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(createMedia, directory)), ct);
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct)
{
Core.Initialize();
var vlc = CameraStreamer.CreatePreferredLibVlc();
Media? media = null;
MediaPlayer? player = null;
IPodcastCapture? capture = null;
PodcastBroadcaster? broadcaster = null;
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PostIt", "podcast-capture", Guid.NewGuid().ToString("N"));
@ -69,15 +65,12 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
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.");
capture = await createCapture(directory, ct);
ct.ThrowIfCancellationRequested();
broadcaster = new PodcastBroadcaster(api, podcast, capture, directory);
broadcaster.Completion = broadcaster.UploadAsync(broadcaster._cancellation.Token);
return broadcaster;
}
@ -85,13 +78,10 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
{
if (broadcaster is not null)
{
broadcaster._player.EncounteredError -= broadcaster.OnEncoderError;
broadcaster._cancellation.Dispose();
broadcaster._stopGate.Dispose();
}
player?.Dispose();
media?.Dispose();
vlc.Dispose();
if (capture is not null) await capture.DisposeAsync();
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
if (podcast is not null)
{
@ -122,7 +112,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
{
if (!_stopped)
{
await Task.Run(_player.Stop);
await _capture.StopAsync();
_stopped = true;
}
}
@ -138,9 +128,9 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
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 finalPass = _stopped || _capture.Completion.IsCompleted;
if (_capture.Completion.IsCompleted)
await _capture.Completion;
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.");
@ -174,7 +164,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
return;
}
if (lastSegment.Elapsed > TimeSpan.FromSeconds(25))
throw new IOException("Aucun segment produit depuis 25 secondes. Verifiez la capture et les encodeurs LibVLC.");
throw new IOException("Aucun segment produit depuis 25 secondes. Verifiez la camera et les encodeurs H.264/AAC.");
await Task.Delay(400, ct);
}
}
@ -207,8 +197,6 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
}
}
private void OnEncoderError(object? sender, EventArgs e) => _encoderFailed = true;
public async ValueTask DisposeAsync()
{
if (_disposed) return;
@ -217,10 +205,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
try { await Completion; }
finally
{
_player.EncounteredError -= OnEncoderError;
_player.Dispose();
_media.Dispose();
_vlc.Dispose();
await _capture.DisposeAsync();
_cancellation.Dispose();
_stopGate.Dispose();
// Keep failed, unacknowledged segments for manual recovery.

View file

@ -0,0 +1,222 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>Segments a single-program H.264 MPEG-TS encoder output at IDR frames.</summary>
public sealed class TransportStreamCapture(string directory)
{
private const int PacketSize = 188;
private const int MaxSegmentBytes = 8 * 1024 * 1024;
private readonly MemoryStream _frame = new();
private readonly MemoryStream _video = new();
private readonly StringBuilder _playlist = new("#EXTM3U\n");
private byte[]? _pat;
private byte[]? _pmt;
private byte[]? _configuration;
private int _pmtPid = -1;
private int _videoPid = -1;
private FileStream? _segment;
private int _sequence;
private long? _framePts;
private long _startPts;
private long _lastPts;
private long _frameDuration = 3000;
private long? _previousPts;
private long _ptsEpoch;
public async Task RunAsync(Stream input, CancellationToken ct)
{
var packet = new byte[PacketSize];
try
{
while (true)
{
var read = 0;
while (read < PacketSize)
{
var count = await input.ReadAsync(packet.AsMemory(read), ct);
if (count == 0)
{
if (read != 0) throw new InvalidDataException("Paquet MPEG-TS tronque.");
FlushFrame();
CloseSegment(_lastPts + _frameDuration);
return;
}
read += count;
}
Consume(packet);
}
}
finally
{
_segment?.Dispose();
_frame.Dispose();
_video.Dispose();
}
}
private void Consume(byte[] packet)
{
if (packet[0] != 0x47 || (packet[1] & 0x80) != 0 || (packet[3] & 0xc0) != 0)
throw new InvalidDataException("Flux MPEG-TS invalide.");
var pid = ((packet[1] & 0x1f) << 8) | packet[2];
var hasPayload = (packet[3] & 0x10) != 0;
if (!hasPayload) return;
var offset = 4;
if ((packet[3] & 0x20) != 0) offset += 1 + packet[4];
if (offset >= PacketSize) throw new InvalidDataException("Adaptation MPEG-TS invalide.");
var start = (packet[1] & 0x40) != 0;
if (pid == 0 || pid == _pmtPid)
{
if (!start) throw new InvalidDataException("Table MPEG-TS multi-paquets non prise en charge.");
var section = offset + 1 + packet[offset];
if (section + 3 > PacketSize) throw new InvalidDataException("Table MPEG-TS tronquee.");
var end = section + 3 + (((packet[section + 1] & 0x0f) << 8) | packet[section + 2]);
if (end > PacketSize) throw new InvalidDataException("Table MPEG-TS trop grande.");
if (pid == 0)
{
if (packet[section] != 0 || end - section < 16)
throw new InvalidDataException("PAT MPEG-TS invalide.");
_pmtPid = ((packet[section + 10] & 0x1f) << 8) | packet[section + 11];
_pat = (byte[])packet.Clone();
}
else
{
if (packet[section] != 2 || end - section < 16)
throw new InvalidDataException("PMT MPEG-TS invalide.");
var entry = section + 12 + (((packet[section + 10] & 0x0f) << 8) | packet[section + 11]);
while (entry + 5 <= end - 4)
{
if (packet[entry] == 0x1b)
_videoPid = ((packet[entry + 1] & 0x1f) << 8) | packet[entry + 2];
entry += 5 + (((packet[entry + 3] & 0x0f) << 8) | packet[entry + 4]);
}
_pmt = (byte[])packet.Clone();
}
return;
}
if (pid == _videoPid && start)
{
FlushFrame();
if (offset + 14 > PacketSize || packet[offset] != 0 || packet[offset + 1] != 0
|| packet[offset + 2] != 1 || (packet[offset + 7] & 0x80) == 0)
throw new InvalidDataException("Horodatage video MPEG-TS absent.");
var ptsOffset = offset + 9;
var pts = ((long)(packet[ptsOffset] & 0x0e) << 29)
| ((long)packet[ptsOffset + 1] << 22) | ((long)(packet[ptsOffset + 2] & 0xfe) << 14)
| ((long)packet[ptsOffset + 3] << 7) | (uint)(packet[ptsOffset + 4] >> 1);
if (_previousPts is { } previous && pts < previous)
{
if (previous - pts < (1L << 32))
throw new InvalidDataException("Horodatages video non croissants.");
_ptsEpoch += 1L << 33;
}
_previousPts = pts;
_framePts = pts + _ptsEpoch;
offset += 9 + packet[offset + 8];
if (offset > PacketSize) throw new InvalidDataException("En-tete PES invalide.");
}
if (_framePts is null) return;
_frame.Write(packet);
if (pid == _videoPid) _video.Write(packet, offset, PacketSize - offset);
if (_frame.Length > MaxSegmentBytes)
throw new IOException("Une image de capture depasse 8 Mio.");
}
private void FlushFrame()
{
if (_framePts is not { } pts) return;
var video = _video.GetBuffer().AsSpan(0, (int)_video.Length);
var idr = false;
var hasConfiguration = false;
var hasSlice = false;
for (var i = 0; i + 3 < video.Length; i++)
if (video[i] == 0 && video[i + 1] == 0 && video[i + 2] == 1)
{
var type = video[i + 3] & 0x1f;
if (type == 5) idr = true;
if (type is 7 or 8) hasConfiguration = true;
if (type is 1 or 5) hasSlice = true;
}
if (hasConfiguration && !hasSlice)
{
using var packets = new MemoryStream();
for (var i = 0; i < _frame.Length; i += PacketSize)
{
var packet = _frame.GetBuffer().AsSpan(i, PacketSize);
if ((((packet[1] & 0x1f) << 8) | packet[2]) == _videoPid)
packets.Write(packet);
}
_configuration = packets.ToArray();
}
if (_segment is not null && idr && pts - _startPts >= 180000)
CloseSegment(pts);
if (_segment is null && idr)
{
if (_pat is null || _pmt is null)
throw new InvalidDataException("Tables MPEG-TS manquantes.");
_startPts = pts;
_segment = File.Create(Path.Combine(directory, SegmentName()));
_segment.Write(_pat);
_segment.Write(_pmt);
if (!hasConfiguration && _configuration is not null) WriteConfiguration(pts);
}
if (_segment is not null)
{
if (_segment.Length + _frame.Length > MaxSegmentBytes || pts - _startPts > 14 * 90000)
throw new IOException("Segment trop grand ou absence d'image cle depuis 14 secondes.");
_segment.Write(_frame.GetBuffer(), 0, (int)_frame.Length);
if (pts > _lastPts && _lastPts >= _startPts)
_frameDuration = Math.Min(pts - _lastPts, 9000);
_lastPts = pts;
}
_frame.SetLength(0);
_video.SetLength(0);
_framePts = null;
}
private string SegmentName() => $"segment-{_sequence:D8}.ts";
private void WriteConfiguration(long pts)
{
var packets = (byte[])_configuration!.Clone();
var first = _frame.GetBuffer()[3] & 0x0f;
var counter = (first - packets.Length / PacketSize) & 0x0f;
for (var i = 0; i < packets.Length; i += PacketSize)
{
packets[i + 3] = (byte)((packets[i + 3] & 0xf0) | counter);
counter = (counter + 1) & 0x0f;
if ((packets[i + 1] & 0x40) == 0) continue;
var offset = i + 4;
if ((packets[i + 3] & 0x20) != 0) offset += packets[offset] + 1;
offset += 9;
if (offset + 5 > i + PacketSize) throw new InvalidDataException("Configuration H.264 invalide.");
packets[offset] = (byte)(0x21 | ((pts >> 29) & 0x0e));
packets[offset + 1] = (byte)(pts >> 22);
packets[offset + 2] = (byte)(1 | ((pts >> 14) & 0xfe));
packets[offset + 3] = (byte)(pts >> 7);
packets[offset + 4] = (byte)(1 | ((pts << 1) & 0xfe));
}
_segment!.Write(packets);
}
private void CloseSegment(long endPts)
{
if (_segment is null) return;
_segment.Dispose();
_segment = null;
var duration = (endPts - _startPts) / 90000d;
if (duration <= 0 || duration > 15) throw new InvalidDataException("Duree MPEG-TS invalide.");
_playlist.Append("#EXTINF:").Append(duration.ToString("0.######", CultureInfo.InvariantCulture))
.Append(",\n").Append(SegmentName()).Append('\n');
var index = Path.Combine(directory, "index.m3u8");
File.WriteAllText(index + ".tmp", _playlist.ToString());
File.Move(index + ".tmp", index, overwrite: true);
_sequence++;
}
}

View file

@ -15,8 +15,6 @@ using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public sealed record CaptureDevice(string Name, string Value);
public partial class PodcastsPageViewModel : ViewModelBase
{
private readonly PodcastApiClient _client;
@ -36,7 +34,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
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 bool CanBroadcast => Platform.CreatePodcastCaptureAsync is not null
|| (!OperatingSystem.IsAndroid() && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()));
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
@ -89,9 +88,9 @@ public partial class PodcastsPageViewModel : ViewModelBase
_api = api;
_session = session;
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
DetectDevicesCommand = new RelayCommand(DetectDevices, () => IsDesktop && !IsBroadcasting && !IsBusy);
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsBroadcasting && !IsBusy);
StartCommand = new AsyncRelayCommand(StartAsync,
() => IsDesktop && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
() => CanBroadcast && !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);
@ -150,15 +149,24 @@ public partial class PodcastsPageViewModel : ViewModelBase
private void DetectDevices()
{
try
{
Cameras.Clear();
Microphones.Clear();
if (Platform.GetPodcastCaptureDevices is { } getDevices)
{
var devices = getDevices();
foreach (var item in devices.Cameras) Cameras.Add(item);
foreach (var item in devices.Microphones) Microphones.Add(item);
}
else
{
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.";
@ -172,8 +180,14 @@ public partial class PodcastsPageViewModel : ViewModelBase
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);
var camera = SelectedCamera.Value;
var microphone = string.IsNullOrWhiteSpace(SelectedMicrophone?.Value) ? null : SelectedMicrophone.Value;
if (Platform.RequestPodcastPermissionsAsync is { } requestPermissions)
await requestPermissions(microphone, _pageCancellation.Token);
_broadcaster = Platform.CreatePodcastCaptureAsync is { } createCapture
? await PodcastBroadcaster.StartAsync(_client, Title,
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token)
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token);
_broadcaster.Progress += OnProgress;
_session.LogoutCompleted += OnLogout;
IsBroadcasting = true;

View file

@ -2,13 +2,12 @@
x:Class="PostIt.Views.Blogs.VideoPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
xmlns:vm="using:PostIt.ViewModels"
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" BorderThickness="1" BorderBrush="Green">
<vlc:VideoView x:Name="Video" />
<ContentControl x:Name="VideoSurface" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />

View file

@ -21,6 +21,13 @@ public partial class VideoPage : ContentPage
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
Platform.PlaybackSuspending += SuspendPlayback;
Platform.PlaybackResuming += ResumePlayback;
InitializePlayer();
}
private void InitializePlayer()
{
ReleasePlayer();
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
return;
@ -39,13 +46,15 @@ public partial class VideoPage : ContentPage
: new Media(_libVlc, _podcastInput);
if (_podcastInput is not null)
media.AddOption(":demux=ts");
Video.MediaPlayer = _player;
var surface = Platform.CreateVideoSurface?.Invoke(_player)
?? new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
VideoSurface.Content = surface;
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();
if (ReferenceEquals(_player, player) && surface is LibVLCSharp.Avalonia.VideoView desktopVideo)
desktopVideo.TryUpdateNativeControlPosition();
}, DispatcherPriority.Loaded);
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
@ -63,12 +72,16 @@ public partial class VideoPage : ContentPage
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
{
Platform.PlaybackSuspending -= SuspendPlayback;
Platform.PlaybackResuming -= ResumePlayback;
ReleasePlayer();
base.OnNavigatedFrom(e);
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
Platform.PlaybackSuspending -= SuspendPlayback;
Platform.PlaybackResuming -= ResumePlayback;
ReleasePlayer();
base.OnDetachedFromVisualTree(e);
}
@ -76,8 +89,12 @@ public partial class VideoPage : ContentPage
private void ReleasePlayer()
{
PlayButton.IsEnabled = false;
Video.MediaPlayer = null;
_podcastInput?.Cancel();
if (VideoSurface.Content is LibVLCSharp.Avalonia.VideoView desktopVideo)
desktopVideo.MediaPlayer = null;
if (VideoSurface.Content is IDisposable nativeSurface)
nativeSurface.Dispose();
VideoSurface.Content = null;
if (_player is not null)
{
_player.EncounteredError -= Player_EncounteredError;
@ -95,6 +112,14 @@ public partial class VideoPage : ContentPage
_libVlc = null;
}
private void SuspendPlayback()
{
ReleasePlayer();
PlaybackStatus.Text = "Lecture suspendue.";
}
private void ResumePlayback() => InitializePlayer();
private void PodcastFailed(string message) => Dispatcher.UIThread.Post(() => PlaybackStatus.Text = message);
private static bool CanLaunchExternally(Uri source)

View file

@ -1,6 +1,7 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:services="using:PostIt.Services"
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
x:Class="PostIt.Views.PodcastsPage"
x:DataType="vm:PodcastsPageViewModel"
@ -9,17 +10,17 @@
<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}">
<StackPanel Spacing="8" IsVisible="{Binding CanBroadcast}" 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>
<DataTemplate x:DataType="services: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>
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />