UI
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
#include <vamp_zero/health.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/playback.hpp>
|
||||
#include <vamp_zero/ui.hpp>
|
||||
@@ -350,11 +350,9 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
}
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) {
|
||||
static_cast<void>(state.apply(command));
|
||||
}};
|
||||
state.set_observer([&ui, &config](vamp_zero::app_state app) {
|
||||
auto status = ui.publish(vamp_zero::make_ui_state(config, std::move(app)));
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) { static_cast<void>(state.apply(command)); }, [&state](vamp_zero::menu_action action) { static_cast<void>(state.apply(action)); }};
|
||||
state.set_observer([&ui](vamp_zero::app_state app) {
|
||||
auto status = ui.publish(std::move(app));
|
||||
if (!status) {
|
||||
modulus::log_error("failed to publish UI state: " + status.error().describe());
|
||||
}
|
||||
@@ -362,7 +360,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
|
||||
if (audio != nullptr) {
|
||||
audio->sync(state.snapshot());
|
||||
}
|
||||
auto published = ui.publish(vamp_zero::make_ui_state(config, state.snapshot()));
|
||||
auto published = ui.publish(state.snapshot());
|
||||
if (!published) {
|
||||
return std::unexpected(published.error());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <array>
|
||||
@@ -99,24 +99,6 @@ private:
|
||||
std::set<std::string> identifiers_{};
|
||||
};
|
||||
|
||||
auto portal_html() -> std::string {
|
||||
return R"html(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Product Vamp</title><style>@font-face{font-family:PetalumaScript;src:url('/assets/PetalumaScript.otf') format('opentype');font-display:swap}body,button,pre{font-family:PetalumaScript;font-size:22px}pre{white-space:pre-wrap;overflow-wrap:anywhere}button{font:inherit}</style></head><body><main><h1>Product Vamp</h1><pre id="status">Loading...</pre><p><button id="prev">Previous</button> <button id="select">Select</button> <button id="next">Next</button></p><script>
|
||||
async function refresh(){const r=await fetch('/api/status');const j=await r.json();document.getElementById('status').textContent=JSON.stringify(j.payload||j,null,2);}
|
||||
document.getElementById('prev').onclick=async()=>{await fetch('/api/remote/previous',{method:'POST'});refresh();};
|
||||
document.getElementById('select').onclick=async()=>{await fetch('/api/remote/select',{method:'POST'});refresh();};
|
||||
document.getElementById('next').onclick=async()=>{await fetch('/api/remote/next',{method:'POST'});refresh();};
|
||||
refresh();
|
||||
</script></main></body></html>)html";
|
||||
}
|
||||
|
||||
auto endpoint_port(std::string const &endpoint) -> std::string {
|
||||
auto separator = endpoint.rfind(':');
|
||||
if (separator == std::string::npos || separator + 1U >= endpoint.size()) {
|
||||
return "8080";
|
||||
}
|
||||
return endpoint.substr(separator + 1U);
|
||||
}
|
||||
|
||||
auto remote_response(vamp_zero::app_state const &state) -> ligo::service_response {
|
||||
return ligo::text_response("text/plain; charset=utf-8", vamp_zero::remote_display_text(state));
|
||||
}
|
||||
@@ -170,13 +152,30 @@ auto vamp_zero::control_state::apply(vamp_protocol::page_command command) -> vam
|
||||
return updated;
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::apply(vamp_zero::menu_action const &action) -> vamp_zero::app_state {
|
||||
auto observer = std::function<void(vamp_zero::app_state)>{};
|
||||
auto updated = vamp_zero::app_state{};
|
||||
{
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
auto revision = state_.input_revision;
|
||||
state_ = vamp_zero::apply_menu_action(std::move(state_), action);
|
||||
updated = snapshot_locked();
|
||||
if (revision != state_.input_revision) {
|
||||
observer = observer_;
|
||||
}
|
||||
}
|
||||
if (observer) {
|
||||
observer(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::heartbeat() -> vamp_zero::app_state {
|
||||
auto observer = std::function<void(vamp_zero::app_state)>{};
|
||||
auto updated = vamp_zero::app_state{};
|
||||
{
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
last_heartbeat_ = std::chrono::steady_clock::now();
|
||||
++state_.heartbeat_count;
|
||||
auto notify = !state_.pico_connected;
|
||||
state_.pico_connected = true;
|
||||
updated = state_;
|
||||
@@ -223,10 +222,7 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
|
||||
return;
|
||||
}
|
||||
if (error.empty()) {
|
||||
auto count = state_.select_count;
|
||||
state_ = vamp_zero::apply_app_command(std::move(state_), vamp_protocol::page_command::SELECT);
|
||||
state_.select_count = count;
|
||||
state_.last_command = "playback-completed";
|
||||
} else {
|
||||
state_.audio.playback_error = std::move(error);
|
||||
}
|
||||
@@ -238,30 +234,10 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
|
||||
}
|
||||
}
|
||||
|
||||
auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value {
|
||||
auto control = boost::json::object{};
|
||||
control["ap_ssid"] = config.control.ap_ssid;
|
||||
control["zero_ip"] = config.control.zero_ip;
|
||||
control["endpoint"] = "http://" + config.control.zero_ip + ":" + endpoint_port(config.control.endpoint.bind);
|
||||
auto root = boost::json::object{};
|
||||
root["device"] = "zero";
|
||||
root["git_sha"] = VAMP_GIT_SHA;
|
||||
root["control"] = std::move(control);
|
||||
root["state"] = vamp_zero::app_status_json(state);
|
||||
return root;
|
||||
}
|
||||
|
||||
auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_router {
|
||||
auto router = ligo::service_router{};
|
||||
auto assets = state.config().runtime.assets_path;
|
||||
auto artwork = std::make_shared<title_assets const>(assets, *state.snapshot().catalog);
|
||||
router.add_response_route("GET", "/assets/PetalumaScript.otf", [assets](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
auto font = read_asset(assets, "fonts/petaluma/PetalumaScript.otf", 1024U * 1024U);
|
||||
if (!font) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "PetalumaScript font unavailable"));
|
||||
}
|
||||
co_return ligo::text_response("font/otf", std::move(*font));
|
||||
});
|
||||
router.add_response_route("POST", vamp_protocol::remote_title_path, [artwork](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
auto const *id = payload.is_object() && payload.as_object().size() == 1U ? payload.as_object().if_contains("id") : nullptr;
|
||||
if (id == nullptr || !id->is_string() || !vamp_protocol::valid_title_id(id->as_string())) {
|
||||
@@ -276,12 +252,6 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
|
||||
router.add_route("GET", "/api/health", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
||||
co_return boost::json::object{{"product", "product-vamp"}, {"device", "zero"}, {"status", "ready"}};
|
||||
});
|
||||
router.add_response_route("GET", vamp_protocol::root_path, [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
co_return ligo::text_response("text/html; charset=utf-8", portal_html());
|
||||
});
|
||||
router.add_route("GET", vamp_protocol::status_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
||||
co_return vamp_zero::portal_status_json(state.config(), state.snapshot());
|
||||
});
|
||||
router.add_response_route("GET", vamp_protocol::remote_display_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
auto updated = state.heartbeat();
|
||||
co_return remote_response(updated);
|
||||
@@ -0,0 +1,131 @@
|
||||
#include <vamp_zero/menu.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
|
||||
|
||||
auto vamp_zero::menu_count(vamp_zero::app_state const &state) -> std::size_t {
|
||||
switch (state.mode) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
return 2U;
|
||||
case vamp_zero::app_mode::SETTINGS_MENU:
|
||||
return 4U;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
return state.catalog->groups.size() + 1U;
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
if (auto group = selected_group(state)) {
|
||||
return group->parts.size() + 1U;
|
||||
}
|
||||
return 1U;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SCORE:
|
||||
return 0U;
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
|
||||
auto vamp_zero::menu_selection(vamp_zero::app_state const &state) -> std::size_t {
|
||||
switch (state.mode) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
return state.selected_main_menu_index;
|
||||
case vamp_zero::app_mode::SETTINGS_MENU:
|
||||
return state.selected_settings_index;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
return state.selected_song_index;
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
return state.selected_part_index;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SCORE:
|
||||
return 0U;
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
|
||||
auto vamp_zero::menu_title(vamp_zero::app_state const &state) -> std::string {
|
||||
switch (state.mode) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
return "Main Menu";
|
||||
case vamp_zero::app_mode::SETTINGS_MENU:
|
||||
return "Settings";
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
return "Performance";
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
if (auto group = selected_group(state)) {
|
||||
return group->title;
|
||||
}
|
||||
return "Parts";
|
||||
case vamp_zero::app_mode::PERFORMANCE_SCORE:
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
auto vamp_zero::menu_row(vamp_zero::app_state const &state, std::size_t row) -> vamp_zero::menu_item {
|
||||
if (row >= menu_count(state)) {
|
||||
return {};
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::MAIN_MENU) {
|
||||
return {row == 0U ? "Performance" : "Settings", row == 0U ? std::to_string(state.catalog->groups.size()) + " songs" : ""};
|
||||
}
|
||||
if (row == 0U) {
|
||||
return {"Back", ""};
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU) {
|
||||
if (row == 1U) {
|
||||
return {"Playback", state.audio.playback_enabled ? "On" : "Off"};
|
||||
}
|
||||
if (row == 2U) {
|
||||
return {"Metronome", state.audio.metronome_enabled ? "On" : "Off"};
|
||||
}
|
||||
return {"Tempo", std::to_string(state.audio.metronome_bpm) + " BPM"};
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
|
||||
auto const &group = state.catalog->groups[row - 1U];
|
||||
return {group.title, group.composer};
|
||||
}
|
||||
if (auto group = selected_group(state)) {
|
||||
auto const &part = state.catalog->parts[group->parts[row - 1U]];
|
||||
return {part.part, std::to_string(part.total_pages) + " pages"};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
auto vamp_zero::apply_menu_action(vamp_zero::app_state state, vamp_zero::menu_action const &action) -> vamp_zero::app_state {
|
||||
if (state.input_revision != action.revision || state.catalog != action.catalog || menu_count(state) == 0U) {
|
||||
return state;
|
||||
}
|
||||
if (action.kind == vamp_zero::menu_action_kind::NEXT || action.kind == vamp_zero::menu_action_kind::PREVIOUS) {
|
||||
return apply_app_command(std::move(state), action.kind == vamp_zero::menu_action_kind::NEXT ? vamp_protocol::page_command::NEXT : vamp_protocol::page_command::PREVIOUS);
|
||||
}
|
||||
if (action.row >= menu_count(state)) {
|
||||
return state;
|
||||
}
|
||||
if (action.kind == vamp_zero::menu_action_kind::TEMPO_DOWN || action.kind == vamp_zero::menu_action_kind::TEMPO_UP) {
|
||||
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU || action.row != 3U) {
|
||||
return state;
|
||||
}
|
||||
state.selected_settings_index = 3U;
|
||||
state.audio.tempo_editing = true;
|
||||
state = apply_app_command(std::move(state), action.kind == vamp_zero::menu_action_kind::TEMPO_UP ? vamp_protocol::page_command::NEXT : vamp_protocol::page_command::PREVIOUS);
|
||||
state.audio.tempo_editing = false;
|
||||
return state;
|
||||
}
|
||||
switch (state.mode) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
state.selected_main_menu_index = action.row;
|
||||
break;
|
||||
case vamp_zero::app_mode::SETTINGS_MENU:
|
||||
state.selected_settings_index = action.row;
|
||||
state.audio.tempo_editing = false;
|
||||
break;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
state.selected_song_index = action.row;
|
||||
break;
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
state.selected_part_index = action.row;
|
||||
break;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SCORE:
|
||||
return state;
|
||||
}
|
||||
return apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#include <vamp_zero/menu_view.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include <vamp_zero/color.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto chevron(luma::draw_context &context, luma::rect bounds, std::bool_t back, vamp_style::color color) -> void {
|
||||
auto x = bounds.x + bounds.width / 2.0F;
|
||||
auto y = bounds.y + bounds.height / 2.0F;
|
||||
auto direction = back ? -1.0F : 1.0F;
|
||||
context.stroke_line({x - 4.0F * direction, y - 7.0F}, {x + 3.0F * direction, y}, 2.0F, vamp_zero::to_luma(color));
|
||||
context.stroke_line({x + 3.0F * direction, y}, {x - 4.0F * direction, y + 7.0F}, 2.0F, vamp_zero::to_luma(color));
|
||||
}
|
||||
|
||||
auto draw_switch(luma::draw_context &context, luma::rect row, std::bool_t on) -> void {
|
||||
auto track = luma::rect{row.x + row.width - 88.0F, row.y + 20.0F, 60.0F, 32.0F};
|
||||
auto color = vamp_zero::to_luma(on ? vamp_style::accent : vamp_style::muted);
|
||||
context.fill_rect({track.x + 16.0F, track.y, 28.0F, 32.0F}, color);
|
||||
context.fill_ellipse({track.x, track.y, 32.0F, 32.0F}, color);
|
||||
context.fill_ellipse({track.x + 28.0F, track.y, 32.0F, 32.0F}, color);
|
||||
context.fill_ellipse({track.x + (on ? 32.0F : 4.0F), track.y + 4.0F, 24.0F, 24.0F}, vamp_zero::to_luma(vamp_style::background));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::menu_layout(luma::rect bounds, vamp_zero::app_state const &state) -> std::vector<vamp_zero::menu_target> {
|
||||
auto targets = std::vector<vamp_zero::menu_target>{};
|
||||
auto count = menu_count(state);
|
||||
if (count == 0U) {
|
||||
return targets;
|
||||
}
|
||||
auto add = [&](luma::rect rect, vamp_zero::menu_action_kind kind, std::size_t row) {
|
||||
targets.push_back({rect, {kind, row, state.input_revision, state.catalog}});
|
||||
};
|
||||
auto main = state.mode == vamp_zero::app_mode::MAIN_MENU;
|
||||
if (!main) {
|
||||
add({bounds.x + 36.0F, bounds.y + 20.0F, 56.0F, 48.0F}, vamp_zero::menu_action_kind::ACTIVATE, 0U);
|
||||
}
|
||||
auto start = main ? 0U : 1U;
|
||||
auto capacity = std::max<std::size_t>(1U, static_cast<std::size_t>(std::max<std::float32_t>(72.0F, bounds.height - 172.0F) / 72.0F));
|
||||
auto visible = std::min(count - start, capacity);
|
||||
auto selected = menu_selection(state);
|
||||
auto first = std::max<std::size_t>(start, selected >= visible && visible != 0U ? selected - visible + 1U : start);
|
||||
for (auto i = std::size_t{}; i < visible; ++i) {
|
||||
auto row = first + i;
|
||||
auto rectangle = luma::rect{bounds.x + 36.0F, bounds.y + 112.0F + static_cast<std::float32_t>(i) * 72.0F, bounds.width - 72.0F, 72.0F};
|
||||
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && row == 3U) {
|
||||
add({rectangle.x + rectangle.width - 240.0F, rectangle.y + 12.0F, 48.0F, 48.0F}, vamp_zero::menu_action_kind::TEMPO_DOWN, row);
|
||||
add({rectangle.x + rectangle.width - 64.0F, rectangle.y + 12.0F, 48.0F, 48.0F}, vamp_zero::menu_action_kind::TEMPO_UP, row);
|
||||
}
|
||||
add(rectangle, vamp_zero::menu_action_kind::ACTIVATE, row);
|
||||
}
|
||||
if (count - start > visible) {
|
||||
if (selected > start) {
|
||||
add({bounds.x + bounds.width - 156.0F, bounds.y + bounds.height - 56.0F, 56.0F, 48.0F}, vamp_zero::menu_action_kind::PREVIOUS, 0U);
|
||||
}
|
||||
if (selected + 1U < count) {
|
||||
add({bounds.x + bounds.width - 92.0F, bounds.y + bounds.height - 56.0F, 56.0F, 48.0F}, vamp_zero::menu_action_kind::NEXT, 0U);
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
auto vamp_zero::draw_label(luma::draw_context &context, luma::rect bounds, std::string text, std::float32_t size, vamp_style::color color) -> void {
|
||||
if (text.empty() || bounds.width <= 0.0F || bounds.height <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
auto style = luma::text_style{.font = std::string{vamp_style::ui_font_path}, .size = size, .line_height = size + 5.0F};
|
||||
if (context.measure_text(text, style).width > bounds.width) {
|
||||
do {
|
||||
auto end = text.size() - 1U;
|
||||
while (end > 0U && (static_cast<std::uint8_t>(text[end]) & 0xc0U) == 0x80U) {
|
||||
--end;
|
||||
}
|
||||
text.resize(end);
|
||||
} while (!text.empty() && context.measure_text(text + "...", style).width > bounds.width);
|
||||
text += "...";
|
||||
}
|
||||
context.push_clip(bounds);
|
||||
context.draw_text(text, {bounds.x, bounds.y + (bounds.height - style.line_height) / 2.0F}, style, vamp_zero::to_luma(color));
|
||||
context.pop_clip();
|
||||
}
|
||||
|
||||
auto vamp_zero::draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
|
||||
context.fill_rect({bounds.x, bounds.y, bounds.width, 80.0F}, vamp_zero::to_luma(vamp_style::panel));
|
||||
context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
|
||||
draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink);
|
||||
if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) {
|
||||
auto summary = part->part + " " + std::to_string(state.page_index + 1U) + " / " + std::to_string(part->total_pages);
|
||||
draw_label(context, {bounds.x + 310.0F, bounds.y + 14.0F, bounds.width - 550.0F, 48.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
|
||||
}
|
||||
context.fill_ellipse({bounds.x + bounds.width - 184.0F, bounds.y + 33.0F, 10.0F, 10.0F}, vamp_zero::to_luma(state.pico_connected ? vamp_style::connected : vamp_style::muted));
|
||||
draw_label(context, {bounds.x + bounds.width - 162.0F, bounds.y + 12.0F, 132.0F, 52.0F}, state.pico_connected ? "Pico connected" : "Pico offline", 17.0F, vamp_style::muted);
|
||||
}
|
||||
|
||||
auto vamp_zero::draw_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
|
||||
auto main = state.mode == vamp_zero::app_mode::MAIN_MENU;
|
||||
auto title_x = bounds.x + (main ? 36.0F : 112.0F);
|
||||
draw_label(context, {title_x, bounds.y + 14.0F, bounds.x + bounds.width - 36.0F - title_x, 60.0F}, menu_title(state), 34.0F, vamp_style::ink);
|
||||
auto targets = menu_layout(bounds, state);
|
||||
// Paint full rows first; stepper buttons sit within the tempo row.
|
||||
for (auto const &target : targets) {
|
||||
auto const &action = target.action;
|
||||
if (action.kind != vamp_zero::menu_action_kind::ACTIVATE) {
|
||||
continue;
|
||||
}
|
||||
auto row = target.bounds;
|
||||
auto selected = menu_selection(state) == action.row;
|
||||
if (selected) {
|
||||
context.fill_rect(row, vamp_zero::to_luma(vamp_style::selection));
|
||||
}
|
||||
if (!main && action.row == 0U) {
|
||||
chevron(context, row, true, vamp_style::ink);
|
||||
continue;
|
||||
}
|
||||
if (selected) {
|
||||
context.fill_rect({row.x, row.y + 12.0F, 3.0F, row.height - 24.0F}, vamp_zero::to_luma(vamp_style::accent));
|
||||
}
|
||||
context.stroke_line({row.x, row.y + row.height}, {row.x + row.width, row.y + row.height}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
|
||||
auto item = menu_row(state, action.row);
|
||||
auto settings = state.mode == vamp_zero::app_mode::SETTINGS_MENU;
|
||||
auto songs = state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
draw_label(context, {row.x + 22.0F, row.y + (songs ? 4.0F : 12.0F), row.width - (songs ? 90.0F : 290.0F), songs ? 36.0F : 48.0F}, item.label, 26.0F, vamp_style::ink);
|
||||
if (songs) {
|
||||
draw_label(context, {row.x + 22.0F, row.y + 39.0F, row.width - 90.0F, 28.0F}, item.detail, 17.0F, vamp_style::muted);
|
||||
} else if (settings && action.row < 3U) {
|
||||
draw_switch(context, row, action.row == 1U ? state.audio.playback_enabled : state.audio.metronome_enabled);
|
||||
} else {
|
||||
draw_label(context, {row.x + row.width - (settings ? 181.0F : 250.0F), row.y + 12.0F, settings ? 112.0F : 180.0F, 48.0F}, item.detail, 20.0F, settings && state.audio.tempo_editing ? vamp_style::accent : vamp_style::muted);
|
||||
}
|
||||
if (!settings) {
|
||||
chevron(context, {row.x + row.width - 64.0F, row.y + 12.0F, 48.0F, 48.0F}, false, vamp_style::muted);
|
||||
}
|
||||
}
|
||||
for (auto const &target : targets) {
|
||||
auto kind = target.action.kind;
|
||||
auto rect = target.bounds;
|
||||
if (kind == vamp_zero::menu_action_kind::ACTIVATE) {
|
||||
continue;
|
||||
}
|
||||
if (kind == vamp_zero::menu_action_kind::NEXT || kind == vamp_zero::menu_action_kind::PREVIOUS) {
|
||||
context.stroke_rect(rect, 1.0F, vamp_zero::to_luma(vamp_style::rule));
|
||||
chevron(context, rect, kind == vamp_zero::menu_action_kind::PREVIOUS, vamp_style::ink);
|
||||
continue;
|
||||
}
|
||||
auto up = kind == vamp_zero::menu_action_kind::TEMPO_UP;
|
||||
auto enabled = up ? state.audio.metronome_bpm < state.audio.metronome_max_bpm : state.audio.metronome_bpm > state.audio.metronome_min_bpm;
|
||||
auto color = vamp_zero::to_luma(enabled ? vamp_style::accent : vamp_style::rule);
|
||||
context.stroke_rect(rect, 1.0F, color);
|
||||
context.stroke_line({rect.x + 16.0F, rect.y + 24.0F}, {rect.x + 32.0F, rect.y + 24.0F}, 2.0F, color);
|
||||
if (up) {
|
||||
context.stroke_line({rect.x + 24.0F, rect.y + 16.0F}, {rect.x + 24.0F, rect.y + 32.0F}, 2.0F, color);
|
||||
}
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU && state.catalog->groups.empty()) {
|
||||
draw_label(context, {bounds.x + 36.0F, bounds.y + 112.0F, bounds.width - 72.0F, 72.0F}, "No songs available", 26.0F, vamp_style::muted);
|
||||
}
|
||||
}
|
||||
|
||||
auto vamp_zero::menu_touch::handle(luma::touch_event const &event, luma::rect bounds, vamp_zero::app_state const &state) -> std::optional<vamp_zero::menu_action> {
|
||||
auto result = std::optional<vamp_zero::menu_action>{};
|
||||
for (auto const &point : event.points) {
|
||||
if (point.phase == luma::touch_phase::DOWN) {
|
||||
fingers_.insert(point.id);
|
||||
target_.reset();
|
||||
if (fingers_.size() == 1U) {
|
||||
auto targets = menu_layout(bounds, state);
|
||||
auto found = std::find_if(targets.begin(), targets.end(), [&](auto const &target) { return luma::contains(target.bounds, point.position); });
|
||||
if (found != targets.end()) {
|
||||
target_ = *found;
|
||||
origin_ = point.position;
|
||||
pressed_ = event.time;
|
||||
}
|
||||
}
|
||||
} else if (fingers_.contains(point.id)) {
|
||||
if (point.phase == luma::touch_phase::CANCEL || std::abs(point.position.x - origin_.x) > 14.0F || std::abs(point.position.y - origin_.y) > 14.0F) {
|
||||
target_.reset();
|
||||
}
|
||||
if (point.phase == luma::touch_phase::UP || point.phase == luma::touch_phase::CANCEL) {
|
||||
if (target_ && fingers_.size() == 1U && luma::contains(target_->bounds, point.position) && event.time - pressed_ < std::chrono::milliseconds{750} && target_->action.revision == state.input_revision && target_->action.catalog == state.catalog) {
|
||||
result = target_->action;
|
||||
}
|
||||
fingers_.erase(point.id);
|
||||
target_.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+53
-131
@@ -9,21 +9,18 @@
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/json/array.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/value.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/menu.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto main_menu_performance = std::size_t{0};
|
||||
constexpr auto main_menu_playback = std::size_t{1};
|
||||
constexpr auto main_menu_metronome = std::size_t{2};
|
||||
constexpr auto main_menu_tempo = std::size_t{3};
|
||||
constexpr auto main_menu_count = std::size_t{4};
|
||||
constexpr auto settings_playback = std::size_t{1};
|
||||
constexpr auto settings_metronome = std::size_t{2};
|
||||
constexpr auto settings_tempo = std::size_t{3};
|
||||
constexpr auto main_menu_count = std::size_t{2};
|
||||
|
||||
auto wrap_next(std::size_t value, std::size_t count) -> std::size_t {
|
||||
if (count == 0U) {
|
||||
@@ -69,6 +66,11 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
||||
state.catalog = vamp_zero::empty_catalog();
|
||||
}
|
||||
state.audio = clamp_audio_state(state.audio);
|
||||
state.selected_settings_index = std::min<std::size_t>(state.selected_settings_index, 3U);
|
||||
state.remembered_settings_index = std::clamp<std::size_t>(state.remembered_settings_index, 1U, 3U);
|
||||
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && state.selected_settings_index != 0U) {
|
||||
state.remembered_settings_index = state.selected_settings_index;
|
||||
}
|
||||
state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U);
|
||||
state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.catalog->groups.size()) - 1U);
|
||||
if (auto const *group = selected_group(state); group != nullptr) {
|
||||
@@ -85,7 +87,7 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
}
|
||||
if (state.mode != vamp_zero::app_mode::MAIN_MENU) {
|
||||
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
|
||||
state.audio.tempo_editing = false;
|
||||
}
|
||||
if (auto pages = active_pages(state); pages != 0U) {
|
||||
@@ -96,62 +98,6 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
||||
return state;
|
||||
}
|
||||
|
||||
auto command_json(vamp_zero::app_state const &state) -> boost::json::object {
|
||||
auto commands = boost::json::object{};
|
||||
commands["last"] = state.last_command;
|
||||
commands["next_count"] = state.next_count;
|
||||
commands["previous_count"] = state.previous_count;
|
||||
commands["select_count"] = state.select_count;
|
||||
commands["heartbeat_count"] = state.heartbeat_count;
|
||||
return commands;
|
||||
}
|
||||
|
||||
auto audio_json(vamp_zero::app_state const &state) -> boost::json::object {
|
||||
auto audio = boost::json::object{};
|
||||
audio["playback_enabled"] = state.audio.playback_enabled;
|
||||
audio["playback_error"] = state.audio.playback_error;
|
||||
audio["metronome_enabled"] = state.audio.metronome_enabled;
|
||||
audio["metronome_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_bpm);
|
||||
audio["metronome_min_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_min_bpm);
|
||||
audio["metronome_max_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_max_bpm);
|
||||
audio["metronome_step"] = static_cast<std::uint64_t>(state.audio.metronome_step);
|
||||
audio["tempo_editing"] = state.audio.tempo_editing;
|
||||
return audio;
|
||||
}
|
||||
|
||||
auto part_json(vamp_zero::library_part const &part) -> boost::json::object {
|
||||
auto result = boost::json::object{};
|
||||
result["id"] = part.id;
|
||||
result["title"] = part.title;
|
||||
result["composer"] = part.composer;
|
||||
result["key_signature"] = part.key_signature;
|
||||
result["part"] = part.part;
|
||||
result["source_path"] = part.source_path;
|
||||
result["measures"] = static_cast<std::uint64_t>(part.measure_count);
|
||||
result["pages"] = static_cast<std::uint64_t>(part.total_pages);
|
||||
return result;
|
||||
}
|
||||
|
||||
auto performance_json(vamp_zero::app_state const &state) -> boost::json::object {
|
||||
auto groups = boost::json::array{};
|
||||
for (auto const &group : state.catalog->groups) {
|
||||
auto parts = boost::json::array{};
|
||||
for (auto const &part : group.parts) {
|
||||
parts.push_back(part_json(state.catalog->parts[part]));
|
||||
}
|
||||
auto item = boost::json::object{};
|
||||
item["title"] = group.title;
|
||||
item["composer"] = group.composer;
|
||||
item["parts"] = std::move(parts);
|
||||
groups.push_back(std::move(item));
|
||||
}
|
||||
auto library = boost::json::object{};
|
||||
library["song_count"] = static_cast<std::uint64_t>(state.catalog->groups.size());
|
||||
library["part_count"] = static_cast<std::uint64_t>(state.catalog->parts.size());
|
||||
library["groups"] = std::move(groups);
|
||||
return library;
|
||||
}
|
||||
|
||||
auto sanitize_line_value(std::string value) -> std::string {
|
||||
for (auto &ch : value) {
|
||||
if (ch == '\r' || ch == '\n') {
|
||||
@@ -165,16 +111,23 @@ auto sanitize_line_value(std::string value) -> std::string {
|
||||
|
||||
auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::page_command command) -> vamp_zero::app_state {
|
||||
state = clamp_state(std::move(state));
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
++state.next_count;
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
++state.previous_count;
|
||||
} else {
|
||||
++state.select_count;
|
||||
}
|
||||
state.last_command = vamp_protocol::page_command_name(command);
|
||||
++state.input_revision;
|
||||
switch (state.mode) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
if (command == vamp_protocol::page_command::NEXT || command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_main_menu_index = 1U - state.selected_main_menu_index;
|
||||
} else if (state.selected_main_menu_index == main_menu_performance) {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
state.selected_song_index = 0U;
|
||||
state.selected_part_index = 0U;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0U;
|
||||
} else {
|
||||
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
state.selected_settings_index = state.remembered_settings_index;
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
case vamp_zero::app_mode::SETTINGS_MENU:
|
||||
if (state.audio.tempo_editing) {
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
increase_bpm(state.audio);
|
||||
@@ -184,20 +137,16 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
state.audio.tempo_editing = false;
|
||||
}
|
||||
} else if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_main_menu_index = wrap_next(state.selected_main_menu_index, main_menu_count);
|
||||
state.selected_settings_index = wrap_next(state.selected_settings_index, 4U);
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_main_menu_index = wrap_previous(state.selected_main_menu_index, main_menu_count);
|
||||
} else if (state.selected_main_menu_index == main_menu_performance) {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
state.selected_song_index = 0;
|
||||
state.selected_part_index = 0;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
} else if (state.selected_main_menu_index == main_menu_playback) {
|
||||
state.selected_settings_index = wrap_previous(state.selected_settings_index, 4U);
|
||||
} else if (state.selected_settings_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::MAIN_MENU;
|
||||
} else if (state.selected_settings_index == settings_playback) {
|
||||
state.audio.playback_enabled = !state.audio.playback_enabled;
|
||||
} else if (state.selected_main_menu_index == main_menu_metronome) {
|
||||
} else if (state.selected_settings_index == settings_metronome) {
|
||||
state.audio.metronome_enabled = !state.audio.metronome_enabled;
|
||||
} else if (state.selected_main_menu_index == main_menu_tempo) {
|
||||
} else if (state.selected_settings_index == settings_tempo) {
|
||||
state.audio.tempo_editing = true;
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
@@ -252,51 +201,6 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
return clamp_state(std::move(state));
|
||||
}
|
||||
|
||||
auto vamp_zero::app_status_json(vamp_zero::app_state const &state) -> boost::json::value {
|
||||
auto selection = boost::json::object{};
|
||||
selection["main_menu_index"] = static_cast<std::uint64_t>(state.selected_main_menu_index);
|
||||
selection["song_row_index"] = static_cast<std::uint64_t>(state.selected_song_index);
|
||||
selection["part_row_index"] = static_cast<std::uint64_t>(state.selected_part_index);
|
||||
if (auto const *group = selected_group(state); group != nullptr) {
|
||||
selection["title"] = group->title;
|
||||
selection["composer"] = group->composer;
|
||||
}
|
||||
if (auto const *part = selected_part(state); part != nullptr) {
|
||||
selection["part"] = part_json(*part);
|
||||
}
|
||||
auto active = boost::json::object{};
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
if (auto const *part = active_part(state); part != nullptr) {
|
||||
active["selected"] = true;
|
||||
active["kind"] = "performance";
|
||||
active["part"] = part_json(*part);
|
||||
active["page_index"] = static_cast<std::uint64_t>(state.page_index);
|
||||
active["page_number"] = static_cast<std::uint64_t>(state.page_index + 1U);
|
||||
active["page_total"] = static_cast<std::uint64_t>(part->total_pages);
|
||||
}
|
||||
}
|
||||
if (active.empty()) {
|
||||
active["selected"] = false;
|
||||
active["page_index"] = 0;
|
||||
active["page_number"] = 0;
|
||||
active["page_total"] = 0;
|
||||
}
|
||||
auto remote = boost::json::object{};
|
||||
remote["pico_connected"] = state.pico_connected;
|
||||
remote["heartbeat_count"] = state.heartbeat_count;
|
||||
auto performance = performance_json(state);
|
||||
auto root = boost::json::object{};
|
||||
root["mode"] = vamp_zero::to_string(state.mode);
|
||||
root["library"] = performance;
|
||||
root["performance"] = std::move(performance);
|
||||
root["selection"] = std::move(selection);
|
||||
root["active"] = std::move(active);
|
||||
root["audio"] = audio_json(state);
|
||||
root["commands"] = command_json(state);
|
||||
root["remote"] = std::move(remote);
|
||||
return root;
|
||||
}
|
||||
|
||||
auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::string {
|
||||
auto title = std::string{};
|
||||
auto title_image = std::string{};
|
||||
@@ -314,8 +218,23 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
|
||||
pages = selected->total_pages;
|
||||
}
|
||||
}
|
||||
auto focus = menu_row(state, menu_selection(state)).label;
|
||||
if (!active && (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU || state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU)) {
|
||||
if (auto group = selected_group(state); group != nullptr && !group->parts.empty()) {
|
||||
title = group->title;
|
||||
title_image = state.catalog->parts[group->parts.front()].title_image;
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
|
||||
focus = "Performance";
|
||||
}
|
||||
}
|
||||
}
|
||||
auto output = std::ostringstream{};
|
||||
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
|
||||
output << "focus=" << sanitize_line_value(focus) << "\n";
|
||||
output << "playback=" << (state.audio.playback_enabled ? "1" : "0") << "\n";
|
||||
output << "metronome=" << (state.audio.metronome_enabled ? "1" : "0") << "\n";
|
||||
output << "bpm=" << state.audio.metronome_bpm << "\n";
|
||||
output << "editing=" << (state.audio.tempo_editing ? "1" : "0") << "\n";
|
||||
output << "active=" << (active ? "1" : "0") << "\n";
|
||||
output << "title=" << sanitize_line_value(title) << "\n";
|
||||
output << "title_image=" << title_image << "\n";
|
||||
@@ -332,6 +251,9 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
|
||||
if (state.selected_main_menu_index >= main_menu_count) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range"));
|
||||
}
|
||||
if (state.selected_settings_index >= 4U) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected settings index is out of range"));
|
||||
}
|
||||
if (state.audio.metronome_min_bpm == 0U || state.audio.metronome_min_bpm > 400U) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome minimum BPM is out of range"));
|
||||
}
|
||||
@@ -345,8 +267,8 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
|
||||
state.audio.metronome_bpm > state.audio.metronome_max_bpm) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM is out of range"));
|
||||
}
|
||||
if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::MAIN_MENU) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires the main menu"));
|
||||
if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires settings"));
|
||||
}
|
||||
if (state.selected_song_index >= menu_row_count(state.catalog->groups.size())) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range"));
|
||||
|
||||
@@ -26,14 +26,14 @@ struct vamp_zero::touch_input::state : std::enable_shared_from_this<state> {
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
boost::asio::post(timer.get_executor(), [weak = weak_from_this(), command = *command] {
|
||||
boost::asio::post(timer.get_executor(), [weak = weak_from_this(), command = *command, generation = input_generation] {
|
||||
auto self = weak.lock();
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
auto lock = std::scoped_lock{self->mutex};
|
||||
if (self->stopped) {
|
||||
if (self->stopped || generation != self->input_generation) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ struct vamp_zero::touch_input::state : std::enable_shared_from_this<state> {
|
||||
vamp_protocol::gesture_state gesture{};
|
||||
std::optional<std::int32_t> active_touch{};
|
||||
std::uint64_t timer_generation{};
|
||||
std::uint64_t input_generation{};
|
||||
std::bool_t stopped{};
|
||||
};
|
||||
|
||||
@@ -108,6 +109,15 @@ auto vamp_zero::touch_input::handle(luma::touch_event const &event) -> std::bool
|
||||
auto vamp_zero::touch_input::stop() -> void {
|
||||
auto lock = std::scoped_lock{state_->mutex};
|
||||
state_->stopped = true;
|
||||
++state_->input_generation;
|
||||
state_->active_touch.reset();
|
||||
state_->gesture.reset();
|
||||
state_->schedule();
|
||||
}
|
||||
|
||||
auto vamp_zero::touch_input::reset() -> void {
|
||||
auto lock = std::scoped_lock{state_->mutex};
|
||||
++state_->input_generation;
|
||||
state_->active_touch.reset();
|
||||
state_->gesture.reset();
|
||||
state_->schedule();
|
||||
|
||||
@@ -10,6 +10,8 @@ auto vamp_zero::to_string(vamp_zero::app_mode value) -> std::string {
|
||||
switch (value) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
return "main_menu";
|
||||
case vamp_zero::app_mode::SETTINGS_MENU:
|
||||
return "settings_menu";
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
return "performance_song_menu";
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
|
||||
+52
-206
@@ -6,11 +6,13 @@
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
|
||||
#include <luma.hpp>
|
||||
|
||||
@@ -19,18 +21,20 @@
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/parse.hpp>
|
||||
#include <vamp_zero/menu_view.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/score_loader.hpp>
|
||||
#include <vamp_zero/touch_input.hpp>
|
||||
|
||||
|
||||
struct vamp_zero::ui_runtime {
|
||||
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler)
|
||||
: touch_{std::move(executor), std::move(handler)} {}
|
||||
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler, std::function<void(vamp_zero::menu_action)> menu_handler)
|
||||
: executor_{executor}, touch_{executor, std::move(handler)}, menu_handler_{std::move(menu_handler)} {}
|
||||
boost::asio::any_io_executor executor_;
|
||||
vamp_zero::touch_input touch_;
|
||||
std::function<void(vamp_zero::menu_action)> menu_handler_;
|
||||
std::mutex mutex_{};
|
||||
vamp_zero::ui_state state_{};
|
||||
vamp_zero::app_state state_{};
|
||||
};
|
||||
|
||||
namespace {
|
||||
@@ -78,177 +82,18 @@ struct score_draw_cache {
|
||||
std::weak_ptr<vamp_zero::score_document const> document{};
|
||||
};
|
||||
|
||||
auto bind_port(vamp_zero::config const &config) -> std::uint16_t {
|
||||
auto const &bind = config.control.endpoint.bind;
|
||||
auto colon = bind.rfind(':');
|
||||
if (colon == std::string::npos) {
|
||||
return 8080;
|
||||
}
|
||||
return vamp_zero::parse_integer<std::uint16_t>(std::string_view{bind}.substr(colon + 1U), "port").value_or(8080);
|
||||
}
|
||||
|
||||
auto status_color(vamp_zero::app_state const &state) -> vamp_style::color {
|
||||
return state.pico_connected ? vamp_style::connected : vamp_style::retrying;
|
||||
}
|
||||
|
||||
auto text_style(std::int32_t scale) -> luma::text_style {
|
||||
auto size = 13.0F;
|
||||
if (scale == 2) {
|
||||
size = 20.0F;
|
||||
} else if (scale == 3) {
|
||||
size = 26.0F;
|
||||
} else if (scale >= 4) {
|
||||
size = 34.0F;
|
||||
}
|
||||
return {
|
||||
.font = std::string{vamp_style::ui_font_path},
|
||||
.size = size,
|
||||
.line_height = size + 5.0F,
|
||||
};
|
||||
}
|
||||
|
||||
auto draw_text(luma::draw_context &context, std::float32_t x, std::float32_t y, std::string const &text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void {
|
||||
if (text.empty() || max_width <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
auto style = text_style(scale);
|
||||
context.push_clip({.x = x, .y = y, .width = max_width, .height = style.line_height});
|
||||
context.draw_text(text, {.x = x, .y = y}, style, vamp_zero::to_luma(color));
|
||||
context.pop_clip();
|
||||
}
|
||||
|
||||
auto draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::ui_state const &state) -> void {
|
||||
context.fill_rect(
|
||||
{.x = bounds.x, .y = bounds.y, .width = bounds.width, .height = 80.0F}, vamp_zero::to_luma(vamp_style::panel));
|
||||
context.fill_rect({.x = bounds.x, .y = bounds.y + 78.0F, .width = bounds.width, .height = 2.0F},
|
||||
vamp_zero::to_luma(vamp_style::accent));
|
||||
draw_text(context, bounds.x + 28.0F, bounds.y + 18.0F, "PRODUCT VAMP", 4, vamp_style::ink, 360.0F);
|
||||
draw_text(context, bounds.x + 30.0F, bounds.y + 55.0F, "AP " + state.ap_ssid + " " + state.zero_ip + ":" + std::to_string(state.port), 1, vamp_style::muted, 520.0F);
|
||||
auto indicator = status_color(state.app);
|
||||
auto x = bounds.x + bounds.width - 148.0F;
|
||||
context.fill_rect({.x = x, .y = bounds.y + 22.0F, .width = 24.0F, .height = 24.0F}, vamp_zero::to_luma(indicator));
|
||||
draw_text(context, x + 36.0F, bounds.y + 26.0F, "PICO", 2, vamp_style::ink, 88.0F);
|
||||
}
|
||||
|
||||
auto draw_row(luma::draw_context &context, luma::rect row, std::string const &primary, std::string const &secondary, std::bool_t selected) -> void {
|
||||
if (selected) {
|
||||
context.fill_rect(row, vamp_zero::to_luma(vamp_style::selection));
|
||||
}
|
||||
context.stroke_line({.x = row.x, .y = row.y + row.height},
|
||||
{.x = row.x + row.width, .y = row.y + row.height},
|
||||
1.0F,
|
||||
vamp_zero::to_luma(vamp_style::rule));
|
||||
draw_text(context, row.x + 16.0F, row.y + 10.0F, primary, 2, vamp_style::ink, row.width * 0.62F);
|
||||
if (!secondary.empty()) {
|
||||
draw_text(context, row.x + row.width - 250.0F, row.y + 17.0F, secondary, 1, selected ? vamp_style::ink : vamp_style::muted, 230.0F);
|
||||
}
|
||||
}
|
||||
|
||||
auto first_visible_row(std::size_t selected, std::size_t total, std::size_t max_rows) -> std::size_t {
|
||||
if (total <= max_rows || selected < max_rows) {
|
||||
return 0U;
|
||||
}
|
||||
return selected - max_rows + 1U;
|
||||
}
|
||||
|
||||
auto draw_menu_rows(luma::draw_context &context, luma::rect bounds, std::size_t count, std::size_t selected, auto const &draw_item) -> void {
|
||||
auto total = count + 1U;
|
||||
auto visible = std::min<std::size_t>(total, 7U);
|
||||
auto first = first_visible_row(selected, total, visible);
|
||||
for (auto row = std::size_t{}; row < visible; ++row) {
|
||||
auto index = first + row;
|
||||
auto rectangle = luma::rect{.x = bounds.x + 36.0F,
|
||||
.y = bounds.y + 112.0F + static_cast<std::float32_t>(row) * 54.0F,
|
||||
.width = bounds.width - 72.0F,
|
||||
.height = 54.0F};
|
||||
if (index == 0U) {
|
||||
draw_row(context, rectangle, "BACK", "", selected == 0U);
|
||||
} else {
|
||||
draw_item(index - 1U, rectangle, index == selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "MENU", 4, vamp_style::ink, 260.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "PRODUCT VAMP", 1, vamp_style::muted, 320.0F);
|
||||
auto list_y = bounds.y + 112.0F;
|
||||
auto row_height = std::float32_t{54.0F};
|
||||
auto row_width = bounds.width - 72.0F;
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height},
|
||||
"Songs",
|
||||
std::to_string(state.catalog->groups.size()) + " SONGS",
|
||||
state.selected_main_menu_index == 0U);
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height},
|
||||
"Playback",
|
||||
state.audio.playback_enabled ? "ON" : "OFF",
|
||||
state.selected_main_menu_index == 1U);
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height},
|
||||
"Metronome",
|
||||
state.audio.metronome_enabled ? "ON" : "OFF",
|
||||
state.selected_main_menu_index == 2U);
|
||||
auto tempo = std::to_string(state.audio.metronome_bpm) + " BPM";
|
||||
if (state.audio.tempo_editing) {
|
||||
tempo = "EDIT " + tempo;
|
||||
}
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height},
|
||||
"Tempo",
|
||||
tempo,
|
||||
state.selected_main_menu_index == 3U);
|
||||
}
|
||||
|
||||
auto draw_performance_song_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "PERFORMANCE", 4, vamp_style::ink, 360.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.catalog->groups.size()) + " SONGS " + std::to_string(state.catalog->parts.size()) + " PARTS", 1, vamp_style::muted, 320.0F);
|
||||
draw_menu_rows(context, bounds, state.catalog->groups.size(), state.selected_song_index, [&](std::size_t index, luma::rect row, std::bool_t selected) {
|
||||
auto const &group = state.catalog->groups[index];
|
||||
draw_row(context, row, group.title, std::to_string(group.parts.size()) + " PARTS", selected);
|
||||
});
|
||||
if (state.catalog->groups.empty()) {
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 194.0F, "NO SONGS FOUND", 3, vamp_style::retrying, 420.0F);
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_performance_part_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
|
||||
auto const *group = selected_group(state);
|
||||
draw_text(context,
|
||||
bounds.x + 36.0F,
|
||||
bounds.y + 28.0F,
|
||||
group == nullptr ? std::string{"PARTS"} : group->title,
|
||||
4,
|
||||
vamp_style::ink,
|
||||
bounds.width - 72.0F);
|
||||
if (group == nullptr) {
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO PARTS FOUND", 3, vamp_style::retrying, 420.0F);
|
||||
return;
|
||||
}
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, group->composer, 1, vamp_style::muted, bounds.width - 72.0F);
|
||||
draw_menu_rows(context, bounds, group->parts.size(), state.selected_part_index, [&](std::size_t index, luma::rect row, std::bool_t selected) {
|
||||
auto part_index = group->parts[index];
|
||||
if (part_index >= state.catalog->parts.size()) {
|
||||
return;
|
||||
}
|
||||
auto const &part = state.catalog->parts[part_index];
|
||||
draw_row(context, row, part.part, std::to_string(part.total_pages) + " PAGES", selected);
|
||||
});
|
||||
}
|
||||
|
||||
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache) -> void {
|
||||
if (active_part(state) == nullptr) {
|
||||
draw_performance_song_menu(context, bounds, state);
|
||||
vamp_zero::draw_menu(context, bounds, state);
|
||||
return;
|
||||
}
|
||||
if (loaded.loading) {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "LOADING SCORE", 4, vamp_style::ink, bounds.width - 72.0F);
|
||||
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "LOADING SCORE", 34.0F, vamp_style::ink);
|
||||
return;
|
||||
}
|
||||
if (loaded.error || !loaded.document) {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, loaded.error ? loaded.error->describe() : "Score unavailable", 1, vamp_style::muted, bounds.width - 72.0F);
|
||||
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, 360.0F, 48.0F}, "SCORE ERROR", 34.0F, vamp_style::danger);
|
||||
vamp_zero::draw_label(context, {bounds.x + 38.0F, bounds.y + 86.0F, bounds.width - 72.0F, 32.0F}, loaded.error ? loaded.error->describe() : "Score unavailable", 17.0F, vamp_style::muted);
|
||||
return;
|
||||
}
|
||||
auto paper_bounds = luma::rect{
|
||||
@@ -257,14 +102,9 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
|
||||
.width = bounds.width - 48.0F,
|
||||
.height = bounds.height - 36.0F,
|
||||
};
|
||||
if (!state.audio.playback_error.empty()) {
|
||||
draw_text(context, bounds.x + 24.0F, bounds.y + 12.0F, state.audio.playback_error, 1, vamp_style::danger, bounds.width - 48.0F);
|
||||
paper_bounds.y += 48.0F;
|
||||
paper_bounds.height -= 48.0F;
|
||||
}
|
||||
if (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE PAGE ERROR", 3, vamp_style::danger, bounds.width - 72.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, status.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
|
||||
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger);
|
||||
vamp_zero::draw_label(context, {bounds.x + 38.0F, bounds.y + 86.0F, bounds.width - 72.0F, 32.0F}, status.error().describe(), 17.0F, vamp_style::muted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +116,7 @@ public:
|
||||
return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits);
|
||||
}
|
||||
auto draw(luma::draw_context &context, luma::theme const &) const -> void override {
|
||||
auto state = vamp_zero::ui_state{};
|
||||
auto state = vamp_zero::app_state{};
|
||||
auto loaded = vamp_zero::score_load_state{};
|
||||
{
|
||||
auto lock = std::scoped_lock{runtime_->mutex_};
|
||||
@@ -286,39 +126,53 @@ public:
|
||||
score_cache_.select(loaded.document);
|
||||
auto area = bounds();
|
||||
context.fill_rect(area, vamp_zero::to_luma(vamp_style::background));
|
||||
draw_header(context, area, state);
|
||||
vamp_zero::draw_header(context, area, state);
|
||||
auto content = luma::rect{
|
||||
.x = area.x,
|
||||
.y = area.y + 80.0F,
|
||||
.width = area.width,
|
||||
.height = std::max<std::float32_t>(0.0F, area.height - 80.0F),
|
||||
};
|
||||
if (state.app.mode == vamp_zero::app_mode::MAIN_MENU) {
|
||||
draw_main_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
|
||||
draw_performance_song_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU) {
|
||||
draw_performance_part_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
draw_score(context, content, state.app, loaded, score_cache_);
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
draw_score(context, content, state, loaded, score_cache_);
|
||||
} else {
|
||||
draw_main_menu(context, content, state.app);
|
||||
vamp_zero::draw_menu(context, content, state);
|
||||
}
|
||||
displayed_ = std::move(state);
|
||||
}
|
||||
auto handle_touch(luma::touch_event const &event) -> std::bool_t override {
|
||||
return runtime_->touch_.handle(event);
|
||||
if (!displayed_) {
|
||||
return false;
|
||||
}
|
||||
auto area = bounds();
|
||||
auto action = menu_touch_.handle(event, {area.x, area.y + 80.0F, area.width, std::max<std::float32_t>(0.0F, area.height - 80.0F)}, *displayed_);
|
||||
if (action && runtime_->menu_handler_) {
|
||||
boost::asio::post(runtime_->executor_, [weak = std::weak_ptr{runtime_}, action = *action] {
|
||||
if (auto runtime = weak.lock()) {
|
||||
runtime->menu_handler_(action);
|
||||
}
|
||||
});
|
||||
}
|
||||
auto lock = std::scoped_lock{runtime_->mutex_};
|
||||
auto const ¤t = runtime_->state_;
|
||||
if (displayed_->mode == vamp_zero::app_mode::PERFORMANCE_SCORE && current.mode == displayed_->mode && current.score_session == displayed_->score_session && current.catalog == displayed_->catalog) {
|
||||
return runtime_->touch_.handle(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private:
|
||||
std::shared_ptr<vamp_zero::ui_runtime> runtime_;
|
||||
std::shared_ptr<vamp_zero::score_loader> loader_;
|
||||
mutable score_draw_cache score_cache_;
|
||||
mutable std::optional<vamp_zero::app_state> displayed_{};
|
||||
vamp_zero::menu_touch menu_touch_{};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler)
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler, std::function<void(vamp_zero::menu_action)> menu_handler)
|
||||
: application_{std::make_shared<luma::application>(std::move(config))},
|
||||
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler))},
|
||||
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler), std::move(menu_handler))},
|
||||
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
|
||||
if (auto application = weak.lock()) {
|
||||
static_cast<void>(application->request_layout());
|
||||
@@ -343,32 +197,24 @@ auto vamp_zero::zero_ui::stop() -> void {
|
||||
application_->request_stop();
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error> {
|
||||
auto vamp_zero::zero_ui::publish(vamp_zero::app_state state) -> std::expected<void, modulus::error> {
|
||||
{
|
||||
auto lock = std::scoped_lock{runtime_->mutex_};
|
||||
if (!state.app.catalog) {
|
||||
state.app.catalog = vamp_zero::empty_catalog();
|
||||
if (state.catalog == runtime_->state_.catalog && state.input_revision < runtime_->state_.input_revision) {
|
||||
return {};
|
||||
}
|
||||
score_loader_->select(state.app.catalog, vamp_zero::selected_score_reference(state.app));
|
||||
if (!state.catalog) {
|
||||
state.catalog = vamp_zero::empty_catalog();
|
||||
}
|
||||
if (runtime_->state_.mode != state.mode || runtime_->state_.score_session != state.score_session || runtime_->state_.catalog != state.catalog) {
|
||||
runtime_->touch_.reset();
|
||||
}
|
||||
score_loader_->select(state.catalog, vamp_zero::selected_score_reference(state));
|
||||
runtime_->state_ = std::move(state);
|
||||
}
|
||||
return application_->request_layout();
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
|
||||
auto lock = std::scoped_lock{runtime_->mutex_};
|
||||
return runtime_->state_;
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::selected_score_loader() const -> std::shared_ptr<vamp_zero::score_loader> {
|
||||
return score_loader_;
|
||||
}
|
||||
|
||||
auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state {
|
||||
return vamp_zero::ui_state{
|
||||
.app = std::move(app),
|
||||
.ap_ssid = config.control.ap_ssid,
|
||||
.zero_ip = config.control.zero_ip,
|
||||
.port = bind_port(config),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user