diff --git a/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml b/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
index 733ded4..fdd8017 100644
--- a/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
+++ b/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
@@ -58,14 +58,16 @@
BorderThickness="1"
Foreground="#bb5649"
Focusable="False"
- ToolTip.Tip="刷新新闻"
+ ToolTip.Tip="刷新今日新闻"
Click="OnRefreshButtonClick">
-
-
diff --git a/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs b/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
index 328066b..f8784bc 100644
--- a/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
@@ -625,13 +625,84 @@ public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
return;
}
- _cachedNews.Clear();
- _loadedDates.Clear();
- _dailyViews.Clear();
- NewsStackPanel.Children.Clear();
- _earliestLoadedDate = DateTime.Today;
+ _isLoading = true;
+ RefreshButtonText.Text = "刷新中...";
+ RefreshIcon.IsEnabled = false;
- 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)
diff --git a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml
index a874d6a..8caae8c 100644
--- a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml
+++ b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml
@@ -1,4 +1,4 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+ CornerRadius="8"
+ Padding="12">
+
-
-
+
+
diff --git a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
index 79960cb..c8cf252 100644
--- a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
@@ -6,6 +6,7 @@ using System.Reflection;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
@@ -38,7 +39,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
- private SKColor _currentInkColor = SKColors.Black;
+ private SKColor _selectedInkColor = SKColors.Black;
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
@@ -66,9 +67,22 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ApplyCellSize(_currentCellSize);
RefreshFromSettings();
ApplyThemeVisual(force: true);
+ InitializeColorPicker();
SetToolMode(WhiteboardToolMode.Pen);
}
+ private void InitializeColorPicker()
+ {
+ if (InkColorPicker is not null)
+ {
+ InkColorPicker.Color = new Color(
+ _selectedInkColor.Alpha,
+ _selectedInkColor.Red,
+ _selectedInkColor.Green,
+ _selectedInkColor.Blue);
+ }
+ }
+
public int NoteRetentionDays => _noteRetentionDays;
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -149,7 +163,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
_isNightModeApplied = isNightMode;
- _currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
@@ -157,8 +170,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
- InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
- RecolorAllStrokes(_currentInkColor);
RefreshToolButtonVisuals();
}
@@ -204,6 +215,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()
{
if (_disposed)
@@ -300,12 +335,22 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
if (mode == WhiteboardToolMode.Pen)
{
- InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
+ InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
RefreshToolButtonVisuals();
}
+ private void SetInkColor(SKColor color)
+ {
+ _selectedInkColor = color;
+ if (_toolMode == WhiteboardToolMode.Pen)
+ {
+ InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
+ }
+ RefreshToolButtonVisuals();
+ }
+
private void RefreshToolButtonVisuals()
{
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
@@ -350,7 +395,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
{
- SetToolMode(WhiteboardToolMode.Pen);
+ if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
+ {
+ if (ColorPickerPopup.IsOpen)
+ {
+ ColorPickerPopup.Close();
+ }
+ else
+ {
+ ColorPickerPopup.Open();
+ }
+ }
+ else
+ {
+ 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 OnEraserButtonClick(object? sender, RoutedEventArgs e)
@@ -509,14 +574,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
- var componentId = _componentId;
- var placementId = _placementId;
- var retentionDays = _noteRetentionDays;
- _ = Task.Run(() => _notePersistenceService.SaveNote(
- componentId,
- placementId,
- noteSnapshot,
- retentionDays));
+ try
+ {
+ _notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
+ }
+ catch
+ {
+ }
}
private async void SchedulePersistedNoteLoad()
@@ -553,7 +617,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
{
ClearAllStrokes();
ApplyNoteSnapshot(noteSnapshot);
- RecolorAllStrokes(_currentInkColor);
}
finally
{
diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
index b7ae167..452a9f7 100644
--- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
+++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
@@ -3276,4 +3276,19 @@ public partial class MainWindow
_isComponentLibraryComponentGestureActive = false;
ApplyComponentLibraryComponentOffset();
}
+
+ internal void SaveAllWhiteboardNotes()
+ {
+ foreach (var pageGrid in _desktopPageComponentGrids.Values)
+ {
+ foreach (var host in pageGrid.Children.OfType())
+ {
+ var contentHost = TryGetContentHost(host);
+ if (contentHost?.Child is WhiteboardWidget whiteboard)
+ {
+ whiteboard.ForceSaveNote();
+ }
+ }
+ }
+ }
}
diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs
index d3d753e..abbf02f 100644
--- a/LanMountainDesktop/Views/MainWindow.axaml.cs
+++ b/LanMountainDesktop/Views/MainWindow.axaml.cs
@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
var wasVisible = IsVisible;
var windowState = WindowState.ToString();
+ SaveAllWhiteboardNotes();
PersistSettings();
_componentEditorWindowService.Close();
if (_detachedComponentLibraryWindow is not null)
diff --git a/VoiceHubLanDesktop/Localization/en-US.json b/VoiceHubLanDesktop/Localization/en-US.json
new file mode 100644
index 0000000..f7bcf23
--- /dev/null
+++ b/VoiceHubLanDesktop/Localization/en-US.json
@@ -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"
+}
diff --git a/VoiceHubLanDesktop/Localization/zh-CN.json b/VoiceHubLanDesktop/Localization/zh-CN.json
new file mode 100644
index 0000000..fe3fce4
--- /dev/null
+++ b/VoiceHubLanDesktop/Localization/zh-CN.json
@@ -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": "自动刷新排期数据的时间间隔"
+}
diff --git a/VoiceHubLanDesktop/Models/PluginSettings.cs b/VoiceHubLanDesktop/Models/PluginSettings.cs
new file mode 100644
index 0000000..b6d7278
--- /dev/null
+++ b/VoiceHubLanDesktop/Models/PluginSettings.cs
@@ -0,0 +1,27 @@
+namespace VoiceHubLanDesktop.Models;
+
+///
+/// 插件设置
+///
+public sealed class PluginSettings
+{
+ ///
+ /// API 地址
+ ///
+ public string ApiUrl { get; set; } = "https://voicehub.lao-shui.top/api/songs/public";
+
+ ///
+ /// 是否显示点歌人
+ ///
+ public bool ShowRequester { get; set; } = true;
+
+ ///
+ /// 是否显示投票数
+ ///
+ public bool ShowVoteCount { get; set; } = false;
+
+ ///
+ /// 刷新间隔(分钟)
+ ///
+ public int RefreshIntervalMinutes { get; set; } = 60;
+}
diff --git a/VoiceHubLanDesktop/Models/SongModels.cs b/VoiceHubLanDesktop/Models/SongModels.cs
new file mode 100644
index 0000000..9f3f195
--- /dev/null
+++ b/VoiceHubLanDesktop/Models/SongModels.cs
@@ -0,0 +1,113 @@
+using System.Text.Json.Serialization;
+
+namespace VoiceHubLanDesktop.Models;
+
+///
+/// 歌曲信息
+///
+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; }
+}
+
+///
+/// 排期歌曲项目
+///
+public sealed class SongItem
+{
+ ///
+ /// 播放日期 (yyyy-MM-dd)
+ ///
+ [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;
+ }
+}
+
+///
+/// 组件状态
+///
+public enum ComponentState
+{
+ ///
+ /// 加载中
+ ///
+ Loading,
+
+ ///
+ /// 正常显示
+ ///
+ Normal,
+
+ ///
+ /// 网络错误
+ ///
+ NetworkError,
+
+ ///
+ /// 暂无排期
+ ///
+ NoSchedule
+}
+
+///
+/// 显示数据
+///
+public sealed class DisplayData
+{
+ public ComponentState State { get; set; }
+ public IReadOnlyList Songs { get; set; } = [];
+ public DateTime? DisplayDate { get; set; }
+ public string ErrorMessage { get; set; } = string.Empty;
+}
diff --git a/VoiceHubLanDesktop/README.md b/VoiceHubLanDesktop/README.md
new file mode 100644
index 0000000..22e23dc
--- /dev/null
+++ b/VoiceHubLanDesktop/README.md
@@ -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
diff --git a/VoiceHubLanDesktop/Services/VoiceHubApiService.cs b/VoiceHubLanDesktop/Services/VoiceHubApiService.cs
new file mode 100644
index 0000000..c3f10fe
--- /dev/null
+++ b/VoiceHubLanDesktop/Services/VoiceHubApiService.cs
@@ -0,0 +1,113 @@
+using System.Net.Http;
+using System.Text.Json;
+using VoiceHubLanDesktop.Models;
+
+namespace VoiceHubLanDesktop.Services;
+
+///
+/// VoiceHub API 服务
+///
+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>> 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>(jsonResponse, _jsonOptions);
+
+ if (items is null)
+ {
+ return ApiResult>.Failure("数据解析失败");
+ }
+
+ return ApiResult>.Success(items);
+ }
+ catch (HttpRequestException ex)
+ {
+ if (attempt == MaxRetryCount - 1)
+ {
+ return ApiResult>.Failure($"网络错误: {ex.Message}");
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ if (attempt == MaxRetryCount - 1)
+ {
+ return ApiResult>.Failure("请求超时");
+ }
+ }
+ catch (JsonException ex)
+ {
+ return ApiResult>.Failure($"数据格式错误: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ return ApiResult>.Failure($"未知错误: {ex.Message}");
+ }
+
+ // 指数退避
+ await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
+ }
+
+ return ApiResult>.Failure("获取数据失败");
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+}
+
+///
+/// API 结果
+///
+public sealed class ApiResult
+{
+ 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 Success(T data) => new(true, data, null);
+ public static ApiResult Failure(string errorMessage) => new(false, default, errorMessage);
+}
diff --git a/VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs b/VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
new file mode 100644
index 0000000..1fe9893
--- /dev/null
+++ b/VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
@@ -0,0 +1,164 @@
+using VoiceHubLanDesktop.Models;
+
+namespace VoiceHubLanDesktop.Services;
+
+///
+/// 排期管理服务
+///
+public sealed class VoiceHubScheduleService
+{
+ private readonly VoiceHubApiService _apiService;
+ private readonly VoiceHubSettingsService _settingsService;
+ private IReadOnlyList _cachedSchedule = [];
+ private DateTime _cacheTime = DateTime.MinValue;
+ private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
+
+ public event EventHandler? ScheduleUpdated;
+
+ public VoiceHubScheduleService(VoiceHubApiService apiService, VoiceHubSettingsService settingsService)
+ {
+ _apiService = apiService;
+ _settingsService = settingsService;
+ }
+
+ ///
+ /// 获取今日排期
+ ///
+ public async Task 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);
+ }
+
+ ///
+ /// 强制刷新
+ ///
+ public async Task RefreshAsync(CancellationToken cancellationToken = default)
+ {
+ _cachedSchedule = [];
+ _cacheTime = DateTime.MinValue;
+ return await GetTodayScheduleAsync(cancellationToken);
+ }
+
+ ///
+ /// 清除缓存
+ ///
+ public void ClearCache()
+ {
+ _cachedSchedule = [];
+ _cacheTime = DateTime.MinValue;
+ }
+
+ private DisplayData BuildDisplayData(IReadOnlyList 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 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
+ };
+ }
+}
+
+///
+/// 排期更新事件参数
+///
+public sealed class ScheduleUpdatedEventArgs : EventArgs
+{
+ public IReadOnlyList Songs { get; }
+ public DateTime DisplayDate { get; }
+
+ public ScheduleUpdatedEventArgs(IReadOnlyList songs, DateTime displayDate)
+ {
+ Songs = songs;
+ DisplayDate = displayDate;
+ }
+}
diff --git a/VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs b/VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
new file mode 100644
index 0000000..13fe04b
--- /dev/null
+++ b/VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
@@ -0,0 +1,97 @@
+using LanMountainDesktop.PluginSdk;
+using VoiceHubLanDesktop.Models;
+
+namespace VoiceHubLanDesktop.Services;
+
+///
+/// 插件设置服务
+///
+public sealed class VoiceHubSettingsService
+{
+ private readonly IPluginSettingsService _settingsService;
+ private const string SettingsSectionId = "voicehub-settings";
+ private PluginSettings? _cachedSettings;
+
+ public event EventHandler? SettingsChanged;
+
+ public VoiceHubSettingsService(IPluginSettingsService settingsService)
+ {
+ _settingsService = settingsService;
+ }
+
+ ///
+ /// 获取设置
+ ///
+ public PluginSettings GetSettings()
+ {
+ if (_cachedSettings != null)
+ {
+ return _cachedSettings;
+ }
+
+ var settings = new PluginSettings();
+
+ try
+ {
+ var apiUrl = _settingsService.GetValue(SettingsScope.Plugin, "apiUrl", SettingsSectionId);
+ if (!string.IsNullOrWhiteSpace(apiUrl))
+ {
+ settings.ApiUrl = apiUrl;
+ }
+
+ var showRequester = _settingsService.GetValue(SettingsScope.Plugin, "showRequester", SettingsSectionId);
+ if (showRequester.HasValue)
+ {
+ settings.ShowRequester = showRequester.Value;
+ }
+
+ var showVoteCount = _settingsService.GetValue(SettingsScope.Plugin, "showVoteCount", SettingsSectionId);
+ if (showVoteCount.HasValue)
+ {
+ settings.ShowVoteCount = showVoteCount.Value;
+ }
+
+ var refreshInterval = _settingsService.GetValue(SettingsScope.Plugin, "refreshInterval", SettingsSectionId);
+ if (!string.IsNullOrWhiteSpace(refreshInterval) && int.TryParse(refreshInterval, out var minutes))
+ {
+ settings.RefreshIntervalMinutes = minutes;
+ }
+ }
+ catch
+ {
+ // 使用默认值
+ }
+
+ _cachedSettings = settings;
+ return settings;
+ }
+
+ ///
+ /// 保存设置
+ ///
+ 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
+ {
+ // 忽略保存错误
+ }
+ }
+
+ ///
+ /// 清除缓存
+ ///
+ public void ClearCache()
+ {
+ _cachedSettings = null;
+ }
+}
diff --git a/VoiceHubLanDesktop/SongModels.cs b/VoiceHubLanDesktop/SongModels.cs
new file mode 100644
index 0000000..e027c12
--- /dev/null
+++ b/VoiceHubLanDesktop/SongModels.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Serialization;
+
+namespace VoiceHubLanDesktop;
+
+///
+/// 歌曲信息
+///
+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; }
+}
+
+///
+/// 排期歌曲项目
+///
+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;
+ }
+}
diff --git a/VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml b/VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
new file mode 100644
index 0000000..9291085
--- /dev/null
+++ b/VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs b/VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
new file mode 100644
index 0000000..704b9ce
--- /dev/null
+++ b/VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
@@ -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;
+
+///
+/// 广播站排期显示组件
+///
+public sealed partial class VoiceHubScheduleControl : UserControl
+{
+ private readonly VoiceHubScheduleService _scheduleService;
+ private readonly VoiceHubSettingsService _settingsService;
+ private readonly DispatcherTimer? _refreshTimer;
+ private CancellationTokenSource? _loadCts;
+
+ public ObservableCollection 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;
+ }
+}
diff --git a/VoiceHubLanDesktop/VoiceHubApiService.cs b/VoiceHubLanDesktop/VoiceHubApiService.cs
new file mode 100644
index 0000000..e40f7d4
--- /dev/null
+++ b/VoiceHubLanDesktop/VoiceHubApiService.cs
@@ -0,0 +1,102 @@
+using System.Net.Http;
+using System.Text.Json;
+
+namespace VoiceHubLanDesktop;
+
+///
+/// VoiceHub API 服务
+///
+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>> 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>(jsonResponse, _jsonOptions);
+
+ if (items is null)
+ {
+ return ApiResult>.Failure("数据解析失败");
+ }
+
+ return ApiResult>.Success(items);
+ }
+ catch (HttpRequestException ex)
+ {
+ if (attempt == MaxRetryCount - 1)
+ {
+ return ApiResult>.Failure($"网络错误: {ex.Message}");
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ if (attempt == MaxRetryCount - 1)
+ {
+ return ApiResult>.Failure("请求超时");
+ }
+ }
+ catch (JsonException ex)
+ {
+ return ApiResult>.Failure($"数据格式错误: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ return ApiResult>.Failure($"未知错误: {ex.Message}");
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
+ }
+
+ return ApiResult>.Failure("获取数据失败");
+ }
+
+ public void Dispose() => _httpClient.Dispose();
+}
+
+public sealed class ApiResult
+{
+ 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 Success(T data) => new(true, data, null);
+ public static ApiResult Failure(string errorMessage) => new(false, default, errorMessage);
+}
diff --git a/VoiceHubLanDesktop/VoiceHubLanDesktop.csproj b/VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
new file mode 100644
index 0000000..edd414f
--- /dev/null
+++ b/VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ 1.0.0
+ true
+ bin\$(Configuration)\$(TargetFramework)\content\
+ false
+ false
+ $(OutputPath)
+ $(Version)
+ $(MSBuildThisFileDirectory)
+ .laapp
+ $(AssemblyName).$(LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)
+ $(LanMountainPluginPackageOutputDirectory)$(LanMountainPluginPackageFileName)
+
+
+
+
+
+
+
+
+
diff --git a/VoiceHubLanDesktop/VoiceHubPlugin.cs b/VoiceHubLanDesktop/VoiceHubPlugin.cs
new file mode 100644
index 0000000..db76d0f
--- /dev/null
+++ b/VoiceHubLanDesktop/VoiceHubPlugin.cs
@@ -0,0 +1,103 @@
+using LanMountainDesktop.PluginSdk;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace VoiceHubLanDesktop;
+
+///
+/// VoiceHub 广播站排期插件入口
+///
+[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();
+ services.AddSingleton();
+
+ // 注册桌面组件 - 最小 3x4 网格,允许等比例缩放
+ services.AddPluginDesktopComponent(
+ 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
+ };
+ }
+}
diff --git a/VoiceHubLanDesktop/VoiceHubScheduleService.cs b/VoiceHubLanDesktop/VoiceHubScheduleService.cs
new file mode 100644
index 0000000..94a7119
--- /dev/null
+++ b/VoiceHubLanDesktop/VoiceHubScheduleService.cs
@@ -0,0 +1,154 @@
+using LanMountainDesktop.PluginSdk;
+
+namespace VoiceHubLanDesktop;
+
+///
+/// 排期管理服务
+///
+public sealed class VoiceHubScheduleService
+{
+ private readonly VoiceHubApiService _apiService;
+ private readonly IPluginSettingsService _settingsService;
+ private IReadOnlyList _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 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(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 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 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 Songs { get; set; } = [];
+ public DateTime? DisplayDate { get; set; }
+ public string ErrorMessage { get; set; } = string.Empty;
+}
diff --git a/VoiceHubLanDesktop/VoiceHubScheduleWidget.cs b/VoiceHubLanDesktop/VoiceHubScheduleWidget.cs
new file mode 100644
index 0000000..94391aa
--- /dev/null
+++ b/VoiceHubLanDesktop/VoiceHubScheduleWidget.cs
@@ -0,0 +1,393 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using LanMountainDesktop.PluginSdk;
+
+namespace VoiceHubLanDesktop;
+
+///
+/// 广播站排期显示组件
+///
+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()
+ ?? 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()
+ ?.GetValue(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);
+ }
+}
diff --git a/VoiceHubLanDesktop/plugin.json b/VoiceHubLanDesktop/plugin.json
new file mode 100644
index 0000000..81f3fbe
--- /dev/null
+++ b/VoiceHubLanDesktop/plugin.json
@@ -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": []
+}