Share podcast devices with calls and persist network selection
Reuse camera and microphone preferences across podcast and chat-room pages. Restore available LAN/VPN addresses without persisting disclosure consent, and cover shared preferences and save failures with regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ff1a016c91
commit
377afc711a
8 changed files with 236 additions and 13 deletions
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
* [PostIt] Share podcast camera/microphone preferences with chat-room calls and persist the selected LAN/VPN address. Restore only currently available network addresses without remembering address-disclosure consent; require an explicit microphone choice for calls when podcasts use no microphone.
|
||||
* [PostIt] Remember detected podcast cameras/microphones, selected devices (including no microphone) and the default title in user settings; restore them when reopening the page and report persistence failures.
|
||||
* [Podcasts] Reuse persisted LiveFlow entities to prepare, edit and delete emissions from PostIt with a title, scheduled start and estimated duration. Keep start/stop manual and plans private; optionally publish presentation-only announcements on Yavsc.Org without opening anonymous playback. Preserve broadcast identifiers, legacy recordings and authenticated HLS/MP4 access; add a scheduling migration and lifecycle/privacy regressions.
|
||||
* [PostIt/API/Yavsc.Org] Expose the private PayPal recipient setting in PostIt's performer editor and authenticated profile API; validate addresses, allow disabling with an empty value and preserve the setting for older clients. Make Web donation settings discoverable from the performer profile and podcast catalogue.
|
||||
|
|
|
|||
|
|
@ -93,6 +93,14 @@ le materiel disponible en conservant les choix encore presents. Le choix
|
|||
« sans microphone » est memorise lui aussi. Charger le titre d'une emission
|
||||
preparee ne remplace pas le titre par defaut ; une saisie explicite le modifie.
|
||||
Les erreurs de sauvegarde sont affichees dans la page.
|
||||
Les appels des salons de discussion reutilisent ces memes choix camera/micro
|
||||
et enregistrent leurs changements dans les memes parametres. La page Podcasts
|
||||
les recharge a son activation. Les appels exigent toujours un microphone :
|
||||
le choix podcast « sans microphone » laisse donc le micro d'appel non selectionne.
|
||||
L'adresse LAN/VPN choisie pour les appels est aussi memorisee, mais restauree
|
||||
uniquement si elle est encore presente sur une interface locale active.
|
||||
Sinon, une nouvelle selection est requise. Le consentement au partage
|
||||
d'adresse avec le pair n'est jamais memorise.
|
||||
|
||||
PostIt permet de preparer, modifier et supprimer une emission avant son
|
||||
demarrage : titre, date/heure locale, duree entiere positive en minutes et
|
||||
|
|
|
|||
|
|
@ -2,12 +2,103 @@ using Avalonia.Controls;
|
|||
using Avalonia.Headless.XUnit;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Abstract.Chat.Calls;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class LanCallViewModelTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public async Task Calls_restore_shared_devices_and_persist_choices_and_network_address()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"postit-call-preferences-{Guid.NewGuid():N}.json");
|
||||
var previousDevices = Platform.GetLanCallDevices;
|
||||
try
|
||||
{
|
||||
var settings = new Settings(path)
|
||||
{
|
||||
PodcastCameras = [new("Front", "front"), new("Rear", "rear")],
|
||||
PodcastMicrophones = [new("None", ""), new("USB", "usb"), new("Built-in", "built-in")],
|
||||
PodcastCameraId = "rear", PodcastMicrophoneId = "usb"
|
||||
};
|
||||
await using (var vm = new LanCallViewModel(new Signaling(), settings: settings))
|
||||
{
|
||||
Assert.Equal("rear", vm.Camera?.Value);
|
||||
Assert.Equal("usb", vm.Microphone?.Value);
|
||||
Assert.False(vm.AllowAddressDisclosure);
|
||||
vm.Camera = vm.Cameras[0];
|
||||
vm.Microphone = vm.Microphones[1];
|
||||
Assert.Equal("front", settings.PodcastCameraId);
|
||||
Assert.Equal("built-in", settings.PodcastMicrophoneId);
|
||||
Assert.NotEmpty(vm.Addresses);
|
||||
vm.Address = vm.Addresses.Last();
|
||||
settings.Save();
|
||||
var preferred = vm.Address;
|
||||
Platform.GetLanCallDevices = () => new(
|
||||
[new("Rear", "rear"), new("Front", "front")],
|
||||
[new("Built-in", "built-in"), new("USB", "usb")]);
|
||||
vm.RefreshDevicesCommand.Execute(null);
|
||||
Assert.Equal("front", vm.Camera?.Value);
|
||||
Assert.Equal("built-in", vm.Microphone?.Value);
|
||||
Assert.Equal(preferred, vm.Address);
|
||||
Assert.Contains(settings.PodcastMicrophones, d => d.Value == "");
|
||||
|
||||
var reloaded = new Settings(path);
|
||||
reloaded.Authentication.Authority = "";
|
||||
reloaded.Load();
|
||||
await using var restored = new LanCallViewModel(new Signaling(), settings: reloaded);
|
||||
Assert.Equal(preferred, restored.Address);
|
||||
Assert.Equal("front", restored.Camera?.Value);
|
||||
Assert.Equal("built-in", restored.Microphone?.Value);
|
||||
Assert.False(restored.AllowAddressDisclosure);
|
||||
settings.PodcastCameraId = "rear";
|
||||
Assert.Equal("rear", vm.Camera?.Value);
|
||||
settings.PodcastMicrophoneId = "";
|
||||
Assert.Null(vm.Microphone);
|
||||
vm.AllowAddressDisclosure = true;
|
||||
vm.Room = "General";
|
||||
vm.Target = "bob";
|
||||
await vm.InviteCommand.ExecuteAsync(null);
|
||||
Assert.Contains("microphone", vm.Status);
|
||||
}
|
||||
}
|
||||
finally { Platform.GetLanCallDevices = previousDevices; File.Delete(path); }
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Call_preference_save_failures_are_reported_without_losing_the_choice()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"postit-call-preferences-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
try
|
||||
{
|
||||
var settings = new Settings(path);
|
||||
await using var vm = new LanCallViewModel(new Signaling(), settings: settings);
|
||||
vm.Address = null;
|
||||
vm.Address = "127.0.0.1";
|
||||
Assert.Equal("127.0.0.1", settings.LanCallAddress);
|
||||
Assert.Contains("sauvegarde impossible", vm.Status);
|
||||
Assert.True(settings.IsDirty);
|
||||
}
|
||||
finally { Directory.Delete(path); }
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Missing_saved_network_address_requires_a_new_selection_and_does_not_restore_consent()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"postit-call-preferences-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var settings = new Settings(path) { LanCallAddress = "not-a-local-address" };
|
||||
await using var vm = new LanCallViewModel(new Signaling(), settings: settings);
|
||||
Assert.Null(vm.Address);
|
||||
Assert.False(vm.AllowAddressDisclosure);
|
||||
Assert.Equal("not-a-local-address", settings.LanCallAddress);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
private static CallSession Session() => new(Guid.NewGuid(), "General", "alice", "bob", false,
|
||||
DateTimeOffset.UtcNow.AddMinutes(1), CallTransports.LanSrtp);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ using Avalonia.Headless.XUnit;
|
|||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
|
|
@ -37,13 +39,18 @@ public sealed class PodcastPreferencesTests
|
|||
Assert.Equal("rear", restored.PodcastCameraId);
|
||||
Assert.Equal("", restored.PodcastMicrophoneId);
|
||||
Platform.GetPodcastCaptureDevices = () => throw new InvalidOperationException("Must use saved devices.");
|
||||
await using var otherApi = new YavscApiClient(restored, new TokenStore(path + ".tokens"));
|
||||
await using var otherApi = new PreferencesApi(restored, new TokenStore(path + ".tokens"));
|
||||
var other = new PodcastsPageViewModel(new PodcastApiClient(otherApi, () => restored.ApiUrl), otherApi, session);
|
||||
Assert.Equal(2, other.Cameras.Count);
|
||||
Assert.Equal(2, other.Microphones.Count);
|
||||
Assert.Equal("rear", other.SelectedCamera?.Value);
|
||||
Assert.Equal("", other.SelectedMicrophone?.Value);
|
||||
Assert.Equal(vm.Title, other.Title);
|
||||
restored.PodcastCameraId = "front";
|
||||
restored.PodcastMicrophoneId = "usb";
|
||||
await other.ActivateAsync();
|
||||
Assert.Equal("front", other.SelectedCamera?.Value);
|
||||
Assert.Equal("usb", other.SelectedMicrophone?.Value);
|
||||
|
||||
Platform.GetPodcastCaptureDevices = () => new([], []);
|
||||
other.DetectDevicesCommand.Execute(null);
|
||||
|
|
@ -57,6 +64,17 @@ public sealed class PodcastPreferencesTests
|
|||
File.Delete(path);
|
||||
File.Delete(path + ".tokens");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private sealed class PreferencesApi(Settings settings, TokenStore store) : YavscApiClient(settings, store)
|
||||
{
|
||||
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
object response = path.EndsWith("/account/me", StringComparison.Ordinal)
|
||||
? new Me { UserId = "owner" } : new List<PodcastResponse>();
|
||||
return Task.FromResult((T)response);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ namespace PostIt.ViewModels.Chat
|
|||
|
||||
Calls = new CallSignalingClient(_connection);
|
||||
if (SupportsLanCalls)
|
||||
LanCall = new LanCallViewModel(Calls) { Room = SelectedRoom ?? "" };
|
||||
LanCall = new LanCallViewModel(Calls, settings: _settings) { Room = SelectedRoom ?? "" };
|
||||
else
|
||||
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; transport non disponible sur cette plateforme."));
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
private string? _activeMicrophone;
|
||||
private string? _activeAddress;
|
||||
private int _activePort;
|
||||
private readonly Settings? _settings;
|
||||
private bool _restoringPreferences;
|
||||
|
||||
public ObservableCollection<string> Addresses { get; } = [];
|
||||
public ObservableCollection<CaptureDevice> Cameras { get; } = [];
|
||||
|
|
@ -53,9 +55,11 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
[ObservableProperty] public partial Control? RemoteVideo { get; set; }
|
||||
public bool CanConfigure => !Busy || Incoming;
|
||||
|
||||
public LanCallViewModel(ILanCallSignaling signaling, Func<string, int, ILanCallMedia>? createMedia = null)
|
||||
public LanCallViewModel(ILanCallSignaling signaling, Func<string, int, ILanCallMedia>? createMedia = null,
|
||||
Settings? settings = null)
|
||||
{
|
||||
_signaling = signaling;
|
||||
_settings = settings;
|
||||
_createMedia = createMedia ?? Platform.CreateLanCallMedia ??
|
||||
((address, port) => new LinuxLanCallVideo(address, port));
|
||||
signaling.Invitation += OnInvitation;
|
||||
|
|
@ -64,22 +68,98 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
signaling.Ended += OnEnded;
|
||||
signaling.Interrupted += OnInterrupted;
|
||||
Platform.PlaybackSuspending += OnSuspending;
|
||||
if (settings != null)
|
||||
{
|
||||
_restoringPreferences = true;
|
||||
try
|
||||
{
|
||||
foreach (var device in settings.PodcastCameras) Cameras.Add(device);
|
||||
foreach (var device in settings.PodcastMicrophones.Where(d => !string.IsNullOrEmpty(d.Value)))
|
||||
Microphones.Add(device);
|
||||
RestoreDevices();
|
||||
RefreshAddresses();
|
||||
}
|
||||
catch (NetworkInformationException ex) { Status = $"Adresses reseau indisponibles : {ex.Message}"; }
|
||||
finally { _restoringPreferences = false; }
|
||||
settings.PropertyChanged += OnSettingsChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
|
||||
{
|
||||
if (_restoringPreferences || !CanConfigure) return;
|
||||
if (args.PropertyName is not (nameof(Settings.PodcastCameraId) or nameof(Settings.PodcastMicrophoneId)
|
||||
or nameof(Settings.PodcastCameras) or nameof(Settings.PodcastMicrophones))) return;
|
||||
_restoringPreferences = true;
|
||||
try
|
||||
{
|
||||
Cameras.Clear();
|
||||
Microphones.Clear();
|
||||
foreach (var device in _settings!.PodcastCameras) Cameras.Add(device);
|
||||
foreach (var device in _settings.PodcastMicrophones.Where(d => !string.IsNullOrEmpty(d.Value))) Microphones.Add(device);
|
||||
RestoreDevices();
|
||||
}
|
||||
finally { _restoringPreferences = false; }
|
||||
}
|
||||
|
||||
private void RestoreDevices()
|
||||
{
|
||||
Camera = Cameras.FirstOrDefault(d => d.Value == _settings?.PodcastCameraId) ?? Cameras.FirstOrDefault();
|
||||
Microphone = _settings?.PodcastMicrophoneId == "" ? null
|
||||
: Microphones.FirstOrDefault(d => d.Value == _settings?.PodcastMicrophoneId) ?? Microphones.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void RefreshAddresses()
|
||||
{
|
||||
var preferred = _settings?.LanCallAddress ?? Address;
|
||||
Addresses.Clear();
|
||||
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(n => n.OperationalStatus == OperationalStatus.Up)
|
||||
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
|
||||
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
|
||||
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
|
||||
Addresses.Add(address);
|
||||
Address = preferred == null ? Addresses.FirstOrDefault() : Addresses.FirstOrDefault(a => a == preferred);
|
||||
}
|
||||
|
||||
partial void OnCameraChanged(CaptureDevice? value)
|
||||
{
|
||||
if (_restoringPreferences || _settings == null || value == null) return;
|
||||
_settings.PodcastCameraId = value.Value;
|
||||
SavePreferences();
|
||||
}
|
||||
|
||||
partial void OnMicrophoneChanged(CaptureDevice? value)
|
||||
{
|
||||
if (_restoringPreferences || _settings == null || value == null) return;
|
||||
_settings.PodcastMicrophoneId = value.Value;
|
||||
SavePreferences();
|
||||
}
|
||||
|
||||
partial void OnAddressChanged(string? value)
|
||||
{
|
||||
if (_restoringPreferences || _settings == null || value == null) return;
|
||||
_settings.LanCallAddress = value;
|
||||
SavePreferences();
|
||||
}
|
||||
|
||||
private void SavePreferences()
|
||||
{
|
||||
try { _settings?.Save(); }
|
||||
catch (Exception ex) when (ex is System.IO.IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Status = $"Preferences d'appel appliquees, mais sauvegarde impossible : {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshDevices()
|
||||
{
|
||||
_restoringPreferences = true;
|
||||
try
|
||||
{
|
||||
if (!CanConfigure) throw new InvalidOperationException("Terminez l'appel avant de changer les peripheriques.");
|
||||
Addresses.Clear();
|
||||
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(n => n.OperationalStatus == OperationalStatus.Up)
|
||||
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
|
||||
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
|
||||
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
|
||||
Addresses.Add(address);
|
||||
Address = Addresses.FirstOrDefault();
|
||||
RefreshAddresses();
|
||||
Cameras.Clear();
|
||||
Microphones.Clear();
|
||||
if (Platform.GetLanCallDevices is { } getDevices)
|
||||
|
|
@ -97,14 +177,24 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
foreach (var device in streamer.GetCaptureDevices()) Cameras.Add(new(device.Key, device.Value));
|
||||
foreach (var device in streamer.GetAudioCaptureDevices()) Microphones.Add(new(device.Key, device.Value));
|
||||
}
|
||||
Camera = Cameras.FirstOrDefault();
|
||||
Microphone = Microphones.FirstOrDefault();
|
||||
RestoreDevices();
|
||||
Status = Cameras.Count == 0 ? "Aucune camera detectee. Verifiez les peripheriques puis actualisez."
|
||||
: Incoming && _call != null
|
||||
? $"{_call.Caller} propose un appel dans {_call.RoomName}. Selectionnez vos peripheriques puis acceptez ou refusez."
|
||||
: "Selectionnez le pair, l'adresse LAN/VPN et les peripheriques. Utilisez un casque.";
|
||||
if (_settings != null)
|
||||
{
|
||||
_settings.PodcastCameras = Cameras.ToArray();
|
||||
var noMicrophone = _settings.PodcastMicrophones.Where(d => string.IsNullOrEmpty(d.Value));
|
||||
_settings.PodcastMicrophones = noMicrophone.Concat(Microphones).ToArray();
|
||||
if (Camera != null) _settings.PodcastCameraId = Camera.Value;
|
||||
if (Microphone != null) _settings.PodcastMicrophoneId = Microphone.Value;
|
||||
if (Address != null) _settings.LanCallAddress = Address;
|
||||
SavePreferences();
|
||||
}
|
||||
}
|
||||
catch (Exception error) { Status = "Peripheriques indisponibles : " + error.Message; }
|
||||
finally { _restoringPreferences = false; }
|
||||
}
|
||||
|
||||
private void ValidateSelection()
|
||||
|
|
@ -324,6 +414,7 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
_signaling.Ended -= OnEnded;
|
||||
_signaling.Interrupted -= OnInterrupted;
|
||||
Platform.PlaybackSuspending -= OnSuspending;
|
||||
if (_settings != null) _settings.PropertyChanged -= OnSettingsChanged;
|
||||
try { await StopAsync("Appel arrete.", true); }
|
||||
finally { _lifetime.Dispose(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -267,6 +267,16 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
|
||||
public async Task ActivateAsync()
|
||||
{
|
||||
_restoringPreferences = true;
|
||||
try
|
||||
{
|
||||
Cameras.Clear();
|
||||
Microphones.Clear();
|
||||
foreach (var device in _api.Settings.PodcastCameras) Cameras.Add(device);
|
||||
foreach (var device in _api.Settings.PodcastMicrophones) Microphones.Add(device);
|
||||
RestoreDeviceSelection();
|
||||
}
|
||||
finally { _restoringPreferences = false; }
|
||||
if (_pageCancellation.IsCancellationRequested)
|
||||
{
|
||||
_pageCancellation.Dispose();
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ public partial class Settings : ViewModelBase
|
|||
public partial string? PodcastCameraId { get; set; }
|
||||
[ObservableProperty]
|
||||
public partial string? PodcastMicrophoneId { get; set; }
|
||||
[ObservableProperty]
|
||||
public partial string? LanCallAddress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[JsonIgnore]
|
||||
|
|
@ -156,6 +158,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 OnLanCallAddressChanged(string? value) => MarkDirty();
|
||||
|
||||
/// <summary>
|
||||
/// Authentication can be reassigned wholesale by
|
||||
|
|
@ -418,6 +421,7 @@ public partial class Settings : ViewModelBase
|
|||
PodcastMicrophones = settings.PodcastMicrophones ?? [];
|
||||
PodcastCameraId = settings.PodcastCameraId;
|
||||
PodcastMicrophoneId = settings.PodcastMicrophoneId;
|
||||
LanCallAddress = settings.LanCallAddress;
|
||||
if (!(settings.Authentication is null))
|
||||
{
|
||||
this.Authentication = new AuthenticationSettings();
|
||||
|
|
|
|||
Loading…
Reference in a new issue