Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 32 additions & 20 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,26 +489,36 @@ client.disconnect(SendspinGoodbyeReason::SHUTDOWN);

## Sending Commands

If you added the controller role, use it to send playback commands:
If you added the controller role, use it to send playback commands. `send_command` takes a `ClientCommandControllerObject`, built with designated initializers - set only the field the command uses:

```cpp
controller.send_command(SendspinControllerCommand::PLAY);
controller.send_command(SendspinControllerCommand::PAUSE);
controller.send_command(SendspinControllerCommand::NEXT);
controller.send_command(SendspinControllerCommand::PREVIOUS);
controller.send_command(SendspinControllerCommand::STOP);
controller.send_command(SendspinControllerCommand::SHUFFLE);
controller.send_command(SendspinControllerCommand::UNSHUFFLE);
controller.send_command(SendspinControllerCommand::REPEAT_OFF);
controller.send_command(SendspinControllerCommand::REPEAT_ONE);
controller.send_command(SendspinControllerCommand::REPEAT_ALL);

// Volume and mute take additional arguments
controller.send_command(SendspinControllerCommand::VOLUME, 75); // Volume 0-100
controller.send_command(SendspinControllerCommand::MUTE, {}, true); // Mute on
controller.send_command(SendspinControllerCommand::MUTE, {}, false); // Mute off
controller.send_command({.command = SendspinControllerCommand::PLAY});
controller.send_command({.command = SendspinControllerCommand::PAUSE});
controller.send_command({.command = SendspinControllerCommand::NEXT});
controller.send_command({.command = SendspinControllerCommand::PREVIOUS});
controller.send_command({.command = SendspinControllerCommand::STOP});
controller.send_command({.command = SendspinControllerCommand::SHUFFLE});
controller.send_command({.command = SendspinControllerCommand::UNSHUFFLE});
controller.send_command({.command = SendspinControllerCommand::REPEAT_OFF});
controller.send_command({.command = SendspinControllerCommand::REPEAT_ONE});
controller.send_command({.command = SendspinControllerCommand::REPEAT_ALL});

// Commands that carry a parameter set the matching field:
controller.send_command({.command = SendspinControllerCommand::VOLUME, .volume = 75});
controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = true});
controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = false});

// Seek to an absolute position (0 to the controller state's seek_max_ms):
controller.send_command({.command = SendspinControllerCommand::SEEK, .position_ms = 30000});

// Seek by a signed offset from the current position (negative seeks backward):
controller.send_command({.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = -10000});
```

Fields that do not match the command are ignored when the message is serialized. The server clamps seeks to the seekable range and ignores any command not present in the controller state's `supported_commands`.

> **Deprecated:** the earlier positional overload `send_command(cmd, volume, mute)` still works but cannot carry seek parameters and will be removed in v0.8.0. Migrate to the struct form above.

## Accessing Roles

