diff --git a/CHANGELOG.md b/CHANGELOG.md index 412139a3c..41b544604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added +* [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. * [API] Initialize the required address field when creating a performer profile so a first configuration can be saved. diff --git a/doc/Architecture.md b/doc/Architecture.md index fb56fcb52..17df96891 100644 --- a/doc/Architecture.md +++ b/doc/Architecture.md @@ -86,6 +86,14 @@ une panne de l'API renvoie une erreur explicite, pas un catalogue vide. ### Emissions preparees +PostIt memorise dans ses parametres utilisateur les cameras et microphones +detectes, leurs identifiants selectionnes et le titre par defaut. La page +les restaure sans redetection automatique ; le bouton de detection actualise +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. + PostIt permet de preparer, modifier et supprimer une emission avant son demarrage : titre, date/heure locale, duree entiere positive en minutes et annonce publique optionnelle. Les horaires sont previsionnels : la camera, diff --git a/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs b/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs index 5e4f9e2ef..36bf33d4e 100644 --- a/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs +++ b/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs @@ -22,7 +22,7 @@ public sealed class PodcastBroadcasterTests var factory = Platform.CreatePodcastCaptureAsync; var store = new TokenStore(tokenPath); store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null)); - await using var api = new YavscApiClient(new Settings(), store); + await using var api = new YavscApiClient(new Settings(tokenPath + ".settings"), store); await using var transport = new Transport(); FakeCapture? capture = null; PodcastsPageViewModel? vm = null; @@ -72,6 +72,7 @@ public sealed class PodcastBroadcasterTests Platform.RequestPodcastPermissionsAsync = permissions; Platform.CreatePodcastCaptureAsync = factory; File.Delete(tokenPath); + File.Delete(tokenPath + ".settings"); } }, TestContext.Current.CancellationToken); @@ -84,7 +85,7 @@ public sealed class PodcastBroadcasterTests var factory = Platform.CreatePodcastCaptureAsync; var store = new TokenStore(tokenPath); store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null)); - await using var api = new YavscApiClient(new Settings(), store); + await using var api = new YavscApiClient(new Settings(tokenPath + ".settings"), store); await using var transport = new Transport(); try { @@ -119,6 +120,7 @@ public sealed class PodcastBroadcasterTests Platform.RequestPodcastPermissionsAsync = permissions; Platform.CreatePodcastCaptureAsync = factory; File.Delete(tokenPath); + File.Delete(tokenPath + ".settings"); } } diff --git a/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs b/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs new file mode 100644 index 000000000..ff822e492 --- /dev/null +++ b/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs @@ -0,0 +1,79 @@ +using Avalonia.Headless.XUnit; +using PostIt.Services; +using PostIt.ViewModels; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public sealed class PodcastPreferencesTests +{ + [AvaloniaFact] + public async Task Detection_selection_and_title_survive_settings_reload_without_detecting_again() + { + var path = Path.Combine(Path.GetTempPath(), $"postit-preferences-{Guid.NewGuid():N}.json"); + var previous = Platform.GetPodcastCaptureDevices; + try + { + Platform.GetPodcastCaptureDevices = () => new( + [new("Front", "front"), new("Rear", "rear")], + [new("None", ""), new("USB", "usb")]); + var settings = new Settings(path); + await using var api = new YavscApiClient(settings, new TokenStore(path + ".tokens")); + var session = new SessionStatusViewModel(); + var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => settings.ApiUrl), api, session); + vm.DetectDevicesCommand.Execute(null); + vm.SelectedCamera = vm.Cameras[1]; + vm.SelectedMicrophone = vm.Microphones[0]; + vm.Title = "Mon emission habituelle"; + vm.DetectDevicesCommand.Execute(null); + Assert.Equal("rear", vm.SelectedCamera?.Value); + Assert.Equal("", vm.SelectedMicrophone?.Value); + + var restored = new Settings(path); + restored.Authentication.Authority = ""; + restored.Load(); + Assert.False(restored.IsDirty); + Assert.Equal("Mon emission habituelle", restored.PodcastDefaultTitle); + 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")); + 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); + + Platform.GetPodcastCaptureDevices = () => new([], []); + other.DetectDevicesCommand.Execute(null); + Assert.Null(other.SelectedCamera); + Assert.Null(other.SelectedMicrophone); + Assert.Contains("Aucune camera", other.Status); + } + finally + { + Platform.GetPodcastCaptureDevices = previous; + File.Delete(path); + File.Delete(path + ".tokens"); + } + } + + [AvaloniaFact] + public async Task Failed_save_is_reported_without_losing_the_title() + { + var path = Path.Combine(Path.GetTempPath(), $"postit-preferences-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + try + { + var settings = new Settings(path); + await using var api = new YavscApiClient(settings, new TokenStore(path + ".tokens")); + var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => settings.ApiUrl), api, new()); + vm.Title = "Titre conserve"; + Assert.Equal("Titre conserve", vm.Title); + Assert.Contains("sauvegarde impossible", vm.Status); + Assert.True(settings.IsDirty); + } + finally { Directory.Delete(path); File.Delete(path + ".tokens"); } + } +} diff --git a/src/PostIt/PostIt.Tests/PodcastScheduleTests.cs b/src/PostIt/PostIt.Tests/PodcastScheduleTests.cs index b3b49bbad..16101c420 100644 --- a/src/PostIt/PostIt.Tests/PodcastScheduleTests.cs +++ b/src/PostIt/PostIt.Tests/PodcastScheduleTests.cs @@ -17,7 +17,7 @@ public sealed class PodcastScheduleTests store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null)); try { - await using var api = new ScheduleApi(store); + await using var api = new ScheduleApi(store, tokenPath + ".settings"); var session = new SessionStatusViewModel { Api = api }; session.Refresh(); var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => "https://api.example/"), api, session) @@ -53,11 +53,16 @@ public sealed class PodcastScheduleTests Assert.Null(api.Saved); Assert.Contains("Preparation impossible", vm.Status); Assert.False(vm.IsBroadcasting); + vm.SelectedPodcast = api.Plan with { Title = "Titre programme distinct" }; + Assert.Equal("Titre programme distinct", vm.Title); + Assert.Equal("Nouveau titre", api.Settings.PodcastDefaultTitle); + vm.NewPlanCommand.Execute(null); + Assert.Equal("Nouveau titre", vm.Title); } - finally { File.Delete(tokenPath); } + finally { File.Delete(tokenPath); File.Delete(tokenPath + ".settings"); } } - private sealed class ScheduleApi(TokenStore store) : YavscApiClient(new Settings(), store) + private sealed class ScheduleApi(TokenStore store, string settingsPath) : YavscApiClient(new Settings(settingsPath), store) { public PodcastResponse Plan { get; private set; } = new(Guid.NewGuid(), "Concert", "owner", "Owner", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Planned, 0, 0, 0); diff --git a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs index 2e38a9dd6..109fcb242 100644 --- a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -32,6 +33,7 @@ public partial class PodcastsPageViewModel : ViewModelBase private CaptureDevice? _microphone; private PodcastResponse? _selected; private Control? _capturePreview; + private bool _restoringPreferences; public Control? CapturePreview { get => _capturePreview; private set => SetProperty(ref _capturePreview, value); } public ObservableCollection Podcasts { get; } = new(); @@ -41,7 +43,20 @@ public partial class PodcastsPageViewModel : ViewModelBase || (!OperatingSystem.IsAndroid() && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())); - public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } } + public string Title + { + get => _title; + set + { + if (!SetProperty(ref _title, value)) return; + UpdateCommands(); + if (!_restoringPreferences) + { + _api.Settings.PodcastDefaultTitle = value; + SavePreferences(); + } + } + } [ObservableProperty] public partial DateTimeOffset? ScheduledDate { get; set; } = DateTimeOffset.Now.AddDays(1); @@ -70,13 +85,31 @@ public partial class PodcastsPageViewModel : ViewModelBase public CaptureDevice? SelectedCamera { get => _camera; - set { if (SetProperty(ref _camera, value)) UpdateCommands(); } + set + { + if (!SetProperty(ref _camera, value)) return; + UpdateCommands(); + if (!_restoringPreferences && value != null) + { + _api.Settings.PodcastCameraId = value.Value; + SavePreferences(); + } + } } public CaptureDevice? SelectedMicrophone { get => _microphone; - set { if (SetProperty(ref _microphone, value)) UpdateCommands(); } + set + { + if (!SetProperty(ref _microphone, value)) return; + UpdateCommands(); + if (!_restoringPreferences && value != null) + { + _api.Settings.PodcastMicrophoneId = value.Value; + SavePreferences(); + } + } } public PodcastResponse? SelectedPodcast @@ -87,7 +120,8 @@ public partial class PodcastsPageViewModel : ViewModelBase if (!SetProperty(ref _selected, value)) return; if (value is { State: PodcastState.Planned } && value.OwnerId == _ownerId) { - Title = value.Title; + _title = value.Title; + OnPropertyChanged(nameof(Title)); ScheduledDate = value.ScheduledStartAt?.ToLocalTime(); ScheduledTime = value.ScheduledStartAt?.ToLocalTime().TimeOfDay; PlannedDurationMinutes = value.PlannedDurationMinutes ?? 60; @@ -127,7 +161,11 @@ public partial class PodcastsPageViewModel : ViewModelBase () => !IsBusy && !IsBroadcasting && _session.IsLoggedIn && !string.IsNullOrWhiteSpace(Title)); UpdatePlanCommand = new AsyncRelayCommand(() => SavePlanAsync(true), () => !IsBusy && !IsBroadcasting && SelectedPodcast is { State: PodcastState.Planned } p && p.OwnerId == _ownerId); - NewPlanCommand = new RelayCommand(() => SelectedPodcast = null, () => !IsBusy && !IsBroadcasting); + NewPlanCommand = new RelayCommand(() => + { + SelectedPodcast = null; + Title = _api.Settings.PodcastDefaultTitle; + }, () => !IsBusy && !IsBroadcasting); StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy); PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy); FinishCommand = new AsyncRelayCommand(FinishAsync, @@ -139,6 +177,32 @@ public partial class PodcastsPageViewModel : ViewModelBase RetryReplayCommand = new AsyncRelayCommand(RetryReplayAsync, () => SelectedPodcast is { State: PodcastState.Completed, Replay.State: PodcastReplayState.Failed } p && p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy); + _title = _api.Settings.PodcastDefaultTitle; + _restoringPreferences = true; + try + { + foreach (var device in _api.Settings.PodcastCameras) Cameras.Add(device); + foreach (var device in _api.Settings.PodcastMicrophones) Microphones.Add(device); + RestoreDeviceSelection(); + } + finally { _restoringPreferences = false; } + } + + private void RestoreDeviceSelection() + { + SelectedCamera = Cameras.FirstOrDefault(d => d.Value == _api.Settings.PodcastCameraId) + ?? Cameras.FirstOrDefault(); + SelectedMicrophone = Microphones.FirstOrDefault(d => d.Value == _api.Settings.PodcastMicrophoneId) + ?? (Microphones.Count > 1 ? Microphones[1] : Microphones.FirstOrDefault()); + } + + private void SavePreferences() + { + try { _api.Settings.Save(); } + catch (Exception ex) when (ex is System.IO.IOException or UnauthorizedAccessException) + { + Status = $"Preferences podcast appliquees, mais sauvegarde impossible : {ex.Message}"; + } } private void UpdateCommands() @@ -219,6 +283,7 @@ public partial class PodcastsPageViewModel : ViewModelBase private void DetectDevices() { + _restoringPreferences = true; try { Cameras.Clear(); @@ -238,11 +303,16 @@ public partial class PodcastsPageViewModel : ViewModelBase Microphones.Add(new("Sans microphone", "")); foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value)); } - SelectedCamera = Cameras.Count == 0 ? null : Cameras[0]; - SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0]; + RestoreDeviceSelection(); + _api.Settings.PodcastCameras = Cameras.ToArray(); + _api.Settings.PodcastMicrophones = Microphones.ToArray(); + if (SelectedCamera != null) _api.Settings.PodcastCameraId = SelectedCamera.Value; + if (SelectedMicrophone != null) _api.Settings.PodcastMicrophoneId = SelectedMicrophone.Value; Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer."; + SavePreferences(); } catch (Exception ex) { Status = $"Detection impossible : {ex.Message}. Verifiez l'installation de LibVLC."; } + finally { _restoringPreferences = false; } } private async Task StartAsync() diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 72eea5d35..49707c036 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -10,6 +10,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Globalization; using PostIt.Models; +using PostIt.Services; [assembly: InternalsVisibleTo("PostIt.Tests")] @@ -17,6 +18,10 @@ namespace PostIt.ViewModels; public partial class Settings : ViewModelBase { + public Settings() { } + + internal Settings(string settingsFileFullName) => SettingsFileFullName = settingsFileFullName; + [JsonIgnore] public string? SettingsFileFullName { get; private set; } @@ -41,6 +46,17 @@ public partial class Settings : ViewModelBase [ObservableProperty] public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty; + [ObservableProperty] + public partial string PodcastDefaultTitle { get; set; } = string.Empty; + [ObservableProperty] + public partial CaptureDevice[] PodcastCameras { get; set; } = []; + [ObservableProperty] + public partial CaptureDevice[] PodcastMicrophones { get; set; } = []; + [ObservableProperty] + public partial string? PodcastCameraId { get; set; } + [ObservableProperty] + public partial string? PodcastMicrophoneId { get; set; } + [ObservableProperty] [JsonIgnore] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); @@ -135,6 +151,11 @@ public partial class Settings : ViewModelBase partial void OnApiUrlChanged(string value) => MarkDirty(); partial void OnSearchTextChanged(string value) => MarkDirty(); partial void OnProviderOngoingRequestsSortOptionChanged(string value) => MarkDirty(); + partial void OnPodcastDefaultTitleChanged(string value) => MarkDirty(); + partial void OnPodcastCamerasChanged(CaptureDevice[] value) => MarkDirty(); + partial void OnPodcastMicrophonesChanged(CaptureDevice[] value) => MarkDirty(); + partial void OnPodcastCameraIdChanged(string? value) => MarkDirty(); + partial void OnPodcastMicrophoneIdChanged(string? value) => MarkDirty(); /// /// Authentication can be reassigned wholesale by @@ -392,6 +413,11 @@ public partial class Settings : ViewModelBase : this.ApiUrl; this.SearchText = settings.SearchText ?? string.Empty; this.ProviderOngoingRequestsSortOption = settings.ProviderOngoingRequestsSortOption ?? string.Empty; + PodcastDefaultTitle = settings.PodcastDefaultTitle ?? string.Empty; + PodcastCameras = settings.PodcastCameras ?? []; + PodcastMicrophones = settings.PodcastMicrophones ?? []; + PodcastCameraId = settings.PodcastCameraId; + PodcastMicrophoneId = settings.PodcastMicrophoneId; if (!(settings.Authentication is null)) { this.Authentication = new AuthenticationSettings();