feat(android): share podcast playback and foreground broadcasting
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:
parent
2dcbcd35b3
commit
ad92744a94
24 changed files with 1495 additions and 70 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,7 +1,26 @@
|
||||||
# Changelog
|
# 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
|
### Added
|
||||||
|
|
||||||
|
|
|
||||||
81
INSTALL.md
81
INSTALL.md
|
|
@ -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
|
camera/microphone permissions, drivers and codecs must still be verified
|
||||||
on each target Windows/Linux machine.
|
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
|
## Resources
|
||||||
|
|
||||||
System resources needed to install.
|
System resources needed to install.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AvaloniaVersionBase>12.1.3</AvaloniaVersionBase>
|
<AvaloniaVersionBase>12.1.4</AvaloniaVersionBase>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||||
|
|
@ -13,11 +13,13 @@
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Android" 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.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
|
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
|
||||||
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.2" />
|
<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="Live.Avalonia" Version="1.4.2" />
|
||||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ public class MainActivity : AvaloniaMainActivity
|
||||||
/// The current MainActivity instance.
|
/// The current MainActivity instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static MainActivity? Current { get; private set; }
|
public static MainActivity? Current { get; private set; }
|
||||||
|
internal static bool IsForeground { get; private set; }
|
||||||
|
|
||||||
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
|
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.Config config = new FontRequestEmojiCompatConfig(this, fontRequest);
|
||||||
EmojiCompat.Init(config);
|
EmojiCompat.Init(config);
|
||||||
PlatformBootstrap.InitPlatform();
|
PlatformBootstrap.InitPlatform();
|
||||||
base.OnCreate(savedInstanceState);
|
|
||||||
Current = this;
|
Current = this;
|
||||||
|
base.OnCreate(savedInstanceState);
|
||||||
}
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Receives the deep-link Intent fired by the system browser after the
|
/// 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))
|
.HandlePermissionResult(requestCode, grantResults))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,12 @@ internal static class PlatformBootstrap
|
||||||
};
|
};
|
||||||
|
|
||||||
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,17 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia.Android" />
|
<PackageReference Include="Avalonia.Android" />
|
||||||
|
<PackageReference Include="LibVLCSharp" />
|
||||||
|
<PackageReference Include="VideoLAN.LibVLC.Android" />
|
||||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
||||||
<PackageReference Include="Xamarin.AndroidX.Browser" />
|
<PackageReference Include="Xamarin.AndroidX.Browser" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||||
</ItemGroup>
|
</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
|
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
|
||||||
extension's injected HotReload agent (references Microsoft.Maui.*).
|
extension's injected HotReload agent (references Microsoft.Maui.*).
|
||||||
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
|
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
<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.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
|
<application
|
||||||
android:label="PostIt"
|
android:label="PostIt"
|
||||||
android:icon="@drawable/Icon"
|
android:icon="@drawable/Icon"
|
||||||
|
|
|
||||||
309
src/PostIt/PostIt.Android/Services/AndroidPodcastCapture.cs
Normal file
309
src/PostIt/PostIt.Android/Services/AndroidPodcastCapture.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs
Normal file
37
src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/PostIt/PostIt.Android/Services/PodcastCaptureService.cs
Normal file
109
src/PostIt/PostIt.Android/Services/PodcastCaptureService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/PostIt/PostIt.Android/Services/PodcastPermissions.cs
Normal file
47
src/PostIt/PostIt.Android/Services/PodcastPermissions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs
Normal file
100
src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs
Normal 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
|
||||||
|
|
@ -464,11 +464,31 @@ public class BlogReaderTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[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 vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
|
||||||
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
175
src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
Normal file
175
src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/PostIt/PostIt.Tests/TransportStreamCaptureTests.cs
Normal file
146
src/PostIt/PostIt.Tests/TransportStreamCaptureTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/PostIt/PostIt/Services/IPodcastCapture.cs
Normal file
16
src/PostIt/PostIt/Services/IPodcastCapture.cs
Normal 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();
|
||||||
|
}
|
||||||
62
src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs
Normal file
62
src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,19 +2,28 @@ using System;
|
||||||
using IdentityModel.OidcClient.Browser;
|
using IdentityModel.OidcClient.Browser;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LibVLCSharp.Shared;
|
||||||
|
|
||||||
namespace PostIt.Services;
|
namespace PostIt.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-platform access to native integration points used by the OIDC
|
/// Native integration points populated by platform projects at startup.
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Platform
|
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>
|
/// <summary>
|
||||||
/// Default redirect URI for the running platform. The shared
|
/// Default redirect URI for the running platform. The shared
|
||||||
/// default uses a custom URI scheme (<c>postit://callback</c>)
|
/// default uses a custom URI scheme (<c>postit://callback</c>)
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,11 @@ namespace PostIt.Services;
|
||||||
public sealed class PodcastBroadcaster : IAsyncDisposable
|
public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly PodcastApiClient _api;
|
private readonly PodcastApiClient _api;
|
||||||
private readonly LibVLC _vlc;
|
private readonly IPodcastCapture _capture;
|
||||||
private readonly MediaPlayer _player;
|
|
||||||
private readonly Media _media;
|
|
||||||
private readonly string _directory;
|
private readonly string _directory;
|
||||||
private readonly CancellationTokenSource _cancellation = new();
|
private readonly CancellationTokenSource _cancellation = new();
|
||||||
private readonly SemaphoreSlim _stopGate = new(1);
|
private readonly SemaphoreSlim _stopGate = new(1);
|
||||||
private volatile bool _stopped;
|
private volatile bool _stopped;
|
||||||
private volatile bool _encoderFailed;
|
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public PodcastResponse Podcast { get; private set; }
|
public PodcastResponse Podcast { get; private set; }
|
||||||
|
|
@ -31,15 +28,12 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||||
public event Action<PodcastResponse>? Progress;
|
public event Action<PodcastResponse>? Progress;
|
||||||
|
|
||||||
private PodcastBroadcaster(PodcastApiClient api, PodcastResponse podcast,
|
private PodcastBroadcaster(PodcastApiClient api, PodcastResponse podcast,
|
||||||
LibVLC vlc, MediaPlayer player, Media media, string directory)
|
IPodcastCapture capture, string directory)
|
||||||
{
|
{
|
||||||
_api = api;
|
_api = api;
|
||||||
Podcast = podcast;
|
Podcast = podcast;
|
||||||
_vlc = vlc;
|
_capture = capture;
|
||||||
_player = player;
|
|
||||||
_media = media;
|
|
||||||
_directory = directory;
|
_directory = directory;
|
||||||
_player.EncounteredError += OnEncoderError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ConfigureOutput(Media media, string directory)
|
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,
|
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
|
||||||
Func<LibVLC, Media> createMedia, CancellationToken ct)
|
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();
|
IPodcastCapture? capture = null;
|
||||||
var vlc = CameraStreamer.CreatePreferredLibVlc();
|
|
||||||
Media? media = null;
|
|
||||||
MediaPlayer? player = null;
|
|
||||||
PodcastBroadcaster? broadcaster = null;
|
PodcastBroadcaster? broadcaster = null;
|
||||||
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"PostIt", "podcast-capture", Guid.NewGuid().ToString("N"));
|
"PostIt", "podcast-capture", Guid.NewGuid().ToString("N"));
|
||||||
|
|
@ -69,15 +65,12 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
media = createMedia(vlc);
|
|
||||||
ConfigureOutput(media, directory);
|
|
||||||
player = new MediaPlayer(vlc);
|
|
||||||
podcast = await api.CreateAsync(title, ct);
|
podcast = await api.CreateAsync(title, ct);
|
||||||
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
|
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
broadcaster = new PodcastBroadcaster(api, podcast, vlc, player, media, directory);
|
capture = await createCapture(directory, ct);
|
||||||
if (!player.Play(media))
|
ct.ThrowIfCancellationRequested();
|
||||||
throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
|
broadcaster = new PodcastBroadcaster(api, podcast, capture, directory);
|
||||||
broadcaster.Completion = broadcaster.UploadAsync(broadcaster._cancellation.Token);
|
broadcaster.Completion = broadcaster.UploadAsync(broadcaster._cancellation.Token);
|
||||||
return broadcaster;
|
return broadcaster;
|
||||||
}
|
}
|
||||||
|
|
@ -85,13 +78,10 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||||
{
|
{
|
||||||
if (broadcaster is not null)
|
if (broadcaster is not null)
|
||||||
{
|
{
|
||||||
broadcaster._player.EncounteredError -= broadcaster.OnEncoderError;
|
|
||||||
broadcaster._cancellation.Dispose();
|
broadcaster._cancellation.Dispose();
|
||||||
broadcaster._stopGate.Dispose();
|
broadcaster._stopGate.Dispose();
|
||||||
}
|
}
|
||||||
player?.Dispose();
|
if (capture is not null) await capture.DisposeAsync();
|
||||||
media?.Dispose();
|
|
||||||
vlc.Dispose();
|
|
||||||
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||||
if (podcast is not null)
|
if (podcast is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -122,7 +112,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||||
{
|
{
|
||||||
if (!_stopped)
|
if (!_stopped)
|
||||||
{
|
{
|
||||||
await Task.Run(_player.Stop);
|
await _capture.StopAsync();
|
||||||
_stopped = true;
|
_stopped = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,9 +128,9 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
var finalPass = _stopped;
|
var finalPass = _stopped || _capture.Completion.IsCompleted;
|
||||||
if (_encoderFailed)
|
if (_capture.Completion.IsCompleted)
|
||||||
throw new InvalidOperationException("La capture LibVLC a echoue. Verifiez camera, microphone et encodeurs H.264/AAC.");
|
await _capture.Completion;
|
||||||
var pendingBytes = Directory.EnumerateFiles(_directory, "*.ts").Sum(path => new FileInfo(path).Length);
|
var pendingBytes = Directory.EnumerateFiles(_directory, "*.ts").Sum(path => new FileInfo(path).Length);
|
||||||
if (pendingBytes > 64L * 1024 * 1024)
|
if (pendingBytes > 64L * 1024 * 1024)
|
||||||
throw new IOException("Le reseau est trop lent : la capture depasse 64 Mio de segments en attente.");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (lastSegment.Elapsed > TimeSpan.FromSeconds(25))
|
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);
|
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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|
@ -217,10 +205,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||||
try { await Completion; }
|
try { await Completion; }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_player.EncounteredError -= OnEncoderError;
|
await _capture.DisposeAsync();
|
||||||
_player.Dispose();
|
|
||||||
_media.Dispose();
|
|
||||||
_vlc.Dispose();
|
|
||||||
_cancellation.Dispose();
|
_cancellation.Dispose();
|
||||||
_stopGate.Dispose();
|
_stopGate.Dispose();
|
||||||
// Keep failed, unacknowledged segments for manual recovery.
|
// Keep failed, unacknowledged segments for manual recovery.
|
||||||
|
|
|
||||||
222
src/PostIt/PostIt/Services/TransportStreamCapture.cs
Normal file
222
src/PostIt/PostIt/Services/TransportStreamCapture.cs
Normal 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,6 @@ using Yavsc.Api.Client;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
public sealed record CaptureDevice(string Name, string Value);
|
|
||||||
|
|
||||||
public partial class PodcastsPageViewModel : ViewModelBase
|
public partial class PodcastsPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly PodcastApiClient _client;
|
private readonly PodcastApiClient _client;
|
||||||
|
|
@ -36,7 +34,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
||||||
public ObservableCollection<PodcastResponse> Podcasts { get; } = new();
|
public ObservableCollection<PodcastResponse> Podcasts { get; } = new();
|
||||||
public ObservableCollection<CaptureDevice> Cameras { get; } = new();
|
public ObservableCollection<CaptureDevice> Cameras { get; } = new();
|
||||||
public ObservableCollection<CaptureDevice> Microphones { 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(); } }
|
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
|
||||||
|
|
@ -89,9 +88,9 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
||||||
_api = api;
|
_api = api;
|
||||||
_session = session;
|
_session = session;
|
||||||
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
|
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
|
||||||
DetectDevicesCommand = new RelayCommand(DetectDevices, () => IsDesktop && !IsBroadcasting && !IsBusy);
|
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsBroadcasting && !IsBusy);
|
||||||
StartCommand = new AsyncRelayCommand(StartAsync,
|
StartCommand = new AsyncRelayCommand(StartAsync,
|
||||||
() => IsDesktop && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
|
() => CanBroadcast && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
|
||||||
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
|
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
|
||||||
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
|
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
|
||||||
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
|
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
|
||||||
|
|
@ -151,14 +150,23 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Core.Initialize();
|
|
||||||
using var vlc = CameraStreamer.CreatePreferredLibVlc();
|
|
||||||
var capture = new CameraStreamer(vlc);
|
|
||||||
Cameras.Clear();
|
Cameras.Clear();
|
||||||
foreach (var item in capture.GetCaptureDevices()) Cameras.Add(new(item.Key, item.Value));
|
|
||||||
Microphones.Clear();
|
Microphones.Clear();
|
||||||
Microphones.Add(new("Sans microphone", ""));
|
if (Platform.GetPodcastCaptureDevices is { } getDevices)
|
||||||
foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value));
|
{
|
||||||
|
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);
|
||||||
|
foreach (var item in capture.GetCaptureDevices()) Cameras.Add(new(item.Key, item.Value));
|
||||||
|
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];
|
SelectedCamera = Cameras.Count == 0 ? null : Cameras[0];
|
||||||
SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0];
|
SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0];
|
||||||
Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer.";
|
Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer.";
|
||||||
|
|
@ -172,8 +180,14 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (SelectedCamera is null) throw new InvalidOperationException("Choisissez une camera.");
|
if (SelectedCamera is null) throw new InvalidOperationException("Choisissez une camera.");
|
||||||
_broadcaster = await PodcastBroadcaster.StartAsync(_client, Title, SelectedCamera.Value,
|
var camera = SelectedCamera.Value;
|
||||||
string.IsNullOrWhiteSpace(SelectedMicrophone?.Value) ? null : SelectedMicrophone.Value, _pageCancellation.Token);
|
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;
|
_broadcaster.Progress += OnProgress;
|
||||||
_session.LogoutCompleted += OnLogout;
|
_session.LogoutCompleted += OnLogout;
|
||||||
IsBroadcasting = true;
|
IsBroadcasting = true;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
x:Class="PostIt.Views.Blogs.VideoPage"
|
x:Class="PostIt.Views.Blogs.VideoPage"
|
||||||
xmlns="https://github.com/avaloniaui"
|
xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
x:DataType="vm:VideoPageViewModel">
|
x:DataType="vm:VideoPageViewModel">
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
|
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
|
||||||
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
|
||||||
<Border Grid.Row="1" Background="Black" BorderThickness="1" BorderBrush="Green">
|
<Border Grid.Row="1" Background="Black" BorderThickness="1" BorderBrush="Green">
|
||||||
<vlc:VideoView x:Name="Video" />
|
<ContentControl x:Name="VideoSurface" />
|
||||||
</Border>
|
</Border>
|
||||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
|
||||||
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
|
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ public partial class VideoPage : ContentPage
|
||||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnNavigatedTo(e);
|
base.OnNavigatedTo(e);
|
||||||
|
Platform.PlaybackSuspending += SuspendPlayback;
|
||||||
|
Platform.PlaybackResuming += ResumePlayback;
|
||||||
|
InitializePlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializePlayer()
|
||||||
|
{
|
||||||
ReleasePlayer();
|
ReleasePlayer();
|
||||||
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
|
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
|
||||||
return;
|
return;
|
||||||
|
|
@ -39,13 +46,15 @@ public partial class VideoPage : ContentPage
|
||||||
: new Media(_libVlc, _podcastInput);
|
: new Media(_libVlc, _podcastInput);
|
||||||
if (_podcastInput is not null)
|
if (_podcastInput is not null)
|
||||||
media.AddOption(":demux=ts");
|
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;
|
var player = _player;
|
||||||
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
|
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(_player, player))
|
if (ReferenceEquals(_player, player) && surface is LibVLCSharp.Avalonia.VideoView desktopVideo)
|
||||||
Video.TryUpdateNativeControlPosition();
|
desktopVideo.TryUpdateNativeControlPosition();
|
||||||
}, DispatcherPriority.Loaded);
|
}, DispatcherPriority.Loaded);
|
||||||
PlayButton.IsEnabled = true;
|
PlayButton.IsEnabled = true;
|
||||||
PlaybackStatus.Text = vm.MimeType;
|
PlaybackStatus.Text = vm.MimeType;
|
||||||
|
|
@ -63,12 +72,16 @@ public partial class VideoPage : ContentPage
|
||||||
|
|
||||||
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
|
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
|
||||||
{
|
{
|
||||||
|
Platform.PlaybackSuspending -= SuspendPlayback;
|
||||||
|
Platform.PlaybackResuming -= ResumePlayback;
|
||||||
ReleasePlayer();
|
ReleasePlayer();
|
||||||
base.OnNavigatedFrom(e);
|
base.OnNavigatedFrom(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
|
Platform.PlaybackSuspending -= SuspendPlayback;
|
||||||
|
Platform.PlaybackResuming -= ResumePlayback;
|
||||||
ReleasePlayer();
|
ReleasePlayer();
|
||||||
base.OnDetachedFromVisualTree(e);
|
base.OnDetachedFromVisualTree(e);
|
||||||
}
|
}
|
||||||
|
|
@ -76,8 +89,12 @@ public partial class VideoPage : ContentPage
|
||||||
private void ReleasePlayer()
|
private void ReleasePlayer()
|
||||||
{
|
{
|
||||||
PlayButton.IsEnabled = false;
|
PlayButton.IsEnabled = false;
|
||||||
Video.MediaPlayer = null;
|
|
||||||
_podcastInput?.Cancel();
|
_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)
|
if (_player is not null)
|
||||||
{
|
{
|
||||||
_player.EncounteredError -= Player_EncounteredError;
|
_player.EncounteredError -= Player_EncounteredError;
|
||||||
|
|
@ -95,6 +112,14 @@ public partial class VideoPage : ContentPage
|
||||||
_libVlc = null;
|
_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 void PodcastFailed(string message) => Dispatcher.UIThread.Post(() => PlaybackStatus.Text = message);
|
||||||
|
|
||||||
private static bool CanLaunchExternally(Uri source)
|
private static bool CanLaunchExternally(Uri source)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:services="using:PostIt.Services"
|
||||||
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
|
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
|
||||||
x:Class="PostIt.Views.PodcastsPage"
|
x:Class="PostIt.Views.PodcastsPage"
|
||||||
x:DataType="vm:PodcastsPageViewModel"
|
x:DataType="vm:PodcastsPageViewModel"
|
||||||
|
|
@ -9,17 +10,17 @@
|
||||||
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
|
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
|
||||||
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
|
<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" />
|
<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" />
|
<TextBox Text="{Binding Title}" PlaceholderText="Titre du direct" MaxLength="160" />
|
||||||
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
|
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
|
||||||
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
|
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
|
||||||
<ComboBox.ItemTemplate>
|
<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.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<ComboBox ItemsSource="{Binding Microphones}" SelectedItem="{Binding SelectedMicrophone}" PlaceholderText="Microphone">
|
<ComboBox ItemsSource="{Binding Microphones}" SelectedItem="{Binding SelectedMicrophone}" PlaceholderText="Microphone">
|
||||||
<ComboBox.ItemTemplate>
|
<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.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
|
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue