mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26ff11b16b | ||
|
|
b83cfb47b0 | ||
|
|
a0bb83c743 |
@@ -1,13 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
using LanMountainDesktop.Settings.Core;
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.Settings;
|
namespace LanMountainDesktop.Services.Settings
|
||||||
|
{
|
||||||
|
|
||||||
public enum WallpaperMediaType
|
public enum WallpaperMediaType
|
||||||
{
|
{
|
||||||
@@ -66,10 +69,272 @@ public sealed record UpdateSettingsState(
|
|||||||
long? PendingUpdatePublishedAtUtcMs,
|
long? PendingUpdatePublishedAtUtcMs,
|
||||||
long? LastUpdateCheckUtcMs);
|
long? LastUpdateCheckUtcMs);
|
||||||
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
||||||
|
public enum PluginPackageSourceKind
|
||||||
|
{
|
||||||
|
ReleaseAsset = 0,
|
||||||
|
RawFallback = 1,
|
||||||
|
WorkspaceLocal = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogSourceInfo(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string? SourceUrl,
|
||||||
|
string? CachePath,
|
||||||
|
bool IsOfficial,
|
||||||
|
int Priority);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogSharedContractInfo(
|
||||||
|
string Id,
|
||||||
|
string Version,
|
||||||
|
string AssemblyName);
|
||||||
|
|
||||||
|
public sealed record PluginCapabilityInfo(
|
||||||
|
string Id,
|
||||||
|
string? Version,
|
||||||
|
string? AssemblyName);
|
||||||
|
|
||||||
|
public sealed record PluginPackageSourceInfo(
|
||||||
|
PluginPackageSourceKind Kind,
|
||||||
|
string Url,
|
||||||
|
string Sha256,
|
||||||
|
long PackageSizeBytes);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogManifestInfo(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
string Author,
|
||||||
|
string Version,
|
||||||
|
string ApiVersion,
|
||||||
|
string EntranceAssembly,
|
||||||
|
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogCompatibilityInfo(
|
||||||
|
string MinHostVersion,
|
||||||
|
string ApiVersion);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogRepositoryInfo(
|
||||||
|
string IconUrl,
|
||||||
|
string ProjectUrl,
|
||||||
|
string ReadmeUrl,
|
||||||
|
string HomepageUrl,
|
||||||
|
string RepositoryUrl,
|
||||||
|
IReadOnlyList<string> Tags,
|
||||||
|
string ReleaseNotes);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogPublicationInfo(
|
||||||
|
string ReleaseTag,
|
||||||
|
string ReleaseAssetName,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
long PackageSizeBytes,
|
||||||
|
string Sha256,
|
||||||
|
string? Md5);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogItemInfo(
|
||||||
|
PluginCatalogManifestInfo Manifest,
|
||||||
|
PluginCatalogCompatibilityInfo Compatibility,
|
||||||
|
PluginCatalogRepositoryInfo Repository,
|
||||||
|
PluginCatalogPublicationInfo Publication,
|
||||||
|
IReadOnlyList<PluginPackageSourceInfo> PackageSources,
|
||||||
|
IReadOnlyList<PluginCapabilityInfo> Capabilities)
|
||||||
|
{
|
||||||
|
public string Id => Manifest.Id;
|
||||||
|
|
||||||
|
public string Name => Manifest.Name;
|
||||||
|
|
||||||
|
public string Description => Manifest.Description;
|
||||||
|
|
||||||
|
public string Author => Manifest.Author;
|
||||||
|
|
||||||
|
public string Version => Manifest.Version;
|
||||||
|
|
||||||
|
public string ApiVersion => Manifest.ApiVersion;
|
||||||
|
|
||||||
|
public string MinHostVersion => Compatibility.MinHostVersion;
|
||||||
|
|
||||||
|
public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty;
|
||||||
|
|
||||||
|
public string Sha256 => Publication.Sha256;
|
||||||
|
|
||||||
|
public long PackageSizeBytes => Publication.PackageSizeBytes;
|
||||||
|
|
||||||
|
public string IconUrl => Repository.IconUrl;
|
||||||
|
|
||||||
|
public string ProjectUrl => Repository.ProjectUrl;
|
||||||
|
|
||||||
|
public string ReadmeUrl => Repository.ReadmeUrl;
|
||||||
|
|
||||||
|
public string HomepageUrl => Repository.HomepageUrl;
|
||||||
|
|
||||||
|
public string RepositoryUrl => Repository.RepositoryUrl;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Tags => Repository.Tags;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCatalogDependencyInfo> Dependencies =>
|
||||||
|
Manifest.SharedContracts
|
||||||
|
.Select(contract => new PluginCatalogDependencyInfo(
|
||||||
|
contract.Id,
|
||||||
|
contract.Version,
|
||||||
|
contract.AssemblyName))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public DateTimeOffset PublishedAt => Publication.PublishedAt;
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
|
||||||
|
|
||||||
|
public string ReleaseTag => Publication.ReleaseTag;
|
||||||
|
|
||||||
|
public string ReleaseAssetName => Publication.ReleaseAssetName;
|
||||||
|
|
||||||
|
public string ReleaseNotes => Repository.ReleaseNotes;
|
||||||
|
|
||||||
|
public static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item)
|
||||||
|
{
|
||||||
|
return new PluginMarketPluginInfo(
|
||||||
|
item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Description,
|
||||||
|
item.Author,
|
||||||
|
item.Version,
|
||||||
|
item.ApiVersion,
|
||||||
|
item.MinHostVersion,
|
||||||
|
item.DownloadUrl,
|
||||||
|
item.ReleaseTag,
|
||||||
|
item.ReleaseAssetName,
|
||||||
|
item.IconUrl,
|
||||||
|
item.ReadmeUrl,
|
||||||
|
item.HomepageUrl,
|
||||||
|
item.RepositoryUrl,
|
||||||
|
item.Tags.ToArray(),
|
||||||
|
item.Dependencies.Select(dependency => new PluginMarketDependencyInfo(
|
||||||
|
dependency.Id,
|
||||||
|
dependency.Version,
|
||||||
|
dependency.AssemblyName)).ToArray(),
|
||||||
|
item.PublishedAt,
|
||||||
|
item.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin)
|
||||||
|
{
|
||||||
|
return new PluginCatalogItemInfo(
|
||||||
|
new PluginCatalogManifestInfo(
|
||||||
|
plugin.Id,
|
||||||
|
plugin.Name,
|
||||||
|
plugin.Description,
|
||||||
|
plugin.Author,
|
||||||
|
plugin.Version,
|
||||||
|
plugin.ApiVersion,
|
||||||
|
string.Empty,
|
||||||
|
plugin.Dependencies
|
||||||
|
.Select(dependency => new PluginCatalogSharedContractInfo(
|
||||||
|
dependency.Id,
|
||||||
|
dependency.Version,
|
||||||
|
dependency.AssemblyName))
|
||||||
|
.ToArray()),
|
||||||
|
new PluginCatalogCompatibilityInfo(
|
||||||
|
plugin.MinHostVersion,
|
||||||
|
plugin.ApiVersion),
|
||||||
|
new PluginCatalogRepositoryInfo(
|
||||||
|
plugin.IconUrl,
|
||||||
|
plugin.RepositoryUrl,
|
||||||
|
plugin.ReadmeUrl,
|
||||||
|
plugin.HomepageUrl,
|
||||||
|
plugin.RepositoryUrl,
|
||||||
|
plugin.Tags,
|
||||||
|
string.Empty),
|
||||||
|
new PluginCatalogPublicationInfo(
|
||||||
|
plugin.ReleaseTag,
|
||||||
|
plugin.ReleaseAssetName,
|
||||||
|
plugin.PublishedAt,
|
||||||
|
plugin.UpdatedAt,
|
||||||
|
0,
|
||||||
|
string.Empty,
|
||||||
|
null),
|
||||||
|
string.IsNullOrWhiteSpace(plugin.DownloadUrl)
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
new PluginPackageSourceInfo(
|
||||||
|
string.IsNullOrWhiteSpace(plugin.ReleaseTag)
|
||||||
|
? PluginPackageSourceKind.RawFallback
|
||||||
|
: PluginPackageSourceKind.ReleaseAsset,
|
||||||
|
plugin.DownloadUrl,
|
||||||
|
string.Empty,
|
||||||
|
0)
|
||||||
|
],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogIndexResult(
|
||||||
|
bool Success,
|
||||||
|
IReadOnlyList<PluginCatalogItemInfo> Plugins,
|
||||||
|
IReadOnlyList<PluginCatalogSourceInfo> Sources,
|
||||||
|
string? Source,
|
||||||
|
string? SourceLocation,
|
||||||
|
string? WarningMessage,
|
||||||
|
string? ErrorMessage)
|
||||||
|
{
|
||||||
|
public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result)
|
||||||
|
{
|
||||||
|
return new PluginMarketIndexResult(
|
||||||
|
result.Success,
|
||||||
|
result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(),
|
||||||
|
result.Source,
|
||||||
|
result.SourceLocation,
|
||||||
|
result.WarningMessage,
|
||||||
|
result.ErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginInstallDiagnostic(
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
string? Details = null);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogInstallResult(
|
||||||
|
bool Success,
|
||||||
|
string? PluginId,
|
||||||
|
string? PluginName,
|
||||||
|
PluginManifest? InstalledManifest,
|
||||||
|
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
|
||||||
|
string? ErrorMessage)
|
||||||
|
{
|
||||||
|
public static implicit operator PluginMarketInstallResult(PluginCatalogInstallResult result)
|
||||||
|
{
|
||||||
|
return new PluginMarketInstallResult(
|
||||||
|
result.Success,
|
||||||
|
result.PluginId,
|
||||||
|
result.PluginName,
|
||||||
|
result.ErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogDependencyInfo(
|
||||||
|
string Id,
|
||||||
|
string Version,
|
||||||
|
string AssemblyName)
|
||||||
|
{
|
||||||
|
public static implicit operator PluginMarketDependencyInfo(PluginCatalogDependencyInfo dependency)
|
||||||
|
{
|
||||||
|
return new PluginMarketDependencyInfo(
|
||||||
|
dependency.Id,
|
||||||
|
dependency.Version,
|
||||||
|
dependency.AssemblyName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogSharedContractInfo and PluginCatalogItemInfo instead.")]
|
||||||
public sealed record PluginMarketDependencyInfo(
|
public sealed record PluginMarketDependencyInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Version,
|
string Version,
|
||||||
string AssemblyName);
|
string AssemblyName);
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogItemInfo instead.")]
|
||||||
public sealed record PluginMarketPluginInfo(
|
public sealed record PluginMarketPluginInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -89,6 +354,8 @@ public sealed record PluginMarketPluginInfo(
|
|||||||
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
||||||
DateTimeOffset PublishedAt,
|
DateTimeOffset PublishedAt,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogIndexResult instead.")]
|
||||||
public sealed record PluginMarketIndexResult(
|
public sealed record PluginMarketIndexResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
IReadOnlyList<PluginMarketPluginInfo> Plugins,
|
IReadOnlyList<PluginMarketPluginInfo> Plugins,
|
||||||
@@ -96,12 +363,39 @@ public sealed record PluginMarketIndexResult(
|
|||||||
string? SourceLocation,
|
string? SourceLocation,
|
||||||
string? WarningMessage,
|
string? WarningMessage,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogInstallResult instead.")]
|
||||||
public sealed record PluginMarketInstallResult(
|
public sealed record PluginMarketInstallResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
string? PluginId,
|
string? PluginId,
|
||||||
string? PluginName,
|
string? PluginName,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
public interface IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginCatalogService : IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPackageSourceResolver
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginCompatibilityEvaluator
|
||||||
|
{
|
||||||
|
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginInstallOrchestrator
|
||||||
|
{
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IGridSettingsService
|
public interface IGridSettingsService
|
||||||
{
|
{
|
||||||
GridSettingsState Get();
|
GridSettingsState Get();
|
||||||
@@ -223,10 +517,17 @@ public interface IPluginManagementSettingsService
|
|||||||
bool DeleteInstalledPlugin(string pluginId);
|
bool DeleteInstalledPlugin(string pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IPluginMarketSettingsService
|
public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Obsolete("Use IPluginCatalogSettingsService instead.")]
|
||||||
|
public interface IPluginMarketSettingsService : IPluginCatalogSettingsService
|
||||||
{
|
{
|
||||||
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
|
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
|
||||||
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
new Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IApplicationInfoService
|
public interface IApplicationInfoService
|
||||||
@@ -252,6 +553,20 @@ public interface ISettingsFacadeService
|
|||||||
ILauncherCatalogService LauncherCatalog { get; }
|
ILauncherCatalogService LauncherCatalog { get; }
|
||||||
ILauncherPolicyService LauncherPolicy { get; }
|
ILauncherPolicyService LauncherPolicy { get; }
|
||||||
IPluginManagementSettingsService PluginManagement { get; }
|
IPluginManagementSettingsService PluginManagement { get; }
|
||||||
|
IPluginCatalogSettingsService PluginCatalog { get; }
|
||||||
|
[Obsolete("Use PluginCatalog instead.")]
|
||||||
IPluginMarketSettingsService PluginMarket { get; }
|
IPluginMarketSettingsService PluginMarket { get; }
|
||||||
IApplicationInfoService ApplicationInfo { get; }
|
IApplicationInfoService ApplicationInfo { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.PluginMarket
|
||||||
|
{
|
||||||
|
internal enum PluginPackageSourceKind
|
||||||
|
{
|
||||||
|
ReleaseAsset = 0,
|
||||||
|
RawFallback = 1,
|
||||||
|
WorkspaceLocal = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -870,14 +870,41 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
|||||||
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
|
public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var result = await _indexService.LoadAsync(cancellationToken);
|
return LoadCatalogCoreAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PluginCatalogInstallResult> InstallAsync(
|
||||||
|
string pluginId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InstallCatalogCoreAsync(pluginId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<PluginMarketInstallResult> IPluginMarketSettingsService.InstallAsync(
|
||||||
|
string pluginId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage);
|
||||||
if (!result.Success || result.Document is null)
|
if (!result.Success || result.Document is null)
|
||||||
{
|
{
|
||||||
return new PluginMarketIndexResult(
|
_cachedPlugins.Clear();
|
||||||
|
return new PluginCatalogIndexResult(
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
|
sources,
|
||||||
result.Source?.ToString(),
|
result.Source?.ToString(),
|
||||||
result.SourceLocation,
|
result.SourceLocation,
|
||||||
result.WarningMessage,
|
result.WarningMessage,
|
||||||
@@ -889,81 +916,189 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
|||||||
.Select(entry =>
|
.Select(entry =>
|
||||||
{
|
{
|
||||||
_cachedPlugins[entry.Id] = entry;
|
_cachedPlugins[entry.Id] = entry;
|
||||||
return new PluginMarketPluginInfo(
|
return MapCatalogItem(entry);
|
||||||
entry.Id,
|
|
||||||
entry.Name,
|
|
||||||
entry.Description,
|
|
||||||
entry.Author,
|
|
||||||
entry.Version,
|
|
||||||
entry.ApiVersion,
|
|
||||||
entry.MinHostVersion,
|
|
||||||
entry.DownloadUrl,
|
|
||||||
entry.ReleaseTag,
|
|
||||||
entry.ReleaseAssetName,
|
|
||||||
entry.IconUrl,
|
|
||||||
entry.ReadmeUrl,
|
|
||||||
entry.HomepageUrl,
|
|
||||||
entry.RepositoryUrl,
|
|
||||||
entry.Tags,
|
|
||||||
entry.SharedContracts
|
|
||||||
.Select(contract => new PluginMarketDependencyInfo(
|
|
||||||
contract.Id,
|
|
||||||
contract.Version,
|
|
||||||
contract.AssemblyName))
|
|
||||||
.ToArray(),
|
|
||||||
entry.PublishedAt,
|
|
||||||
entry.UpdatedAt);
|
|
||||||
})
|
})
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return new PluginMarketIndexResult(
|
return new PluginCatalogIndexResult(
|
||||||
true,
|
true,
|
||||||
plugins,
|
plugins,
|
||||||
|
sources,
|
||||||
result.Source?.ToString(),
|
result.Source?.ToString(),
|
||||||
result.SourceLocation,
|
result.SourceLocation,
|
||||||
result.WarningMessage,
|
result.WarningMessage,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PluginMarketInstallResult> InstallAsync(
|
private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
|
||||||
string pluginId,
|
string pluginId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(pluginId))
|
if (string.IsNullOrWhiteSpace(pluginId))
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, null, null, "Plugin id is required.");
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")],
|
||||||
|
"Plugin id is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_installService is null || _pluginRuntimeService is null)
|
if (_installService is null || _pluginRuntimeService is null)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(
|
return new PluginCatalogInstallResult(
|
||||||
false,
|
false,
|
||||||
pluginId,
|
pluginId,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
|
||||||
"Plugin runtime is unavailable.");
|
"Plugin runtime is unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
|
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
|
||||||
{
|
{
|
||||||
var load = await LoadIndexAsync(cancellationToken);
|
var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!load.Success)
|
if (!load.Success)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage);
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
pluginId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")],
|
||||||
|
load.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
|
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index.");
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
pluginId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")],
|
||||||
|
"Plugin was not found in the official catalog.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _installService.InstallAsync(entry, cancellationToken);
|
var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage);
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
entry.Id,
|
||||||
|
entry.Name,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")],
|
||||||
|
result.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null);
|
return new PluginCatalogInstallResult(
|
||||||
|
true,
|
||||||
|
result.Manifest?.Id ?? entry.Id,
|
||||||
|
result.Manifest?.Name ?? entry.Name,
|
||||||
|
result.Manifest,
|
||||||
|
[],
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry)
|
||||||
|
{
|
||||||
|
var manifest = new PluginCatalogManifestInfo(
|
||||||
|
entry.Id,
|
||||||
|
entry.Name,
|
||||||
|
entry.Description,
|
||||||
|
entry.Author,
|
||||||
|
entry.Version,
|
||||||
|
entry.ApiVersion,
|
||||||
|
string.Empty,
|
||||||
|
entry.SharedContracts
|
||||||
|
.Select(contract => new PluginCatalogSharedContractInfo(
|
||||||
|
contract.Id,
|
||||||
|
contract.Version,
|
||||||
|
contract.AssemblyName))
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
var compatibility = new PluginCatalogCompatibilityInfo(
|
||||||
|
entry.MinHostVersion,
|
||||||
|
entry.ApiVersion);
|
||||||
|
|
||||||
|
var repository = new PluginCatalogRepositoryInfo(
|
||||||
|
entry.IconUrl,
|
||||||
|
entry.ProjectUrl,
|
||||||
|
entry.ReadmeUrl,
|
||||||
|
entry.HomepageUrl,
|
||||||
|
entry.RepositoryUrl,
|
||||||
|
entry.Tags.ToArray(),
|
||||||
|
entry.ReleaseNotes);
|
||||||
|
|
||||||
|
var publication = new PluginCatalogPublicationInfo(
|
||||||
|
entry.ReleaseTag,
|
||||||
|
entry.ReleaseAssetName,
|
||||||
|
entry.PublishedAt,
|
||||||
|
entry.UpdatedAt,
|
||||||
|
entry.PackageSizeBytes,
|
||||||
|
entry.Sha256,
|
||||||
|
null);
|
||||||
|
|
||||||
|
var sources = BuildPackageSources(entry);
|
||||||
|
|
||||||
|
return new PluginCatalogItemInfo(
|
||||||
|
manifest,
|
||||||
|
compatibility,
|
||||||
|
repository,
|
||||||
|
publication,
|
||||||
|
sources,
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.DownloadUrl))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceKind = entry.HasReleaseDownloadMetadata
|
||||||
|
? PluginPackageSourceKind.ReleaseAsset
|
||||||
|
: PluginPackageSourceKind.RawFallback;
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginPackageSourceInfo(
|
||||||
|
sourceKind,
|
||||||
|
entry.DownloadUrl,
|
||||||
|
entry.Sha256,
|
||||||
|
entry.PackageSizeBytes)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
|
||||||
|
string? sourceId,
|
||||||
|
string? sourceLocation,
|
||||||
|
string? warningMessage)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId)
|
||||||
|
? "plugin-catalog"
|
||||||
|
: sourceId.Trim();
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginCatalogSourceInfo(
|
||||||
|
normalizedSourceId,
|
||||||
|
normalizedSourceId,
|
||||||
|
string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
0)
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -1054,6 +1189,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
|||||||
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
||||||
PluginManagement = _pluginManagementSettingsService;
|
PluginManagement = _pluginManagementSettingsService;
|
||||||
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
|
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
|
||||||
|
PluginCatalog = _pluginMarketSettingsService;
|
||||||
PluginMarket = _pluginMarketSettingsService;
|
PluginMarket = _pluginMarketSettingsService;
|
||||||
ApplicationInfo = new ApplicationInfoService();
|
ApplicationInfo = new ApplicationInfoService();
|
||||||
}
|
}
|
||||||
@@ -1086,6 +1222,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
|||||||
|
|
||||||
public IPluginManagementSettingsService PluginManagement { get; }
|
public IPluginManagementSettingsService PluginManagement { get; }
|
||||||
|
|
||||||
|
public IPluginCatalogSettingsService PluginCatalog { get; }
|
||||||
|
|
||||||
public IPluginMarketSettingsService PluginMarket { get; }
|
public IPluginMarketSettingsService PluginMarket { get; }
|
||||||
|
|
||||||
public IApplicationInfoService ApplicationInfo { get; }
|
public IApplicationInfoService ApplicationInfo { get; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
@@ -31,7 +31,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
private bool _isLoadingIcon;
|
private bool _isLoadingIcon;
|
||||||
|
|
||||||
public PluginMarketItemViewModel(
|
public PluginMarketItemViewModel(
|
||||||
PluginMarketPluginInfo plugin,
|
PluginCatalogItemInfo plugin,
|
||||||
LocalizationService localizationService,
|
LocalizationService localizationService,
|
||||||
string languageCode)
|
string languageCode)
|
||||||
{
|
{
|
||||||
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
ActionTooltip = L("market.button.install", "Install");
|
ActionTooltip = L("market.button.install", "Install");
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketPluginInfo Info { get; }
|
public PluginCatalogItemInfo Info { get; }
|
||||||
|
|
||||||
public string PluginId => Info.Id;
|
public string PluginId => Info.Id;
|
||||||
|
|
||||||
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
public string ReadmeUrl => Info.ReadmeUrl;
|
public string ReadmeUrl => Info.ReadmeUrl;
|
||||||
|
|
||||||
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies;
|
public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
|
||||||
|
|
||||||
public string IconFallbackText { get; }
|
public string IconFallbackText { get; }
|
||||||
|
|
||||||
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
_readmeService = readmeService;
|
_readmeService = readmeService;
|
||||||
_primaryActionAsync = primaryActionAsync;
|
_primaryActionAsync = primaryActionAsync;
|
||||||
|
|
||||||
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
|
Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
|
||||||
VersionLabel = L("market.detail.version", "Version");
|
VersionLabel = L("market.detail.version", "Version");
|
||||||
PublisherLabel = L("market.detail.author", "Author");
|
PublisherLabel = L("market.detail.author", "Author");
|
||||||
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
||||||
@@ -271,7 +275,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public PluginMarketItemViewModel Item { get; }
|
public PluginMarketItemViewModel Item { get; }
|
||||||
|
|
||||||
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; }
|
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
|
||||||
|
|
||||||
public string DrawerTitle => Item.Name;
|
public string DrawerTitle => Item.Name;
|
||||||
|
|
||||||
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
if (_isInitialized)
|
if (_isInitialized)
|
||||||
@@ -370,6 +378,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly IPluginCatalogSettingsService _pluginCatalog;
|
||||||
private readonly LocalizationService _localizationService;
|
private readonly LocalizationService _localizationService;
|
||||||
private readonly AirAppMarketIconService _iconService;
|
private readonly AirAppMarketIconService _iconService;
|
||||||
private readonly AirAppMarketReadmeService _readmeService;
|
private readonly AirAppMarketReadmeService _readmeService;
|
||||||
@@ -386,6 +395,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
AirAppMarketReadmeService readmeService)
|
AirAppMarketReadmeService readmeService)
|
||||||
{
|
{
|
||||||
_settingsFacade = settingsFacade;
|
_settingsFacade = settingsFacade;
|
||||||
|
_pluginCatalog = _settingsFacade.PluginCatalog;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
_iconService = iconService;
|
_iconService = iconService;
|
||||||
_readmeService = readmeService;
|
_readmeService = readmeService;
|
||||||
@@ -468,7 +478,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
|
|
||||||
var result = await _settingsFacade.PluginMarket.LoadIndexAsync();
|
var result = await _pluginCatalog.LoadCatalogAsync();
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
_hasLoadedMarket = false;
|
_hasLoadedMarket = false;
|
||||||
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
||||||
item.Name);
|
item.Name);
|
||||||
|
|
||||||
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
|
var result = await _pluginCatalog.InstallAsync(item.PluginId);
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
|
|
||||||
foreach (var visual in _itemVisuals)
|
foreach (var visual in _itemVisuals)
|
||||||
{
|
{
|
||||||
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA"));
|
visual.Host.Background = Brushes.Transparent;
|
||||||
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,14 +58,16 @@
|
|||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
Foreground="#bb5649"
|
Foreground="#bb5649"
|
||||||
Focusable="False"
|
Focusable="False"
|
||||||
ToolTip.Tip="刷新新闻"
|
ToolTip.Tip="刷新今日新闻"
|
||||||
Click="OnRefreshButtonClick">
|
Click="OnRefreshButtonClick">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
<fi:SymbolIcon Symbol="ArrowSync"
|
<fi:SymbolIcon x:Name="RefreshIcon"
|
||||||
|
Symbol="ArrowSync"
|
||||||
IconVariant="Regular"
|
IconVariant="Regular"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
Foreground="#bb5649" />
|
Foreground="#bb5649" />
|
||||||
<TextBlock Text="刷新"
|
<TextBlock x:Name="RefreshButtonText"
|
||||||
|
Text="刷新"
|
||||||
FontSize="13"
|
FontSize="13"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -625,13 +625,84 @@ public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cachedNews.Clear();
|
_isLoading = true;
|
||||||
_loadedDates.Clear();
|
RefreshButtonText.Text = "刷新中...";
|
||||||
_dailyViews.Clear();
|
RefreshIcon.IsEnabled = false;
|
||||||
NewsStackPanel.Children.Clear();
|
|
||||||
_earliestLoadedDate = DateTime.Today;
|
|
||||||
|
|
||||||
await LoadInitialNewsAsync();
|
try
|
||||||
|
{
|
||||||
|
var allNews = await FetchJuyaNewsAsync();
|
||||||
|
|
||||||
|
if (!_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var todayNews = allNews.FirstOrDefault(n => n.Date.Date == today);
|
||||||
|
|
||||||
|
if (todayNews != null)
|
||||||
|
{
|
||||||
|
_cachedNews[today] = todayNews;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
var existingIndex = _loadedDates.IndexOf(today);
|
||||||
|
if (existingIndex >= 0 && _dailyViews.Count > existingIndex)
|
||||||
|
{
|
||||||
|
var oldView = _dailyViews[existingIndex];
|
||||||
|
var insertIndex = NewsStackPanel.Children.IndexOf(oldView);
|
||||||
|
|
||||||
|
if (insertIndex >= 0)
|
||||||
|
{
|
||||||
|
NewsStackPanel.Children.RemoveAt(insertIndex);
|
||||||
|
_dailyViews.RemoveAt(existingIndex);
|
||||||
|
|
||||||
|
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||||
|
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Insert(insertIndex, newView);
|
||||||
|
_dailyViews.Insert(existingIndex, newView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||||
|
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Insert(0, newView);
|
||||||
|
_dailyViews.Insert(0, newView);
|
||||||
|
_loadedDates.Insert(0, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryOpenUrl(string? url)
|
private void TryOpenUrl(string? url)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
d:DesignHeight="480"
|
d:DesignHeight="480"
|
||||||
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
|
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Background="#F1F4F9"
|
Background="#F1F4F9"
|
||||||
CornerRadius="20"
|
CornerRadius="20"
|
||||||
@@ -91,4 +92,41 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Popup x:Name="ColorPickerPopup"
|
||||||
|
Placement="Top"
|
||||||
|
PlacementTarget="{Binding #PenButton}"
|
||||||
|
IsLightDismissEnabled="True"
|
||||||
|
WindowManagerAddShadowHint="False">
|
||||||
|
<Border Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<ColorView x:Name="InkColorPicker"
|
||||||
|
IsAlphaEnabled="False"
|
||||||
|
IsColorSpectrumVisible="True"
|
||||||
|
IsColorPaletteVisible="True"
|
||||||
|
IsHexInputVisible="True"
|
||||||
|
ColorChanged="OnColorPickerColorChanged" />
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="粗细"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="12" />
|
||||||
|
<Slider x:Name="InkThicknessSlider"
|
||||||
|
Grid.Column="1"
|
||||||
|
Minimum="1"
|
||||||
|
Maximum="8"
|
||||||
|
Value="2.5"
|
||||||
|
SmallChange="0.5"
|
||||||
|
LargeChange="1"
|
||||||
|
ValueChanged="OnInkThicknessSliderValueChanged" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Reflection;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
@@ -38,7 +39,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
||||||
private bool? _isNightModeApplied;
|
private bool? _isNightModeApplied;
|
||||||
private SKColor _currentInkColor = SKColors.Black;
|
private SKColor _selectedInkColor = SKColors.Black;
|
||||||
|
private float _selectedInkThickness = 2.5f;
|
||||||
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
||||||
private string _placementId = string.Empty;
|
private string _placementId = string.Empty;
|
||||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
@@ -66,9 +68,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
RefreshFromSettings();
|
RefreshFromSettings();
|
||||||
ApplyThemeVisual(force: true);
|
ApplyThemeVisual(force: true);
|
||||||
|
InitializeColorPicker();
|
||||||
SetToolMode(WhiteboardToolMode.Pen);
|
SetToolMode(WhiteboardToolMode.Pen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializeColorPicker()
|
||||||
|
{
|
||||||
|
if (InkColorPicker is not null)
|
||||||
|
{
|
||||||
|
InkColorPicker.Color = new Color(
|
||||||
|
_selectedInkColor.Alpha,
|
||||||
|
_selectedInkColor.Red,
|
||||||
|
_selectedInkColor.Green,
|
||||||
|
_selectedInkColor.Blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (InkThicknessSlider is not null)
|
||||||
|
{
|
||||||
|
InkThicknessSlider.Value = _selectedInkThickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int NoteRetentionDays => _noteRetentionDays;
|
public int NoteRetentionDays => _noteRetentionDays;
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
@@ -97,7 +117,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
settings.IgnorePressure = true;
|
settings.IgnorePressure = true;
|
||||||
settings.InkThickness = 2.5f;
|
settings.InkThickness = _selectedInkThickness;
|
||||||
settings.EraserSize = new Size(20, 20);
|
settings.EraserSize = new Size(20, 20);
|
||||||
settings.IsBitmapCacheEnabled = true;
|
settings.IsBitmapCacheEnabled = true;
|
||||||
settings.MaxBitmapCacheSize = 2048;
|
settings.MaxBitmapCacheSize = 2048;
|
||||||
@@ -135,7 +155,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
|
|
||||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||||
}
|
}
|
||||||
@@ -149,7 +168,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isNightModeApplied = isNightMode;
|
_isNightModeApplied = isNightMode;
|
||||||
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
|
|
||||||
|
|
||||||
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
||||||
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
||||||
@@ -157,8 +175,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
||||||
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
||||||
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
|
||||||
RecolorAllStrokes(_currentInkColor);
|
|
||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +220,30 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ForceSaveNote()
|
||||||
|
{
|
||||||
|
if (_disposed || !HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_noteDirty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteDirty = false;
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -300,12 +340,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
|
|
||||||
if (mode == WhiteboardToolMode.Pen)
|
if (mode == WhiteboardToolMode.Pen)
|
||||||
{
|
{
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetInkColor(SKColor color)
|
||||||
|
{
|
||||||
|
_selectedInkColor = color;
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen)
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||||
|
}
|
||||||
|
RefreshToolButtonVisuals();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetInkThickness(float thickness)
|
||||||
|
{
|
||||||
|
_selectedInkThickness = Math.Clamp(thickness, 1.0f, 8.0f);
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen)
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkThickness = _selectedInkThickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshToolButtonVisuals()
|
private void RefreshToolButtonVisuals()
|
||||||
{
|
{
|
||||||
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
||||||
@@ -349,9 +408,34 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
|
||||||
|
{
|
||||||
|
if (ColorPickerPopup.IsOpen)
|
||||||
|
{
|
||||||
|
ColorPickerPopup.Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ColorPickerPopup.Open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
SetToolMode(WhiteboardToolMode.Pen);
|
SetToolMode(WhiteboardToolMode.Pen);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var color = e.NewColor;
|
||||||
|
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
|
{
|
||||||
|
SetInkThickness((float)e.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -509,14 +593,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
_noteDirty = false;
|
_noteDirty = false;
|
||||||
_noteSaveTimer.Stop();
|
_noteSaveTimer.Stop();
|
||||||
var noteSnapshot = BuildNoteSnapshot();
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
var componentId = _componentId;
|
try
|
||||||
var placementId = _placementId;
|
{
|
||||||
var retentionDays = _noteRetentionDays;
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||||
_ = Task.Run(() => _notePersistenceService.SaveNote(
|
}
|
||||||
componentId,
|
catch
|
||||||
placementId,
|
{
|
||||||
noteSnapshot,
|
}
|
||||||
retentionDays));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void SchedulePersistedNoteLoad()
|
private async void SchedulePersistedNoteLoad()
|
||||||
@@ -553,7 +636,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
{
|
{
|
||||||
ClearAllStrokes();
|
ClearAllStrokes();
|
||||||
ApplyNoteSnapshot(noteSnapshot);
|
ApplyNoteSnapshot(noteSnapshot);
|
||||||
RecolorAllStrokes(_currentInkColor);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3276,4 +3276,19 @@ public partial class MainWindow
|
|||||||
_isComponentLibraryComponentGestureActive = false;
|
_isComponentLibraryComponentGestureActive = false;
|
||||||
ApplyComponentLibraryComponentOffset();
|
ApplyComponentLibraryComponentOffset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void SaveAllWhiteboardNotes()
|
||||||
|
{
|
||||||
|
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||||
|
{
|
||||||
|
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||||
|
{
|
||||||
|
var contentHost = TryGetContentHost(host);
|
||||||
|
if (contentHost?.Child is WhiteboardWidget whiteboard)
|
||||||
|
{
|
||||||
|
whiteboard.ForceSaveNote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
var wasVisible = IsVisible;
|
var wasVisible = IsVisible;
|
||||||
var windowState = WindowState.ToString();
|
var windowState = WindowState.ToString();
|
||||||
|
|
||||||
|
SaveAllWhiteboardNotes();
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
_componentEditorWindowService.Close();
|
_componentEditorWindowService.Close();
|
||||||
if (_detachedComponentLibraryWindow is not null)
|
if (_detachedComponentLibraryWindow is not null)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
@@ -12,6 +13,8 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
|||||||
|
|
||||||
internal sealed class AirAppMarketInstallService : IDisposable
|
internal sealed class AirAppMarketInstallService : IDisposable
|
||||||
{
|
{
|
||||||
|
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||||
|
|
||||||
private readonly PluginRuntimeService _runtime;
|
private readonly PluginRuntimeService _runtime;
|
||||||
private readonly PluginsInstallHelperClient _helperClient = new();
|
private readonly PluginsInstallHelperClient _helperClient = new();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
@@ -38,107 +41,226 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
|
||||||
Directory.CreateDirectory(_downloadsDirectory);
|
if (OperatingSystem.IsWindows())
|
||||||
var downloadPath = Path.Combine(
|
|
||||||
_downloadsDirectory,
|
|
||||||
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
AppLogger.Info(
|
var helperPath = ResolveHelperPath();
|
||||||
"PluginMarket",
|
if (!File.Exists(helperPath))
|
||||||
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
|
||||||
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
|
|
||||||
AppLogger.Info(
|
|
||||||
"PluginMarket",
|
|
||||||
$"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'.");
|
|
||||||
|
|
||||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
|
||||||
{
|
{
|
||||||
var localCopyResult = await _downloadService.DownloadAsync(
|
|
||||||
localPackagePath,
|
|
||||||
downloadPath,
|
|
||||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
if (!localCopyResult.Success)
|
|
||||||
{
|
|
||||||
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var downloadResult = await _downloadService.DownloadAsync(
|
|
||||||
resolvedDownloadUrl,
|
|
||||||
downloadPath,
|
|
||||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
if (!downloadResult.Success)
|
|
||||||
{
|
|
||||||
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var actualSize = new FileInfo(downloadPath).Length;
|
|
||||||
string actualHash;
|
|
||||||
await using (var hashStream = File.OpenRead(downloadPath))
|
|
||||||
{
|
|
||||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
|
||||||
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
AppLogger.Error(
|
|
||||||
"PluginMarket",
|
|
||||||
$"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
|
||||||
File.Delete(downloadPath);
|
|
||||||
return new AirAppMarketInstallResult(
|
return new AirAppMarketInstallResult(
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}.");
|
$"Plugins install helper was not found at '{helperPath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_downloadsDirectory);
|
||||||
|
var sources = plugin.GetPackageSourcesInInstallOrder();
|
||||||
|
if (sources.Count == 0)
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"Plugin does not declare any package sources.");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'.");
|
||||||
|
|
||||||
|
var sourceErrors = new List<string>();
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
var attemptResult = await TryInstallFromSourceAsync(plugin, source, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (attemptResult.Success)
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(true, attemptResult.Manifest, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptResult.Fatal)
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(false, null, attemptResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(attemptResult.ErrorMessage))
|
||||||
|
{
|
||||||
|
sourceErrors.Add($"{source.SourceKind}: {attemptResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinedMessage = sourceErrors.Count == 0
|
||||||
|
? $"Failed to install plugin '{plugin.Id}' from all available package sources."
|
||||||
|
: $"Failed to install plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}";
|
||||||
|
return new AirAppMarketInstallResult(false, null, combinedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var attemptPath = Path.Combine(
|
||||||
|
_downloadsDirectory,
|
||||||
|
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Resolved package source for '{plugin.Id}' to '{resolvedDownloadUrl}' using '{source.SourceKind}'.");
|
||||||
|
|
||||||
|
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, attemptPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!acquireResult.Success)
|
||||||
|
{
|
||||||
|
TryDeleteFile(attemptPath);
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, false, null, acquireResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var verificationResult = await VerifyPackageAsync(plugin, attemptPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!verificationResult.Success)
|
||||||
|
{
|
||||||
|
TryDeleteFile(attemptPath);
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, false, null, verificationResult.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginManifest manifest;
|
PluginManifest manifest;
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
var helperResult = await _helperClient.InstallPackageAsync(
|
var helperResult = await _helperClient.InstallPackageAsync(
|
||||||
downloadPath,
|
attemptPath,
|
||||||
_runtime.PluginsDirectory,
|
_runtime.PluginsDirectory,
|
||||||
cancellationToken);
|
cancellationToken).ConfigureAwait(false);
|
||||||
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
||||||
{
|
{
|
||||||
return new AirAppMarketInstallResult(
|
var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
|
||||||
false,
|
AppLogger.Error(
|
||||||
null,
|
"PluginMarket",
|
||||||
helperResult.ErrorMessage ?? "Plugins install helper failed.");
|
$"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
|
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
manifest = _runtime.InstallPluginPackage(downloadPath);
|
manifest = _runtime.InstallPluginPackage(attemptPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginMarket",
|
"PluginMarket",
|
||||||
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
|
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'.");
|
||||||
return new AirAppMarketInstallResult(true, manifest, null);
|
return new AirAppMarketInstallAttemptResult(true, true, manifest, null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
AppLogger.Warn(
|
AppLogger.Warn(
|
||||||
"PluginMarket",
|
"PluginMarket",
|
||||||
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Error(
|
AppLogger.Error(
|
||||||
"PluginMarket",
|
"PluginMarket",
|
||||||
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.",
|
$"Install attempt failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.",
|
||||||
ex);
|
ex);
|
||||||
return new AirAppMarketInstallResult(false, null, ex.Message);
|
TryDeleteFile(attemptPath);
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketAcquisitionResult> AcquirePackageAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
string resolvedDownloadUrl,
|
||||||
|
string attemptPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
||||||
|
{
|
||||||
|
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Copying workspace package for '{plugin.Id}' from '{localPackagePath}' to '{attemptPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var localCopyResult = await _downloadService.DownloadAsync(
|
||||||
|
localPackagePath,
|
||||||
|
attemptPath,
|
||||||
|
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!localCopyResult.Success)
|
||||||
|
{
|
||||||
|
return new AirAppMarketAcquisitionResult(false, localCopyResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppMarketAcquisitionResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
|
||||||
|
{
|
||||||
|
return new AirAppMarketAcquisitionResult(
|
||||||
|
false,
|
||||||
|
$"Workspace package source '{source.Url}' could not be resolved to a local file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadResult = await _downloadService.DownloadAsync(
|
||||||
|
resolvedDownloadUrl,
|
||||||
|
attemptPath,
|
||||||
|
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!downloadResult.Success)
|
||||||
|
{
|
||||||
|
return new AirAppMarketAcquisitionResult(false, downloadResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppMarketAcquisitionResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketVerificationResult> VerifyPackageAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
string attemptPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var actualSize = new FileInfo(attemptPath).Length;
|
||||||
|
string actualHash;
|
||||||
|
await using (var hashStream = File.OpenRead(attemptPath))
|
||||||
|
{
|
||||||
|
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken).ConfigureAwait(false);
|
||||||
|
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualSize != plugin.PackageSizeBytes || !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
AppLogger.Error(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
||||||
|
return new AirAppMarketVerificationResult(
|
||||||
|
false,
|
||||||
|
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppMarketVerificationResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveHelperPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore cleanup failures for temporary install artifacts.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,4 +274,18 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record AirAppMarketInstallAttemptResult(
|
||||||
|
bool Success,
|
||||||
|
bool Fatal,
|
||||||
|
PluginManifest? Manifest,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
private sealed record AirAppMarketAcquisitionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
private sealed record AirAppMarketVerificationResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,46 @@ internal sealed class AirAppMarketReleaseResolverService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
|
||||||
if (!plugin.HasReleaseDownloadMetadata)
|
var firstSource = plugin.GetPackageSourcesInInstallOrder().FirstOrDefault();
|
||||||
|
if (firstSource is null)
|
||||||
{
|
{
|
||||||
return plugin.DownloadUrl;
|
return plugin.DownloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await ResolveDownloadUrlAsync(plugin, firstSource, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ResolveDownloadUrlAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
|
||||||
|
return source.SourceKind switch
|
||||||
|
{
|
||||||
|
PluginPackageSourceKind.ReleaseAsset => await ResolveReleaseAssetDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false),
|
||||||
|
PluginPackageSourceKind.RawFallback => source.Url,
|
||||||
|
PluginPackageSourceKind.WorkspaceLocal => source.Url,
|
||||||
|
_ => source.Url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveReleaseAssetDownloadUrlAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sourceUrl = source.Url;
|
||||||
|
if (!plugin.HasReleaseDownloadMetadata)
|
||||||
|
{
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
|
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
|
||||||
{
|
{
|
||||||
return plugin.DownloadUrl;
|
return sourceUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
|
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
|
||||||
@@ -46,15 +78,15 @@ internal sealed class AirAppMarketReleaseResolverService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
|
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
|
||||||
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken);
|
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken).ConfigureAwait(false);
|
||||||
var asset = release?.Assets.FirstOrDefault(candidate =>
|
var asset = release?.Assets.FirstOrDefault(candidate =>
|
||||||
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
|
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl;
|
return asset?.BrowserDownloadUrl ?? releaseDownloadUrl;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return plugin.DownloadUrl;
|
return releaseDownloadUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
VoiceHubLanDesktop/Localization/en-US.json
Normal file
18
VoiceHubLanDesktop/Localization/en-US.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"widget.display_name": "Radio Station Schedule",
|
||||||
|
"widget.category": "Info",
|
||||||
|
"widget.loading": "Loading schedule...",
|
||||||
|
"widget.retry": "Retry",
|
||||||
|
"widget.no_schedule": "No schedule data",
|
||||||
|
"widget.network_error": "Network error",
|
||||||
|
"settings.title": "VoiceHub Settings",
|
||||||
|
"settings.description": "Configure radio station schedule data source and display options",
|
||||||
|
"settings.apiUrl.title": "API URL",
|
||||||
|
"settings.apiUrl.description": "VoiceHub backend API URL for fetching schedule data",
|
||||||
|
"settings.showRequester.title": "Show Requester",
|
||||||
|
"settings.showRequester.description": "Display requester information in the schedule list",
|
||||||
|
"settings.showVoteCount.title": "Show Vote Count",
|
||||||
|
"settings.showVoteCount.description": "Display song vote count in the schedule list",
|
||||||
|
"settings.refreshInterval.title": "Refresh Interval",
|
||||||
|
"settings.refreshInterval.description": "Time interval for automatic schedule data refresh"
|
||||||
|
}
|
||||||
18
VoiceHubLanDesktop/Localization/zh-CN.json
Normal file
18
VoiceHubLanDesktop/Localization/zh-CN.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"widget.display_name": "广播站排期",
|
||||||
|
"widget.category": "信息",
|
||||||
|
"widget.loading": "正在加载排期...",
|
||||||
|
"widget.retry": "重试",
|
||||||
|
"widget.no_schedule": "暂无排期数据",
|
||||||
|
"widget.network_error": "网络错误",
|
||||||
|
"settings.title": "VoiceHub 设置",
|
||||||
|
"settings.description": "配置广播站排期数据源和显示选项",
|
||||||
|
"settings.apiUrl.title": "API 地址",
|
||||||
|
"settings.apiUrl.description": "VoiceHub 后端 API 地址,用于获取排期数据",
|
||||||
|
"settings.showRequester.title": "显示点歌人",
|
||||||
|
"settings.showRequester.description": "在排期列表中显示点歌人信息",
|
||||||
|
"settings.showVoteCount.title": "显示投票数",
|
||||||
|
"settings.showVoteCount.description": "在排期列表中显示歌曲投票数",
|
||||||
|
"settings.refreshInterval.title": "刷新间隔",
|
||||||
|
"settings.refreshInterval.description": "自动刷新排期数据的时间间隔"
|
||||||
|
}
|
||||||
27
VoiceHubLanDesktop/Models/PluginSettings.cs
Normal file
27
VoiceHubLanDesktop/Models/PluginSettings.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件设置
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// API 地址
|
||||||
|
/// </summary>
|
||||||
|
public string ApiUrl { get; set; } = "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否显示点歌人
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowRequester { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否显示投票数
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowVoteCount { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新间隔(分钟)
|
||||||
|
/// </summary>
|
||||||
|
public int RefreshIntervalMinutes { get; set; } = 60;
|
||||||
|
}
|
||||||
113
VoiceHubLanDesktop/Models/SongModels.cs
Normal file
113
VoiceHubLanDesktop/Models/SongModels.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲信息
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Song
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲标题
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 艺术家/歌手
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("artist")]
|
||||||
|
public string Artist { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 点歌人
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("requester")]
|
||||||
|
public string Requester { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 投票数/热度
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("voteCount")]
|
||||||
|
public int VoteCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期歌曲项目
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SongItem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 播放日期 (yyyy-MM-dd)
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("playDate")]
|
||||||
|
public string PlayDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 播放序号
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("sequence")]
|
||||||
|
public int Sequence { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲信息
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("song")]
|
||||||
|
public Song Song { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取播放日期
|
||||||
|
/// </summary>
|
||||||
|
public DateTime GetPlayDate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PlayDate))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
|
||||||
|
System.Globalization.DateTimeStyles.None, out var result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 组件状态
|
||||||
|
/// </summary>
|
||||||
|
public enum ComponentState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 加载中
|
||||||
|
/// </summary>
|
||||||
|
Loading,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 正常显示
|
||||||
|
/// </summary>
|
||||||
|
Normal,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 网络错误
|
||||||
|
/// </summary>
|
||||||
|
NetworkError,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 暂无排期
|
||||||
|
/// </summary>
|
||||||
|
NoSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 显示数据
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DisplayData
|
||||||
|
{
|
||||||
|
public ComponentState State { get; set; }
|
||||||
|
public IReadOnlyList<SongItem> Songs { get; set; } = [];
|
||||||
|
public DateTime? DisplayDate { get; set; }
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
62
VoiceHubLanDesktop/README.md
Normal file
62
VoiceHubLanDesktop/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# VoiceHubLanDesktop
|
||||||
|
|
||||||
|
VoiceHub 广播站排期插件,用于 LanMountainDesktop 桌面应用。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 📻 **排期显示**:展示 VoiceHub 广播站当日排期歌曲
|
||||||
|
- 🔄 **自动刷新**:支持自定义刷新间隔(5分钟 ~ 2小时)
|
||||||
|
- ⚙️ **灵活配置**:可自定义 API 地址、显示选项
|
||||||
|
- 🌐 **多语言支持**:支持中文和英文
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
将 `.laapp` 包放入 LanMountainDesktop 的插件目录:
|
||||||
|
```
|
||||||
|
%LocalAppData%\LanMountainDesktop\Extensions\Plugins\
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
在 LanMountainDesktop 设置中找到 "VoiceHub 设置":
|
||||||
|
|
||||||
|
| 选项 | 说明 | 默认值 |
|
||||||
|
|-----|------|--------|
|
||||||
|
| API 地址 | VoiceHub 后端 API 地址 | `https://voicehub.lao-shui.top/api/songs/public` |
|
||||||
|
| 显示点歌人 | 是否显示点歌人信息 | 是 |
|
||||||
|
| 显示投票数 | 是否显示歌曲投票数 | 否 |
|
||||||
|
| 刷新间隔 | 自动刷新时间间隔 | 1小时 |
|
||||||
|
|
||||||
|
## 组件规格
|
||||||
|
|
||||||
|
- **最小尺寸**:3 × 4 网格
|
||||||
|
- **缩放模式**:等比例缩放
|
||||||
|
- **放置位置**:桌面
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd VoiceHubLanDesktop
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet pack
|
||||||
|
# 或使用脚本
|
||||||
|
../scripts/Pack-PluginPackages.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- .NET 10
|
||||||
|
- Avalonia UI 11.3.12
|
||||||
|
- LanMountainDesktop.PluginSdk 4.0.0
|
||||||
|
- CommunityToolkit.Mvvm 8.2.1
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
113
VoiceHubLanDesktop/Services/VoiceHubApiService.cs
Normal file
113
VoiceHubLanDesktop/Services/VoiceHubApiService.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VoiceHub API 服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubApiService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
private const int MaxRetryCount = 3;
|
||||||
|
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
public VoiceHubApiService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取公开排期数据
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
|
||||||
|
string? apiUrl = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(_requestTimeout);
|
||||||
|
|
||||||
|
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
|
||||||
|
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
|
||||||
|
|
||||||
|
if (items is null)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数退避
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API 结果
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiResult<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public T? Data { get; }
|
||||||
|
public string? ErrorMessage { get; }
|
||||||
|
|
||||||
|
private ApiResult(bool isSuccess, T? data, string? errorMessage)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
Data = data;
|
||||||
|
ErrorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApiResult<T> Success(T data) => new(true, data, null);
|
||||||
|
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
|
||||||
|
}
|
||||||
164
VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
Normal file
164
VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期管理服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubScheduleService
|
||||||
|
{
|
||||||
|
private readonly VoiceHubApiService _apiService;
|
||||||
|
private readonly VoiceHubSettingsService _settingsService;
|
||||||
|
private IReadOnlyList<SongItem> _cachedSchedule = [];
|
||||||
|
private DateTime _cacheTime = DateTime.MinValue;
|
||||||
|
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public event EventHandler<ScheduleUpdatedEventArgs>? ScheduleUpdated;
|
||||||
|
|
||||||
|
public VoiceHubScheduleService(VoiceHubApiService apiService, VoiceHubSettingsService settingsService)
|
||||||
|
{
|
||||||
|
_apiService = apiService;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取今日排期
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = _settingsService.GetSettings();
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
|
||||||
|
{
|
||||||
|
return BuildDisplayData(_cachedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 API 获取
|
||||||
|
var result = await _apiService.GetPublicScheduleAsync(settings.ApiUrl, cancellationToken);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NetworkError,
|
||||||
|
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = result.Data ?? [];
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
_cachedSchedule = items;
|
||||||
|
_cacheTime = DateTime.Now;
|
||||||
|
|
||||||
|
return BuildDisplayData(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 强制刷新
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DisplayData> RefreshAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_cachedSchedule = [];
|
||||||
|
_cacheTime = DateTime.MinValue;
|
||||||
|
return await GetTodayScheduleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除缓存
|
||||||
|
/// </summary>
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
_cachedSchedule = [];
|
||||||
|
_cacheTime = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
|
||||||
|
{
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤有效日期
|
||||||
|
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
|
||||||
|
|
||||||
|
if (validItems.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无有效排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到今天或最近未来的排期
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var todaySchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() == today)
|
||||||
|
.OrderBy(s => s.Sequence)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<SongItem> displayItems;
|
||||||
|
DateTime actualDate;
|
||||||
|
|
||||||
|
if (todaySchedule.Count > 0)
|
||||||
|
{
|
||||||
|
displayItems = todaySchedule;
|
||||||
|
actualDate = today;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 找最近的未来排期
|
||||||
|
var futureSchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() > today)
|
||||||
|
.GroupBy(s => s.GetPlayDate())
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (futureSchedule != null)
|
||||||
|
{
|
||||||
|
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
|
||||||
|
actualDate = futureSchedule.Key;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发更新事件
|
||||||
|
ScheduleUpdated?.Invoke(this, new ScheduleUpdatedEventArgs(displayItems, actualDate));
|
||||||
|
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.Normal,
|
||||||
|
Songs = displayItems,
|
||||||
|
DisplayDate = actualDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期更新事件参数
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScheduleUpdatedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public IReadOnlyList<SongItem> Songs { get; }
|
||||||
|
public DateTime DisplayDate { get; }
|
||||||
|
|
||||||
|
public ScheduleUpdatedEventArgs(IReadOnlyList<SongItem> songs, DateTime displayDate)
|
||||||
|
{
|
||||||
|
Songs = songs;
|
||||||
|
DisplayDate = displayDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
Normal file
97
VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件设置服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubSettingsService
|
||||||
|
{
|
||||||
|
private readonly IPluginSettingsService _settingsService;
|
||||||
|
private const string SettingsSectionId = "voicehub-settings";
|
||||||
|
private PluginSettings? _cachedSettings;
|
||||||
|
|
||||||
|
public event EventHandler<PluginSettings>? SettingsChanged;
|
||||||
|
|
||||||
|
public VoiceHubSettingsService(IPluginSettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取设置
|
||||||
|
/// </summary>
|
||||||
|
public PluginSettings GetSettings()
|
||||||
|
{
|
||||||
|
if (_cachedSettings != null)
|
||||||
|
{
|
||||||
|
return _cachedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = new PluginSettings();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", SettingsSectionId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiUrl))
|
||||||
|
{
|
||||||
|
settings.ApiUrl = apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
var showRequester = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showRequester", SettingsSectionId);
|
||||||
|
if (showRequester.HasValue)
|
||||||
|
{
|
||||||
|
settings.ShowRequester = showRequester.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var showVoteCount = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showVoteCount", SettingsSectionId);
|
||||||
|
if (showVoteCount.HasValue)
|
||||||
|
{
|
||||||
|
settings.ShowVoteCount = showVoteCount.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshInterval = _settingsService.GetValue<string>(SettingsScope.Plugin, "refreshInterval", SettingsSectionId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(refreshInterval) && int.TryParse(refreshInterval, out var minutes))
|
||||||
|
{
|
||||||
|
settings.RefreshIntervalMinutes = minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedSettings = settings;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存设置
|
||||||
|
/// </summary>
|
||||||
|
public void SaveSettings(PluginSettings settings)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "apiUrl", settings.ApiUrl, sectionId: SettingsSectionId);
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "showRequester", settings.ShowRequester, sectionId: SettingsSectionId);
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "showVoteCount", settings.ShowVoteCount, sectionId: SettingsSectionId);
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "refreshInterval", settings.RefreshIntervalMinutes.ToString(), sectionId: SettingsSectionId);
|
||||||
|
|
||||||
|
_cachedSettings = settings;
|
||||||
|
SettingsChanged?.Invoke(this, settings);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略保存错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除缓存
|
||||||
|
/// </summary>
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
_cachedSettings = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
VoiceHubLanDesktop/SongModels.cs
Normal file
52
VoiceHubLanDesktop/SongModels.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲信息
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Song
|
||||||
|
{
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("artist")]
|
||||||
|
public string Artist { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("requester")]
|
||||||
|
public string Requester { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("voteCount")]
|
||||||
|
public int VoteCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期歌曲项目
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SongItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("playDate")]
|
||||||
|
public string PlayDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("sequence")]
|
||||||
|
public int Sequence { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("song")]
|
||||||
|
public Song Song { get; set; } = new();
|
||||||
|
|
||||||
|
public DateTime GetPlayDate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PlayDate))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
|
||||||
|
System.Globalization.DateTimeStyles.None, out var result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
Normal file
144
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
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"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="400"
|
||||||
|
x:Class="VoiceHubLanDesktop.Views.VoiceHubScheduleControl"
|
||||||
|
x:DataType="VoiceHubLanDesktop.Views.VoiceHubScheduleControl">
|
||||||
|
|
||||||
|
<Design.DataContext>
|
||||||
|
<VoiceHubLanDesktop.Views.VoiceHubScheduleControl/>
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
Padding="12,8"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="16"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"/>
|
||||||
|
<TextBlock Text="{Binding TitleText}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding DateText}"
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||||
|
Margin="8,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<Grid Grid.Row="1">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<StackPanel x:Name="LoadingPanel"
|
||||||
|
Orientation="Vertical"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12"
|
||||||
|
IsVisible="{Binding IsLoading}">
|
||||||
|
<ProgressBar IsIndeterminate="True"
|
||||||
|
Width="100"
|
||||||
|
Height="4"/>
|
||||||
|
<TextBlock Text="正在加载排期..."
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 排期列表 -->
|
||||||
|
<ScrollViewer x:Name="SchedulePanel"
|
||||||
|
IsVisible="{Binding IsNormal}"
|
||||||
|
Padding="8,8,8,8">
|
||||||
|
<ItemsControl ItemsSource="{Binding Songs}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12,10"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<!-- 序号 -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Background="{DynamicResource SystemAccentColor}"
|
||||||
|
CornerRadius="12"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Sequence}"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="White"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 歌曲信息 -->
|
||||||
|
<StackPanel Grid.Column="1" Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Song.Title}"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="Medium"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1"/>
|
||||||
|
<TextBlock FontSize="12"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1">
|
||||||
|
<Run Text="{Binding Song.Artist}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<StackPanel x:Name="EmptyPanel"
|
||||||
|
Orientation="Vertical"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8"
|
||||||
|
IsVisible="{Binding IsEmpty}">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="48"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"/>
|
||||||
|
<TextBlock Text="{Binding EmptyMessage}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<StackPanel x:Name="ErrorPanel"
|
||||||
|
Orientation="Vertical"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8"
|
||||||
|
IsVisible="{Binding IsError}">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="48"
|
||||||
|
Foreground="#FFB00020"/>
|
||||||
|
<TextBlock Text="{Binding ErrorMessage}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="#FFB00020"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MaxWidth="200"
|
||||||
|
TextAlignment="Center"/>
|
||||||
|
<Button Content="重试"
|
||||||
|
Command="{Binding RetryCommand}"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
168
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
Normal file
168
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
using VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 广播站排期显示组件
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class VoiceHubScheduleControl : UserControl
|
||||||
|
{
|
||||||
|
private readonly VoiceHubScheduleService _scheduleService;
|
||||||
|
private readonly VoiceHubSettingsService _settingsService;
|
||||||
|
private readonly DispatcherTimer? _refreshTimer;
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
|
public ObservableCollection<SongItem> Songs { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty] private string _titleText = "广播站排期";
|
||||||
|
[ObservableProperty] private string _dateText = "";
|
||||||
|
[ObservableProperty] private string _emptyMessage = "暂无排期数据";
|
||||||
|
[ObservableProperty] private string _errorMessage = "";
|
||||||
|
[ObservableProperty] private bool _isLoading = true;
|
||||||
|
[ObservableProperty] private bool _isNormal = false;
|
||||||
|
[ObservableProperty] private bool _isEmpty = false;
|
||||||
|
[ObservableProperty] private bool _isError = false;
|
||||||
|
|
||||||
|
public VoiceHubScheduleControl(
|
||||||
|
VoiceHubScheduleService scheduleService,
|
||||||
|
VoiceHubSettingsService settingsService,
|
||||||
|
IPluginRuntimeContext runtimeContext)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = this;
|
||||||
|
|
||||||
|
_scheduleService = scheduleService;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
// 设置刷新定时器
|
||||||
|
var settings = _settingsService.GetSettings();
|
||||||
|
_refreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes)
|
||||||
|
};
|
||||||
|
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
|
||||||
|
_refreshTimer.Start();
|
||||||
|
|
||||||
|
// 监听设置变化
|
||||||
|
_settingsService.SettingsChanged += OnSettingsChanged;
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
_ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, PluginSettings settings)
|
||||||
|
{
|
||||||
|
if (_refreshTimer != null)
|
||||||
|
{
|
||||||
|
_refreshTimer.Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes);
|
||||||
|
}
|
||||||
|
_scheduleService.ClearCache();
|
||||||
|
_ = RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
SetState(ComponentState.Loading);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var displayData = await _scheduleService.GetTodayScheduleAsync(_loadCts.Token);
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ApplyDisplayData(displayData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 忽略取消
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
SetState(ComponentState.NetworkError, $"加载失败: {ex.Message}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDisplayData(DisplayData data)
|
||||||
|
{
|
||||||
|
switch (data.State)
|
||||||
|
{
|
||||||
|
case ComponentState.Normal:
|
||||||
|
Songs.Clear();
|
||||||
|
foreach (var song in data.Songs)
|
||||||
|
{
|
||||||
|
Songs.Add(song);
|
||||||
|
}
|
||||||
|
DateText = data.DisplayDate?.ToString("MM月dd日") ?? "";
|
||||||
|
SetState(ComponentState.Normal);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ComponentState.NoSchedule:
|
||||||
|
EmptyMessage = data.ErrorMessage ?? "暂无排期数据";
|
||||||
|
SetState(ComponentState.NoSchedule);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ComponentState.NetworkError:
|
||||||
|
SetState(ComponentState.NetworkError, data.ErrorMessage ?? "网络错误");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
SetState(ComponentState.Loading);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetState(ComponentState state, string? message = null)
|
||||||
|
{
|
||||||
|
IsLoading = state == ComponentState.Loading;
|
||||||
|
IsNormal = state == ComponentState.Normal;
|
||||||
|
IsEmpty = state == ComponentState.NoSchedule;
|
||||||
|
IsError = state == ComponentState.NetworkError;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
if (state == ComponentState.NetworkError)
|
||||||
|
{
|
||||||
|
ErrorMessage = message;
|
||||||
|
}
|
||||||
|
else if (state == ComponentState.NoSchedule)
|
||||||
|
{
|
||||||
|
EmptyMessage = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RetryAsync()
|
||||||
|
{
|
||||||
|
_scheduleService.ClearCache();
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDetachedFromVisualTree(e);
|
||||||
|
|
||||||
|
_refreshTimer?.Stop();
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_settingsService.SettingsChanged -= OnSettingsChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
VoiceHubLanDesktop/VoiceHubApiService.cs
Normal file
102
VoiceHubLanDesktop/VoiceHubApiService.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VoiceHub API 服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubApiService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
private const int MaxRetryCount = 3;
|
||||||
|
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
public VoiceHubApiService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
|
||||||
|
string? apiUrl = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(_requestTimeout);
|
||||||
|
|
||||||
|
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
|
||||||
|
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
|
||||||
|
|
||||||
|
if (items is null)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _httpClient.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApiResult<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public T? Data { get; }
|
||||||
|
public string? ErrorMessage { get; }
|
||||||
|
|
||||||
|
private ApiResult(bool isSuccess, T? data, string? errorMessage)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
Data = data;
|
||||||
|
ErrorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApiResult<T> Success(T data) => new(true, data, null);
|
||||||
|
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
|
||||||
|
}
|
||||||
26
VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
Normal file
26
VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
|
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||||
|
<LanMountainPluginBuildOutputDirectory>$(OutputPath)</LanMountainPluginBuildOutputDirectory>
|
||||||
|
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
|
||||||
|
<LanMountainPluginPackageOutputDirectory>$(MSBuildThisFileDirectory)</LanMountainPluginPackageOutputDirectory>
|
||||||
|
<LanMountainPluginPackageExtension>.laapp</LanMountainPluginPackageExtension>
|
||||||
|
<LanMountainPluginPackageFileName>$(AssemblyName).$(LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</LanMountainPluginPackageFileName>
|
||||||
|
<LanMountainPluginPackagePath>$(LanMountainPluginPackageOutputDirectory)$(LanMountainPluginPackageFileName)</LanMountainPluginPackagePath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||||
|
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
103
VoiceHubLanDesktop/VoiceHubPlugin.cs
Normal file
103
VoiceHubLanDesktop/VoiceHubPlugin.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VoiceHub 广播站排期插件入口
|
||||||
|
/// </summary>
|
||||||
|
[PluginEntrance]
|
||||||
|
public sealed class VoiceHubPlugin : PluginBase
|
||||||
|
{
|
||||||
|
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
var localizer = CreateLocalizer(context);
|
||||||
|
|
||||||
|
// 注册服务
|
||||||
|
services.AddSingleton<VoiceHubApiService>();
|
||||||
|
services.AddSingleton<VoiceHubScheduleService>();
|
||||||
|
|
||||||
|
// 注册桌面组件 - 最小 3x4 网格,允许等比例缩放
|
||||||
|
services.AddPluginDesktopComponent<VoiceHubScheduleWidget>(
|
||||||
|
CreateScheduleComponentOptions(localizer));
|
||||||
|
|
||||||
|
// 注册设置页面
|
||||||
|
services.AddPluginSettingsSection(
|
||||||
|
id: "voicehub-settings",
|
||||||
|
titleLocalizationKey: "settings.title",
|
||||||
|
configure: builder =>
|
||||||
|
{
|
||||||
|
builder.AddText(
|
||||||
|
key: "apiUrl",
|
||||||
|
titleLocalizationKey: "settings.apiUrl.title",
|
||||||
|
descriptionLocalizationKey: "settings.apiUrl.description",
|
||||||
|
defaultValue: "https://voicehub.lao-shui.top/api/songs/public");
|
||||||
|
|
||||||
|
builder.AddBoolean(
|
||||||
|
key: "showRequester",
|
||||||
|
titleLocalizationKey: "settings.showRequester.title",
|
||||||
|
descriptionLocalizationKey: "settings.showRequester.description",
|
||||||
|
defaultValue: true);
|
||||||
|
|
||||||
|
builder.AddBoolean(
|
||||||
|
key: "showVoteCount",
|
||||||
|
titleLocalizationKey: "settings.showVoteCount.title",
|
||||||
|
descriptionLocalizationKey: "settings.showVoteCount.description",
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
builder.AddSelection(
|
||||||
|
key: "refreshInterval",
|
||||||
|
titleLocalizationKey: "settings.refreshInterval.title",
|
||||||
|
descriptionLocalizationKey: "settings.refreshInterval.description",
|
||||||
|
defaultValue: "60",
|
||||||
|
choices:
|
||||||
|
[
|
||||||
|
new SettingsOptionChoice("5分钟", "5"),
|
||||||
|
new SettingsOptionChoice("15分钟", "15"),
|
||||||
|
new SettingsOptionChoice("30分钟", "30"),
|
||||||
|
new SettingsOptionChoice("1小时", "60"),
|
||||||
|
new SettingsOptionChoice("2小时", "120")
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
descriptionLocalizationKey: "settings.description",
|
||||||
|
iconKey: "Settings",
|
||||||
|
sortOrder: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginLocalizer CreateLocalizer(HostBuilderContext context)
|
||||||
|
{
|
||||||
|
var pluginDirectory = context.Properties.TryGetValue("LanMountainDesktop.PluginDirectory", out var directoryValue) &&
|
||||||
|
directoryValue is string resolvedPluginDirectory &&
|
||||||
|
!string.IsNullOrWhiteSpace(resolvedPluginDirectory)
|
||||||
|
? resolvedPluginDirectory
|
||||||
|
: AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
var properties = context.Properties
|
||||||
|
.Where(pair => pair.Key is string)
|
||||||
|
.ToDictionary(pair => (string)pair.Key, pair => (object?)pair.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return new PluginLocalizer(pluginDirectory, PluginLocalizer.ResolveLanguageCode(properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginDesktopComponentOptions CreateScheduleComponentOptions(PluginLocalizer localizer)
|
||||||
|
{
|
||||||
|
return new PluginDesktopComponentOptions
|
||||||
|
{
|
||||||
|
ComponentId = "com.voicehub.schedule",
|
||||||
|
DisplayName = localizer.GetString("widget.display_name", "广播站排期"),
|
||||||
|
DisplayNameLocalizationKey = "widget.display_name",
|
||||||
|
IconKey = "Radio",
|
||||||
|
Category = localizer.GetString("widget.category", "信息"),
|
||||||
|
MinWidthCells = 3,
|
||||||
|
MinHeightCells = 4,
|
||||||
|
AllowDesktopPlacement = true,
|
||||||
|
AllowStatusBarPlacement = false,
|
||||||
|
ResizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||||
|
CornerRadiusPreset = PluginCornerRadiusPreset.Default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
154
VoiceHubLanDesktop/VoiceHubScheduleService.cs
Normal file
154
VoiceHubLanDesktop/VoiceHubScheduleService.cs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期管理服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubScheduleService
|
||||||
|
{
|
||||||
|
private readonly VoiceHubApiService _apiService;
|
||||||
|
private readonly IPluginSettingsService _settingsService;
|
||||||
|
private IReadOnlyList<SongItem> _cachedSchedule = [];
|
||||||
|
private DateTime _cacheTime = DateTime.MinValue;
|
||||||
|
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private const string SettingsSectionId = "voicehub-settings";
|
||||||
|
|
||||||
|
public VoiceHubScheduleService(VoiceHubApiService apiService, IPluginSettingsService settingsService)
|
||||||
|
{
|
||||||
|
_apiService = apiService;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var apiUrl = GetApiUrl();
|
||||||
|
|
||||||
|
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
|
||||||
|
{
|
||||||
|
return BuildDisplayData(_cachedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _apiService.GetPublicScheduleAsync(apiUrl, cancellationToken);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NetworkError,
|
||||||
|
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = result.Data ?? [];
|
||||||
|
_cachedSchedule = items;
|
||||||
|
_cacheTime = DateTime.Now;
|
||||||
|
|
||||||
|
return BuildDisplayData(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
_cachedSchedule = [];
|
||||||
|
_cacheTime = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetApiUrl()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", sectionId: SettingsSectionId);
|
||||||
|
return string.IsNullOrWhiteSpace(apiUrl)
|
||||||
|
? "https://voicehub.lao-shui.top/api/songs/public"
|
||||||
|
: apiUrl;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
|
||||||
|
{
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
|
||||||
|
|
||||||
|
if (validItems.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无有效排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var todaySchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() == today)
|
||||||
|
.OrderBy(s => s.Sequence)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<SongItem> displayItems;
|
||||||
|
DateTime actualDate;
|
||||||
|
|
||||||
|
if (todaySchedule.Count > 0)
|
||||||
|
{
|
||||||
|
displayItems = todaySchedule;
|
||||||
|
actualDate = today;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var futureSchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() > today)
|
||||||
|
.GroupBy(s => s.GetPlayDate())
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (futureSchedule != null)
|
||||||
|
{
|
||||||
|
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
|
||||||
|
actualDate = futureSchedule.Key;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.Normal,
|
||||||
|
Songs = displayItems,
|
||||||
|
DisplayDate = actualDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ComponentState
|
||||||
|
{
|
||||||
|
Loading,
|
||||||
|
Normal,
|
||||||
|
NetworkError,
|
||||||
|
NoSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DisplayData
|
||||||
|
{
|
||||||
|
public ComponentState State { get; set; }
|
||||||
|
public IReadOnlyList<SongItem> Songs { get; set; } = [];
|
||||||
|
public DateTime? DisplayDate { get; set; }
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
393
VoiceHubLanDesktop/VoiceHubScheduleWidget.cs
Normal file
393
VoiceHubLanDesktop/VoiceHubScheduleWidget.cs
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 广播站排期显示组件
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class VoiceHubScheduleWidget : Border
|
||||||
|
{
|
||||||
|
private readonly PluginDesktopComponentContext _context;
|
||||||
|
private readonly PluginLocalizer _localizer;
|
||||||
|
private readonly VoiceHubScheduleService _scheduleService;
|
||||||
|
private readonly PluginAppearanceSnapshot? _appearanceSnapshot;
|
||||||
|
private readonly TextBlock _titleTextBlock;
|
||||||
|
private readonly TextBlock _dateTextBlock;
|
||||||
|
private readonly StackPanel _contentPanel;
|
||||||
|
private readonly StackPanel _loadingPanel;
|
||||||
|
private readonly StackPanel _errorPanel;
|
||||||
|
private readonly DispatcherTimer? _refreshTimer;
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
|
public VoiceHubScheduleWidget(PluginDesktopComponentContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_localizer = PluginLocalizer.Create(context);
|
||||||
|
_scheduleService = context.GetService<VoiceHubScheduleService>()
|
||||||
|
?? throw new InvalidOperationException("VoiceHubScheduleService is not available.");
|
||||||
|
_appearanceSnapshot = context.GetAppearanceSnapshot();
|
||||||
|
|
||||||
|
// 创建 UI 元素
|
||||||
|
_titleTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
FontWeight = FontWeight.Bold,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
_dateTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
_contentPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
_loadingPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Vertical,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Spacing = 12,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new ProgressBar
|
||||||
|
{
|
||||||
|
IsIndeterminate = true,
|
||||||
|
Width = 100,
|
||||||
|
Height = 4
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = T("widget.loading", "正在加载排期..."),
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_errorPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Vertical,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Spacing = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置背景和边框
|
||||||
|
Background = new LinearGradientBrush
|
||||||
|
{
|
||||||
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||||
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||||
|
GradientStops =
|
||||||
|
[
|
||||||
|
new GradientStop(Color.Parse("#FF07111F"), 0),
|
||||||
|
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
|
||||||
|
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
|
||||||
|
// 构建主布局
|
||||||
|
Child = new Grid
|
||||||
|
{
|
||||||
|
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||||
|
RowSpacing = 12,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
// 标题栏
|
||||||
|
new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
||||||
|
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||||
|
Padding = new Thickness(12, 8),
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Spacing = 8,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = "📻",
|
||||||
|
FontSize = 16,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
},
|
||||||
|
_titleTextBlock,
|
||||||
|
_dateTextBlock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 内容区域
|
||||||
|
new ScrollViewer
|
||||||
|
{
|
||||||
|
Padding = new Thickness(8),
|
||||||
|
Content = _contentPanel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetRow(((Grid)Child).Children[1], 1);
|
||||||
|
|
||||||
|
// 设置刷新定时器
|
||||||
|
var refreshInterval = GetRefreshInterval();
|
||||||
|
_refreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMinutes(refreshInterval)
|
||||||
|
};
|
||||||
|
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
|
||||||
|
|
||||||
|
// 事件处理
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
|
// 初始化显示
|
||||||
|
SetTitle();
|
||||||
|
ApplyScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_refreshTimer?.Start();
|
||||||
|
_ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_refreshTimer?.Stop();
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
ShowLoading();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var displayData = await _scheduleService.GetTodayScheduleAsync(_loadCts.Token);
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ApplyDisplayData(displayData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 忽略取消
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ShowError($"加载失败: {ex.Message}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDisplayData(DisplayData data)
|
||||||
|
{
|
||||||
|
switch (data.State)
|
||||||
|
{
|
||||||
|
case ComponentState.Normal:
|
||||||
|
ShowContent(data);
|
||||||
|
break;
|
||||||
|
case ComponentState.NoSchedule:
|
||||||
|
ShowError(data.ErrorMessage ?? "暂无排期数据");
|
||||||
|
break;
|
||||||
|
case ComponentState.NetworkError:
|
||||||
|
ShowError(data.ErrorMessage ?? "网络错误");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowLoading()
|
||||||
|
{
|
||||||
|
if (Child is not Grid mainGrid) return;
|
||||||
|
mainGrid.Children[1] = _loadingPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowError(string message)
|
||||||
|
{
|
||||||
|
_errorPanel.Children.Clear();
|
||||||
|
_errorPanel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "⚠️",
|
||||||
|
FontSize = 48,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFF87171"))
|
||||||
|
});
|
||||||
|
_errorPanel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = message,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFF87171")),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
MaxWidth = 200,
|
||||||
|
TextAlignment = TextAlignment.Center
|
||||||
|
});
|
||||||
|
_errorPanel.Children.Add(new Button
|
||||||
|
{
|
||||||
|
Content = T("widget.retry", "重试"),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 8, 0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
var retryButton = (Button)_errorPanel.Children[2];
|
||||||
|
retryButton.Click += async (_, _) => await RefreshAsync();
|
||||||
|
|
||||||
|
if (Child is not Grid mainGrid) return;
|
||||||
|
mainGrid.Children[1] = _errorPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowContent(DisplayData data)
|
||||||
|
{
|
||||||
|
_contentPanel.Children.Clear();
|
||||||
|
|
||||||
|
var basis = GetLayoutBasis();
|
||||||
|
var titleSize = Math.Clamp(basis * 0.055, 12, 16);
|
||||||
|
var detailSize = Math.Clamp(basis * 0.045, 10, 13);
|
||||||
|
|
||||||
|
foreach (var item in data.Songs)
|
||||||
|
{
|
||||||
|
var card = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = _appearanceSnapshot.ResolveCornerRadius(
|
||||||
|
PluginCornerRadiusPreset.Md,
|
||||||
|
new CornerRadius(8)),
|
||||||
|
Padding = new Thickness(12, 10),
|
||||||
|
Child = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||||
|
ColumnSpacing = 12,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
// 序号
|
||||||
|
new Border
|
||||||
|
{
|
||||||
|
Width = 24,
|
||||||
|
Height = 24,
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Sequence.ToString(),
|
||||||
|
FontSize = 11,
|
||||||
|
FontWeight = FontWeight.Bold,
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 歌曲信息
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 4,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Song.Title,
|
||||||
|
FontSize = titleSize,
|
||||||
|
FontWeight = FontWeight.Medium,
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
MaxLines = 1
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{item.Song.Artist}",
|
||||||
|
FontSize = detailSize,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
MaxLines = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetColumn(((Grid)card.Child!).Children[1], 1);
|
||||||
|
_contentPanel.Children.Add(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新日期显示
|
||||||
|
_dateTextBlock.Text = data.DisplayDate?.ToString("MM月dd日") ?? "";
|
||||||
|
|
||||||
|
if (Child is not Grid mainGrid) return;
|
||||||
|
mainGrid.Children[1] = new ScrollViewer
|
||||||
|
{
|
||||||
|
Padding = new Thickness(8),
|
||||||
|
Content = _contentPanel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetTitle()
|
||||||
|
{
|
||||||
|
_titleTextBlock.Text = T("widget.display_name", "广播站排期");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyScale()
|
||||||
|
{
|
||||||
|
var basis = GetLayoutBasis();
|
||||||
|
Padding = new Thickness(Math.Clamp(basis * 0.06, 10, 18));
|
||||||
|
CornerRadius = _appearanceSnapshot.ResolveCornerRadius(
|
||||||
|
PluginCornerRadiusPreset.Island,
|
||||||
|
new CornerRadius(Math.Clamp(basis * 0.12, 16, 28)));
|
||||||
|
_titleTextBlock.FontSize = Math.Clamp(basis * 0.065, 12, 16);
|
||||||
|
_dateTextBlock.FontSize = Math.Clamp(basis * 0.05, 10, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double GetLayoutBasis()
|
||||||
|
{
|
||||||
|
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 3;
|
||||||
|
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
|
||||||
|
return Math.Max(_context.CellSize * 3, Math.Min(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetRefreshInterval()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var interval = _context.GetService<IPluginSettingsService>()
|
||||||
|
?.GetValue<string>(SettingsScope.Plugin, "refreshInterval", sectionId: "voicehub-settings");
|
||||||
|
if (!string.IsNullOrWhiteSpace(interval) && int.TryParse(interval, out var minutes))
|
||||||
|
{
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
_scheduleService.ClearCache();
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string key, string fallback)
|
||||||
|
{
|
||||||
|
return _localizer.GetString(key, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
VoiceHubLanDesktop/plugin.json
Normal file
10
VoiceHubLanDesktop/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "com.voicehub.landesktop",
|
||||||
|
"name": "VoiceHub 广播站排期",
|
||||||
|
"description": "展示 VoiceHub 广播站当日排期歌曲,按播放顺序显示歌曲信息",
|
||||||
|
"author": "VoiceHub",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "4.0.0",
|
||||||
|
"entranceAssembly": "VoiceHubLanDesktop.dll",
|
||||||
|
"sharedContracts": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user