This commit is contained in:
fuenmao
2026-10-09 16:46:30 +08:00
parent 4a634ef4c2
commit e6b90a06ab
63 changed files with 4170 additions and 1518 deletions
@@ -0,0 +1,21 @@
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
/// <summary>播放能力契约,未来音箱接入不要求是 MiAirX。</summary>
public interface IAudioPlaybackGateway
{
bool IsConfigured { get; }
Task PlayAsync(CancellationToken cancellationToken = default);
Task PlayUrlAsync(string musicUrl, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
Task SetVolumeAsync(int volume, CancellationToken cancellationToken = default);
}
public interface ITcmMusicRuntime
{
TcmMusicTransitionOutput GetStatus();
bool HasPlayableTracks(string constitutionCode);
Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken);
Task PlayTestMusicAsync(CancellationToken cancellationToken);
Task SetManualVolumeAsync(int volume, CancellationToken cancellationToken);
Task StopMusicAsync(CancellationToken cancellationToken);
}
@@ -6,7 +6,7 @@ using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
public sealed class MiAirXClient
public sealed class MiAirXClient : IAudioPlaybackGateway
{
private readonly HttpClient _httpClient;
private readonly MiAirXOptions _options;
@@ -23,7 +23,6 @@ public sealed class MiAirXClient
public bool IsConfigured => _options.Enabled &&
!string.IsNullOrWhiteSpace(_options.BaseUrl) &&
!string.IsNullOrWhiteSpace(_options.DeviceId) &&
!string.IsNullOrWhiteSpace(_options.MusicUrl) &&
(!_options.RequireWebPassword || !string.IsNullOrWhiteSpace(_options.WebPassword));
public Task PlayAsync(CancellationToken cancellationToken = default)
@@ -48,6 +47,15 @@ public sealed class MiAirXClient
}
private async Task SendCommandAsync(string path, object payload, CancellationToken cancellationToken)
{
try { await SendCommandCoreAsync(path, payload, cancellationToken); }
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException("MiAirX 请求超时,未确认音箱操作完成", exception);
}
}
private async Task SendCommandCoreAsync(string path, object payload, CancellationToken cancellationToken)
{
var baseUri = GetBaseUri();
if (!string.IsNullOrWhiteSpace(_options.WebPassword))
@@ -6,11 +6,14 @@ using System.Diagnostics;
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
/// <summary>Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。</summary>
public sealed class TcmMusicTransitionService : IHostedService
public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime
{
private readonly MiAirXClient _miAirX;
private const int DemoStartVolume = 50;
private const int DemoFadeStepSeconds = 5;
private readonly IAudioPlaybackGateway _miAirX;
private readonly TcmMusicOptions _options;
private readonly ILogger<TcmMusicTransitionService> _logger;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _operationLock = new(1, 1);
private readonly object _statusLock = new();
private TcmMusicTransitionOutput _status = new();
@@ -18,14 +21,15 @@ public sealed class TcmMusicTransitionService : IHostedService
private Task? _activeTask;
public TcmMusicTransitionService(
IHttpClientFactory httpClientFactory,
IOptions<MiAirXOptions> miAirXOptions,
IAudioPlaybackGateway audioGateway,
IOptions<TcmMusicOptions> musicOptions,
ILogger<TcmMusicTransitionService> logger)
ILogger<TcmMusicTransitionService> logger,
TimeProvider time)
{
_miAirX = new MiAirXClient(httpClientFactory.CreateClient(nameof(TcmMusicTransitionService)), miAirXOptions);
_miAirX = audioGateway;
_options = musicOptions.Value;
_logger = logger;
_time = time;
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -53,24 +57,22 @@ public sealed class TcmMusicTransitionService : IHostedService
public bool HasPlayableTracks(string constitutionCode)
=> _miAirX.IsConfigured &&
_options.Tracks.TryGetValue(constitutionCode, out var tracks) && tracks.Count > 0 &&
_options.Tracks is not null && _options.Tracks.TryGetValue(constitutionCode, out var tracks) &&
tracks is not null && tracks.Count > 0 &&
tracks.All(track => IsValidTrack(track));
public async Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken)
{
if (!HasPlayableTracks(constitutionCode))
throw new InvalidOperationException($"{constitutionCode} 的体质音乐 OSS URL 尚未配置完整");
if (_options.StartVolume is < 1 or > 100 || _options.FadeStepSeconds is < 1 or > 60)
throw new InvalidOperationException("体质音乐渐弱参数无效");
var tracks = _options.Tracks[constitutionCode].ToArray();
await _operationLock.WaitAsync(cancellationToken);
try
{
await CancelCurrentLoopAsync();
await _miAirX.SetVolumeAsync(_options.StartVolume, cancellationToken);
await _miAirX.SetVolumeAsync(DemoStartVolume, cancellationToken);
await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken);
UpdateStatus(true, _options.StartVolume, "体质音乐正在自动渐弱");
UpdateStatus(true, DemoStartVolume, "体质音乐正在自动渐弱");
_activeCancellation = new CancellationTokenSource();
_activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token);
}
@@ -119,23 +121,23 @@ public sealed class TcmMusicTransitionService : IHostedService
string constitutionCode,
CancellationToken cancellationToken)
{
var clock = Stopwatch.StartNew();
var currentVolume = _options.StartVolume;
var nextFadeSecond = (double)_options.FadeStepSeconds;
var start = _time.GetTimestamp();
var currentVolume = DemoStartVolume;
var nextFadeSecond = (double)DemoFadeStepSeconds;
var trackStartSecond = 0d;
var trackIndex = 0;
try
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
var elapsed = clock.Elapsed.TotalSeconds;
await Task.Delay(TimeSpan.FromSeconds(1), _time, cancellationToken);
var elapsed = _time.GetElapsedTime(start).TotalSeconds;
while (elapsed >= nextFadeSecond && currentVolume > 0)
{
currentVolume--;
await _miAirX.SetVolumeAsync(currentVolume, cancellationToken);
UpdateStatus(true, currentVolume, "体质音乐正在自动渐弱");
nextFadeSecond += _options.FadeStepSeconds;
nextFadeSecond += DemoFadeStepSeconds;
}
if (currentVolume == 0)
@@ -149,7 +151,7 @@ public sealed class TcmMusicTransitionService : IHostedService
if (elapsed - trackStartSecond < tracks[trackIndex].DurationSeconds) continue;
trackIndex = (trackIndex + 1) % tracks.Count;
await _miAirX.PlayUrlAsync(tracks[trackIndex].Url, cancellationToken);
trackStartSecond = clock.Elapsed.TotalSeconds;
trackStartSecond = _time.GetElapsedTime(start).TotalSeconds;
_logger.LogInformation("体质 {ConstitutionCode} 循环播放 {Track},继承音量 {Volume}",
constitutionCode, tracks[trackIndex].Title, currentVolume);
}
@@ -192,7 +194,7 @@ public sealed class TcmMusicTransitionService : IHostedService
}
private static bool IsValidTrack(TcmMusicTrackOptions track)
=> track.DurationSeconds > 0 &&
=> track is not null && double.IsFinite(track.DurationSeconds) && track.DurationSeconds > 0 &&
Uri.TryCreate(track.Url, UriKind.Absolute, out var uri) &&
uri.Scheme is "http" or "https";
}