Prepare podcast emissions using LiveFlow
Persist scheduled emissions, expose owner-only planning controls in PostIt and publish optional presentation-only announcements on Yavsc.Org. Preserve manual broadcasting and authenticated playback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e3b4414ab1
commit
4a5a3c8844
28 changed files with 5960 additions and 29 deletions
|
|
@ -122,6 +122,22 @@ public sealed class PodcastBroadcasterTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prepared_broadcast_starts_the_existing_emission_instead_of_creating_another()
|
||||
{
|
||||
await using var transport = new Transport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
FakeCapture? capture = null;
|
||||
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Prepared",
|
||||
(directory, _) => Task.FromResult<IPodcastCapture>(capture = new FakeCapture(directory)),
|
||||
TestContext.Current.CancellationToken, transport.PodcastId);
|
||||
Assert.Equal(transport.PodcastId, broadcaster.Podcast.Id);
|
||||
Assert.Equal(transport.PodcastId, transport.StartedId);
|
||||
Assert.Equal(0, transport.CreateCount);
|
||||
await capture!.StopAsync();
|
||||
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
|
||||
{
|
||||
|
|
@ -217,10 +233,14 @@ public sealed class PodcastBroadcasterTests
|
|||
public List<string> Events { get; } = [];
|
||||
public bool FailUpload { get; init; }
|
||||
public int CreateCount { get; private set; }
|
||||
public Guid PodcastId => _podcast.Id;
|
||||
public Guid? StartedId { get; private set; }
|
||||
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
|
||||
else if (path.EndsWith("/start", StringComparison.Ordinal))
|
||||
StartedId = Guid.Parse(new Uri(path).Segments[^2].TrimEnd('/'));
|
||||
else CreateCount++;
|
||||
return Task.FromResult((T)(object)_podcast);
|
||||
}
|
||||
|
|
|
|||
88
src/PostIt/PostIt.Tests/PodcastScheduleTests.cs
Normal file
88
src/PostIt/PostIt.Tests/PodcastScheduleTests.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using Avalonia.Headless.XUnit;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class PodcastScheduleTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public async Task Preparation_and_editing_send_schedule_and_optional_announcement_without_capture()
|
||||
{
|
||||
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-plan-{Guid.NewGuid():N}.json");
|
||||
var store = new TokenStore(tokenPath);
|
||||
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
|
||||
try
|
||||
{
|
||||
await using var api = new ScheduleApi(store);
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
session.Refresh();
|
||||
var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => "https://api.example/"), api, session)
|
||||
{
|
||||
Title = "Concert", ScheduledDate = DateTimeOffset.Now.AddDays(7),
|
||||
ScheduledTime = new TimeSpan(14, 15, 0), PlannedDurationMinutes = 90
|
||||
};
|
||||
Assert.True(vm.PrepareCommand.CanExecute(null));
|
||||
await vm.PrepareCommand.ExecuteAsync(null);
|
||||
Assert.Equal(HttpMethod.Post, api.SavedMethod);
|
||||
Assert.EndsWith("/podcasts/planned", api.SavedPath);
|
||||
Assert.Equal(90, api.Saved?.PlannedDurationMinutes);
|
||||
Assert.Null(api.Saved?.PublicAnnouncement);
|
||||
Assert.Equal(14, api.Saved?.ScheduledStartAt.Hour);
|
||||
Assert.Equal(PodcastState.Planned, vm.SelectedPodcast?.State);
|
||||
Assert.False(vm.FinishCommand.CanExecute(null));
|
||||
Assert.True(vm.DeleteCommand.CanExecute(null));
|
||||
Assert.True(vm.UpdatePlanCommand.CanExecute(null));
|
||||
vm.Title = "Nouveau titre";
|
||||
vm.PublishAnnouncement = true;
|
||||
vm.Announcement = "Venez au concert";
|
||||
await vm.UpdatePlanCommand.ExecuteAsync(null);
|
||||
Assert.Equal(HttpMethod.Put, api.SavedMethod);
|
||||
Assert.EndsWith($"/podcasts/{api.Plan.Id}/planned", api.SavedPath);
|
||||
Assert.Equal("Venez au concert", api.Saved?.PublicAnnouncement);
|
||||
Assert.Equal("Nouveau titre", vm.SelectedPodcast?.Title);
|
||||
vm.PublishAnnouncement = false;
|
||||
await vm.UpdatePlanCommand.ExecuteAsync(null);
|
||||
Assert.Null(api.Saved?.PublicAnnouncement);
|
||||
vm.PlannedDurationMinutes = 0;
|
||||
api.Saved = null;
|
||||
await vm.UpdatePlanCommand.ExecuteAsync(null);
|
||||
Assert.Null(api.Saved);
|
||||
Assert.Contains("Preparation impossible", vm.Status);
|
||||
Assert.False(vm.IsBroadcasting);
|
||||
}
|
||||
finally { File.Delete(tokenPath); }
|
||||
}
|
||||
|
||||
private sealed class ScheduleApi(TokenStore store) : YavscApiClient(new Settings(), store)
|
||||
{
|
||||
public PodcastResponse Plan { get; private set; } = new(Guid.NewGuid(), "Concert", "owner", "Owner",
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Planned, 0, 0, 0);
|
||||
public PreparePodcastRequest? Saved { get; set; }
|
||||
public string? SavedPath { get; private set; }
|
||||
public HttpMethod? SavedMethod { get; private set; }
|
||||
|
||||
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
object response;
|
||||
if (path.EndsWith("/account/me", StringComparison.Ordinal)) response = new Me { UserId = "owner" };
|
||||
else if (method == HttpMethod.Get) response = new List<PodcastResponse> { Plan };
|
||||
else
|
||||
{
|
||||
Saved = Assert.IsType<PreparePodcastRequest>(body);
|
||||
SavedPath = path;
|
||||
SavedMethod = method;
|
||||
Plan = Plan with
|
||||
{
|
||||
Title = Saved.Title, ScheduledStartAt = Saved.ScheduledStartAt,
|
||||
PlannedDurationMinutes = Saved.PlannedDurationMinutes, PublicAnnouncement = Saved.PublicAnnouncement
|
||||
};
|
||||
response = Plan;
|
||||
}
|
||||
return Task.FromResult((T)response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,10 +52,10 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
|||
}
|
||||
|
||||
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
|
||||
string camera, string? microphone, CancellationToken ct)
|
||||
string camera, string? microphone, CancellationToken ct, Guid? plannedId = null)
|
||||
=> await StartAsync(api, title,
|
||||
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(
|
||||
vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), directory, preview: true)), ct);
|
||||
vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), directory, preview: true)), ct, plannedId);
|
||||
|
||||
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
|
||||
Func<LibVLC, Media> createMedia, CancellationToken ct)
|
||||
|
|
@ -63,7 +63,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
|||
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(createMedia, directory)), ct);
|
||||
|
||||
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
|
||||
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct)
|
||||
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct, Guid? plannedId = null)
|
||||
{
|
||||
IPodcastCapture? capture = null;
|
||||
PodcastBroadcaster? broadcaster = null;
|
||||
|
|
@ -73,7 +73,9 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
|
|||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
podcast = await api.CreateAsync(title, ct);
|
||||
podcast = plannedId.HasValue ? await api.StartAsync(plannedId.Value, ct) : await api.CreateAsync(title, ct);
|
||||
if (podcast.State != PodcastState.Live)
|
||||
throw new InvalidOperationException("Cette emission est deja terminee. Preparez une nouvelle emission.");
|
||||
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
capture = await createCapture(directory, ct);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
|
||||
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial DateTimeOffset? ScheduledDate { get; set; } = DateTimeOffset.Now.AddDays(1);
|
||||
[ObservableProperty]
|
||||
public partial TimeSpan? ScheduledTime { get; set; } = new TimeSpan(20, 0, 0);
|
||||
[ObservableProperty]
|
||||
public partial decimal PlannedDurationMinutes { get; set; } = 60;
|
||||
[ObservableProperty]
|
||||
public partial bool PublishAnnouncement { get; set; }
|
||||
[ObservableProperty]
|
||||
public partial string Announcement { get; set; } = "";
|
||||
|
||||
public string Status { get => _status; private set => SetProperty(ref _status, value); }
|
||||
public bool IsBroadcasting
|
||||
|
|
@ -72,12 +82,28 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
public PodcastResponse? SelectedPodcast
|
||||
{
|
||||
get => _selected;
|
||||
set { if (SetProperty(ref _selected, value)) UpdateCommands(); }
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selected, value)) return;
|
||||
if (value is { State: PodcastState.Planned } && value.OwnerId == _ownerId)
|
||||
{
|
||||
Title = value.Title;
|
||||
ScheduledDate = value.ScheduledStartAt?.ToLocalTime();
|
||||
ScheduledTime = value.ScheduledStartAt?.ToLocalTime().TimeOfDay;
|
||||
PlannedDurationMinutes = value.PlannedDurationMinutes ?? 60;
|
||||
Announcement = value.PublicAnnouncement ?? "";
|
||||
PublishAnnouncement = !string.IsNullOrEmpty(Announcement);
|
||||
}
|
||||
UpdateCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand RefreshCommand { get; }
|
||||
public IRelayCommand DetectDevicesCommand { get; }
|
||||
public IAsyncRelayCommand StartCommand { get; }
|
||||
public IAsyncRelayCommand PrepareCommand { get; }
|
||||
public IAsyncRelayCommand UpdatePlanCommand { get; }
|
||||
public IRelayCommand NewPlanCommand { get; }
|
||||
public IAsyncRelayCommand StopCommand { get; }
|
||||
public IAsyncRelayCommand PlayCommand { get; }
|
||||
public IAsyncRelayCommand FinishCommand { get; }
|
||||
|
|
@ -95,11 +121,17 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsBroadcasting && !IsBusy);
|
||||
StartCommand = new AsyncRelayCommand(StartAsync,
|
||||
() => CanBroadcast && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
|
||||
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
|
||||
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title)
|
||||
&& (SelectedPodcast == null || SelectedPodcast is { State: PodcastState.Planned } p && p.OwnerId == _ownerId));
|
||||
PrepareCommand = new AsyncRelayCommand(() => SavePlanAsync(false),
|
||||
() => !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);
|
||||
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
|
||||
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
|
||||
FinishCommand = new AsyncRelayCommand(FinishAsync,
|
||||
() => SelectedPodcast is { State: not PodcastState.Completed } p
|
||||
() => SelectedPodcast is { State: PodcastState.Live or PodcastState.Interrupted } p
|
||||
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
|
||||
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
|
||||
() => SelectedPodcast is { State: not PodcastState.Live } p
|
||||
|
|
@ -114,6 +146,9 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
RefreshCommand.NotifyCanExecuteChanged();
|
||||
DetectDevicesCommand.NotifyCanExecuteChanged();
|
||||
StartCommand.NotifyCanExecuteChanged();
|
||||
PrepareCommand.NotifyCanExecuteChanged();
|
||||
UpdatePlanCommand.NotifyCanExecuteChanged();
|
||||
NewPlanCommand.NotifyCanExecuteChanged();
|
||||
StopCommand.NotifyCanExecuteChanged();
|
||||
PlayCommand.NotifyCanExecuteChanged();
|
||||
FinishCommand.NotifyCanExecuteChanged();
|
||||
|
|
@ -138,6 +173,34 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private async Task SavePlanAsync(bool update)
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (ScheduledDate == null || ScheduledTime == null || PlannedDurationMinutes != decimal.Truncate(PlannedDurationMinutes)
|
||||
|| PlannedDurationMinutes is < 1 or > int.MaxValue)
|
||||
throw new InvalidOperationException("Choisissez un debut et une duree entiere positive en minutes.");
|
||||
if (PublishAnnouncement && string.IsNullOrWhiteSpace(Announcement))
|
||||
throw new InvalidOperationException("Saisissez le texte de l'annonce publique ou desactivez sa publication.");
|
||||
var local = DateTime.SpecifyKind(ScheduledDate.Value.Date + ScheduledTime.Value, DateTimeKind.Unspecified);
|
||||
if (TimeZoneInfo.Local.IsInvalidTime(local) || TimeZoneInfo.Local.IsAmbiguousTime(local))
|
||||
throw new InvalidOperationException("Cette heure est inexistante ou ambigue lors du changement d'heure. Choisissez une autre heure.");
|
||||
var start = new DateTimeOffset(local, TimeZoneInfo.Local.GetUtcOffset(local));
|
||||
var plan = await _client.PrepareAsync(new PreparePodcastRequest
|
||||
{
|
||||
Title = Title, ScheduledStartAt = start,
|
||||
PlannedDurationMinutes = (int)PlannedDurationMinutes,
|
||||
PublicAnnouncement = PublishAnnouncement ? Announcement : null
|
||||
}, update ? SelectedPodcast?.Id : null, _pageCancellation.Token);
|
||||
await RefreshAsync();
|
||||
SelectedPodcast = plan;
|
||||
Status = "Emission preparee. Debut et duree previsionnels : demarrage et arret manuels.";
|
||||
}
|
||||
catch (Exception ex) { Status = $"Preparation impossible : {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
public async Task ActivateAsync()
|
||||
{
|
||||
if (_pageCancellation.IsCancellationRequested)
|
||||
|
|
@ -194,8 +257,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
await requestPermissions(microphone, _pageCancellation.Token);
|
||||
_broadcaster = Platform.CreatePodcastCaptureAsync is { } createCapture
|
||||
? await PodcastBroadcaster.StartAsync(_client, Title,
|
||||
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token)
|
||||
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token);
|
||||
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token, SelectedPodcast?.Id)
|
||||
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token, SelectedPodcast?.Id);
|
||||
_broadcaster.Progress += OnProgress;
|
||||
CapturePreview = _broadcaster.Preview;
|
||||
_session.LogoutCompleted += OnLogout;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,26 @@
|
|||
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
|
||||
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
|
||||
<TextBlock Text="Lecture et emission reservees aux utilisateurs connectes. Quitter cette page arrete la capture." TextWrapping="Wrap" />
|
||||
<StackPanel Spacing="8" IsEnabled="{Binding !IsBroadcasting}">
|
||||
<TextBlock Text="Preparer une emission" FontWeight="SemiBold" />
|
||||
<TextBox Text="{Binding Title, Mode=TwoWay}" PlaceholderText="Titre de l'emission" MaxLength="160" />
|
||||
<TextBlock Text="Debut prevu (heure locale)" />
|
||||
<DatePicker SelectedDate="{Binding ScheduledDate, Mode=TwoWay}" />
|
||||
<TimePicker SelectedTime="{Binding ScheduledTime, Mode=TwoWay}" />
|
||||
<TextBlock Text="Duree prevue en minutes" />
|
||||
<NumericUpDown Value="{Binding PlannedDurationMinutes, Mode=TwoWay}" Minimum="1" Maximum="2147483647" Increment="1" />
|
||||
<CheckBox Content="Publier une annonce visible sans connexion sur Yavsc.Org"
|
||||
IsChecked="{Binding PublishAnnouncement, Mode=TwoWay}" />
|
||||
<TextBox Text="{Binding Announcement, Mode=TwoWay}" MaxLength="1023" AcceptsReturn="True"
|
||||
IsVisible="{Binding PublishAnnouncement}" PlaceholderText="Annonce publique (ne pas inclure d'informations privees)" />
|
||||
<TextBlock Text="Sans annonce, la preparation est privee. Les horaires sont previsionnels : demarrage et arret manuels. L'ecoute reste reservee aux utilisateurs connectes."
|
||||
TextWrapping="Wrap" />
|
||||
<WrapPanel>
|
||||
<Button Content="Nouvelle emission / direct immediat" Command="{Binding NewPlanCommand}" Margin="0,0,8,8" />
|
||||
<Button Content="Preparer une nouvelle emission" Command="{Binding PrepareCommand}" Margin="0,0,8,8" />
|
||||
<Button Content="Enregistrer les modifications de l'emission selectionnee" Command="{Binding UpdatePlanCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsBroadcasting}">
|
||||
<TextBlock Text="Apercu local de la capture" FontWeight="SemiBold" />
|
||||
<Border Background="Black" CornerRadius="4" ClipToBounds="True">
|
||||
|
|
@ -58,7 +78,6 @@
|
|||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="8" IsVisible="{Binding CanBroadcast}" IsEnabled="{Binding !IsBroadcasting}">
|
||||
<TextBox Text="{Binding Title}" PlaceholderText="Titre du direct" MaxLength="160" />
|
||||
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
|
||||
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
|
||||
<ComboBox.ItemTemplate>
|
||||
|
|
@ -88,6 +107,8 @@
|
|||
<TextBlock Text="{Binding Replay.State, StringFormat='MP4 : {0}'}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Replay.Error}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding ScheduledStartAt, StringFormat='Debut prevu : {0:g}'}" />
|
||||
<TextBlock Text="{Binding PlannedDurationMinutes, StringFormat='Duree prevue : {0} min'}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
|
@ -99,14 +120,14 @@
|
|||
</WrapPanel>
|
||||
<Border Classes="warning-danger">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="warning-title" Text="⚠ Suppression du replay" />
|
||||
<TextBlock Text="Cette action supprime définitivement le replay sélectionné. Cette opération est irréversible."
|
||||
<TextBlock Classes="warning-title" Text="⚠ Suppression du podcast / emission preparee" />
|
||||
<TextBlock Text="Cette action supprime définitivement le podcast ou l'émission préparée sélectionnée. Cette opération est irréversible."
|
||||
TextWrapping="Wrap" />
|
||||
<CheckBox x:Name="ConfirmDelete"
|
||||
Classes="warning-check"
|
||||
Content="Je confirme la suppression du replay sélectionné" />
|
||||
Content="Je confirme la suppression du podcast sélectionné" />
|
||||
<Button Classes="delete-danger"
|
||||
Content="Supprimer mon replay"
|
||||
Content="Supprimer mon podcast"
|
||||
Command="{Binding DeleteCommand}"
|
||||
IsEnabled="{Binding #ConfirmDelete.IsChecked}" />
|
||||
</StackPanel>
|
||||
|
|
|
|||
Loading…
Reference in a new issue