Files

163 lines
9.8 KiB
C#

using MusicBridge.Agent.Media;
using MusicBridge.Agent.Network;
namespace MusicBridge.Agent.Desktop;
internal sealed class AgentWindow : Form
{
private readonly Func<MediaState> read;
private readonly IMediaService media;
private readonly PairingService pairing;
private readonly Func<string> 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<string, Button> 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<MediaState> read, IMediaService media, PairingService pairing, Func<string> 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<TrustedDevice>().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<object>().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);
}
}