Implement desktop tray interface with media controls and QR pairing; add related tests
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using MusicBridge.Agent.Media;
|
||||
using MusicBridge.Agent.Network;
|
||||
|
||||
namespace MusicBridge.Agent.Desktop;
|
||||
|
||||
internal sealed class DesktopHost(Func<MediaState> read, IMediaService media, PairingService pairing,
|
||||
Func<string> network, Action showQr, Action exit, bool visible = true) : IDisposable
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private Thread? thread;
|
||||
private AgentWindow? window;
|
||||
private bool disposed;
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
thread = new Thread(Run) { IsBackground = true, Name = "MusicBridge Desktop" };
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
return ready.Task;
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
using var view = new AgentWindow(read, media, pairing, network, showQr);
|
||||
view.Opacity = visible ? 1 : 0;
|
||||
view.ShowInTaskbar = visible;
|
||||
using var menu = new ContextMenuStrip();
|
||||
void ShowWindow() { view.Show(); view.WindowState = FormWindowState.Normal; view.Activate(); }
|
||||
menu.Items.Add("Открыть MusicBridge", null, (_, _) => ShowWindow());
|
||||
var toggle = menu.Items.Add("Play / Pause", null, async (_, _) => await view.SendAsync(new("toggle")));
|
||||
menu.Items.Add("Подключить iPhone", null, (_, _) => { pairing.Open(); showQr(); });
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
menu.Items.Add("Завершить MusicBridge", null, (_, _) => { view.AllowClose = true; view.Close(); });
|
||||
using var icon = new NotifyIcon { Icon = SystemIcons.Application, Text = "MusicBridge", ContextMenuStrip = menu, Visible = visible };
|
||||
icon.DoubleClick += (_, _) => ShowWindow();
|
||||
using var timer = new System.Windows.Forms.Timer { Interval = 1000 };
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
var state = read();
|
||||
toggle.Enabled = state.Capabilities.Toggle;
|
||||
var text = state.HasSession ? $"MusicBridge — {state.Title}" : "MusicBridge — нет плеера";
|
||||
icon.Text = text.Length > 63 ? text[..63] : text;
|
||||
};
|
||||
view.Shown += (_, _) => { timer.Start(); ready.TrySetResult(); };
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) { ready.TrySetCanceled(); return; }
|
||||
window = view;
|
||||
}
|
||||
Application.Run(view);
|
||||
icon.Visible = false;
|
||||
}
|
||||
catch (Exception ex) { ready.TrySetException(ex); }
|
||||
finally
|
||||
{
|
||||
lock (gate) window = null;
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
disposed = true;
|
||||
if (window is { IsHandleCreated: true } view)
|
||||
{
|
||||
try { view.BeginInvoke(() => { view.AllowClose = true; view.Close(); }); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
thread?.Join(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
// Detach only this process; never hide or close the user's shared terminal window.
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool FreeConsole();
|
||||
}
|
||||
Reference in New Issue
Block a user