screen orientation and awake

This commit is contained in:
Paul Schneider 2026-11-05 10:44:19 +00:00
commit fa8038c676
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
25 changed files with 941 additions and 110 deletions

View file

@ -5,6 +5,8 @@
### Added
* [PostIt.Android] Keep the screen awake during foreground video playback and release it on pause, stop, completion, error or video teardown. Add a persisted, default-off podcast option to keep the screen awake during camera preview/live capture; release on capture end and allow normal sleep in the background without changing the broadcast service. Include native LibVLC libraries when building with a single runtime identifier (including physical ARM64 phones).
* [PostIt/Podcasts] Preview the selected camera before starting a live broadcast, without creating an emission, encoding files or opening the microphone. Release preview on stop, navigation, logout and Android backgrounding, and before starting the actual broadcast. Correct Android portrait orientation for rear and front cameras: subtract the sensor rotation already carried by Camera2's SurfaceTexture matrix, reversing the remaining rotation when that matrix mirrors the front camera (it was turned 90° or upside down); track display changes for both preview and encoded frames, preserving aspect ratio and fixed live stream dimensions.
* [PostIt] Schedule silent OIDC renewal independently of API traffic and SignalR reconnects, while the app is active or background chat is enabled. Derive the wait from the access-token expiration minus the existing 60-second safety margin instead of polling; recalculate after token rotation, login/logout, activity and preference changes. Persist rotated tokens and serialize with request-driven renewal/logout. Run the same maintenance in headless Android chat; background execution and absolute refresh-token lifetime remain subject to Android/identity-provider limits.
* [PostIt.Android] Request Android's battery optimization exemption when explicitly saving settings with background chat enabled, only if not already exempt. Keep automatic settings saves and reconnects free of prompts; refusal does not disable SignalR.
* [PostIt.Android] Add an opt-in ordinary background service for the existing SignalR ChatHub connection, without a permanent notification; show native alerts only for received messages/events and attempt restart after phone startup where Android permits it. Reuse one process-wide connection between service, pages and calls, restore locally remembered rooms per account/host and clean up on opt-out/logout. No Firebase, Google push configuration or server push migration; explain background-data/battery settings and Android execution limits.

View file

@ -180,6 +180,16 @@ 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.
Video playback keeps the screen awake while PostIt is visible. Pausing,
stopping, reaching the end, a playback error or leaving the video releases
this request; backgrounding the app always allows the screen to sleep.
The podcast page offers **Garder l'ecran allume pendant l'apercu camera et le
direct** on Android. This preference is saved and disabled by default. It
only keeps the screen awake during an active camera preview or broadcast
while PostIt is visible, and can be changed during capture. Turning it off
does not stop capture or alter the existing background broadcast service.
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.

View file

@ -123,6 +123,10 @@ de fichier média temporaire ou de moteur WebRTC Android ajouté.
segments ni de lecture de capture par callbacks managés.
- La prévisualisation réutilise la surface EGL existante. Le retour
distant utilise la surface Android LibVLC déjà employée pour les Podcasts.
La rotation capteur/écran tient compte de celle déjà portée par la matrice
`SurfaceTexture` (miroir de la caméra avant compris) et suit les changements
portrait/paysage. Les dimensions
encodées restent fixes pendant l'appel, avec bandes noires si nécessaire.
- Le service de capture partagé affiche une notification d'appel avec
l'action « Raccrocher » ; cette action ne publie aucun Podcast.
Un Podcast et un appel ne peuvent pas posséder la capture simultanément.

View file

@ -53,6 +53,40 @@ Le rafraîchissement silencieux de session reste unique par processus.
Cette restauration couvre la recréation d'activité dans le même processus,
pas la destruction du processus par Android.
## Prévisualisation et orientation des Podcasts
« Prévisualiser la caméra » ouvre un aperçu local avant « Démarrer le
direct », sans exiger de titre. Cette phase ne crée aucune émission côté
serveur, n'enregistre aucun segment et n'ouvre pas le microphone. Le choix
de caméra est verrouillé pendant l'aperçu ; l'arrêter pour changer de caméra.
Le microphone sélectionné n'est ouvert qu'au démarrage du direct.
« Arrêter la prévisualisation », la sortie de page et la déconnexion libèrent
la caméra. Le passage au direct ferme d'abord l'aperçu, puis ouvre la capture
avec les périphériques sélectionnés : les images d'essai ne sont pas publiées.
Sous Android, un aperçu seul s'arrête aussi au passage en arrière-plan,
sans lancer de service de capture ni de notification de direct.
Les Podcasts en direct conservent leur service caméra/microphone existant.
Le chemin Android partage le rendu Camera2/EGL entre aperçu seul et encodage.
La rotation physique utilise l'orientation du capteur, la caméra avant/arrière
et la rotation de l'écran. Camera2 peut déjà inclure la rotation du capteur
dans la matrice `SurfaceTexture`, ainsi qu'un miroir pour la caméra avant :
seule la rotation restante est appliquée, dans le sens inverse si la matrice
contient un miroir, afin de ne pas tourner l'image deux fois.
Un changement de rotation d'écran recalcule ce rendu pendant la capture.
L'aperçu conserve les proportions ; les dimensions encodées restent celles
du démarrage du direct, avec des bandes noires si la nouvelle orientation
l'exige, pour ne pas modifier le format HLS/SRTP en cours d'émission.
Les tests partagés couvrent l'aperçu sans appel API, le passage au direct,
les arrêts et une sortie de page pendant l'ouverture de caméra. L'activité
Debug `CameraFrameRotationTestActivity` vérifie les quatre rotations avec
encodage, puis l'aperçu sans encodeur et ses changements de rotation.
Une validation physique caméra avant/arrière en portrait et paysage reste
nécessaire : les tests synthétiques ne vérifient pas le comportement du HAL
de chaque téléphone.
## Administration et profil personnel
L'accueil présente un seul accès **Administration**, visible et exécutable

View file

