163 lines
9.8 KiB
C#
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);
|
|
}
|
|
}
|