d5e827112a
- Created PROTOCOL.md to outline the MusicBridge protocol v1, detailing setup, TLS, pairing, state management, and command structure. - Implemented SystemVolume class for managing system audio levels, including methods for setting, reading, changing, and muting volume. - Added unit tests for the MusicBridge agent, covering pairing, identity storage, command validation, and media state management. - Included tests for real-time media reading from Windows and ensured proper handling of artwork caching and retrieval.
218 lines
11 KiB
C#
218 lines
11 KiB
C#
using Windows.Media.Control;
|
|
|
|
namespace MusicBridge.Agent.Media;
|
|
|
|
internal sealed class MediaService : IMediaService, IDisposable
|
|
{
|
|
private readonly GlobalSystemMediaTransportControlsSessionManager manager;
|
|
private GlobalSystemMediaTransportControlsSession? observed;
|
|
private GlobalSystemMediaTransportControlsSessionMediaProperties? metadata;
|
|
private readonly ArtworkCache artwork = new();
|
|
private long metadataRevision;
|
|
private long loadedRevision = -1;
|
|
private DateTimeOffset nextMetadataRefresh;
|
|
private string? artworkError;
|
|
private bool disposed;
|
|
public event Action? Changed;
|
|
public Artwork? GetArtwork(string id) => artwork.Get(id);
|
|
|
|
public MediaService(GlobalSystemMediaTransportControlsSessionManager manager)
|
|
{
|
|
this.manager = manager;
|
|
manager.CurrentSessionChanged += OnCurrentSessionChanged;
|
|
manager.SessionsChanged += OnSessionsChanged;
|
|
}
|
|
|
|
// Console commands and remote clients must not race each other.
|
|
private readonly SemaphoreSlim gate = new(1, 1);
|
|
|
|
public async Task<MediaState> ReadAsync(CancellationToken cancellationToken)
|
|
{
|
|
await gate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
var state = new MediaState();
|
|
try
|
|
{
|
|
var session = manager.GetCurrentSession();
|
|
Observe(session);
|
|
if (session is not null)
|
|
{
|
|
var revision = Interlocked.Read(ref metadataRevision);
|
|
if (metadata is null || loadedRevision != revision || DateTimeOffset.UtcNow >= nextMetadataRefresh)
|
|
{
|
|
metadata = null;
|
|
artwork.Clear();
|
|
metadata = await session.TryGetMediaPropertiesAsync().AsTask(cancellationToken);
|
|
await LoadArtworkAsync(metadata, cancellationToken);
|
|
loadedRevision = revision; // Events during this read remain dirty for the next read.
|
|
nextMetadataRefresh = DateTimeOffset.UtcNow.AddSeconds(10);
|
|
}
|
|
var media = metadata;
|
|
var playback = session.GetPlaybackInfo();
|
|
var timeline = session.GetTimelineProperties();
|
|
var range = GetSeekRange(timeline);
|
|
var c = playback.Controls;
|
|
state = state with
|
|
{
|
|
HasSession = true, Title = media.Title, Artist = media.Artist,
|
|
Source = session.SourceAppUserModelId,
|
|
ArtworkId = artwork.Current?.Id, ArtworkError = artworkError,
|
|
PlaybackStatus = playback.PlaybackStatus.ToString(),
|
|
PositionSeconds = GetPosition(timeline, playback),
|
|
StartSeconds = timeline.StartTime.TotalSeconds, EndSeconds = timeline.EndTime.TotalSeconds,
|
|
MinSeekSeconds = range.Min, MaxSeekSeconds = range.Max,
|
|
PlaybackRate = double.IsFinite(playback.PlaybackRate ?? 1) ? playback.PlaybackRate ?? 1 : 1,
|
|
Capabilities = new(c.IsPlayEnabled, c.IsPauseEnabled, c.IsPlayPauseToggleEnabled,
|
|
c.IsNextEnabled, c.IsPreviousEnabled, c.IsPlaybackPositionEnabled && range.Max > range.Min)
|
|
};
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
metadata = null;
|
|
artwork.Clear();
|
|
state = state with { MediaError = ex.Message };
|
|
}
|
|
try
|
|
{
|
|
var volume = SystemVolume.Read();
|
|
state = state with { Volume = volume.Level, Muted = volume.Muted };
|
|
}
|
|
catch (Exception ex) { state = state with { VolumeError = ex.Message }; }
|
|
return state with { Timestamp = DateTimeOffset.UtcNow };
|
|
}
|
|
finally { gate.Release(); }
|
|
}
|
|
|
|
public async Task<CommandResult> ExecuteAsync(MediaCommand command, CancellationToken cancellationToken)
|
|
{
|
|
var invalid = CommandValidation.Validate(command);
|
|
if (invalid is not null) return invalid with { Id = command.Id };
|
|
await gate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
return (await ExecuteCoreAsync(command)) with { Id = command.Id };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, "operation_failed", ex.Message, command.Id);
|
|
}
|
|
finally { gate.Release(); Changed?.Invoke(); }
|
|
}
|
|
|
|
private async Task LoadArtworkAsync(GlobalSystemMediaTransportControlsSessionMediaProperties properties, CancellationToken cancellationToken)
|
|
{
|
|
artworkError = null;
|
|
if (properties.Thumbnail is null) return;
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(TimeSpan.FromSeconds(2));
|
|
try
|
|
{
|
|
using var source = await properties.Thumbnail.OpenReadAsync().AsTask(timeout.Token);
|
|
if (source.Size > ArtworkCache.MaxBytes) throw new InvalidDataException("Обложка больше 2 МиБ.");
|
|
using var stream = source.AsStreamForRead();
|
|
await artwork.LoadAsync(stream, timeout.Token);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
|
catch (OperationCanceledException) { artworkError = "Время чтения обложки истекло."; }
|
|
catch (Exception ex) { artworkError = ex.Message; }
|
|
}
|
|
|
|
// Event callbacks only invalidate/notify; WinRT reads run under the shared gate.
|
|
private void OnCurrentSessionChanged(GlobalSystemMediaTransportControlsSessionManager sender, CurrentSessionChangedEventArgs args) => Invalidate();
|
|
private void OnSessionsChanged(GlobalSystemMediaTransportControlsSessionManager sender, SessionsChangedEventArgs args) => Invalidate();
|
|
private void OnMediaChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args) => Invalidate();
|
|
private void OnPlaybackChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args) => Changed?.Invoke();
|
|
private void OnTimelineChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args) => Changed?.Invoke();
|
|
private void Invalidate() { Interlocked.Increment(ref metadataRevision); Changed?.Invoke(); }
|
|
|
|
private void Observe(GlobalSystemMediaTransportControlsSession? session)
|
|
{
|
|
if (ReferenceEquals(observed, session)) return;
|
|
if (observed is not null)
|
|
{
|
|
observed.MediaPropertiesChanged -= OnMediaChanged;
|
|
observed.PlaybackInfoChanged -= OnPlaybackChanged;
|
|
observed.TimelinePropertiesChanged -= OnTimelineChanged;
|
|
}
|
|
observed = session;
|
|
metadata = null;
|
|
artwork.Clear();
|
|
artworkError = null;
|
|
if (observed is not null)
|
|
{
|
|
observed.MediaPropertiesChanged += OnMediaChanged;
|
|
observed.PlaybackInfoChanged += OnPlaybackChanged;
|
|
observed.TimelinePropertiesChanged += OnTimelineChanged;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (disposed) return;
|
|
disposed = true;
|
|
manager.CurrentSessionChanged -= OnCurrentSessionChanged;
|
|
manager.SessionsChanged -= OnSessionsChanged;
|
|
Observe(null);
|
|
// Owner stops server/read loop before disposing the media service.
|
|
Changed = null;
|
|
gate.Dispose();
|
|
}
|
|
|
|
private async Task<CommandResult> ExecuteCoreAsync(MediaCommand command)
|
|
{
|
|
switch (command.Type)
|
|
{
|
|
case "volume": SystemVolume.Set((float)command.Value!.Value); return Ok();
|
|
case "volumeRelative": SystemVolume.Change((float)command.Value!.Value); return Ok();
|
|
case "toggleMute": SystemVolume.ToggleMute(); return Ok();
|
|
}
|
|
var session = manager.GetCurrentSession();
|
|
if (session is null) return CommandValidation.Error("no_session", "Медиаплеер не найден.");
|
|
var playback = session.GetPlaybackInfo();
|
|
var controls = playback.Controls;
|
|
bool accepted;
|
|
switch (command.Type)
|
|
{
|
|
case "play":
|
|
if (!controls.IsPlayEnabled) return Unsupported();
|
|
accepted = await session.TryPlayAsync(); break;
|
|
case "pause":
|
|
if (!controls.IsPauseEnabled) return Unsupported();
|
|
accepted = await session.TryPauseAsync(); break;
|
|
case "toggle":
|
|
if (!controls.IsPlayPauseToggleEnabled) return Unsupported();
|
|
accepted = await session.TryTogglePlayPauseAsync(); break;
|
|
case "next":
|
|
if (!controls.IsNextEnabled) return Unsupported();
|
|
accepted = await session.TrySkipNextAsync(); break;
|
|
case "previous":
|
|
if (!controls.IsPreviousEnabled) return Unsupported();
|
|
accepted = await session.TrySkipPreviousAsync(); break;
|
|
default:
|
|
if (!controls.IsPlaybackPositionEnabled) return Unsupported();
|
|
var timeline = session.GetTimelineProperties();
|
|
var range = GetSeekRange(timeline);
|
|
if (range.Max <= range.Min) return Unsupported();
|
|
var target = command.Value!.Value;
|
|
if (command.Type == "seekRelative") target += GetPosition(timeline, playback);
|
|
target = Math.Clamp(target, range.Min, range.Max);
|
|
accepted = await session.TryChangePlaybackPositionAsync(TimeSpan.FromSeconds(target).Ticks);
|
|
break;
|
|
}
|
|
return accepted ? Ok() : CommandValidation.Error("rejected", "Плеер отклонил команду.");
|
|
}
|
|
|
|
private static CommandResult Ok() => new(true, "ok", "Команда выполнена.");
|
|
private static CommandResult Unsupported() => CommandValidation.Error("unsupported", "Команда сейчас недоступна у плеера.");
|
|
private static (double Min, double Max) GetSeekRange(GlobalSystemMediaTransportControlsSessionTimelineProperties t)
|
|
=> TimelineMath.SeekRange(t.StartTime.TotalSeconds, t.EndTime.TotalSeconds, t.MinSeekTime.TotalSeconds, t.MaxSeekTime.TotalSeconds);
|
|
private static double GetPosition(GlobalSystemMediaTransportControlsSessionTimelineProperties t,
|
|
GlobalSystemMediaTransportControlsSessionPlaybackInfo p) => TimelineMath.Position(t.Position.TotalSeconds,
|
|
t.StartTime.TotalSeconds, t.EndTime.TotalSeconds,
|
|
p.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing,
|
|
t.LastUpdatedTime, DateTimeOffset.UtcNow, p.PlaybackRate ?? 1);
|
|
}
|