Persist PostIt podcast device and title preferences
All checks were successful
Forgejo Release / release (push) Successful in 13m7s
All checks were successful
Forgejo Release / release (push) Successful in 13m7s
Remember detected capture devices, camera and microphone selections, and the default podcast title in user settings. Restore preferences on page creation and surface save failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4a5a3c8844
commit
ff1a016c91
7 changed files with 203 additions and 12 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
79
src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs
Normal file
79
src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs
Normal file
|
|
@ -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"); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<PodcastResponse> 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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue