217 lines
8.6 KiB
C#
217 lines
8.6 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, ITcmMusicRuntime
|
|
{
|
|
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();
|
|
private CancellationTokenSource? _activeCancellation;
|
|
private Task? _activeTask;
|
|
|
|
public TcmMusicTransitionService(
|
|
IAudioPlaybackGateway audioGateway,
|
|
IOptions<TcmMusicOptions> musicOptions,
|
|
ILogger<TcmMusicTransitionService> logger,
|
|
TimeProvider time)
|
|
{
|
|
_miAirX = audioGateway;
|
|
_options = musicOptions.Value;
|
|
_logger = logger;
|
|
_time = time;
|
|
}
|
|
|
|
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 is not null && _options.Tracks.TryGetValue(constitutionCode, out var tracks) &&
|
|
tracks is not null && tracks.Count > 0 &&
|
|
tracks.All(track => IsValidTrack(track));
|
|
|
|
public Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken) =>
|
|
StartAsync(constitutionCode, onlyIfIdle: false, cancellationToken);
|
|
|
|
internal Task<bool> TryStartIdlePlaybackAsync(string constitutionCode, CancellationToken cancellationToken) =>
|
|
StartAsync(constitutionCode, onlyIfIdle: true, cancellationToken);
|
|
|
|
private async Task<bool> StartAsync(string constitutionCode, bool onlyIfIdle, CancellationToken cancellationToken)
|
|
{
|
|
if (!HasPlayableTracks(constitutionCode))
|
|
throw new InvalidOperationException($"{constitutionCode} 的体质音乐 OSS URL 尚未配置完整");
|
|
var tracks = _options.Tracks[constitutionCode].ToArray();
|
|
await _operationLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
if (onlyIfIdle && _activeTask is { IsCompleted: false }) return false;
|
|
await CancelCurrentLoopAsync();
|
|
await _miAirX.SetVolumeAsync(DemoStartVolume, cancellationToken);
|
|
await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken);
|
|
UpdateStatus(true, DemoStartVolume, "体质音乐正在自动渐弱");
|
|
_activeCancellation = new CancellationTokenSource();
|
|
_activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token);
|
|
return true;
|
|
}
|
|
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 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), _time, cancellationToken);
|
|
var elapsed = _time.GetElapsedTime(start).TotalSeconds;
|
|
while (elapsed >= nextFadeSecond && currentVolume > 0)
|
|
{
|
|
currentVolume--;
|
|
await _miAirX.SetVolumeAsync(currentVolume, cancellationToken);
|
|
UpdateStatus(true, currentVolume, "体质音乐正在自动渐弱");
|
|
nextFadeSecond += DemoFadeStepSeconds;
|
|
}
|
|
|
|
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 = _time.GetElapsedTime(start).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 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";
|
|
}
|
|
|
|
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;
|
|
}
|