screen orientation and awake
This commit is contained in:
parent
256fd5738c
commit
fa8038c676
25 changed files with 941 additions and 110 deletions
|
|
@ -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.
|
||||
|
|
|
|||
10
INSTALL.md
10
INSTALL.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ internal static class PlatformBootstrap
|
|||
Platform.GetPodcastCaptureDevices = AndroidPodcastCapture.GetDevices;
|
||||
Platform.RequestPodcastPermissionsAsync = AndroidPodcastCapture.RequestPermissionsAsync;
|
||||
Platform.CreatePodcastCaptureAsync = AndroidPodcastCapture.StartAsync;
|
||||
Platform.CreatePodcastPreviewAsync = AndroidPodcastCapture.StartPreviewAsync;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.*).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
118
src/PostIt/PostIt.Tests/AndroidScreenAwakeTestActivity.cs
Normal file
118
src/PostIt/PostIt.Tests/AndroidScreenAwakeTestActivity.cs
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 () =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
23
src/PostIt/PostIt.Tests/ScreenAwakeRequestsTests.cs
Normal file
23
src/PostIt/PostIt.Tests/ScreenAwakeRequestsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
31
src/PostIt/PostIt/Services/ScreenAwakeRequests.cs
Normal file
31
src/PostIt/PostIt/Services/ScreenAwakeRequests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue