This commit is contained in:
khodowany
2026-09-30 18:23:25 -05:00
parent 896de203b3
commit 2a1aa2c67a
40 changed files with 1197 additions and 610 deletions
+5 -7
View File
@@ -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);
+131
View File
@@ -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);
}
+194
View File
@@ -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
View File
@@ -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"));
+12 -2
View File
@@ -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();
+2
View File
@@ -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
View File
@@ -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 &current = 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),
};
}