From 377afc711a093ed7ad8aa7d943fdafeec2b7d013 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 2 Nov 2026 15:17:23 +0000 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + doc/Architecture.md | 8 ++ .../PostIt.Tests/LanCallViewModelTests.cs | 91 ++++++++++++++ .../PostIt.Tests/PodcastPreferencesTests.cs | 20 +++- .../PostIt/ViewModels/Chat/ChatViewModel.cs | 2 +- .../ViewModels/Chat/LanCallViewModel.cs | 113 ++++++++++++++++-- .../ViewModels/PodcastsPageViewModel.cs | 10 ++ .../PostIt/ViewModels/Settings/Settings.cs | 4 + 8 files changed, 236 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b544604..8efc50e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/doc/Architecture.md b/doc/Architecture.md index 17df96891..8fb43ee2b 100644 --- a/doc/Architecture.md +++ b/doc/Architecture.md @@ -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 diff --git a/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs b/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs index 6afe892a2..316997292 100644 --- a/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs @@ -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); diff --git a/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs b/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs index ff822e492..cd7acbb53 100644 --- a/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs +++ b/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs @@ -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 CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + object response = path.EndsWith("/account/me", StringComparison.Ordinal) + ? new Me { UserId = "owner" } : new List(); + return Task.FromResult((T)response); + } } [AvaloniaFact] diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs index 50dacd192..d44ebcd65 100644 --- a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs @@ -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.")); diff --git a/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs index 5753e7b36..a2055f913 100644 --- a/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs @@ -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 Addresses { get; } = []; public ObservableCollection 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? createMedia = null) + public LanCallViewModel(ILanCallSignaling signaling, Func? 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(); } } diff --git a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs index 109fcb242..06ba92e1a 100644 --- a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs @@ -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(); diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 49707c036..b8bbb2702 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -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(); /// /// 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();