using MusicBridge.Agent.Media; using MusicBridge.Agent.Network; namespace MusicBridge.Agent.Desktop; internal sealed class AgentWindow : Form { private readonly Func read; private readonly IMediaService media; private readonly PairingService pairing; private readonly Func network; private readonly Action openQr; private readonly Label track = new() { Dock = DockStyle.Fill, AutoEllipsis = true }; private readonly Label position = new() { Dock = DockStyle.Fill }; private readonly Label status = new() { Dock = DockStyle.Fill, AutoEllipsis = true }; private readonly Label connection = new() { Dock = DockStyle.Fill, AutoEllipsis = true }; private readonly PictureBox cover = new() { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Color.WhiteSmoke }; private readonly TrackBar seek = new() { Name = "position", AccessibleName = "Позиция трека", Dock = DockStyle.Fill, Minimum = 0, Maximum = 1000, TickStyle = TickStyle.None }; private readonly TrackBar volume = new() { Name = "volume", AccessibleName = "Системная громкость", Dock = DockStyle.Fill, Minimum = 0, Maximum = 100, TickFrequency = 10 }; private readonly ListBox devices = new() { Dock = DockStyle.Fill, DisplayMember = nameof(TrustedDevice.Name) }; private readonly Dictionary commands = []; private readonly System.Windows.Forms.Timer timer = new() { Interval = 500 }; private string? coverId; private bool busy; private bool seekDragging; private bool volumeDragging; private string? seekingTrack; [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] internal bool AllowClose { get; set; } public AgentWindow(Func read, IMediaService media, PairingService pairing, Func network, Action openQr) { this.read = read; this.media = media; this.pairing = pairing; this.network = network; this.openQr = openQr; Text = "MusicBridge"; Font = new Font("Segoe UI", 10); ClientSize = new Size(720, 720); MinimumSize = new Size(620, 680); StartPosition = FormStartPosition.CenterScreen; AutoScaleMode = AutoScaleMode.Dpi; var root = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(20), ColumnCount = 1, RowCount = 9 }; foreach (var height in new[] { 130, 28, 38, 46, 50, 50, 42, -1, 50 }) root.RowStyles.Add(height < 0 ? new RowStyle(SizeType.Percent, 100) : new RowStyle(SizeType.Absolute, height)); var heading = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2 }; heading.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150)); heading.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); track.Font = new Font("Segoe UI", 15); track.Padding = new Padding(15, 12, 0, 0); heading.Controls.Add(cover); heading.Controls.Add(track); root.Controls.Add(heading); root.Controls.Add(position); root.Controls.Add(seek); var controls = new FlowLayoutPanel { Dock = DockStyle.Fill }; foreach (var item in new[] { ("previous", "Назад"), ("toggle", "Play / Pause"), ("next", "Далее"), ("toggleMute", "Без звука") }) { var button = new Button { Text = item.Item2, Name = item.Item1, Size = new Size(130, 36) }; var command = item.Item1; button.Click += async (_, _) => await SendAsync(new(command)); commands.Add(command, button); controls.Controls.Add(button); } root.Controls.Add(controls); root.Controls.Add(volume); root.Controls.Add(connection); var pairControls = new FlowLayoutPanel { Dock = DockStyle.Fill }; var pairButton = new Button { Text = "Подключить iPhone", AutoSize = true, Height = 34 }; pairButton.Click += (_, _) => { pairing.Open(); openQr(); status.Text = "Новый код действует 5 минут."; }; var qrButton = new Button { Text = "Показать QR", AutoSize = true, Height = 34 }; qrButton.Click += (_, _) => openQr(); var revoke = new Button { Text = "Отозвать доступ", AutoSize = true, Height = 34 }; revoke.Click += (_, _) => { if (devices.SelectedItem is not TrustedDevice device) { status.Text = "Выбери устройство в списке."; return; } try { pairing.Revoke(device.Id); status.Text = $"Доступ отозван: {device.Name}"; RefreshState(); } catch (Exception ex) { status.Text = $"Не удалось отозвать доступ: {ex.Message}"; } }; pairControls.Controls.AddRange([pairButton, qrButton, revoke]); root.Controls.Add(pairControls); var group = new GroupBox { Text = "Доверенные устройства", Dock = DockStyle.Fill }; group.Controls.Add(devices); root.Controls.Add(group); root.Controls.Add(status); Controls.Add(root); volume.MouseDown += (_, _) => volumeDragging = true; volume.MouseUp += async (_, _) => { volumeDragging = false; await SetVolumeAsync(); }; volume.KeyUp += async (_, _) => await SetVolumeAsync(); seek.MouseDown += (_, _) => { seekDragging = true; seekingTrack = TrackKey(read()); }; seek.MouseUp += async (_, _) => { seekDragging = false; await SeekAsync(); }; seek.KeyDown += (_, _) => { if (!seekDragging) seekingTrack = TrackKey(read()); seekDragging = true; }; seek.KeyUp += async (_, _) => { seekDragging = false; await SeekAsync(); }; timer.Tick += (_, _) => RefreshState(); Shown += (_, _) => { RefreshState(); timer.Start(); }; FormClosing += (_, e) => { if (!AllowClose && e.CloseReason == CloseReason.UserClosing) { e.Cancel = true; Hide(); } }; } private Task SetVolumeAsync() => SendAsync(new("volume", volume.Value / 100.0)); private Task SeekAsync() { var state = read(); if (seekingTrack != TrackKey(state)) { status.Text = "Трек сменился, перемотка отменена."; return Task.CompletedTask; } return SendAsync(new("seek", state.MinSeekSeconds + (state.MaxSeekSeconds - state.MinSeekSeconds) * seek.Value / 1000.0)); } private static string TrackKey(MediaState state) => state.Source + "\n" + state.Title + "\n" + state.Artist; internal async Task SendAsync(MediaCommand command) { if (busy || IsDisposed) return; busy = true; RefreshState(); try { var result = await media.ExecuteAsync(command, CancellationToken.None); if (!IsDisposed) status.Text = result.Message; } catch (Exception ex) { if (!IsDisposed) status.Text = ex.Message; } finally { busy = false; if (!IsDisposed) RefreshState(); } } internal void RefreshState() { var state = read(); track.Text = state.HasSession ? $"{state.Title}\n{state.Artist}\n{state.PlaybackStatus}" : "Ничего не играет\nОткрой плеер на компьютере"; position.Text = $"{Format(state.PositionSeconds)} / {Format(state.EndSeconds)} Громкость: {(state.Volume is { } v ? $"{v:P0}" : "недоступна")}"; commands["previous"].Enabled = !busy && state.Capabilities.Previous; commands["toggle"].Enabled = !busy && state.Capabilities.Toggle; commands["next"].Enabled = !busy && state.Capabilities.Next; commands["toggleMute"].Enabled = !busy && state.Muted is not null; commands["toggleMute"].Text = state.Muted == true ? "Включить звук" : "Без звука"; volume.Enabled = !busy && state.Volume is not null; seek.Enabled = !busy && state.Capabilities.Seek && state.MaxSeekSeconds > state.MinSeekSeconds; if (!volumeDragging && state.Volume is { } level) volume.Value = Math.Clamp((int)Math.Round(level * 100), 0, 100); if (!seekDragging && state.MaxSeekSeconds > state.MinSeekSeconds) seek.Value = Math.Clamp((int)(1000 * (state.PositionSeconds - state.MinSeekSeconds) / (state.MaxSeekSeconds - state.MinSeekSeconds)), 0, 1000); connection.Text = network(); var saved = pairing.Devices; if (!devices.Items.Cast().Select(d => d.Id).SequenceEqual(saved.Select(d => d.Id))) { var selected = (devices.SelectedItem as TrustedDevice)?.Id; devices.Items.Clear(); devices.Items.AddRange(saved.Cast().ToArray()); for (var i = 0; i < devices.Items.Count; i++) if (((TrustedDevice)devices.Items[i]).Id == selected) devices.SelectedIndex = i; } if (state.ArtworkId != coverId) { cover.Image?.Dispose(); cover.Image = null; coverId = state.ArtworkId; try { if (coverId is { } id && media.GetArtwork(id) is { } artwork) { using var stream = new MemoryStream(artwork.Bytes); using var original = Image.FromStream(stream); if (original.Width <= 8192 && original.Height <= 8192) cover.Image = new Bitmap(original, new Size(150, 150)); } } catch (Exception) { /* Missing or invalid cover must not stop playback controls. */ } } if (state.MediaError is not null || state.VolumeError is not null) status.Text = state.MediaError ?? state.VolumeError; } private static string Format(double seconds) => TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(seconds >= 3600 ? @"h\:mm\:ss" : @"m\:ss"); protected override void Dispose(bool disposing) { if (disposing) { timer.Dispose(); cover.Image?.Dispose(); } base.Dispose(disposing); } }