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