@ -156,6 +156,13 @@ adb shell am start -n fr.pschneider.postit/fr.pschneider.postit.CameraFrameRotat
adb shell run-as fr.pschneider.postit cat files/camera-rotation-test-result.txt
```
Pour un telephone ARM64, ajouter `-r android-arm64` au build et installer
l'APK de `bin/Debug/net10.0-android/android-arm64/`. Le projet transmet ce RID
aux targets natives LibVLC : l'APK doit contenir `libvlc.so` et
`libc++_shared.so`. Utiliser `adb -s <serial>` si plusieurs appareils sont
connectes. Laisser l'appareil deverrouille et les tests au premier plan ;
ne lancer le test suivant qu'apres `PASS` ou `FAIL`.
Attendre la fin du test : `RUNNING` doit devenir `PASS`. Il encode quatre
quadrants colores en MPEG-TS pour 0, 90, 180 et 270 degres, decode une image et
verifie les dimensions (640x480 ou 480x640) et les couleurs aux quatre positions.
@ -184,13 +191,36 @@ Le resultat inclut les angles capteur/ecran et la premiere matrice
SurfaceTexture recue. Les tests unitaires couvrent notamment la matrice
reelle `[0,-1,0,0,-1,0,0,0,0,0,1,0,1,1,0,1]` : pour un capteur a 90 degres
et un ecran a 0, elle porte deja la rotation et ne doit pas recevoir 90 degres
supplementaires. Le test synthetique seul ne detecte pas cette double rotation.
supplementaires. La camera avant mesuree
`[0,-1,0,0,1,0,0,0,0,0,1,0,0,1,0,1]` (capteur a 270 degres) contient en plus
un miroir : la rotation restante y est appliquee dans le sens inverse.
Le test synthetique seul ne detecte pas cette double rotation.
Verifier aussi manuellement les cameras avant/arriere en portrait et paysage,
le son, le direct et le replay, et la poursuite de l'emission en arriere-plan.
L'orientation est fixee au demarrage d'une emission ; tourner le telephone
pendant l'emission ne reconfigure pas le format encode.
### Anti-veille Android
`ScreenAwakeRequestsTests`, `PodcastPreferencesTests` et les regressions
d'apercu de `PodcastBroadcasterTests` couvrent les demandes independantes,
leur liberation, le changement d'option pendant la capture et sa persistance.
Le test natif Debug utilise une video MP4 H.264 locale synthetique, sans API,
camera ni microphone :
```bash
adb push /chemin/video-test.mp4 /data/local/tmp/postit-screen-awake-fixture.mp4
adb shell run-as fr.pschneider.postit cp /data/local/tmp/postit-screen-awake-fixture.mp4 files/screen-awake-fixture.mp4
adb shell am start -n fr.pschneider.postit/fr.pschneider.postit.ScreenAwakeTestActivity
adb shell run-as fr.pschneider.postit cat files/screen-awake-test-result.txt
```
Attendre `PASS` : lecture, pause/reprise, arret, fin, destruction de la surface,
demandes simultanees, arriere-plan et retour au premier plan sont verifies
sur le drapeau Android `KeepScreenOn`. Les changements de page et le retour
temporaire a l'accueil pendant ce test sont automatiques.
## Quand ne PAS écrire de test
Un test qui ne détecte rien n'est pas un test. Si l'invariant

View file

@ -39,6 +39,18 @@ public class MainActivity : AvaloniaMainActivity
PlatformBootstrap.InitPlatform();
Current = this;
base.OnCreate(savedInstanceState);
PostIt.Services.Platform.ScreenAwake.Changed += OnScreenAwakeChanged;
}
private void OnScreenAwakeChanged() => RunOnUiThread(UpdateScreenAwake);
private void UpdateScreenAwake()
{
if (Window is not { } window) return;
if (ReferenceEquals(Current, this) && IsForeground && PostIt.Services.Platform.ScreenAwake.IsRequested)
window.AddFlags(WindowManagerFlags.KeepScreenOn);
else
window.ClearFlags(WindowManagerFlags.KeepScreenOn);
}
/// <summary>
/// Receives the deep-link Intent fired by the system browser after the
@ -72,17 +84,21 @@ public class MainActivity : AvaloniaMainActivity
base.OnResume();
IsForeground = true;
PostIt.Services.Platform.IsApplicationActive = true;
UpdateScreenAwake();
}
protected override void OnPause()
{
IsForeground = false;
PostIt.Services.Platform.IsApplicationActive = false;
UpdateScreenAwake();
base.OnPause();
}
protected override void OnDestroy()
{
PostIt.Services.Platform.ScreenAwake.Changed -= OnScreenAwakeChanged;
Window?.ClearFlags(WindowManagerFlags.KeepScreenOn);
if (ReferenceEquals(Current, this))
{
Current = null;

View file

@ -34,6 +34,7 @@ internal static class PlatformBootstrap
Platform.GetPodcastCaptureDevices = AndroidPodcastCapture.GetDevices;
Platform.RequestPodcastPermissionsAsync = AndroidPodcastCapture.RequestPermissionsAsync;
Platform.CreatePodcastCaptureAsync = AndroidPodcastCapture.StartAsync;
Platform.CreatePodcastPreviewAsync = AndroidPodcastCapture.StartPreviewAsync;
}
}
}

View file

@ -9,6 +9,8 @@
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<!-- LibVLC's native package targets inspect the plural RID property. -->
<RuntimeIdentifiers Condition="'$(RuntimeIdentifiers)' == '' and '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</RuntimeIdentifiers>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
@ -34,6 +36,7 @@
<Compile Include="..\PostIt.Tests\AndroidPodcastCaptureTestActivity.cs" Link="Tests\AndroidPodcastCaptureTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidCameraFrameRotationTestActivity.cs" Link="Tests\AndroidCameraFrameRotationTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidLanCallTestActivity.cs" Link="Tests\AndroidLanCallTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidScreenAwakeTestActivity.cs" Link="Tests\AndroidScreenAwakeTestActivity.cs" />
</ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
extension's injected HotReload agent (references Microsoft.Maui.*).

View file

@ -48,6 +48,9 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
private int _previewHeight;
private int _outputWidth;
private int _outputHeight;
private int _inputWidth;
private int _inputHeight;
private bool _previewOnly;
private SurfaceTexture? _texture;
private FrameListener? _listener;
private Surface? _cameraSurface;
@ -59,7 +62,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
private bool _rendering;
private bool _disposed;
private int _rotation;
private int? _remainingRotation;
private int _remainingRotation = -1;
internal string? FirstTextureTransform { get; private set; }
internal Surface CameraSurface => _cameraSurface
@ -73,7 +76,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
}
internal static async Task<AndroidCameraFrameRotator> CreateAsync(
Surface encoderSurface, int width, int height, int rotation, Action<Exception> failed)
Surface? encoderSurface, int width, int height, int rotation, Action<Exception> failed)
{
var rotator = new AndroidCameraFrameRotator(failed);
try
@ -88,9 +91,12 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
}
}
private void Initialize(Surface encoderSurface, int width, int height, int rotation)
private void Initialize(Surface? encoderSurface, int width, int height, int rotation)
{
_rotation = rotation;
_inputWidth = width;
_inputHeight = height;
_previewOnly = encoderSurface is null;
_display = EGL14.EglGetDisplay(EGL14.EglDefaultDisplay);
if (_display is null || _display.Equals(EGL14.EglNoDisplay))
throw EglError("Obtention de l'affichage EGL");
@ -100,7 +106,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
[
EGL14.EglRedSize, 8, EGL14.EglGreenSize, 8, EGL14.EglBlueSize, 8,
EGL14.EglRenderableType, EGL14.EglOpenglEs2Bit,
EGL14.EglSurfaceType, EGL14.EglWindowBit, RecordableAndroid, 1, EGL14.EglNone
EGL14.EglSurfaceType, EGL14.EglWindowBit | EGL14.EglPbufferBit, RecordableAndroid, 1, EGL14.EglNone
];
var configs = new EGLConfig[1];
var count = new int[1];
@ -111,7 +117,10 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
[EGL14.EglContextClientVersion, 2, EGL14.EglNone], 0);
if (_context is null || _context.Equals(EGL14.EglNoContext))
throw EglError("Creation du contexte EGL");
_window = EGL14.EglCreateWindowSurface(_display, configs[0], encoderSurface, [EGL14.EglNone], 0);
_window = encoderSurface is null
? EGL14.EglCreatePbufferSurface(_display, configs[0],
[EGL14.EglWidth, 1, EGL14.EglHeight, 1, EGL14.EglNone], 0)
: EGL14.EglCreateWindowSurface(_display, configs[0], encoderSurface, [EGL14.EglNone], 0);
if (_window is null || _window.Equals(EGL14.EglNoSurface))
throw EglError("Creation de la surface EGL");
if (!EGL14.EglMakeCurrent(_display, _window, _window, _context))
@ -127,7 +136,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
GLES20.GlTexParameteri(ExternalTexture, GLES20.GlTextureWrapS, GLES20.GlClampToEdge);
GLES20.GlTexParameteri(ExternalTexture, GLES20.GlTextureWrapT, GLES20.GlClampToEdge);
_vertices = CreateBuffer([-1, -1, 1, -1, -1, 1, 1, 1]);
_coordinates = CreateBuffer(CameraFrameOrientation.GetTextureCoordinates(0));
_coordinates = CreateBuffer(CameraFrameOrientation.GetTextureCoordinates(rotation));
GLES20.GlUseProgram(_program);
BindAttribute("position", _vertices);
BindAttribute("textureCoordinate", _coordinates);
@ -147,6 +156,12 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
internal Task StartAsync() => InvokeAsync(() => _rendering = true);
internal Task SetRotationAsync(int rotation) => InvokeAsync(() =>
{
_rotation = rotation;
_remainingRotation = -1;
});
internal Task SetPreviewAsync(Surface? surface, int width = 0, int height = 0, Action<Exception>? failed = null)
{
lock (_lifecycle)
@ -173,7 +188,6 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
{
if (!EGL14.EglMakeCurrent(_display, _window, _window, _context))
throw EglError("Activation de l'encodeur");
GLES20.GlViewport(0, 0, _outputWidth, _outputHeight);
texture.UpdateTexImage();
var presentationTime = _clock.GetPresentationTime(texture.Timestamp, Java.Lang.JavaSystem.NanoTime());
if (presentationTime is null) return;
@ -187,15 +201,19 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
_coordinates.Put(CameraFrameOrientation.GetTextureCoordinates(remainingRotation));
_coordinates.Position(0);
global::Android.Util.Log.Info("PostIt.Podcast",
$"Camera texture transform: [{FirstTextureTransform}], remaining rotation={remainingRotation}");
$"Camera texture transform: [{FirstTextureTransform}], sensor/display rotation={_rotation}, remaining rotation={remainingRotation}");
}
GLES20.GlUniformMatrix4fv(_matrixLocation, 1, false, _textureMatrix, 0);
GLES20.GlDrawArrays(GLES20.GlTriangleStrip, 0, 4);
CheckGl();
if (!EGLExt.EglPresentationTimeANDROID(_display, _window, presentationTime.Value))
throw EglError("Horodatage de l'image camera");
if (!EGL14.EglSwapBuffers(_display, _window))
throw EglError("Envoi de l'image a l'encodeur");
if (!_previewOnly)
{
SetViewport(_outputWidth, _outputHeight);
GLES20.GlDrawArrays(GLES20.GlTriangleStrip, 0, 4);
CheckGl();
if (!EGLExt.EglPresentationTimeANDROID(_display, _window, presentationTime.Value))
throw EglError("Horodatage de l'image camera");
if (!EGL14.EglSwapBuffers(_display, _window))
throw EglError("Envoi de l'image a l'encodeur");
}
DrawPreview();
}
catch (Exception ex)
@ -212,12 +230,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
{
if (!EGL14.EglMakeCurrent(_display, _previewWindow, _previewWindow, _context))
throw EglError("Activation de l'apercu");
GLES20.GlClearColor(0, 0, 0, 1);
GLES20.GlClear(GLES20.GlColorBufferBit);
var scale = Math.Min((double)_previewWidth / _outputWidth, (double)_previewHeight / _outputHeight);
var width = Math.Max(1, (int)(_outputWidth * scale));
var height = Math.Max(1, (int)(_outputHeight * scale));
GLES20.GlViewport((_previewWidth - width) / 2, (_previewHeight - height) / 2, width, height);
SetViewport(_previewWidth, _previewHeight);
GLES20.GlDrawArrays(GLES20.GlTriangleStrip, 0, 4);
CheckGl();
if (!EGL14.EglSwapBuffers(_display, _previewWindow))
@ -232,6 +245,18 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
}
}
private void SetViewport(int targetWidth, int targetHeight)
{
GLES20.GlClearColor(0, 0, 0, 1);
GLES20.GlClear(GLES20.GlColorBufferBit);
var contentWidth = _rotation is 90 or 270 ? _inputHeight : _inputWidth;
var contentHeight = _rotation is 90 or 270 ? _inputWidth : _inputHeight;
var scale = Math.Min((double)targetWidth / contentWidth, (double)targetHeight / contentHeight);
var width = Math.Max(1, (int)(contentWidth * scale));
var height = Math.Max(1, (int)(contentHeight * scale));
GLES20.GlViewport((targetWidth - width) / 2, (targetHeight - height) / 2, width, height);
}
private void ReleasePreview()
{
if (_previewWindow is not null)

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Android.Content;
using Android.Hardware.Camera2;
using Android.Hardware.Camera2.Params;
using Android.Hardware.Display;
using Android.Media;
using Android.OS;
using Android.Views;
@ -20,7 +21,13 @@ namespace PostIt.Android.Services;
[SupportedOSPlatform("android26.0")]
internal sealed class AndroidPodcastCapture : IPodcastCapture
{
private readonly PodcastCaptureService _service;
private static readonly SemaphoreSlim CaptureGate = new(1, 1);
private readonly PodcastCaptureService? _service;
private readonly Context _context;
private DisplayManager? _displayManager;
private RotationListener? _rotationListener;
private int _sensorDegrees;
private bool _frontFacing;
private readonly SemaphoreSlim _stopGate = new(1);
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Handler _handler = new(Looper.MainLooper!);
@ -44,7 +51,11 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
internal string OrientationDiagnostics { get; private set; } = "";
internal string? TextureTransform => _rotator?.FirstTextureTransform;
private AndroidPodcastCapture(PodcastCaptureService service) => _service = service;
private AndroidPodcastCapture(Context context, PodcastCaptureService? service = null)
{
_context = context;
_service = service;
}
private static CameraManager GetManager(Context context)
=> context.GetSystemService(Context.CameraService) as CameraManager
@ -76,6 +87,28 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
=> await StartCoreAsync(camera, microphone,
(stream, token) => new TransportStreamCapture(directory).RunAsync(stream, token), null, null, ct);
public static async Task<IPodcastCapture> StartPreviewAsync(string camera, CancellationToken ct)
{
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
await PodcastPermissions.EnsureAsync(activity, false, ct, notifications: false);
if (!MainActivity.IsForeground)
throw new InvalidOperationException("Revenez dans PostIt pour previsualiser la camera.");
if (!await CaptureGate.WaitAsync(0, ct))
throw new InvalidOperationException("La camera est deja utilisee par un apercu, un podcast ou un appel.");
var capture = new AndroidPodcastCapture(activity);
Platform.PlaybackSuspending += capture.RequestStop;
try
{
await capture.StartNativeAsync(camera, false, null, null, ct);
return capture;
}
catch
{
await capture.DisposeAsync();
throw;
}
}
internal static Task<IPodcastCapture> StartCallAsync(string camera, string microphone,
Func<int, CancellationToken, Task> output, Func<Task> stopOutput, CancellationToken ct) =>
StartCoreAsync(camera, microphone, null, output, stopOutput, ct);
@ -86,8 +119,16 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
{
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, pipeOutput != null);
var capture = new AndroidPodcastCapture(service) { _stopOutput = stopOutput };
if (!await CaptureGate.WaitAsync(0, ct))
throw new InvalidOperationException("La camera est deja utilisee par un apercu, un podcast ou un appel.");
PodcastCaptureService service;
try { service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct, pipeOutput != null); }
catch
{
CaptureGate.Release();
throw;
}
var capture = new AndroidPodcastCapture(service, service) { _stopOutput = stopOutput };
service.Capture = capture;
try
{
@ -115,7 +156,7 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
Func<Stream, CancellationToken, Task>? output,
Func<int, CancellationToken, Task>? pipeOutput, CancellationToken ct)
{
var manager = GetManager(_service);
var manager = GetManager(_context);
using var characteristics = manager.GetCameraCharacteristics(cameraId);
using var formats = characteristics.Get(CameraCharacteristics.ScalerStreamConfigurationMap) as StreamConfigurationMap
?? throw new InvalidOperationException("Formats camera indisponibles.");
@ -128,55 +169,42 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
?? throw new InvalidOperationException("Orientation du capteur camera indisponible.");
using var facing = characteristics.Get(CameraCharacteristics.LensFacing) as Java.Lang.Integer
?? throw new InvalidOperationException("Position du capteur camera indisponible.");
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
#pragma warning disable CS0618, CA1422
var display = OperatingSystem.IsAndroidVersionAtLeast(30)
? activity.Display : activity.WindowManager?.DefaultDisplay;
#pragma warning restore CS0618, CA1422
var displayRotation = display?.Rotation
?? throw new InvalidOperationException("Orientation de l'ecran Android indisponible.");
var displayDegrees = displayRotation switch
_sensorDegrees = sensor.IntValue();
_frontFacing = facing.IntValue() == (int)LensFacing.Front;
var rotation = GetRotation();
var previewOnly = output is null && pipeOutput is null;
if (!previewOnly)
{
SurfaceOrientation.Rotation0 => 0,
SurfaceOrientation.Rotation90 => 90,
SurfaceOrientation.Rotation180 => 180,
SurfaceOrientation.Rotation270 => 270,
_ => throw new InvalidOperationException($"Rotation d'ecran Android inconnue : {displayRotation}.")
};
var rotation = CameraFrameOrientation.GetRotation(sensor.IntValue(), displayDegrees,
facing.IntValue() == (int)LensFacing.Front);
global::Android.Util.Log.Info("PostIt.Podcast",
$"Camera orientation: sensor={sensor.IntValue()}, display={displayDegrees}, facing={facing.IntValue()}, rotation={rotation}");
OrientationDiagnostics = $"sensor={sensor.IntValue()}, display={displayDegrees}, facing={facing.IntValue()}, rotation={rotation}";
#pragma warning disable CS0618
_recorder = OperatingSystem.IsAndroidVersionAtLeast(31)
? new MediaRecorder(_service) : new MediaRecorder();
_recorder = OperatingSystem.IsAndroidVersionAtLeast(31)
? new MediaRecorder(_context) : 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(rotation is 90 or 270 ? size.Height : size.Width,
rotation is 90 or 270 ? 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);
_recorder.Error += OnRecorderError;
if (microphone) _recorder.SetAudioSource(AudioSource.Mic);
_recorder.SetVideoSource(VideoSource.Surface);
_recorder.SetOutputFormat(OutputFormat.Mpeg2Ts);
_recorder.SetVideoSize(rotation is 90 or 270 ? size.Height : size.Width,
rotation is 90 or 270 ? 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);
_surface = _recorder.Surface ?? throw new InvalidOperationException("Surface encodeur indisponible.");
}
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.");
_rotator = await AndroidCameraFrameRotator.CreateAsync(_surface, size.Width, size.Height, rotation, Fail);
_preview = new AndroidCapturePreview(_rotator);
ct.ThrowIfCancellationRequested();
@ -192,15 +220,14 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
_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);
using var request = _camera.CreateCaptureRequest(previewOnly ? CameraTemplate.Preview : CameraTemplate.Record);
request.AddTarget(cameraSurface);
using var built = request.Build();
if (pipeOutput != null)
await pipeOutput(_readPipe.Fd, ct);
else
await pipeOutput(_readPipe!.Fd, ct);
else if (output is not null)
{
var consume = output ?? throw new InvalidOperationException("Destination de capture absente.");
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var handle = new SafeFileHandle((IntPtr)_readPipe!.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
{
@ -209,7 +236,7 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
{
try
{
await consume(stream, CancellationToken.None);
await output(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();
@ -223,15 +250,63 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
}
});
}
await Task.Run(_recorder.Start, ct);
_started = true;
if (_recorder is not null)
{
await Task.Run(_recorder.Start, ct);
_started = true;
}
_displayManager = _context.GetSystemService(Context.DisplayService) as DisplayManager
?? throw new InvalidOperationException("Service d'affichage Android indisponible.");
_rotationListener = new RotationListener(this);
_displayManager.RegisterDisplayListener(_rotationListener, _handler);
await _rotator.SetRotationAsync(GetRotation());
await _rotator.StartAsync();
_session.SetRepeatingRequest(built, null, _handler);
_writePipe.Close();
_writePipe.Dispose();
_writePipe?.Close();
_writePipe?.Dispose();
_writePipe = null;
}
private int GetRotation()
{
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
#pragma warning disable CS0618, CA1422
var display = OperatingSystem.IsAndroidVersionAtLeast(30)
? activity.Display : activity.WindowManager?.DefaultDisplay;
#pragma warning restore CS0618, CA1422
var displayRotation = display?.Rotation
?? throw new InvalidOperationException("Orientation de l'ecran Android indisponible.");
var displayDegrees = displayRotation switch
{
SurfaceOrientation.Rotation0 => 0,
SurfaceOrientation.Rotation90 => 90,
SurfaceOrientation.Rotation180 => 180,
SurfaceOrientation.Rotation270 => 270,
_ => throw new InvalidOperationException($"Rotation d'ecran Android inconnue : {displayRotation}.")
};
var rotation = CameraFrameOrientation.GetRotation(_sensorDegrees, displayDegrees, _frontFacing);
global::Android.Util.Log.Info("PostIt.Podcast",
$"Camera orientation: sensor={_sensorDegrees}, display={displayDegrees}, front={_frontFacing}, rotation={rotation}");
OrientationDiagnostics = $"sensor={_sensorDegrees}, display={displayDegrees}, front={_frontFacing}, rotation={rotation}";
return rotation;
}
private sealed class RotationListener(AndroidPodcastCapture capture) : Java.Lang.Object, DisplayManager.IDisplayListener
{
public void OnDisplayAdded(int displayId) { }
public void OnDisplayRemoved(int displayId) { }
public async void OnDisplayChanged(int displayId)
{
try
{
if (!capture._stopRequested && MainActivity.Current is { IsDestroyed: false }
&& capture._rotator is { } rotator)
await rotator.SetRotationAsync(capture.GetRotation());
}
catch (Exception ex) { capture.Fail(ex); }
}
}
private sealed class DeviceCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraDevice> opened)
: CameraDevice.StateCallback
{
@ -368,6 +443,14 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
finally
{
_started = false;
if (_rotationListener is not null)
{
_displayManager?.UnregisterDisplayListener(_rotationListener);
_rotationListener.Dispose();
_rotationListener = null;
}
_displayManager?.Dispose();
_displayManager = null;
_session?.Close();
_session?.Dispose();
_session = null;
@ -398,12 +481,17 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
try { await StopAsync(); }
finally
{
Platform.PlaybackSuspending -= RequestStop;
_readPipe?.Dispose();
_readPipe = null;
_service.Capture = null;
_service.StopSelf();
if (_service is not null)
{
_service.Capture = null;
_service.StopSelf();
}
_handler.Dispose();
_stopGate.Dispose();
CaptureGate.Release();
}
}
}

View file

@ -19,6 +19,8 @@ internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : Nat
private VideoView? _video;
private ReplayLayout? _layout;
private bool _disposed;
private bool _playbackSubscribed;
private IDisposable? _screenAwake;
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal Task Ready => _ready.Task;
@ -28,6 +30,16 @@ internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : Nat
?? throw new InvalidOperationException("L'activite Android est indisponible.");
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
_video = video;
if (!_disposed)
{
player.Playing += OnPlaybackChanged;
player.Paused += OnPlaybackChanged;
player.Stopped += OnPlaybackChanged;
player.EndReached += OnPlaybackChanged;
player.EncounteredError += OnPlaybackChanged;
_playbackSubscribed = true;
}
UpdateScreenAwake();
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (!_disposed) _ready.TrySetResult();
@ -42,6 +54,7 @@ internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : Nat
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
ReleaseScreenAwake();
_layout?.Stop();
_layout = null;
if (_video is not null) _video.MediaPlayer = null;
@ -52,11 +65,41 @@ internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : Nat
public void Dispose()
{
_disposed = true;
ReleaseScreenAwake();
_ready.TrySetCanceled();
_layout?.Stop();
if (_video is not null) _video.MediaPlayer = null;
}
private void OnPlaybackChanged(object? sender, EventArgs e)
=> Avalonia.Threading.Dispatcher.UIThread.Post(UpdateScreenAwake);
private void UpdateScreenAwake()
{
if (!_disposed && _video is not null && player.IsPlaying)
_screenAwake ??= Platform.ScreenAwake.Acquire();
else
{
_screenAwake?.Dispose();
_screenAwake = null;
}
}
private void ReleaseScreenAwake()
{
if (_playbackSubscribed)
{
player.Playing -= OnPlaybackChanged;
player.Paused -= OnPlaybackChanged;
player.Stopped -= OnPlaybackChanged;
player.EndReached -= OnPlaybackChanged;
player.EncounteredError -= OnPlaybackChanged;
_playbackSubscribed = false;
}
_screenAwake?.Dispose();
_screenAwake = null;
}
private sealed class ReplayLayout : FrameLayout
{
private readonly MediaPlayer _player;

View file

@ -37,14 +37,21 @@ internal static class CameraFrameOrientation
throw new ArgumentException("A SurfaceTexture transform must contain 16 elements.", nameof(textureMatrix));
// SurfaceTexture's usual Y flip changes the sign of the first column's Y.
// Camera2 can also put the sensor rotation in this matrix; don't apply it twice.
var angle = Math.Atan2(-textureMatrix[1], textureMatrix[0]) * 180 / Math.PI;
// Front cameras can add a mirror: with Rot(p)·Mirror·Rot(r) = Rot(p - r)·Mirror,
// the remaining rotation must then be applied in the opposite direction.
var mirrored = textureMatrix[4] * textureMatrix[1] - textureMatrix[0] * textureMatrix[5] < 0;
var angle = mirrored
? Math.Atan2(textureMatrix[1], -textureMatrix[0]) * 180 / Math.PI
: Math.Atan2(-textureMatrix[1], textureMatrix[0]) * 180 / Math.PI;
if (!double.IsFinite(angle) || (textureMatrix[0] == 0 && textureMatrix[1] == 0))
throw new ArgumentException("Invalid SurfaceTexture orientation.", nameof(textureMatrix));
var quarterTurns = (int)Math.Round(angle / 90);
if (Math.Abs(angle - quarterTurns * 90) > 0.01)
throw new ArgumentException("SurfaceTexture orientation is not a quarter turn.", nameof(textureMatrix));
var producerRotation = (quarterTurns * 90 + 360) % 360;
return (clockwiseDegrees - producerRotation + 360) % 360;
return mirrored
? (producerRotation - clockwiseDegrees + 360) % 360
: (clockwiseDegrees - producerRotation + 360) % 360;
}
private static void Validate(int degrees)

View file

@ -17,11 +17,11 @@ internal static class PodcastPermissions
private static TaskCompletionSource<bool>? _pending;
private static string[] _requested = [];
public static async Task EnsureAsync(Activity activity, bool microphone, CancellationToken ct)
public static async Task EnsureAsync(Activity activity, bool microphone, CancellationToken ct, bool notifications = true)
{
var requested = new List<string> { Manifest.Permission.Camera };
if (microphone) requested.Add(Manifest.Permission.RecordAudio);
if (OperatingSystem.IsAndroidVersionAtLeast(33)) requested.Add(Manifest.Permission.PostNotifications);
if (notifications && 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.");
@ -30,7 +30,9 @@ internal static class PodcastPermissions
_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.");
throw new UnauthorizedAccessException(notifications
? "Autorisez la camera, les notifications et, si selectionne, le microphone pour diffuser."
: "Autorisez la camera pour la previsualisation.");
}
public static bool HandleResult(int requestCode, string[]? permissions, Permission[]? results)

View file

@ -27,7 +27,8 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
{
foreach (var rotation in new[] { 0, 90, 180, 270 })
await VerifyRotationAsync(rotation);
File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\npreview=RGBA,detach,reattach,letterbox\nsource=synthetic");
await VerifyPreviewOnlyAsync();
File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\npreview=RGBA,detach,reattach,letterbox,without-encoder,dynamic-rotation\nsource=synthetic");
}
catch (Exception ex)
{
@ -36,6 +37,59 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
finally { Finish(); }
}
private async Task VerifyPreviewOnlyAsync()
{
const int width = 640, height = 480;
using var reader = ImageReader.NewInstance(640, 640, (ImageFormatType)Format.Rgba8888, 3)
?? throw new InvalidOperationException("Preview reader unavailable.");
Exception? failure = null;
using var rotator = await AndroidCameraFrameRotator.CreateAsync(null, width, height, 0,
error => failure = error);
await rotator.SetPreviewAsync(reader.Surface, reader.Width, reader.Height, error => failure = error);
await rotator.StartAsync();
using var paint = new Paint();
Color[] colors = [Color.Red, Color.Green, Color.Blue, Color.Yellow];
foreach (var rotation in new[] { 0, 90, 180, 270 })
{
await rotator.SetRotationAsync(rotation);
var verified = 0;
for (var frame = 0; frame < 20; frame++)
{
if (failure is not null) throw failure;
var canvas = rotator.CameraSurface.LockCanvas(null)
?? throw new InvalidOperationException("Synthetic preview canvas unavailable.");
try
{
for (var index = 0; index < colors.Length; index++)
{
paint.Color = colors[index];
var x = index % 2 * width / 2;
var y = index / 2 * height / 2;
canvas.DrawRect(x, y, x + width / 2, y + height / 2, paint);
}
}
finally
{
rotator.CameraSurface.UnlockCanvasAndPost(canvas);
canvas.Dispose();
}
await Task.Delay(34);
using var image = reader.AcquireLatestImage();
if (image is null) continue;
try
{
if (frame < 3) continue;
VerifyPreview(image, rotation, rotation is 90 or 270 ? height : width,
rotation is 90 or 270 ? width : height);
verified++;
}
finally { image.Close(); }
}
if (verified < 3) throw new InvalidOperationException($"Preview-only rotation {rotation}: insufficient frames.");
}
await rotator.SetPreviewAsync(null);
}
private async Task VerifyRotationAsync(int rotation)
{
const int width = 640;

View file

@ -0,0 +1,118 @@
#if ANDROID
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.OS;
using Android.Views;
using Avalonia.Controls;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.Android;
using PostIt.Android.Services;
using PostIt.Services;
namespace PostIt.Tests;
[Activity(Name = "fr.pschneider.postit.ScreenAwakeTestActivity", Exported = true)]
public sealed class AndroidScreenAwakeTestActivity : Activity
{
protected override async void OnCreate(Bundle? state)
{
base.OnCreate(state);
var result = Path.Combine(FilesDir!.AbsolutePath, "screen-awake-test-result.txt");
File.WriteAllText(result, "RUNNING");
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
await RunAsync(timeout.Token);
File.WriteAllText(result, "PASS\nplayback,pause,resume,stop,end,teardown,overlap,background,foreground");
}
catch (Exception ex) { File.WriteAllText(result, "FAIL\n" + ex); }
finally { Finish(); }
}
private async Task RunAsync(CancellationToken ct)
{
StartActivity(new Intent(this, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask));
await WaitAsync(() => MainActivity.IsForeground && (Avalonia.Application.Current as App)?.View is not null, ct);
var fixture = Path.Combine(FilesDir!.AbsolutePath, "screen-awake-fixture.mp4");
if (!File.Exists(fixture)) throw new IOException("An explicit local video fixture is required.");
var app = (App)Avalonia.Application.Current!;
var navigation = app.View!.FindControl<NavigationPage>("NavRoot")
?? throw new InvalidOperationException("Main navigation unavailable.");
using var vlc = new LibVLC();
using var player = new MediaPlayer(vlc);
AndroidVideoSurface? surface = null;
IDisposable? capture = null;
var attached = false;
try
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
surface = new AndroidVideoSurface(player, false);
await navigation.PushAsync(new ContentPage { Content = surface });
attached = true;
});
await surface!.Ready.WaitAsync(ct);
await WaitAsync(() => !ScreenIsKeptOn(), ct);
using var repeated = new Media(vlc, fixture, FromType.FromPath);
repeated.AddOption(":input-repeat=100");
if (!player.Play(repeated)) throw new InvalidOperationException("Playback did not start.");
await WaitAsync(() => player.IsPlaying && ScreenIsKeptOn(), ct);
player.Pause();
await WaitAsync(() => !player.IsPlaying && !ScreenIsKeptOn(), ct);
if (!player.Play()) throw new InvalidOperationException("Playback did not resume.");
await WaitAsync(ScreenIsKeptOn, ct);
capture = Platform.ScreenAwake.Acquire();
player.Stop();
await WaitAsync(() => !player.IsPlaying && Platform.ScreenAwake.IsRequested && ScreenIsKeptOn(), ct);
capture.Dispose();
capture = null;
await WaitAsync(() => !ScreenIsKeptOn(), ct);
using var once = new Media(vlc, fixture, FromType.FromPath);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
if (!player.Play(once)) throw new InvalidOperationException("Finite playback did not start.");
await WaitAsync(ScreenIsKeptOn, ct);
await ended.Task.WaitAsync(ct);
await WaitAsync(() => !ScreenIsKeptOn(), ct);
if (!player.Play(repeated)) throw new InvalidOperationException("Teardown playback did not start.");
await WaitAsync(ScreenIsKeptOn, ct);
await Dispatcher.UIThread.InvokeAsync(() => surface.Dispose());
await WaitAsync(() => !ScreenIsKeptOn() && !Platform.ScreenAwake.IsRequested, ct);
player.Stop();
capture = Platform.ScreenAwake.Acquire();
await WaitAsync(ScreenIsKeptOn, ct);
StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask));
await WaitAsync(() => !MainActivity.IsForeground && !ScreenIsKeptOn(), ct);
StartActivity(new Intent(this, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask));
await WaitAsync(() => MainActivity.IsForeground && ScreenIsKeptOn(), ct);
capture.Dispose();
capture = null;
await WaitAsync(() => !ScreenIsKeptOn(), ct);
}
finally
{
capture?.Dispose();
await Dispatcher.UIThread.InvokeAsync(() => surface?.Dispose());
player.Stop();
if (attached) await Dispatcher.UIThread.InvokeAsync(async () => await navigation.PopAsync());
}
}
private static bool ScreenIsKeptOn()
=> MainActivity.Current?.Window?.Attributes is { } attributes
&& (attributes.Flags & WindowManagerFlags.KeepScreenOn) != 0;
private static async Task WaitAsync(Func<bool> condition, CancellationToken ct)
{
while (!await Dispatcher.UIThread.InvokeAsync(condition))
await Task.Delay(50, ct);
}
}
#endif

View file

@ -26,26 +26,44 @@ public sealed class CameraFrameOrientationTests
public void Maps_output_corners_to_input_for_clockwise_rotation(int rotation, float[] expected)
=> Assert.Equal(expected, CameraFrameOrientation.GetTextureCoordinates(rotation));
[Theory]
[InlineData(90, 0, -1, 0)]
[InlineData(270, 0, 1, 0)]
[InlineData(180, -1, 0, 0)]
[InlineData(90, 1, 0, 90)]
[InlineData(270, 1, 0, 270)]
[InlineData(0, 0, -1, 270)]
[InlineData(180, 0, -1, 90)]
public void Does_not_repeat_rotation_already_in_surface_texture(
int desired, float x, float y, int expected)
[Fact]
public void Sensor_rotation_and_display_changes_map_corners_for_all_lens_orientations()
{
float[] matrix = [x, y, 0, 0, y, -x, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1];
Assert.Equal(expected, CameraFrameOrientation.GetRemainingRotation(desired, matrix));
foreach (var sensor in new[] { 0, 90, 180, 270 })
foreach (var display in new[] { 0, 90, 180, 270 })
foreach (var front in new[] { false, true })
{
var rotation = CameraFrameOrientation.GetRotation(sensor, display, front);
var coordinates = CameraFrameOrientation.GetTextureCoordinates(rotation);
float[] expected = [0, 0, 1, 0, 0, 1, 1, 1];
var turns = (sensor / 90 + (front ? display / 90 : -display / 90) + 4) % 4;
for (var turn = 0; turn < turns; turn++)
for (var index = 0; index < expected.Length; index += 2)
(expected[index], expected[index + 1]) = (1 - expected[index + 1], expected[index]);
Assert.Equal(expected, coordinates);
}
}
[Theory]
[InlineData(new float[] { 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 }, 90, 90)]
[InlineData(new float[] { 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 }, 270, 270)]
// Measured on a real Android 15 phone in portrait (rear sensor 90°, front sensor 270°).
[InlineData(new float[] { 0, -1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1 }, 90, 0)]
[InlineData(new float[] { 0, -1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1 }, 0, 270)]
[InlineData(new float[] { 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 }, 270, 0)]
[InlineData(new float[] { 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 }, 0, 270)]
[InlineData(new float[] { 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 }, 180, 90)]
public void Does_not_apply_the_rotation_already_in_the_texture_transform_twice(
float[] matrix, int rotation, int expected)
=> Assert.Equal(expected, CameraFrameOrientation.GetRemainingRotation(rotation, matrix));
[Fact]
public void Recognizes_the_measured_rear_camera_transform_even_with_cropping()
public void Rejects_invalid_texture_transforms()
{
float[] matrix = [0, -0.9f, 0, 0, -0.8f, 0, 0, 0, 0, 0, 1, 0, 0.9f, 0.95f, 0, 1];
Assert.Equal(0, CameraFrameOrientation.GetRemainingRotation(90, matrix));
Assert.Throws<ArgumentException>(() => CameraFrameOrientation.GetRemainingRotation(0, new float[15]));
Assert.Throws<ArgumentException>(() => CameraFrameOrientation.GetRemainingRotation(0, new float[16]));
var skewed = new float[] { 0.7f, -0.7f, 0, 0, -0.7f, -0.7f, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 };
Assert.Throws<ArgumentException>(() => CameraFrameOrientation.GetRemainingRotation(0, skewed));
}
[Theory]

View file

@ -12,6 +12,172 @@ namespace PostIt.Tests;
public sealed class PodcastBroadcasterTests
{
[Theory]
[InlineData("stop")]
[InlineData("leave")]
[InlineData("logout")]
[InlineData("failure")]
[InlineData("broadcast")]
public Task Camera_can_be_previewed_without_creating_a_broadcast_and_is_released_before_next_capture(string action)
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastBroadcasterTests).Assembly).Dispatch(async () =>
{
var path = Path.Combine(Path.GetTempPath(), $"postit-local-preview-{Guid.NewGuid():N}");
var previousDevices = Platform.GetPodcastCaptureDevices;
var previousPreview = Platform.CreatePodcastPreviewAsync;
var previousCapture = Platform.CreatePodcastCaptureAsync;
var previousPermissions = Platform.RequestPodcastPermissionsAsync;
var store = new TokenStore(path);
store.Save(new RefreshTokenRecord("access", "refresh", DateTimeOffset.UtcNow.AddHours(1)));
await using var api = new YavscApiClient(new Settings(path + ".settings"), store);
await using var transport = new Transport();
var preview = new PreviewCapture();
PodcastsPageViewModel? vm = null;
try
{
Platform.GetPodcastCaptureDevices = () => new([new("Camera", "camera")], [new("Mic", "microphone")]);
Platform.CreatePodcastPreviewAsync = (camera, _) =>
{
Assert.Equal("camera", camera);
return Task.FromResult<IPodcastCapture>(preview);
};
Platform.RequestPodcastPermissionsAsync = (_, _) => Task.CompletedTask;
Platform.CreatePodcastCaptureAsync = (_, microphone, directory, _) =>
{
Assert.True(preview.Disposed);
Assert.Equal("microphone", microphone);
return Task.FromResult<IPodcastCapture>(new FakeCapture(directory, new Border()));
};
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session);
vm.DetectDevicesCommand.Execute(null);
vm.Title = "";
vm.KeepScreenOnDuringCapture = true;
Assert.False(Platform.ScreenAwake.IsRequested);
Assert.True(vm.PreviewCommand.CanExecute(null));
Assert.False(vm.StartCommand.CanExecute(null));
await vm.PreviewCommand.ExecuteAsync(null);
Assert.True(vm.IsPreviewing);
Assert.True(Platform.ScreenAwake.IsRequested);
vm.KeepScreenOnDuringCapture = false;
Assert.False(Platform.ScreenAwake.IsRequested);
vm.KeepScreenOnDuringCapture = true;
Assert.True(Platform.ScreenAwake.IsRequested);
Assert.True(vm.IsCapturing);
Assert.False(vm.IsBroadcasting);
Assert.Same(preview.Preview, vm.CapturePreview);
Assert.False(vm.DetectDevicesCommand.CanExecute(null));
Assert.True(vm.StopPreviewCommand.CanExecute(null));
Assert.Equal(0, transport.CreateCount);
Assert.Empty(transport.Uploads);
var page = new PodcastsPage { DataContext = vm };
Assert.Same(preview.Preview, page.FindControl<ContentControl>("CapturePreviewHost")!.Content);
preview.OnDispose = () => Assert.Null(vm.CapturePreview);
switch (action)
{
case "stop": await vm.StopPreviewCommand.ExecuteAsync(null); break;
case "leave": await vm.DeactivateAsync(); break;
case "logout": await session.LogoutAsync(); break;
case "failure": preview.Fail(); break;
case "broadcast":
vm.Title = "Emission";
Assert.True(vm.StartCommand.CanExecute(null));
await vm.StartCommand.ExecuteAsync(null);
Assert.True(vm.IsBroadcasting);
Assert.True(Platform.ScreenAwake.IsRequested);
Assert.Equal(1, transport.CreateCount);
await vm.StopAsync();
break;
}
await WaitForUiAsync(() => preview.Disposed && !vm.IsPreviewing && !vm.IsBroadcasting);
Assert.True(preview.Disposed);
Assert.False(vm.IsPreviewing);
Assert.False(Platform.ScreenAwake.IsRequested);
Assert.Null(vm.CapturePreview);
if (action == "failure") Assert.Contains("Camera disconnected", vm.Status);
if (action != "broadcast") Assert.Equal(0, transport.CreateCount);
return true;
}
finally
{
if (vm is not null) await vm.DeactivateAsync();
Platform.GetPodcastCaptureDevices = previousDevices;
Platform.CreatePodcastPreviewAsync = previousPreview;
Platform.CreatePodcastCaptureAsync = previousCapture;
Platform.RequestPodcastPermissionsAsync = previousPermissions;
File.Delete(path);
File.Delete(path + ".settings");
}
}, TestContext.Current.CancellationToken);
[Fact]
public Task Leaving_the_page_during_preview_start_disposes_the_late_camera_without_showing_it()
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastBroadcasterTests).Assembly).Dispatch(async () =>
{
var path = Path.Combine(Path.GetTempPath(), $"postit-preview-race-{Guid.NewGuid():N}");
var previous = Platform.CreatePodcastPreviewAsync;
var previousDevices = Platform.GetPodcastCaptureDevices;
var store = new TokenStore(path);
store.Save(new RefreshTokenRecord("access", "refresh", DateTimeOffset.UtcNow.AddHours(1)));
await using var api = new YavscApiClient(new Settings(path + ".settings"), store);
var preview = new PreviewCapture();
var pending = new TaskCompletionSource<IPodcastCapture>(TaskCreationOptions.RunContinuationsAsynchronously);
try
{
Platform.GetPodcastCaptureDevices = () => new([new("Camera", "camera")], []);
Platform.CreatePodcastPreviewAsync = (_, _) => pending.Task;
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => "https://api.example/"), api, session);
vm.DetectDevicesCommand.Execute(null);
var start = vm.PreviewCommand.ExecuteAsync(null);
var leave = vm.DeactivateAsync();
pending.SetResult(preview);
await WaitForUiAsync(() => start.IsCompleted && leave.IsCompleted);
await start;
await leave;
Assert.True(preview.Disposed);
Assert.Null(vm.CapturePreview);
Assert.False(vm.IsPreviewing);
return true;
}
finally
{
Platform.CreatePodcastPreviewAsync = previous;
Platform.GetPodcastCaptureDevices = previousDevices;
File.Delete(path);
File.Delete(path + ".settings");
}
}, TestContext.Current.CancellationToken);
private static async Task WaitForUiAsync(Func<bool> done)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (!done())
{
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
await Task.Delay(10, timeout.Token);
}
}
private sealed class PreviewCapture : IPodcastCapture
{
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task Completion => _completion.Task;
public Control? Preview { get; } = new Border();
public bool Disposed { get; private set; }
public Action? OnDispose { get; set; }
public void Fail() => _completion.TrySetException(new IOException("Camera disconnected"));
public Task StopAsync() { _completion.TrySetResult(); return Task.CompletedTask; }
public async ValueTask DisposeAsync()
{
OnDispose?.Invoke();
await StopAsync();
Disposed = true;
}
}
[Fact]
public Task Shared_page_exposes_running_capture_preview_and_clears_it_before_disposal()
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastBroadcasterTests).Assembly).Dispatch(async () =>

View file

@ -27,6 +27,8 @@ public sealed class PodcastPreferencesTests
vm.SelectedCamera = vm.Cameras[1];
vm.SelectedMicrophone = vm.Microphones[0];
vm.Title = "Mon emission habituelle";
Assert.False(vm.KeepScreenOnDuringCapture);
vm.KeepScreenOnDuringCapture = true;
vm.DetectDevicesCommand.Execute(null);
Assert.Equal("rear", vm.SelectedCamera?.Value);
Assert.Equal("", vm.SelectedMicrophone?.Value);
@ -38,6 +40,7 @@ public sealed class PodcastPreferencesTests
Assert.Equal("Mon emission habituelle", restored.PodcastDefaultTitle);
Assert.Equal("rear", restored.PodcastCameraId);
Assert.Equal("", restored.PodcastMicrophoneId);
Assert.True(restored.PodcastKeepScreenOn);
Platform.GetPodcastCaptureDevices = () => throw new InvalidOperationException("Must use saved devices.");
await using var otherApi = new PreferencesApi(restored, new TokenStore(path + ".tokens"));
var other = new PodcastsPageViewModel(new PodcastApiClient(otherApi, () => restored.ApiUrl), otherApi, session);
@ -46,11 +49,14 @@ public sealed class PodcastPreferencesTests
Assert.Equal("rear", other.SelectedCamera?.Value);
Assert.Equal("", other.SelectedMicrophone?.Value);
Assert.Equal(vm.Title, other.Title);
Assert.True(other.KeepScreenOnDuringCapture);
restored.PodcastCameraId = "front";
restored.PodcastMicrophoneId = "usb";
restored.PodcastKeepScreenOn = false;
await other.ActivateAsync();
Assert.Equal("front", other.SelectedCamera?.Value);
Assert.Equal("usb", other.SelectedMicrophone?.Value);
Assert.False(other.KeepScreenOnDuringCapture);
Platform.GetPodcastCaptureDevices = () => new([], []);
other.DetectDevicesCommand.Execute(null);

View file

@ -0,0 +1,23 @@
using PostIt.Services;
namespace PostIt.Tests;
public sealed class ScreenAwakeRequestsTests
{
[Fact]
public void Independent_requests_keep_the_screen_awake_until_all_are_released()
{
var requests = new ScreenAwakeRequests();
var states = new List<bool>();
requests.Changed += () => states.Add(requests.IsRequested);
Assert.False(requests.IsRequested);
var playback = requests.Acquire();
var capture = requests.Acquire();
playback.Dispose();
Assert.True(requests.IsRequested);
playback.Dispose();
capture.Dispose();
Assert.False(requests.IsRequested);
Assert.Equal(new[] { true, true, true, false }, states);
}
}

View file

@ -19,7 +19,7 @@ internal sealed class LibVlcPodcastCapture : IPodcastCapture
public Task Completion => _completion.Task;
public Control? Preview => _preview;
public LibVlcPodcastCapture(Func<LibVLC, Media> createMedia, string directory, bool preview = false)
public LibVlcPodcastCapture(Func<LibVLC, Media> createMedia, string? directory, bool preview = false)
{
Core.Initialize();
// The usual Linux capture instance has a dummy video output; preview needs a drawable.
@ -27,7 +27,8 @@ internal sealed class LibVlcPodcastCapture : IPodcastCapture
try
{
_media = createMedia(_vlc);
PodcastBroadcaster.ConfigureOutput(_media, directory, preview);
if (directory is not null) PodcastBroadcaster.ConfigureOutput(_media, directory, preview);
else _media.AddOption(":no-audio");
_player = new MediaPlayer(_vlc);
if (preview)
{

View file

@ -12,6 +12,7 @@ namespace PostIt.Services;
/// </summary>
public static class Platform
{
public static ScreenAwakeRequests ScreenAwake { get; } = new();
private static bool _isApplicationActive = !OperatingSystem.IsAndroid();
public static event Action? ApplicationActivityChanged;
public static bool IsApplicationActive
@ -31,6 +32,7 @@ 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; }
public static Func<string, CancellationToken, Task<IPodcastCapture>>? CreatePodcastPreviewAsync { get; set; }
/// <summary>Platform-specific video surface inside the shared video page.</summary>
public static Func<MediaPlayer, bool, Control>? CreateVideoSurface { get; set; }

View file

@ -0,0 +1,31 @@
using System;
using System.Threading;
namespace PostIt.Services;
public sealed class ScreenAwakeRequests
{
private int _count;
public bool IsRequested => Volatile.Read(ref _count) > 0;
public event Action? Changed;
public IDisposable Acquire()
{
Interlocked.Increment(ref _count);
Changed?.Invoke();
return new Request(this);
}
private sealed class Request(ScreenAwakeRequests owner) : IDisposable
{
private ScreenAwakeRequests? _owner = owner;
public void Dispose()
{
var current = Interlocked.Exchange(ref _owner, null);
if (current is null) return;
Interlocked.Decrement(ref current._count);
current.Changed?.Invoke();
}
}
}

View file

@ -34,7 +34,48 @@ public partial class PodcastsPageViewModel : ViewModelBase
private PodcastResponse? _selected;
private Control? _capturePreview;
private bool _restoringPreferences;
public Control? CapturePreview { get => _capturePreview; private set => SetProperty(ref _capturePreview, value); }
private bool _keepScreenOnDuringCapture;
private IDisposable? _screenAwake;
public bool SupportsCaptureScreenAwake => OperatingSystem.IsAndroid();
public bool KeepScreenOnDuringCapture
{
get => _keepScreenOnDuringCapture;
set
{
if (!SetProperty(ref _keepScreenOnDuringCapture, value)) return;
UpdateScreenAwake();
if (!_restoringPreferences)
{
_api.Settings.PodcastKeepScreenOn = value;
SavePreferences();
}
}
}
private void UpdateScreenAwake()
{
if (KeepScreenOnDuringCapture && IsCapturing)
_screenAwake ??= Platform.ScreenAwake.Acquire();
else
{
_screenAwake?.Dispose();
_screenAwake = null;
}
}
private readonly SemaphoreSlim _previewGate = new(1, 1);
private IPodcastCapture? _previewCapture;
public bool IsPreviewing => _previewCapture is not null;
public bool IsCapturing => IsPreviewing || IsBroadcasting;
public bool HasCapturePreview => CapturePreview is not null;
public Control? CapturePreview
{
get => _capturePreview;
private set
{
if (SetProperty(ref _capturePreview, value)) OnPropertyChanged(nameof(HasCapturePreview));
}
}
public ObservableCollection<PodcastResponse> Podcasts { get; } = new();
public ObservableCollection<CaptureDevice> Cameras { get; } = new();
@ -73,7 +114,13 @@ public partial class PodcastsPageViewModel : ViewModelBase
public bool IsBroadcasting
{
get => _isBroadcasting;
private set { if (SetProperty(ref _isBroadcasting, value)) UpdateCommands(); }
private set
{
if (!SetProperty(ref _isBroadcasting, value)) return;
OnPropertyChanged(nameof(IsCapturing));
UpdateScreenAwake();
UpdateCommands();
}
}
public bool IsBusy
@ -135,6 +182,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
public IAsyncRelayCommand RefreshCommand { get; }
public IRelayCommand DetectDevicesCommand { get; }
public IAsyncRelayCommand StartCommand { get; }
public IAsyncRelayCommand PreviewCommand { get; }
public IAsyncRelayCommand StopPreviewCommand { get; }
public IAsyncRelayCommand PrepareCommand { get; }
public IAsyncRelayCommand UpdatePlanCommand { get; }
public IRelayCommand NewPlanCommand { get; }
@ -152,7 +201,10 @@ public partial class PodcastsPageViewModel : ViewModelBase
_api = api;
_session = session;
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsBroadcasting && !IsBusy);
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsCapturing && !IsBusy);
PreviewCommand = new AsyncRelayCommand(StartPreviewAsync,
() => CanBroadcast && !IsCapturing && !IsBusy && _session.IsLoggedIn && SelectedCamera is not null);
StopPreviewCommand = new AsyncRelayCommand(() => StopPreviewAsync(), () => IsPreviewing && !IsBusy);
StartCommand = new AsyncRelayCommand(StartAsync,
() => CanBroadcast && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title)
@ -178,6 +230,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
() => SelectedPodcast is { State: PodcastState.Completed, Replay.State: PodcastReplayState.Failed } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
_title = _api.Settings.PodcastDefaultTitle;
_keepScreenOnDuringCapture = _api.Settings.PodcastKeepScreenOn;
_restoringPreferences = true;
try
{
@ -210,6 +263,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
RefreshCommand.NotifyCanExecuteChanged();
DetectDevicesCommand.NotifyCanExecuteChanged();
StartCommand.NotifyCanExecuteChanged();
PreviewCommand.NotifyCanExecuteChanged();
StopPreviewCommand.NotifyCanExecuteChanged();
PrepareCommand.NotifyCanExecuteChanged();
UpdatePlanCommand.NotifyCanExecuteChanged();
NewPlanCommand.NotifyCanExecuteChanged();
@ -270,6 +325,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
_restoringPreferences = true;
try
{
KeepScreenOnDuringCapture = _api.Settings.PodcastKeepScreenOn;
Cameras.Clear();
Microphones.Clear();
foreach (var device in _api.Settings.PodcastCameras) Cameras.Add(device);
@ -288,6 +344,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
public async Task DeactivateAsync()
{
_pageCancellation.Cancel();
await StopPreviewAsync();
if (_broadcaster is not null) await StopAsync();
}
@ -325,11 +382,88 @@ public partial class PodcastsPageViewModel : ViewModelBase
finally { _restoringPreferences = false; }
}
private async Task StartPreviewAsync()
{
IsBusy = true;
await _previewGate.WaitAsync();
IPodcastCapture? capture = null;
try
{
var ct = _pageCancellation.Token;
ct.ThrowIfCancellationRequested();
if (_previewCapture is not null) return;
var camera = SelectedCamera?.Value ?? throw new InvalidOperationException("Choisissez une camera.");
capture = Platform.CreatePodcastPreviewAsync is { } createPreview
? await createPreview(camera, ct)
: new LibVlcPodcastCapture(
vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, null), null, preview: true);
ct.ThrowIfCancellationRequested();
if (!_session.IsLoggedIn) throw new InvalidOperationException("La session a ete deconnectee.");
if (capture.Preview is null) throw new InvalidOperationException("Apercu camera indisponible.");
_previewCapture = capture;
CapturePreview = capture.Preview;
_session.LogoutCompleted += OnLogout;
UpdatePreviewState();
Status = "Previsualisation locale uniquement : aucune emission, aucun enregistrement, microphone inactif.";
_ = ObservePreviewAsync(capture);
capture = null;
}
catch (Exception ex) { Status = $"Previsualisation impossible : {ex.Message}"; }
finally
{
try { if (capture is not null) await capture.DisposeAsync(); }
catch (Exception ex) { Status += $" Nettoyage : {ex.Message}"; }
_previewGate.Release();
IsBusy = false;
}
}
private void UpdatePreviewState()
{
OnPropertyChanged(nameof(IsPreviewing));
OnPropertyChanged(nameof(IsCapturing));
UpdateScreenAwake();
UpdateCommands();
}
private async Task ObservePreviewAsync(IPodcastCapture capture)
{
string? failure = null;
try { await capture.Completion; }
catch (Exception ex) { failure = ex.Message; }
await StopPreviewAsync(capture, failure);
}
private async Task<bool> StopPreviewAsync(IPodcastCapture? expected = null, string? failure = null)
{
await _previewGate.WaitAsync();
try
{
if (_previewCapture is not { } capture || (expected is not null && expected != capture)) return true;
_previewCapture = null;
CapturePreview = null;
_session.LogoutCompleted -= OnLogout;
try { await capture.DisposeAsync(); }
finally { UpdatePreviewState(); }
Status = failure is null ? "Previsualisation arretee." : $"Previsualisation interrompue : {failure}";
return true;
}
catch (Exception ex)
{
Status = $"Arret de la previsualisation impossible : {ex.Message}";
return false;
}
finally { _previewGate.Release(); }
}
private async Task StartAsync()
{
IsBusy = true;
try
{
if (!await StopPreviewAsync())
throw new InvalidOperationException("Impossible de liberer la camera de previsualisation.");
_pageCancellation.Token.ThrowIfCancellationRequested();
if (SelectedCamera is null) throw new InvalidOperationException("Choisissez une camera.");
var camera = SelectedCamera.Value;
var microphone = string.IsNullOrWhiteSpace(SelectedMicrophone?.Value) ? null : SelectedMicrophone.Value;
@ -396,6 +530,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
private async void OnLogout()
{
// Releasing capture is required even after the HTTP credentials have been cleared.
await StopPreviewAsync();
await StopAsync();
}

View file

@ -59,6 +59,8 @@ public partial class Settings : ViewModelBase
[ObservableProperty]
public partial string? PodcastMicrophoneId { get; set; }
[ObservableProperty]
public partial bool PodcastKeepScreenOn { get; set; }
[ObservableProperty]
public partial string? LanCallAddress { get; set; }
[ObservableProperty]
public partial bool AndroidKeepChatConnected { get; set; }
@ -166,6 +168,7 @@ public partial class Settings : ViewModelBase
partial void OnPodcastMicrophonesChanged(CaptureDevice[] value) => MarkDirty();
partial void OnPodcastCameraIdChanged(string? value) => MarkDirty();
partial void OnPodcastMicrophoneIdChanged(string? value) => MarkDirty();
partial void OnPodcastKeepScreenOnChanged(bool value) => MarkDirty();
partial void OnLanCallAddressChanged(string? value) => MarkDirty();
partial void OnAndroidKeepChatConnectedChanged(bool value) => MarkDirty();
partial void OnChatRoomsByAccountChanged(Dictionary<string, string[]> value) => MarkDirty();
@ -431,6 +434,7 @@ public partial class Settings : ViewModelBase
PodcastMicrophones = settings.PodcastMicrophones ?? [];
PodcastCameraId = settings.PodcastCameraId;
PodcastMicrophoneId = settings.PodcastMicrophoneId;
PodcastKeepScreenOn = settings.PodcastKeepScreenOn;
LanCallAddress = settings.LanCallAddress;
AndroidKeepChatConnected = settings.AndroidKeepChatConnected;
ChatRoomsByAccount = settings.ChatRoomsByAccount ?? new();

View file

@ -70,7 +70,7 @@
<Button Content="Enregistrer les modifications de l'emission selectionnee" Command="{Binding UpdatePlanCommand}" />
</WrapPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding IsBroadcasting}">
<StackPanel Spacing="8" IsVisible="{Binding HasCapturePreview}">
<TextBlock Text="Apercu local de la capture" FontWeight="SemiBold" />
<Border Background="Black" CornerRadius="4" ClipToBounds="True">
<ContentControl x:Name="CapturePreviewHost" Content="{Binding CapturePreview}" Height="300"
@ -79,7 +79,8 @@
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding CanBroadcast}" IsEnabled="{Binding !IsBroadcasting}">
<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"
IsEnabled="{Binding !IsCapturing}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
@ -89,9 +90,16 @@
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
<WrapPanel>
<Button Content="Previsualiser la camera" Command="{Binding PreviewCommand}" Margin="0,0,8,8" />
<Button Content="Arreter la previsualisation" Command="{Binding StopPreviewCommand}" Margin="0,0,8,8" />
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
</WrapPanel>
</StackPanel>
<Button Content="Arreter et publier le replay" Command="{Binding StopCommand}" />
<CheckBox Content="Garder l'ecran allume pendant l'apercu camera et le direct"
IsVisible="{Binding SupportsCaptureScreenAwake}"
IsChecked="{Binding KeepScreenOnDuringCapture, Mode=TwoWay}" />
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Button Content="Actualiser les directs et replays" Command="{Binding RefreshCommand}" />
<ListBox ItemsSource="{Binding Podcasts}" SelectedItem="{Binding SelectedPodcast}" MinHeight="160" MaxHeight="420"