mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.8.0.5
This commit is contained in:
87
LanMountainDesktop.PluginSdk/PluginAppearanceExtensions.cs
Normal file
87
LanMountainDesktop.PluginSdk/PluginAppearanceExtensions.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public static class PluginAppearanceExtensions
|
||||||
|
{
|
||||||
|
public static CornerRadius ResolveCornerRadius(
|
||||||
|
this PluginAppearanceSnapshot snapshot,
|
||||||
|
PluginCornerRadiusPreset preset)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
var value = snapshot.CornerRadiusTokens.Get(preset);
|
||||||
|
return new CornerRadius(Math.Max(0d, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CornerRadius ResolveCornerRadius(
|
||||||
|
this PluginAppearanceSnapshot snapshot,
|
||||||
|
PluginCornerRadiusPreset preset,
|
||||||
|
CornerRadius fallback)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
var value = snapshot.CornerRadiusTokens.Get(preset);
|
||||||
|
if (!double.IsFinite(value) || value < 0)
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return new CornerRadius(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CornerRadius ResolveCornerRadius(
|
||||||
|
this IPluginAppearanceContext context,
|
||||||
|
PluginCornerRadiusPreset preset)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var value = context.ResolveCornerRadius(preset);
|
||||||
|
return new CornerRadius(Math.Max(0d, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CornerRadius ResolveCornerRadius(
|
||||||
|
this IPluginAppearanceContext context,
|
||||||
|
PluginCornerRadiusPreset preset,
|
||||||
|
double minimum,
|
||||||
|
double maximum)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var value = context.ResolveCornerRadius(preset, minimum, maximum);
|
||||||
|
return new CornerRadius(Math.Max(0d, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CornerRadius ResolveScaledCornerRadius(
|
||||||
|
this IPluginAppearanceContext context,
|
||||||
|
double baseRadius)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var value = context.ResolveScaledCornerRadius(baseRadius);
|
||||||
|
return new CornerRadius(Math.Max(0d, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CornerRadius ResolveScaledCornerRadius(
|
||||||
|
this IPluginAppearanceContext context,
|
||||||
|
double baseRadius,
|
||||||
|
double minimum,
|
||||||
|
double maximum)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var value = context.ResolveScaledCornerRadius(baseRadius, minimum, maximum);
|
||||||
|
return new CornerRadius(Math.Max(0d, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CornerRadius ResolveCornerRadius(
|
||||||
|
this PluginDesktopComponentContext context,
|
||||||
|
PluginCornerRadiusPreset preset,
|
||||||
|
double minimum,
|
||||||
|
double maximum)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var value = context.ResolveCornerRadius(preset, minimum, maximum);
|
||||||
|
return new CornerRadius(Math.Max(0d, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PluginAppearanceSnapshot GetAppearanceSnapshot(
|
||||||
|
this PluginDesktopComponentContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.Appearance.Snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
<TextBlock x:Name="PaintingTitleTextBlock"
|
<TextBlock x:Name="PaintingTitleTextBlock"
|
||||||
Text=""拉波特夫人""
|
Text="“拉波特夫人”"
|
||||||
Foreground="#F8F8F8"
|
Foreground="#F8F8F8"
|
||||||
FontSize="24"
|
FontSize="24"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "自动刷新排期数据的时间间隔"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "com.voicehub.landesktop",
|
|
||||||
"name": "VoiceHub 广播站排期",
|
|
||||||
"description": "展示 VoiceHub 广播站当日排期歌曲,按播放顺序显示歌曲信息",
|
|
||||||
"author": "VoiceHub",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"apiVersion": "4.0.0",
|
|
||||||
"entranceAssembly": "VoiceHubLanDesktop.dll",
|
|
||||||
"sharedContracts": []
|
|
||||||
}
|
|
||||||
@@ -44,6 +44,91 @@ public MyWidget(PluginDesktopComponentContext context)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Corner Radius System
|
||||||
|
|
||||||
|
Plugin widgets must follow the host's corner radius settings to maintain visual consistency with built-in components.
|
||||||
|
|
||||||
|
### Why Plugins Cannot Use XAML Resources
|
||||||
|
|
||||||
|
Plugins run in a separate `AssemblyLoadContext` and cannot directly access the host's resource dictionary. Therefore, `{DynamicResource DesignCornerRadiusComponent}` is not available in plugin XAML. Instead, plugins must resolve corner radius values in code through `PluginDesktopComponentContext`.
|
||||||
|
|
||||||
|
### Available Corner Radius Presets
|
||||||
|
|
||||||
|
| Preset | Default Value | Usage |
|
||||||
|
|--------|---------------|-------|
|
||||||
|
| `Micro` | 6px | Tiny elements |
|
||||||
|
| `Xs` | 12px | Small elements and icon containers |
|
||||||
|
| `Sm` | 14px | Small colored blocks |
|
||||||
|
| `Md` | 20px | Common buttons/cards |
|
||||||
|
| `Lg` | 28px | Normal glass panels |
|
||||||
|
| `Xl` | 32px | Emphasized containers |
|
||||||
|
| `Island` | 36px | Large containers |
|
||||||
|
| `Component` | 18px | **Desktop widget standard radius** |
|
||||||
|
| `Default` | (adaptive) | Adaptive based on cell size |
|
||||||
|
|
||||||
|
### Corner Radius API Reference
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class MyWidget : Border
|
||||||
|
{
|
||||||
|
public MyWidget(PluginDesktopComponentContext context)
|
||||||
|
{
|
||||||
|
// Method 1: Use preset tokens (recommended for consistency)
|
||||||
|
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
|
||||||
|
|
||||||
|
// Method 2: Use preset with fallback (extension method)
|
||||||
|
CornerRadius = context.Appearance.Snapshot.ResolveCornerRadius(
|
||||||
|
PluginCornerRadiusPreset.Md,
|
||||||
|
fallback: new CornerRadius(8));
|
||||||
|
|
||||||
|
// Method 3: Custom radius with global scale applied
|
||||||
|
CornerRadius = context.ResolveScaledCornerRadius(baseRadius: 16, minimum: 8, maximum: 24);
|
||||||
|
|
||||||
|
// Method 4: Access tokens directly
|
||||||
|
var tokens = context.CornerRadiusTokens;
|
||||||
|
CornerRadius = tokens.ToCornerRadius(PluginCornerRadiusPreset.Md);
|
||||||
|
|
||||||
|
// Method 5: Get raw token value (double)
|
||||||
|
double componentRadius = context.CornerRadiusTokens.Component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Always use `PluginCornerRadiusPreset.Component` for the widget root container** - This ensures consistency with built-in widgets.
|
||||||
|
|
||||||
|
2. **Apply corner radius in code, not XAML** - Since plugins cannot access host resources, set `CornerRadius` in the constructor or code-behind.
|
||||||
|
|
||||||
|
3. **Re-apply radius on size changes** - For adaptive layouts, subscribe to `SizeChanged` and recalculate:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public MyWidget(PluginDesktopComponentContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
ApplyCornerRadius();
|
||||||
|
SizeChanged += (_, _) => ApplyCornerRadius();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCornerRadius()
|
||||||
|
{
|
||||||
|
var basis = Math.Min(Bounds.Width, Bounds.Height);
|
||||||
|
CornerRadius = _context.ResolveCornerRadius(
|
||||||
|
PluginCornerRadiusPreset.Component,
|
||||||
|
minimum: Math.Clamp(basis * 0.08, 8, 16),
|
||||||
|
maximum: Math.Clamp(basis * 0.15, 16, 28));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Inner elements can use smaller presets** - For cards or buttons inside your widget:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var innerCard = new Border
|
||||||
|
{
|
||||||
|
CornerRadius = _context.ResolveCornerRadius(PluginCornerRadiusPreset.Md)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
## Manifest Update
|
## Manifest Update
|
||||||
|
|
||||||
Update plugin manifests to API `4.x`:
|
Update plugin manifests to API `4.x`:
|
||||||
|
|||||||
27
test-omo-resolve.js
Normal file
27
test-omo-resolve.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
import { getPlatformPackageCandidates, getBinaryPath } from './node_modules/oh-my-opencode/bin/platform.js';
|
||||||
|
|
||||||
|
const { platform, arch } = process;
|
||||||
|
const libcFamily = undefined; // Windows doesn't need libc
|
||||||
|
const avx2Supported = null; // On Windows, supportsAvx2() returns null
|
||||||
|
|
||||||
|
const packageCandidates = getPlatformPackageCandidates({
|
||||||
|
platform, arch, libcFamily, preferBaseline: avx2Supported === false
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Package candidates:', packageCandidates);
|
||||||
|
|
||||||
|
const resolvedBinaries = packageCandidates.map((pkg) => {
|
||||||
|
try {
|
||||||
|
const binPath = require.resolve(getBinaryPath(pkg, platform));
|
||||||
|
return { pkg, binPath };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to resolve', pkg, e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).filter((x) => x !== null);
|
||||||
|
|
||||||
|
console.log('Resolved binaries:', resolvedBinaries);
|
||||||
Reference in New Issue
Block a user