Files
windows/Media/MediaService.cs
ros d5e827112a Add MusicBridge protocol documentation and implement system volume control
- 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.
2026-09-09 01:05:52 +03:00

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