Files
Evergreen-Health/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs
T
2026-10-08 10:16:43 +08:00

207 lines
8.1 KiB
C#

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Diagnostics;
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
/// <summary>Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。</summary>
public sealed class TcmMusicTransitionService : IHostedService
{
private readonly MiAirXClient _miAirX;
private readonly TcmMusicOptions _options;
private readonly ILogger<TcmMusicTransitionService> _logger;
private readonly SemaphoreSlim _operationLock = new(1, 1);
private readonly object _statusLock = new();
private TcmMusicTransitionOutput _status = new();
private CancellationTokenSource? _activeCancellation;
private Task? _activeTask;
public TcmMusicTransitionService(
IHttpClientFactory httpClientFactory,
IOptions<MiAirXOptions> miAirXOptions,
IOptions<TcmMusicOptions> musicOptions,
ILogger<TcmMusicTransitionService> logger)
{
_miAirX = new MiAirXClient(httpClientFactory.CreateClient(nameof(TcmMusicTransitionService)), miAirXOptions);
_options = musicOptions.Value;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public TcmMusicTransitionOutput GetStatus()
{
lock (_statusLock)
return new TcmMusicTransitionOutput
{
Running = _status.Running,
HasVolume = _status.HasVolume,
VolumePercent = _status.VolumePercent,
Message = _status.Message
};
}
public async Task StopAsync(CancellationToken cancellationToken)
{
try { await StopMusicAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "应用退出时未能停止助眠音乐");
}
}
public bool HasPlayableTracks(string constitutionCode)
=> _miAirX.IsConfigured &&
_options.Tracks.TryGetValue(constitutionCode, out var tracks) && 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.PlayUrlAsync(tracks[0].Url, cancellationToken);
UpdateStatus(true, _options.StartVolume, "体质音乐正在自动渐弱");
_activeCancellation = new CancellationTokenSource();
_activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token);
}
finally { _operationLock.Release(); }
}
public async Task PlayTestMusicAsync(CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
await CancelCurrentLoopAsync();
await _miAirX.PlayAsync(cancellationToken);
UpdateStatus(false, null, "正在播放测试音乐");
}
finally { _operationLock.Release(); }
}
public async Task SetManualVolumeAsync(int volume, CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
// 手动调音量接管当前播放,避免下一次自动渐弱立刻覆盖用户选择。
await CancelCurrentLoopAsync();
await _miAirX.SetVolumeAsync(volume, cancellationToken);
UpdateStatus(false, volume, "音量已手动设置");
}
finally { _operationLock.Release(); }
}
public async Task StopMusicAsync(CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
await CancelCurrentLoopAsync();
await _miAirX.StopAsync(cancellationToken);
UpdateStatus(false, null, "音乐已停止");
}
finally { _operationLock.Release(); }
}
private async Task RunFadeAsync(
IReadOnlyList<TcmMusicTrackOptions> tracks,
string constitutionCode,
CancellationToken cancellationToken)
{
var clock = Stopwatch.StartNew();
var currentVolume = _options.StartVolume;
var nextFadeSecond = (double)_options.FadeStepSeconds;
var trackStartSecond = 0d;
var trackIndex = 0;
try
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
var elapsed = clock.Elapsed.TotalSeconds;
while (elapsed >= nextFadeSecond && currentVolume > 0)
{
currentVolume--;
await _miAirX.SetVolumeAsync(currentVolume, cancellationToken);
UpdateStatus(true, currentVolume, "体质音乐正在自动渐弱");
nextFadeSecond += _options.FadeStepSeconds;
}
if (currentVolume == 0)
{
await _miAirX.StopAsync(cancellationToken);
UpdateStatus(false, 0, "音量已降至 0,音乐已停止");
_logger.LogInformation("体质 {ConstitutionCode} 的助眠音乐已渐弱至 0 并停止", constitutionCode);
return;
}
if (elapsed - trackStartSecond < tracks[trackIndex].DurationSeconds) continue;
trackIndex = (trackIndex + 1) % tracks.Count;
await _miAirX.PlayUrlAsync(tracks[trackIndex].Url, cancellationToken);
trackStartSecond = clock.Elapsed.TotalSeconds;
_logger.LogInformation("体质 {ConstitutionCode} 循环播放 {Track},继承音量 {Volume}",
constitutionCode, tracks[trackIndex].Title, currentVolume);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception exception)
{
_logger.LogWarning(exception, "体质音乐渐弱或续播中断");
try { await _miAirX.StopAsync(CancellationToken.None); }
catch (Exception stopException) { _logger.LogWarning(stopException, "体质音乐中断后停止音箱失败"); }
UpdateStatus(false, null, "自动渐弱中断,请查看后端日志");
}
}
private async Task CancelCurrentLoopAsync()
{
var cancellation = _activeCancellation;
var task = _activeTask;
_activeCancellation = null;
_activeTask = null;
if (cancellation is null) return;
cancellation.Cancel();
try { if (task is not null) await task; }
finally { cancellation.Dispose(); }
UpdateStatus(false, null, "自动渐弱已停止");
}
private void UpdateStatus(bool running, int? volume, string message)
{
lock (_statusLock)
{
_status.Running = running;
if (volume.HasValue)
{
_status.HasVolume = true;
_status.VolumePercent = volume.Value;
}
_status.Message = message;
}
}
private static bool IsValidTrack(TcmMusicTrackOptions track)
=> track.DurationSeconds > 0 &&
Uri.TryCreate(track.Url, UriKind.Absolute, out var uri) &&
uri.Scheme is "http" or "https";
}
public sealed class TcmMusicTransitionOutput
{
public bool Running { get; set; }
public bool HasVolume { get; set; }
public int VolumePercent { get; set; }
public string Message { get; set; } = string.Empty;
}