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:
Paul Schneider 2026-11-02 15:17:23 +00:00
commit 377afc711a
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
8 changed files with 236 additions and 13 deletions

View file

@ -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.

View file

@ -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

View file

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

View file

@ -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]

View file

@ -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."));

View file

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

View file

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

View file

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