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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +