diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Animation/ControlAnimationConstants.cs b/src/Snap.Hutao/Snap.Hutao/Control/Animation/ControlAnimationConstants.cs index e748911f..3c86dca8 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/Animation/ControlAnimationConstants.cs +++ b/src/Snap.Hutao/Snap.Hutao/Control/Animation/ControlAnimationConstants.cs @@ -23,10 +23,12 @@ internal static class ControlAnimationConstants /// /// 图像淡入 /// - public static readonly TimeSpan ImageFadeIn = TimeSpan.FromSeconds(0.3); + public static readonly TimeSpan ImageScaleFadeIn = TimeSpan.FromSeconds(0.3); /// /// 图像淡出 /// - public static readonly TimeSpan ImageFadeOut = TimeSpan.FromSeconds(0.2); + public static readonly TimeSpan ImageScaleFadeOut = TimeSpan.FromSeconds(0.2); + + public static readonly TimeSpan ImageOpacityFadeInOut = TimeSpan.FromSeconds(1); } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Behavior/PeriodicInvokeCommandOrOnActualThemeChangedBehavior.cs b/src/Snap.Hutao/Snap.Hutao/Control/Behavior/PeriodicInvokeCommandOrOnActualThemeChangedBehavior.cs new file mode 100644 index 00000000..9cfd7a1a --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Behavior/PeriodicInvokeCommandOrOnActualThemeChangedBehavior.cs @@ -0,0 +1,86 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.WinUI.Behaviors; +using Microsoft.UI.Xaml; + +namespace Snap.Hutao.Control.Behavior; + +[DependencyProperty("Period", typeof(TimeSpan))] +[DependencyProperty("Command", typeof(ICommand))] +[DependencyProperty("CommandParameter", typeof(object))] +internal sealed partial class PeriodicInvokeCommandOrOnActualThemeChangedBehavior : BehaviorBase, IDisposable +{ + private TaskCompletionSource acutalThemeChangedTaskCompletionSource = new(); + private CancellationTokenSource periodicTimerCancellationTokenSource = new(); + + public void Dispose() + { + periodicTimerCancellationTokenSource.Dispose(); + } + + protected override bool Initialize() + { + AssociatedObject.ActualThemeChanged += OnActualThemeChanged; + return true; + } + + protected override void OnAssociatedObjectLoaded() + { + RunCoreAsync().SafeForget(); + } + + protected override bool Uninitialize() + { + AssociatedObject.ActualThemeChanged -= OnActualThemeChanged; + return true; + } + + private void OnActualThemeChanged(FrameworkElement sender, object args) + { + acutalThemeChangedTaskCompletionSource.TrySetResult(); + periodicTimerCancellationTokenSource.Cancel(); + } + + private void TryExecuteCommand() + { + if (AssociatedObject is null) + { + return; + } + + if (Command is not null && Command.CanExecute(CommandParameter)) + { + Command.Execute(CommandParameter); + } + } + + private async ValueTask RunCoreAsync() + { + using (PeriodicTimer timer = new(Period)) + { + do + { + if (!IsAttached) + { + break; + } + + TryExecuteCommand(); + + try + { + Task nextTickTask = timer.WaitForNextTickAsync(periodicTimerCancellationTokenSource.Token).AsTask(); + await Task.WhenAny(nextTickTask, acutalThemeChangedTaskCompletionSource.Task).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + + acutalThemeChangedTaskCompletionSource = new(); + periodicTimerCancellationTokenSource = new(); + } + while (true); + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Image/CompositionImage.cs b/src/Snap.Hutao/Snap.Hutao/Control/Image/CompositionImage.cs index b35ceec4..270d2333 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/Image/CompositionImage.cs +++ b/src/Snap.Hutao/Snap.Hutao/Control/Image/CompositionImage.cs @@ -192,7 +192,7 @@ internal abstract partial class CompositionImage : Microsoft.UI.Xaml.Controls.Co { await AnimationBuilder .Create() - .Opacity(from: 0D, to: 1D, duration: ControlAnimationConstants.ImageFadeIn) + .Opacity(from: 0D, to: 1D, duration: ControlAnimationConstants.ImageScaleFadeIn) .StartAsync(this, token) .ConfigureAwait(true); } @@ -213,7 +213,7 @@ internal abstract partial class CompositionImage : Microsoft.UI.Xaml.Controls.Co { await AnimationBuilder .Create() - .Opacity(from: 1D, to: 0D, duration: ControlAnimationConstants.ImageFadeOut) + .Opacity(from: 1D, to: 0D, duration: ControlAnimationConstants.ImageScaleFadeOut) .StartAsync(this, token) .ConfigureAwait(true); } diff --git a/src/Snap.Hutao/Snap.Hutao/Model/CollectionsNameValue.cs b/src/Snap.Hutao/Snap.Hutao/Model/CollectionsNameValue.cs index 92c799ae..605289e3 100644 --- a/src/Snap.Hutao/Snap.Hutao/Model/CollectionsNameValue.cs +++ b/src/Snap.Hutao/Snap.Hutao/Model/CollectionsNameValue.cs @@ -11,10 +11,16 @@ internal static class CollectionsNameValue return [.. Enum.GetValues().Select(x => new NameValue(x.ToString(), x))]; } - public static List> FromEnum(Func codiction) + public static List> FromEnum(Func condition) where TEnum : struct, Enum { - return [.. Enum.GetValues().Where(codiction).Select(x => new NameValue(x.ToString(), x))]; + return [.. Enum.GetValues().Where(condition).Select(x => new NameValue(x.ToString(), x))]; + } + + public static List> FromEnum(Func nameSelector) + where TEnum : struct, Enum + { + return [.. Enum.GetValues().Select(x => new NameValue(nameSelector(x), x))]; } public static List> From(IEnumerable sources, Func nameSelector) diff --git a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx index edd66c79..8572ee15 100644 --- a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx +++ b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx @@ -767,6 +767,21 @@ 角色橱窗:{0:MM-dd HH:mm} + + 必应每日一图 + + + 胡桃每日一图 + + + 官方启动器壁纸 + + + 本地随机图片 + + + 无背景图片 + 保存养成计划状态失败 @@ -2339,6 +2354,12 @@ 背景材质 + + 更改窗体的背景图片来源,重启胡桃以尽快生效 + + + 背景图片 + 图片缓存 在此处存放 diff --git a/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs b/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs index 1a13e560..43ea7cd5 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs @@ -34,6 +34,8 @@ internal sealed partial class AppOptions : DbStoreOptions set => SetOption(ref backdropType, SettingEntry.SystemBackdropType, value, EnumToStringOrEmpty); } + public List> BackgroundImageTypes { get; } = CollectionsNameValue.FromEnum(type => type.GetLocalizedDescription()); + public BackgroundImageType BackgroundImageType { get => GetOption(ref backgroundImageType, SettingEntry.BackgroundImageType, EnumParse, BackgroundImageType.HutaoOfficialLauncher).Value; diff --git a/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageService.cs b/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageService.cs index 6217450a..530eca28 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageService.cs @@ -5,9 +5,7 @@ using Snap.Hutao.Control.Media; using Snap.Hutao.Core; using Snap.Hutao.Core.Caching; using Snap.Hutao.Core.IO; -using Snap.Hutao.Service.Game.Scheme; -using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher; -using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher.Content; +using Snap.Hutao.Web.Hutao.Wallpaper; using Snap.Hutao.Web.Response; using System.IO; using Windows.Graphics.Imaging; @@ -23,8 +21,9 @@ internal sealed partial class BackgroundImageService : IBackgroundImageService private readonly IServiceProvider serviceProvider; private readonly RuntimeOptions runtimeOptions; private readonly ITaskContext taskContext; + private readonly AppOptions appOptions; - private HashSet backgroundPathSet; + private HashSet currentBackgroundPathSet; public async ValueTask> GetNextBackgroundImageAsync(BackgroundImage? previous) { @@ -35,7 +34,7 @@ internal sealed partial class BackgroundImageService : IBackgroundImageService return new(false, default!); } - string path = System.Random.Shared.GetItems(backgroundSet.ToArray(), 1)[0]; + string path = System.Random.Shared.GetItems([..backgroundSet], 1)[0]; backgroundSet.Remove(path); if (string.Equals(path, previous?.Path, StringComparison.OrdinalIgnoreCase)) @@ -65,32 +64,47 @@ internal sealed partial class BackgroundImageService : IBackgroundImageService private async ValueTask> SkipOrInitBackgroundAsync() { - if (backgroundPathSet is not { Count: > 0 }) + switch (appOptions.BackgroundImageType) { - string backgroundFolder = runtimeOptions.GetDataFolderBackgroundFolder(); - Directory.CreateDirectory(backgroundFolder); - backgroundPathSet = Directory - .GetFiles(backgroundFolder, "*.*", SearchOption.AllDirectories) - .Where(path => AllowedFormats.Contains(Path.GetExtension(path))) - .ToHashSet(); - - // No image found - if (backgroundPathSet.Count <= 0) - { - ResourceClient resourceClient = serviceProvider.GetRequiredService(); - string launguageCode = serviceProvider.GetRequiredService().LanguageCode; - LaunchScheme scheme = launguageCode is "zh-cn" - ? KnownLaunchSchemes.Get().First(scheme => !scheme.IsOversea && scheme.IsNotCompatOnly) - : KnownLaunchSchemes.Get().First(scheme => scheme.IsOversea && scheme.IsNotCompatOnly); - Response response = await resourceClient.GetContentAsync(scheme, launguageCode).ConfigureAwait(false); - if (response is { Data.Advertisement.Background: string url }) + case BackgroundImageType.LocalFolder: { - ValueFile file = await serviceProvider.GetRequiredService().GetFileFromCacheAsync(url.ToUri()).ConfigureAwait(false); - backgroundPathSet = [file]; + if (currentBackgroundPathSet is not { Count: > 0 }) + { + string backgroundFolder = runtimeOptions.GetDataFolderBackgroundFolder(); + Directory.CreateDirectory(backgroundFolder); + + currentBackgroundPathSet = Directory + .GetFiles(backgroundFolder, "*.*", SearchOption.AllDirectories) + .Where(path => AllowedFormats.Contains(Path.GetExtension(path))) + .ToHashSet(); + } + + break; } - } + + case BackgroundImageType.HutaoBing: + await SetCurrentBackgroundPathSetAsync(client => client.GetBingWallpaperAsync()).ConfigureAwait(false); + break; + case BackgroundImageType.HutaoDaily: + await SetCurrentBackgroundPathSetAsync(client => client.GetTodayWallpaperAsync()).ConfigureAwait(false); + break; + case BackgroundImageType.HutaoOfficialLauncher: + await SetCurrentBackgroundPathSetAsync(client => client.GetLauncherWallpaperAsync()).ConfigureAwait(false); + break; } - return backgroundPathSet; + currentBackgroundPathSet ??= []; + return currentBackgroundPathSet; + + async Task SetCurrentBackgroundPathSetAsync(Func>> responseFactory) + { + HutaoWallpaperClient wallpaperClient = serviceProvider.GetRequiredService(); + Response response = await responseFactory(wallpaperClient).ConfigureAwait(false); + if (response is { Data.Url: Uri url }) + { + ValueFile file = await serviceProvider.GetRequiredService().GetFileFromCacheAsync(url).ConfigureAwait(false); + currentBackgroundPathSet = [file]; + } + } } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageType.cs b/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageType.cs index 4eee1187..4d6f5a9e 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageType.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/BackgroundImage/BackgroundImageType.cs @@ -3,11 +3,21 @@ namespace Snap.Hutao.Service.BackgroundImage; +[Localization] internal enum BackgroundImageType { + [LocalizationKey(nameof(SH.ServiceBackgroundImageTypeNone))] None, + + [LocalizationKey(nameof(SH.ServiceBackgroundImageTypeLocalFolder))] LocalFolder, + + [LocalizationKey(nameof(SH.ServiceBackgroundImageTypeBing))] HutaoBing, + + [LocalizationKey(nameof(SH.ServiceBackgroundImageTypeDaily))] HutaoDaily, + + [LocalizationKey(nameof(SH.ServiceBackgroundImageTypeLauncher))] HutaoOfficialLauncher, } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml b/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml index 057659a1..2531acc4 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml @@ -4,12 +4,24 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:mxi="using:Microsoft.Xaml.Interactivity" + xmlns:shcb="using:Snap.Hutao.Control.Behavior" xmlns:shch="using:Snap.Hutao.Control.Helper" xmlns:shcm="using:Snap.Hutao.Control.Markup" xmlns:shv="using:Snap.Hutao.View" xmlns:shvh="using:Snap.Hutao.View.Helper" + xmlns:shvm="using:Snap.Hutao.ViewModel" xmlns:shvp="using:Snap.Hutao.View.Page" + d:DataContext="{d:DesignInstance Type=shvm:MainViewModel}" mc:Ignorable="d"> + + + + + 0,44,0,0 0,1,0,0 diff --git a/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml.cs b/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml.cs index 4b4a029f..2688a1d6 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml.cs +++ b/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml.cs @@ -5,10 +5,12 @@ using CommunityToolkit.WinUI.Animations; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media.Animation; +using Snap.Hutao.Control.Animation; using Snap.Hutao.Control.Theme; using Snap.Hutao.Service.BackgroundImage; using Snap.Hutao.Service.Navigation; using Snap.Hutao.View.Page; +using Snap.Hutao.ViewModel; namespace Snap.Hutao.View; @@ -19,80 +21,23 @@ namespace Snap.Hutao.View; internal sealed partial class MainView : UserControl { private readonly INavigationService navigationService; - private readonly IBackgroundImageService backgroundImageService; - private TaskCompletionSource acutalThemeChangedTaskCompletionSource = new(); - private CancellationTokenSource periodicTimerCancellationTokenSource = new(); - private BackgroundImage? previousBackgroundImage; /// /// 构造一个新的主视图 /// public MainView() { + DataContext = Ioc.Default.GetRequiredService(); InitializeComponent(); - ActualThemeChanged += OnActualThemeChanged; - IServiceProvider serviceProvider = Ioc.Default; - backgroundImageService = serviceProvider.GetRequiredService(); - RunBackgroundImageLoopAsync(serviceProvider.GetRequiredService()).SafeForget(); - navigationService = serviceProvider.GetRequiredService(); - navigationService - .As()? - .Initialize(NavView, ContentFrame); + if (navigationService is INavigationInitialization navigationInitialization) + { + navigationInitialization.Initialize(NavView, ContentFrame); + } navigationService.Navigate(INavigationAwaiter.Default, true); } - - private async ValueTask RunBackgroundImageLoopAsync(ITaskContext taskContext) - { - using (PeriodicTimer timer = new(TimeSpan.FromMinutes(5))) - { - do - { - (bool isOk, BackgroundImage backgroundImage) = await backgroundImageService.GetNextBackgroundImageAsync(previousBackgroundImage).ConfigureAwait(false); - - if (isOk) - { - previousBackgroundImage = backgroundImage; - await taskContext.SwitchToMainThreadAsync(); - - await AnimationBuilder - .Create() - .Opacity(to: 0D, duration: TimeSpan.FromMilliseconds(1000), easingType: EasingType.Sine, easingMode: EasingMode.EaseIn) - .StartAsync(BackdroundImagePresenter) - .ConfigureAwait(true); - - BackdroundImagePresenter.Source = backgroundImage.ImageSource; - double targetOpacity = ThemeHelper.IsDarkMode(ActualTheme) ? 1 - backgroundImage.Luminance : backgroundImage.Luminance; - - await AnimationBuilder - .Create() - .Opacity(to: targetOpacity, duration: TimeSpan.FromMilliseconds(1000), easingType: EasingType.Sine, easingMode: EasingMode.EaseOut) - .StartAsync(BackdroundImagePresenter) - .ConfigureAwait(true); - } - - try - { - await Task.WhenAny(timer.WaitForNextTickAsync(periodicTimerCancellationTokenSource.Token).AsTask(), acutalThemeChangedTaskCompletionSource.Task).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - - acutalThemeChangedTaskCompletionSource = new(); - periodicTimerCancellationTokenSource = new(); - } - while (true); - } - } - - private void OnActualThemeChanged(FrameworkElement frameworkElement, object args) - { - acutalThemeChangedTaskCompletionSource.TrySetResult(); - periodicTimerCancellationTokenSource.Cancel(); - } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/SettingPage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/SettingPage.xaml index fceeb67f..214288e8 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Page/SettingPage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/SettingPage.xaml @@ -312,6 +312,17 @@ SelectedItem="{Binding SelectedBackdropType, Mode=TwoWay}"/> + + + + + diff --git a/src/Snap.Hutao/Snap.Hutao/ViewModel/MainViewModel.cs b/src/Snap.Hutao/Snap.Hutao/ViewModel/MainViewModel.cs new file mode 100644 index 00000000..fa333520 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/ViewModel/MainViewModel.cs @@ -0,0 +1,56 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.WinUI.Animations; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Animation; +using Snap.Hutao.Control.Animation; +using Snap.Hutao.Control.Theme; +using Snap.Hutao.Service.BackgroundImage; + +namespace Snap.Hutao.ViewModel; + +[ConstructorGenerated] +[Injection(InjectAs.Singleton)] +internal sealed partial class MainViewModel : Abstraction.ViewModel +{ + private readonly IBackgroundImageService backgroundImageService; + private readonly ITaskContext taskContext; + + private BackgroundImage? previousBackgroundImage; + + [Command("UpdateBackgroundCommand")] + private async Task UpdateBackgroundAsync(Image presenter) + { + (bool isOk, BackgroundImage backgroundImage) = await backgroundImageService.GetNextBackgroundImageAsync(previousBackgroundImage).ConfigureAwait(false); + + if (isOk) + { + previousBackgroundImage = backgroundImage; + await taskContext.SwitchToMainThreadAsync(); + + await AnimationBuilder + .Create() + .Opacity( + to: 0D, + duration: ControlAnimationConstants.ImageOpacityFadeInOut, + easingType: EasingType.Quartic, + easingMode: EasingMode.EaseInOut) + .StartAsync(presenter) + .ConfigureAwait(true); + + presenter.Source = backgroundImage.ImageSource; + double targetOpacity = ThemeHelper.IsDarkMode(presenter.ActualTheme) ? 1 - backgroundImage.Luminance : backgroundImage.Luminance; + + await AnimationBuilder + .Create() + .Opacity( + to: targetOpacity, + duration: ControlAnimationConstants.ImageOpacityFadeInOut, + easingType: EasingType.Quartic, + easingMode: EasingMode.EaseInOut) + .StartAsync(presenter) + .ConfigureAwait(true); + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/ViewModel/Setting/SettingViewModel.cs b/src/Snap.Hutao/Snap.Hutao/ViewModel/Setting/SettingViewModel.cs index 5f372957..d175ccfa 100644 --- a/src/Snap.Hutao/Snap.Hutao/ViewModel/Setting/SettingViewModel.cs +++ b/src/Snap.Hutao/Snap.Hutao/ViewModel/Setting/SettingViewModel.cs @@ -12,6 +12,7 @@ using Snap.Hutao.Factory.ContentDialog; using Snap.Hutao.Factory.Picker; using Snap.Hutao.Model; using Snap.Hutao.Service; +using Snap.Hutao.Service.BackgroundImage; using Snap.Hutao.Service.GachaLog.QueryProvider; using Snap.Hutao.Service.Game; using Snap.Hutao.Service.Hutao; @@ -54,6 +55,7 @@ internal sealed partial class SettingViewModel : Abstraction.ViewModel private readonly AppOptions appOptions; private NameValue? selectedBackdropType; + private NameValue? selectedBackgroundImageType; private NameValue? selectedCulture; private NameValue? selectedRegion; private FolderViewModel? cacheFolderView; @@ -87,6 +89,18 @@ internal sealed partial class SettingViewModel : Abstraction.ViewModel } } + public NameValue? SelectedBackgroundImageType + { + get => selectedBackgroundImageType ??= AppOptions.BackgroundImageTypes.Single(t => t.Value == AppOptions.BackgroundImageType); + set + { + if (SetProperty(ref selectedBackgroundImageType, value) && value is not null) + { + AppOptions.BackgroundImageType = value.Value; + } + } + } + public NameValue? SelectedCulture { get => selectedCulture ??= CultureOptions.GetCurrentCultureForSelectionOrDefault(); diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hutao/Wallpaper/Wallpaper.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hutao/Wallpaper/Wallpaper.cs index 4ec26f2e..f160f47c 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hutao/Wallpaper/Wallpaper.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hutao/Wallpaper/Wallpaper.cs @@ -6,7 +6,7 @@ namespace Snap.Hutao.Web.Hutao.Wallpaper; internal sealed class Wallpaper { [JsonPropertyName("url")] - public string Url { get; set; } = default!; + public Uri Url { get; set; } = default!; [JsonPropertyName("source_url")] public string SourceUrl { get; set; } = default!;