In addition to the references returned by `add_*()`, you can access roles at any time through the client's accessor methods. These return `nullptr` if the role was not added.
Expand All @@ -518,7 +528,7 @@ if (auto* p = client.player()) {
p->update_volume(75);
}
if (auto* c = client.controller()) {
c->send_command(SendspinControllerCommand::NEXT);
c->send_command({.command = SendspinControllerCommand::NEXT});
}
if (auto* m = client.metadata()) {
uint32_t progress = m->get_track_progress_ms();
Expand Down Expand Up @@ -574,7 +584,7 @@ int32_t fixed = player.get_fixed_delay_us();
auto& stream = player.get_current_stream_params();

// Controller state
auto& ctrl = controller.get_controller_state(); // volume, muted, repeat, shuffle, supported_commands
auto& ctrl = controller.get_controller_state(); // volume, muted, repeat, shuffle, supported_commands, seek_max_ms

// Metadata
uint32_t progress = metadata.get_track_progress_ms(); // Interpolated
Expand Down Expand Up @@ -820,14 +830,16 @@ Configuration passed to `client.add_visualizer()`.
| `STOP` | Stop playback |
| `NEXT` | Skip to next track |
| `PREVIOUS` | Skip to previous track |
| `VOLUME` | Set volume (pass value via volume parameter) |
| `MUTE` | Set mute state (pass value via mute parameter) |
| `VOLUME` | Set volume (pass value via the `volume` field) |
| `MUTE` | Set mute state (pass value via the `muted` field) |
| `REPEAT_OFF` | Disable repeat |
| `REPEAT_ONE` | Repeat current track |
| `REPEAT_ALL` | Repeat all tracks |
| `SHUFFLE` | Enable shuffle |
| `UNSHUFFLE` | Disable shuffle |
| `SWITCH` | Switch source |
| `SEEK` | Seek to an absolute position (pass value via the `position_ms` field) |
| `SEEK_RELATIVE` | Seek by a signed offset from the current position (pass value via the `offset_ms` field) |

### SendspinPlayerCommand

Expand Down
68 changes: 54 additions & 14 deletions examples/tui_client/tui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ namespace sendspin {

using namespace ftxui;

// How far a single Shift+Arrow relative-seek jumps, in milliseconds.
constexpr int32_t SEEK_STEP_MS = 10000;

// Shift+Arrow key events. FTXUI predefines Ctrl+Arrow but not Shift+Arrow, so match the raw
// xterm-style CSI sequences directly (modifier 2 = Shift): ESC [ 1 ; 2 D/C for Left/Right.
static const Event kSeekBackEvent = Event::Special("\x1B[1;2D"); // Shift+Left
static const Event kSeekForwardEvent = Event::Special("\x1B[1;2C"); // Shift+Right

// Immutable snapshot of TuiState for rendering without holding the mutex.
struct TuiSnapshot {
std::string title;
Expand Down Expand Up @@ -193,7 +201,9 @@ static Element render_now_playing(const TuiSnapshot& snap) {
shortcut_label(snap, "Space", "<space>"),
text(" play/pause ") | color(Color::White) | dim,
shortcut_label(snap, ">", "\u2192"),
text(" next") | color(Color::White) | dim,
text(" next ") | color(Color::White) | dim,
shortcut_label(snap, "Seek", "\u21e7\u2190/\u21e7\u2192"),
text(" seek") | color(Color::White) | dim,
}),
});
};
Expand Down Expand Up @@ -746,9 +756,9 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
set_highlight(state, "Space");
}
if (current == SendspinPlaybackState::PLAYING) {
client.controller()->send_command(SendspinControllerCommand::PAUSE);
client.controller()->send_command({.command = SendspinControllerCommand::PAUSE});
} else {
client.controller()->send_command(SendspinControllerCommand::PLAY);
client.controller()->send_command({.command = SendspinControllerCommand::PLAY});
}
return true;
}
Expand All @@ -759,7 +769,7 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
std::lock_guard<std::mutex> lock(state.mutex);
set_highlight(state, ">");
}
client.controller()->send_command(SendspinControllerCommand::NEXT);
client.controller()->send_command({.command = SendspinControllerCommand::NEXT});
return true;
}

Expand All @@ -769,7 +779,30 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
std::lock_guard<std::mutex> lock(state.mutex);
set_highlight(state, "<");
}
client.controller()->send_command(SendspinControllerCommand::PREVIOUS);
client.controller()->send_command({.command = SendspinControllerCommand::PREVIOUS});
return true;
}

// Seek backward (relative). Server clamps to the seekable range and ignores 'seek_relative' if
// it isn't in the controller's supported_commands.
if (event == kSeekBackEvent) {
{
std::lock_guard<std::mutex> lock(state.mutex);
set_highlight(state, "Seek");
}
client.controller()->send_command(
{.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = -SEEK_STEP_MS});
return true;
}

// Seek forward (relative)
if (event == kSeekForwardEvent) {
{
std::lock_guard<std::mutex> lock(state.mutex);
set_highlight(state, "Seek");
}
client.controller()->send_command(
{.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = SEEK_STEP_MS});
return true;
}

Expand Down Expand Up @@ -805,7 +838,8 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
}
auto& cs = client.controller()->get_controller_state();
uint8_t new_vol = static_cast<uint8_t>(std::min(100, cs.volume + 5));
client.controller()->send_command(SendspinControllerCommand::VOLUME, new_vol);
client.controller()->send_command(
{.command = SendspinControllerCommand::VOLUME, .volume = new_vol});
return true;
}

Expand All @@ -817,7 +851,8 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
}
auto& cs = client.controller()->get_controller_state();
uint8_t new_vol = static_cast<uint8_t>(std::max(0, cs.volume - 5));
client.controller()->send_command(SendspinControllerCommand::VOLUME, new_vol);
client.controller()->send_command(
{.command = SendspinControllerCommand::VOLUME, .volume = new_vol});
return true;
}

Expand All @@ -838,7 +873,8 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
set_highlight(state, "M");
}
auto& cs = client.controller()->get_controller_state();
client.controller()->send_command(SendspinControllerCommand::MUTE, std::nullopt, !cs.muted);
client.controller()->send_command(
{.command = SendspinControllerCommand::MUTE, .muted = !cs.muted});
return true;
}

Expand All @@ -852,13 +888,16 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
}
switch (current) {
case SendspinRepeatMode::OFF:
client.controller()->send_command(SendspinControllerCommand::REPEAT_ALL);
client.controller()->send_command(
{.command = SendspinControllerCommand::REPEAT_ALL});
break;
case SendspinRepeatMode::ALL:
client.controller()->send_command(SendspinControllerCommand::REPEAT_ONE);
client.controller()->send_command(
{.command = SendspinControllerCommand::REPEAT_ONE});
break;
case SendspinRepeatMode::ONE:
client.controller()->send_command(SendspinControllerCommand::REPEAT_OFF);
client.controller()->send_command(
{.command = SendspinControllerCommand::REPEAT_OFF});
break;
}
return true;
Expand All @@ -872,8 +911,9 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
current = state.shuffle;
set_highlight(state, "x");
}
client.controller()->send_command(current ? SendspinControllerCommand::UNSHUFFLE
: SendspinControllerCommand::SHUFFLE);
client.controller()->send_command({.command = current
? SendspinControllerCommand::UNSHUFFLE
: SendspinControllerCommand::SHUFFLE});
return true;
}

Expand All @@ -883,7 +923,7 @@ static bool handle_key(const Event& event, SendspinClient& client, TuiState& sta
std::lock_guard<std::mutex> lock(state.mutex);
set_highlight(state, "g");
}
client.controller()->send_command(SendspinControllerCommand::SWITCH);
client.controller()->send_command({.command = SendspinControllerCommand::SWITCH});
return true;
}

Expand Down
37 changes: 35 additions & 2 deletions include/sendspin/controller_role.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,30 @@ struct ServerStateControllerObject {
bool muted{};
SendspinRepeatMode repeat{SendspinRepeatMode::OFF};
bool shuffle{};
// Maximum absolute position (ms) a 'seek' may target. Present only when the server offers the
// 'seek' command and the seekable range is known; absent for live/unknown-duration streams.
std::optional<uint32_t> seek_max_ms{};
};

/// @brief A playback command sent from the client to the server via client/command messages.
///
/// Construct with designated initializers, setting only the field the command uses:
/// @code
/// controller.send_command({.command = SendspinControllerCommand::PLAY});
/// controller.send_command({.command = SendspinControllerCommand::VOLUME, .volume = 75});
/// controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = true});
/// controller.send_command({.command = SendspinControllerCommand::SEEK, .position_ms = 30000});
/// controller.send_command({.command = SendspinControllerCommand::SEEK_RELATIVE,
/// .offset_ms = -10000});
/// @endcode
/// Fields not relevant to the command are ignored when the message is serialized.
struct ClientCommandControllerObject {
SendspinControllerCommand command{};
std::optional<uint8_t> volume{}; // only for VOLUME (0-100)
std::optional<bool> muted{}; // only for MUTE
std::optional<uint32_t> position_ms{}; // only for SEEK (0 to ServerStateControllerObject::
// seek_max_ms)
std::optional<int32_t> offset_ms{}; // only for SEEK_RELATIVE (signed offset from current)
};

/// @brief Listener for controller role events. All methods fire on the main loop thread.
Expand Down Expand Up @@ -108,8 +132,8 @@ class ControllerRoleListener {
* MyControllerListener listener;
* auto& controller = client.add_controller();
* controller.set_listener(&listener);
* controller.send_command(SendspinControllerCommand::PLAY);
* controller.send_command(SendspinControllerCommand::VOLUME, 75); // volume range is 0-100
* controller.send_command({.command = SendspinControllerCommand::PLAY});
* controller.send_command({.command = SendspinControllerCommand::VOLUME, .volume = 75});
* @endcode
*/
class ControllerRole {
Expand All @@ -131,6 +155,15 @@ class ControllerRole {
void set_listener(ControllerRoleListener* listener);

/// @brief Sends a controller command to the server
/// @param cmd The command plus any command-specific parameters
void send_command(const ClientCommandControllerObject& cmd);

/// @brief Sends a controller command to the server
/// @deprecated Use send_command(const ClientCommandControllerObject&) instead. This overload
/// cannot carry seek parameters and will be removed in v0.8.0.
[[deprecated(
"use send_command(const ClientCommandControllerObject&); this overload cannot carry seek "
"parameters and will be removed in v0.8.0")]]
void send_command(SendspinControllerCommand cmd, std::optional<uint8_t> volume = {},
std::optional<bool> mute = {});

Expand Down
12 changes: 7 additions & 5 deletions src/controller_role.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ void ControllerRole::set_listener(ControllerRoleListener* listener) {
this->impl_->listener = listener;
}

void ControllerRole::send_command(const ClientCommandControllerObject& cmd) {
this->impl_->send_command(cmd);
}

void ControllerRole::send_command(SendspinControllerCommand cmd, std::optional<uint8_t> volume,
std::optional<bool> mute) {
this->impl_->send_command(cmd, volume, mute);
this->impl_->send_command({.command = cmd, .volume = volume, .muted = mute});
}

// ============================================================================
Expand All @@ -57,10 +61,8 @@ void ControllerRole::Impl::attach_inbox(Inbox& inbox) {
this->event_state->slot.bind(inbox, INBOX_TOPIC_CONTROLLER);
}

void ControllerRole::Impl::send_command(SendspinControllerCommand cmd,
std::optional<uint8_t> volume,
std::optional<bool> mute) const {
std::string command_message = format_client_command_message(cmd, volume, mute);
void ControllerRole::Impl::send_command(const ClientCommandControllerObject& cmd) const {
std::string command_message = format_client_command_message(cmd);
this->client->send_text(command_message);
}

Expand Down
3 changes: 1 addition & 2 deletions src/controller_role_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,7 @@ struct ControllerRole::Impl {
// Consumer-facing method implementations
// ========================================

void send_command(SendspinControllerCommand cmd, std::optional<uint8_t> volume,
std::optional<bool> mute) const;
void send_command(const ClientCommandControllerObject& cmd) const;

// ========================================
// Fields
Expand Down
26 changes: 19 additions & 7 deletions src/protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,12 @@ bool process_server_state_message(JsonObject root, ServerStateMessage* state_msg
controller_state.shuffle = *v;
}

// Parse seek_max_ms. Present only when the server offers 'seek' and the range is known;
// left absent (nullopt) otherwise so consumers can distinguish "unknown range" from 0.
if (auto v = read_uint_field<uint32_t>(controller_object["seek_max_ms"], "seek_max_ms")) {
controller_state.seek_max_ms = v;
}

state_msg->controller = std::move(controller_state);
}

Expand Down Expand Up @@ -1118,18 +1124,24 @@ size_t format_client_time_message(char* buf, size_t cap, int64_t client_transmit
return static_cast<size_t>(p - buf);
}

std::string format_client_command_message(SendspinControllerCommand command,
std::optional<uint8_t> volume, std::optional<bool> mute) {
std::string format_client_command_message(const ClientCommandControllerObject& cmd) {
JsonDocument doc = make_json_document();
JsonObject root = doc.to<JsonObject>();

root["type"] = "client/command";
root["payload"]["controller"]["command"] = to_cstr(command);
if (command == SendspinControllerCommand::VOLUME && volume.has_value()) {
root["payload"]["controller"]["volume"] = volume.value();
JsonObject controller = root["payload"]["controller"].to<JsonObject>();
controller["command"] = to_cstr(cmd.command);
if (cmd.command == SendspinControllerCommand::VOLUME && cmd.volume.has_value()) {
controller["volume"] = cmd.volume.value();
}
if (cmd.command == SendspinControllerCommand::MUTE && cmd.muted.has_value()) {
controller["mute"] = cmd.muted.value();
}
if (cmd.command == SendspinControllerCommand::SEEK && cmd.position_ms.has_value()) {
controller["position_ms"] = cmd.position_ms.value();
}
if (command == SendspinControllerCommand::MUTE && mute.has_value()) {
root["payload"]["controller"]["mute"] = mute.value();
if (cmd.command == SendspinControllerCommand::SEEK_RELATIVE && cmd.offset_ms.has_value()) {
controller["offset_ms"] = cmd.offset_ms.value();
}

std::string output;
Expand Down
Loading
Loading