feat(calls): add Android LAN peer calls using Podcast capture
Share the LibVLC SRTP engine with Linux and feed Android MediaRecorder output through its native pipe without transcoding or HLS files. Reuse front-camera capture, preview, remote rendering and foreground service with consent-based controls and background cleanup. Use separate RTP/RTCP ports to avoid the diagnosed Android VLC SDP crash. Add physical-device media checks and lifecycle regressions; document LAN/VPN scope and remaining separate-device validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
cc28ee8b29
commit
a676ef3575
19 changed files with 718 additions and 105 deletions
117
src/PostIt/PostIt.Android/Services/AndroidLanCallMedia.cs
Normal file
117
src/PostIt/PostIt.Android/Services/AndroidLanCallMedia.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Chat.Calls;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
internal sealed class AndroidLanCallMedia : ILanCallMedia
|
||||
{
|
||||
private readonly LibVlcLanCallMedia _media;
|
||||
private readonly ContentControl _local = new();
|
||||
private readonly AndroidVideoSurface _remote;
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private readonly TaskCompletionSource _captureEnded = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly Task _receiving;
|
||||
private IPodcastCapture? _capture;
|
||||
private bool _disposed;
|
||||
|
||||
public Control LocalVideo => _local;
|
||||
public Control RemoteVideo => _remote;
|
||||
public LanCallEndpoint Endpoint => _media.Endpoint;
|
||||
public bool HasRemoteMedia => !_disposed && _media.HasRemoteMedia;
|
||||
public Task Completion { get; }
|
||||
|
||||
public static PodcastCaptureDevices GetDevices()
|
||||
{
|
||||
var devices = AndroidPodcastCapture.GetDevices();
|
||||
return new(devices.Cameras.OrderByDescending(c => c.Name == "Camera avant").ToArray(),
|
||||
devices.Microphones.Where(m => m.Value == "microphone").ToArray());
|
||||
}
|
||||
|
||||
public AndroidLanCallMedia(string localAddress, int port)
|
||||
{
|
||||
_media = new LibVlcLanCallMedia(localAddress, port);
|
||||
_remote = new AndroidVideoSurface(_media.Receiver, replay: false);
|
||||
_receiving = ReceiveAsync();
|
||||
Completion = ObserveAsync();
|
||||
}
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
await _remote.Ready.WaitAsync(TimeSpan.FromSeconds(10), _lifetime.Token);
|
||||
await _media.StartReceivingAsync(_lifetime.Token);
|
||||
}
|
||||
|
||||
private async Task ObserveAsync()
|
||||
{
|
||||
var first = await Task.WhenAny(_media.Completion, _captureEnded.Task, _receiving);
|
||||
await first;
|
||||
if (first == _receiving)
|
||||
{
|
||||
var finished = await Task.WhenAny(_media.Completion, _captureEnded.Task);
|
||||
await finished;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_capture != null) throw new InvalidOperationException("La capture de cet appel est deja demarree.");
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
|
||||
_capture = await AndroidPodcastCapture.StartCallAsync(camera, microphone,
|
||||
(fd, token) => _media.StartEncodedSendingAsync(remote, fd, token), _media.StopAsync, linked.Token);
|
||||
_local.Content = _capture.Preview;
|
||||
_ = ObserveCaptureAsync(_capture);
|
||||
}
|
||||
|
||||
private async Task ObserveCaptureAsync(IPodcastCapture capture)
|
||||
{
|
||||
try
|
||||
{
|
||||
await capture.Completion;
|
||||
if (!_disposed)
|
||||
_captureEnded.TrySetException(new InvalidOperationException("Capture de l'appel arretee."));
|
||||
}
|
||||
catch (Exception error) { _captureEnded.TrySetException(error); }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_lifetime.Cancel();
|
||||
var stop = _capture?.StopAsync();
|
||||
try
|
||||
{
|
||||
if (stop != null) await stop;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_capture != null) await _capture.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_local.Content = null;
|
||||
try { await _media.StopAsync(); }
|
||||
finally
|
||||
{
|
||||
_remote.Dispose();
|
||||
try { await _media.DisposeAsync(); }
|
||||
finally
|
||||
{
|
||||
_lifetime.Dispose();
|
||||
_captureEnded.TrySetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue