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

@ -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();
}
}
}