This commit is contained in:
lincube
2026-04-01 00:34:07 +08:00
parent 3b810fd0ba
commit f50cfed3cc
21 changed files with 201 additions and 1766 deletions

View 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;
}
}

View File

@@ -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="&#x201C;拉波特夫人&#x201D;"
Foreground="#F8F8F8" Foreground="#F8F8F8"
FontSize="24" FontSize="24"
FontWeight="SemiBold" FontWeight="SemiBold"

View File

@@ -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"
}

View File

@@ -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": "自动刷新排期数据的时间间隔"
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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="&#xE7D1;"
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="&#xE7E5;"
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="&#xE783;"
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>

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

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

View File

@@ -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
};
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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": []
}

View File

@@ -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
View 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);