diff --git a/src/Client/ActiveOutputInfo.cs b/src/Client/ActiveOutputInfo.cs
new file mode 100644
index 0000000..18bec31
--- /dev/null
+++ b/src/Client/ActiveOutputInfo.cs
@@ -0,0 +1,17 @@
+namespace Beefweb.Client;
+
+///
+/// Information about active output device.
+///
+public sealed class ActiveOutputInfo
+{
+ ///
+ /// Output type type id.
+ ///
+ public string TypeId { get; set; } = null!;
+
+ ///
+ /// Output device id.
+ ///
+ public string DeviceId { get; set; } = null!;
+}
diff --git a/src/Client/IPlayerClient.cs b/src/Client/IPlayerClient.cs
index 7ca719f..0dc9598 100644
--- a/src/Client/IPlayerClient.cs
+++ b/src/Client/IPlayerClient.cs
@@ -369,6 +369,22 @@ ValueTask SortPlaylist(
/// Request task.
ValueTask SortPlaylistRandomly(PlaylistRef playlist, CancellationToken cancellationToken = default);
+ ///
+ /// Gets information about output devices.
+ ///
+ /// Cancellation token.
+ /// Request task.
+ ValueTask GetOutputs(CancellationToken cancellationToken = default);
+
+ ///
+ /// Sets output device.
+ ///
+ /// Output type id. If this value is null, current output type is not changed.
+ /// Output device id.
+ /// Cancellation token.
+ /// Request task.
+ ValueTask SetOutputDevice(string? typeId, string deviceId, CancellationToken cancellationToken = default);
+
// File browser API
///
diff --git a/src/Client/IPlayerQuery.cs b/src/Client/IPlayerQuery.cs
index dd29c82..663a4a5 100644
--- a/src/Client/IPlayerQuery.cs
+++ b/src/Client/IPlayerQuery.cs
@@ -46,6 +46,12 @@ IPlayerQuery IncludePlaylistItems(
/// Configured query.
IPlayerQuery IncludePlayQueue(IReadOnlyList? columns = null);
+ ///
+ /// Configures query to include outputs configuration.
+ ///
+ /// Configured query.
+ IPlayerQuery IncludeOutputs();
+
///
/// Executes this query and returns player information.
///
diff --git a/src/Client/OutputDeviceInfo.cs b/src/Client/OutputDeviceInfo.cs
new file mode 100644
index 0000000..2a26543
--- /dev/null
+++ b/src/Client/OutputDeviceInfo.cs
@@ -0,0 +1,17 @@
+namespace Beefweb.Client;
+
+///
+/// Information about output device.
+///
+public sealed class OutputDeviceInfo
+{
+ ///
+ /// Output device id.
+ ///
+ public string Id { get; set; } = null!;
+
+ ///
+ /// Output device name.
+ ///
+ public string Name { get; set; } = null!;
+}
diff --git a/src/Client/OutputTypeInfo.cs b/src/Client/OutputTypeInfo.cs
new file mode 100644
index 0000000..ca6685b
--- /dev/null
+++ b/src/Client/OutputTypeInfo.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+
+namespace Beefweb.Client;
+
+///
+/// Information about output device type.
+///
+public sealed class OutputTypeInfo
+{
+ ///
+ /// Output type id.
+ ///
+ public string Id { get; set; } = null!;
+
+ ///
+ /// Output type name.
+ ///
+ public string Name { get; set; } = null!;
+
+ ///
+ /// Available output devices.
+ ///
+ public IList Devices { get; set; } = null!;
+}
diff --git a/src/Client/OutputsInfo.cs b/src/Client/OutputsInfo.cs
new file mode 100644
index 0000000..4fd78b7
--- /dev/null
+++ b/src/Client/OutputsInfo.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+
+namespace Beefweb.Client;
+
+///
+/// Information about audio outputs.
+///
+public sealed class OutputsInfo
+{
+ ///
+ /// Information about active output device.
+ ///
+ public ActiveOutputInfo Active { get; set; } = null!;
+
+ ///
+ /// Available output device types.
+ ///
+ public IList Types { get; set; } = null!;
+
+ ///
+ /// If true player supports multiple output types.
+ ///
+ public bool SupportsMultipleOutputTypes { get; set; }
+}
diff --git a/src/Client/PlayerClient.cs b/src/Client/PlayerClient.cs
index 3bdb2aa..7724866 100644
--- a/src/Client/PlayerClient.cs
+++ b/src/Client/PlayerClient.cs
@@ -394,6 +394,20 @@ await _handler
.ConfigureAwait(false);
}
+ ///
+ public async ValueTask GetOutputs(CancellationToken cancellationToken = default)
+ {
+ var result = await _handler.Get("api/outputs", null, cancellationToken).ConfigureAwait(false);
+ return result.Outputs ?? throw PropertyIsNull("outputs");
+ }
+
+ ///
+ public async ValueTask SetOutputDevice(string? typeId, string deviceId,
+ CancellationToken cancellationToken = default)
+ {
+ await _handler.Post("api/outputs/active", new { typeId, deviceId }, cancellationToken).ConfigureAwait(false);
+ }
+
// File browser API
///
diff --git a/src/Client/PlayerQuery.cs b/src/Client/PlayerQuery.cs
index 0f94c00..8da00d1 100644
--- a/src/Client/PlayerQuery.cs
+++ b/src/Client/PlayerQuery.cs
@@ -14,6 +14,7 @@ internal sealed class PlayerQuery : IPlayerQuery
private bool _includePlayQueue;
private bool _includePlaylists;
private bool _includePlaylistItems;
+ private bool _includeOutputs;
private bool _hasPlaylistItemsParameters;
private IReadOnlyList? _activeItemColumns;
private IReadOnlyList? _playQueueColumns;
@@ -63,6 +64,12 @@ public IPlayerQuery IncludePlayQueue(IReadOnlyList? columns = null)
return this;
}
+ public IPlayerQuery IncludeOutputs()
+ {
+ _includeOutputs = true;
+ return this;
+ }
+
public async ValueTask Execute(CancellationToken cancellationToken = default)
{
return await _handler
@@ -123,6 +130,11 @@ private QueryParameterCollection BuildQuery(bool wantPlaylistItemsParameters)
}
}
+ if (_includeOutputs)
+ {
+ query["outputs"] = true;
+ }
+
if (query.Count == 0)
{
throw new InvalidOperationException(
diff --git a/src/Client/PlayerQueryResult.cs b/src/Client/PlayerQueryResult.cs
index ed699a1..f264438 100644
--- a/src/Client/PlayerQueryResult.cs
+++ b/src/Client/PlayerQueryResult.cs
@@ -27,4 +27,9 @@ public sealed class PlayerQueryResult
/// Play queue contents.
///
public IList? PlayQueue { get; set; }
+
+ ///
+ /// Outputs configuration.
+ ///
+ public OutputsInfo? Outputs { get; set; }
}
diff --git a/src/CommandLineTool/Commands/OutputCommand.cs b/src/CommandLineTool/Commands/OutputCommand.cs
new file mode 100644
index 0000000..f5d82fe
--- /dev/null
+++ b/src/CommandLineTool/Commands/OutputCommand.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Beefweb.Client;
+using Beefweb.CommandLineTool.Services;
+using McMaster.Extensions.CommandLineUtils;
+
+namespace Beefweb.CommandLineTool.Commands;
+
+[Command("outputs", "out", Description = "Show information about output devices or change output device")]
+public class OutputCommand(IClientProvider clientProvider, IConsole console, ITabularWriter writer)
+ : ServerCommandBase(clientProvider)
+{
+ [Option("-a|--all", Description = "Print all available output devices")]
+ public bool All { get; set; }
+
+ [Option("-t|--set-type", Description = "Set new output type")]
+ public string? TypeId { get; set; }
+
+ [Option("-d|--set-device", Description = "Set new output device")]
+ public string? DeviceId { get; set; }
+
+ public override async Task OnExecuteAsync(CancellationToken ct)
+ {
+ await base.OnExecuteAsync(ct);
+
+ var outputs = await Client.GetOutputs(ct);
+
+ if (DeviceId != null)
+ {
+ await SetOutputDevice(outputs, ct);
+ return;
+ }
+
+ if (TypeId != null)
+ {
+ throw new InvalidRequestException("Invalid option combination: --set-type requires --set-device.");
+ }
+
+ if (All)
+ PrintAll(outputs);
+ else
+ PrintCurrent(outputs);
+ }
+
+ private void PrintAll(OutputsInfo outputs)
+ {
+ foreach (var type in outputs.Types)
+ {
+ var isCurrentType = type.Id == outputs.Active.TypeId;
+ var rows = Enumerable.Empty();
+
+ if (outputs.SupportsMultipleOutputTypes)
+ {
+ rows = rows.Concat([
+ [" ", type.Id, type.Name],
+ [" ", "---", "---"]
+ ]);
+ }
+
+ rows = rows.Concat(type.Devices.Select(d => (string[]) [
+ isCurrentType && d.Id == outputs.Active.DeviceId ? "*" : " ",
+ d.Id,
+ d.FormatDeviceName()
+ ]));
+
+ writer.WriteTable(rows.ToList());
+ console.WriteLine();
+ }
+ }
+
+ private void PrintCurrent(OutputsInfo outputs)
+ {
+ var currentType = outputs.Types.First(c => c.Id == outputs.Active.TypeId);
+ var currentDevice = currentType.Devices.First(c => c.Id == outputs.Active.DeviceId);
+
+ var rows = Enumerable.Empty();
+
+ if (outputs.SupportsMultipleOutputTypes)
+ rows = rows.Append([currentType.Id, currentType.Name]);
+
+ rows = rows.Append([currentDevice.Id, currentDevice.FormatDeviceName()]);
+
+ writer.WriteTable(rows.ToList());
+ }
+
+ private async ValueTask SetOutputDevice(OutputsInfo outputs, CancellationToken ct)
+ {
+ var type = TypeId != null
+ ? outputs.Types.FirstOrDefault(c => string.Equals(c.Id, TypeId, StringComparison.OrdinalIgnoreCase))
+ ?? throw new InvalidRequestException($"Unknown output type: {TypeId}.")
+ : outputs.Types.First(c => c.Id == outputs.Active.TypeId);
+
+ var device = type.Devices.FirstOrDefault(c => string.Equals(c.Id, DeviceId, StringComparison.OrdinalIgnoreCase))
+ ?? throw new InvalidRequestException($"Unknown output device: {DeviceId}.");
+
+ await Client.SetOutputDevice(type.Id, device.Id, ct);
+ }
+}
diff --git a/src/CommandLineTool/Program.cs b/src/CommandLineTool/Program.cs
index 628747f..7b502c0 100644
--- a/src/CommandLineTool/Program.cs
+++ b/src/CommandLineTool/Program.cs
@@ -26,6 +26,7 @@ namespace Beefweb.CommandLineTool;
[Subcommand(typeof(SortCommand))]
[Subcommand(typeof(ServersCommand))]
[Subcommand(typeof(PlaylistsCommand))]
+[Subcommand(typeof(OutputCommand))]
[Subcommand(typeof(QueueCommand))]
[Subcommand(typeof(StatusCommand))]
[Subcommand(typeof(NowPlayingCommand))]
diff --git a/src/CommandLineTool/Services/ValueFormatter.cs b/src/CommandLineTool/Services/ValueFormatter.cs
index 688eafc..ba3ac5e 100644
--- a/src/CommandLineTool/Services/ValueFormatter.cs
+++ b/src/CommandLineTool/Services/ValueFormatter.cs
@@ -66,6 +66,11 @@ public static string Format(this VolumeInfo volumeInfo)
};
}
+ public static string FormatDeviceName(this OutputDeviceInfo device)
+ {
+ return device.Name.Replace("\n", " ");
+ }
+
public static string FormatAsTrackTime(this TimeSpan time)
{
return time.Ticks switch