diff --git a/react-web/src/pages/Home.tsx b/react-web/src/pages/Home.tsx
index e8fec718..f616a52c 100644
--- a/react-web/src/pages/Home.tsx
+++ b/react-web/src/pages/Home.tsx
@@ -32,28 +32,48 @@ export default function Home() {
}
}
- const handleActivate = async (name: string) => {
+ const handleActivate = async (id: string, displayName: string) => {
if (!apiUrl) return
try {
- await fetch(`${apiUrl}/set_active?id=${encodeURIComponent(name)}`)
- setStatus(prev => prev ? { ...prev, current: name } : null)
- toast.success(`Activated "${name}"`)
+ await fetch(`${apiUrl}/set_active?id=${encodeURIComponent(id)}`)
+ setStatus(prev => prev ? { ...prev, current: id } : null)
+ toast.success(`Activated "${displayName}"`)
} catch {
toast.error('Failed to activate preset')
}
}
- const handleDelete = async (name: string) => {
+ const handleDelete = async (id: string, displayName: string) => {
if (!apiUrl) return
try {
- await fetch(`${apiUrl}/preset?id=${encodeURIComponent(name)}`, { method: 'DELETE' })
- toast.success(`Deleted "${name}"`)
+ await fetch(`${apiUrl}/preset?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
+ toast.success(`Deleted "${displayName}"`)
retryPresets(r => r + 1)
} catch {
toast.error('Failed to delete preset')
}
}
+ const handleRename = async (id: string, displayName: string) => {
+ if (!apiUrl) return
+ try {
+ const res = await fetch(`${apiUrl}/preset_display_name?id=${encodeURIComponent(id)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ display_name: displayName }),
+ })
+ if (!res.ok) throw new Error('Failed to rename preset')
+ toast.success(`Renamed to "${displayName}"`)
+ retryPresets(r => r + 1)
+ } catch {
+ toast.error('Failed to rename preset')
+ }
+ }
+
+ const activePresetLabel = status?.current && presets?.[status.current]
+ ? (presets[status.current].display_name ?? status.current)
+ : (status?.current ?? null)
+
return (
@@ -66,6 +86,7 @@ export default function Home() {
) : (
@@ -77,9 +98,10 @@ export default function Home() {
retryPresets(r => r + 1)}
/>
)}
diff --git a/react-web/src/pages/ModifyPreset.tsx b/react-web/src/pages/ModifyPreset.tsx
index a5afe6fd..8f5af874 100644
--- a/react-web/src/pages/ModifyPreset.tsx
+++ b/react-web/src/pages/ModifyPreset.tsx
@@ -70,7 +70,7 @@ function ModifyPresetInner({ presetId }: { presetId: string }) {
-
{presetId}
+
{rawPreset?.display_name ?? presetId}
Edit preset
diff --git a/react-web/src/pages/Schedules.tsx b/react-web/src/pages/Schedules.tsx
index b3e4a5b2..95d53bb1 100644
--- a/react-web/src/pages/Schedules.tsx
+++ b/react-web/src/pages/Schedules.tsx
@@ -125,7 +125,13 @@ export default function Schedules() {
}))
}
- const presetNames = presets ? Object.keys(presets) : []
+ const presetOptions = presets
+ ? Object.entries(presets).map(([id, preset]) => ({ id, label: preset.display_name ?? id }))
+ : []
+
+ const presetDisplayById = presets
+ ? Object.fromEntries(Object.entries(presets).map(([id, preset]) => [id, preset.display_name ?? id]))
+ : {}
return (
@@ -180,7 +186,7 @@ export default function Schedules() {
{schedule.name || schedule.preset_id}
-
{schedule.preset_id}
+
{presetDisplayById[schedule.preset_id] ?? schedule.preset_id}
{formatTime(schedule.start_hour, schedule.start_minute)}
{' – '}
@@ -247,8 +253,8 @@ export default function Schedules() {
- {presetNames.map(name => (
- {name}
+ {presetOptions.map(preset => (
+ {preset.label}
))}
diff --git a/shared/matrix/include/shared/matrix/config/MainConfig.h b/shared/matrix/include/shared/matrix/config/MainConfig.h
index f59c4c9e..d1f27107 100644
--- a/shared/matrix/include/shared/matrix/config/MainConfig.h
+++ b/shared/matrix/include/shared/matrix/config/MainConfig.h
@@ -35,6 +35,7 @@ namespace Config {
void set_curr(string id);
bool delete_preset(const string &id);
+ bool set_preset_display_name(const string& id, const string& display_name);
void set_presets(const string& id, std::shared_ptr preset);
diff --git a/shared/matrix/include/shared/matrix/config/data.h b/shared/matrix/include/shared/matrix/config/data.h
index 43f0a866..8d06987b 100644
--- a/shared/matrix/include/shared/matrix/config/data.h
+++ b/shared/matrix/include/shared/matrix/config/data.h
@@ -17,6 +17,7 @@ namespace ConfigData
vector> scenes;
tmillis_t transition_duration = 750;
std::string transition_name = "blend"; ///< Global default transition effect name
+ std::string display_name;
static std::shared_ptr create_default();
~Preset() = default; // Add explicit destructor
diff --git a/shared/matrix/src/shared/matrix/config/MainConfig.cpp b/shared/matrix/src/shared/matrix/config/MainConfig.cpp
index 6f7c176b..09a6b9e9 100644
--- a/shared/matrix/src/shared/matrix/config/MainConfig.cpp
+++ b/shared/matrix/src/shared/matrix/config/MainConfig.cpp
@@ -3,13 +3,40 @@
#include "spdlog/spdlog.h"
#include
#include
+#include
#include
#include
+#include
using namespace spdlog;
namespace Config {
+ namespace {
+ bool is_uuid_like(const std::string &value) {
+ if (value.size() != 36) {
+ return false;
+ }
+
+ for (size_t i = 0; i < value.size(); ++i) {
+ const bool is_hyphen = (i == 8 || i == 13 || i == 18 || i == 23);
+ if (is_hyphen) {
+ if (value[i] != '-') {
+ return false;
+ }
+ continue;
+ }
+
+ const char c = value[i];
+ if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
void MainConfig::mark_dirty() {
unique_lock lock(this->update_mutex);
@@ -65,6 +92,19 @@ namespace Config {
return true;
}
+ bool MainConfig::set_preset_display_name(const string &id, const string &display_name) {
+ unique_lock lock(this->data_mutex);
+
+ const auto it = this->data.presets.find(id);
+ if (it == this->data.presets.end() || !it->second) {
+ return false;
+ }
+
+ it->second->display_name = display_name;
+ this->mark_dirty();
+ return true;
+ }
+
void MainConfig::set_presets(const string &id, std::shared_ptr preset) {
unique_lock lock(this->data_mutex);
spdlog::info("Setting preset {}", id);
@@ -120,6 +160,69 @@ spdlog::info("Setting preset {}", id);
f.close();
this->data = std::move(temp.get());
+ bool migrated = false;
+
+ if (this->data.presets.empty()) {
+ auto preset = ConfigData::Preset::create_default();
+ const auto id = uuid::generate_uuid_v4();
+ preset->display_name = "Default";
+ this->data.presets[id] = std::move(preset);
+ this->data.curr = id;
+ migrated = true;
+ }
+
+ std::map> migrated_presets;
+ std::unordered_map id_map;
+
+ for (const auto &[old_id, old_preset]: this->data.presets) {
+ auto preset = old_preset;
+ if (!preset) {
+ preset = ConfigData::Preset::create_default();
+ migrated = true;
+ }
+
+ std::string new_id = old_id;
+ const bool keep_existing_id = is_uuid_like(old_id) && !migrated_presets.contains(old_id);
+
+ if (!keep_existing_id) {
+ do {
+ new_id = uuid::generate_uuid_v4();
+ } while (migrated_presets.contains(new_id));
+
+ id_map[old_id] = new_id;
+ migrated = true;
+ }
+
+ if (preset->display_name.empty()) {
+ preset->display_name = old_id;
+ migrated = true;
+ }
+
+ migrated_presets[new_id] = std::move(preset);
+ }
+
+ if (migrated) {
+ this->data.presets = std::move(migrated_presets);
+
+ if (id_map.contains(this->data.curr)) {
+ this->data.curr = id_map[this->data.curr];
+ }
+
+ if (!this->data.presets.contains(this->data.curr) && !this->data.presets.empty()) {
+ this->data.curr = this->data.presets.begin()->first;
+ }
+
+ for (auto &[schedule_id, schedule]: this->data.schedules) {
+ (void) schedule_id;
+ if (id_map.contains(schedule.preset_id)) {
+ schedule.preset_id = id_map[schedule.preset_id];
+ }
+ }
+
+ info("Migrated preset IDs to UUID keys and persisted updated config");
+ this->save();
+ }
+
this->dirty = false;
}
diff --git a/shared/matrix/src/shared/matrix/config/data.cpp b/shared/matrix/src/shared/matrix/config/data.cpp
index deeb4940..e83fe024 100644
--- a/shared/matrix/src/shared/matrix/config/data.cpp
+++ b/shared/matrix/src/shared/matrix/config/data.cpp
@@ -52,7 +52,8 @@ namespace ConfigData {
j = json{
{"scenes", scenes_json},
{"transition_duration", p->transition_duration},
- {"transition_name", p->transition_name}
+ {"transition_name", p->transition_name},
+ {"display_name", p->display_name}
};
}
@@ -159,6 +160,7 @@ namespace ConfigData {
p->scenes = std::move(scenes);
p->transition_duration = j.value("transition_duration", static_cast(750));
p->transition_name = j.value("transition_name", std::string("blend"));
+ p->display_name = j.value("display_name", std::string());
}
void from_json(const json &j, std::unique_ptr &p) {
@@ -192,6 +194,7 @@ namespace ConfigData {
preset->scenes = scenes;
preset->transition_duration = 750;
preset->transition_name = "blend";
+ preset->display_name = "Default";
return {
preset,
diff --git a/src_matrix/matrix_control/canvas.cpp b/src_matrix/matrix_control/canvas.cpp
index 2e590ec9..777de8f7 100644
--- a/src_matrix/matrix_control/canvas.cpp
+++ b/src_matrix/matrix_control/canvas.cpp
@@ -173,6 +173,20 @@ namespace
}
}
}
+
+ tmillis_t render_interval_ms_from_visibility(float visibility)
+ {
+ const auto clamped_visibility = std::clamp(visibility, 0.0f, 1.0f);
+
+ // Keep visible scenes responsive while aggressively throttling nearly-hidden scenes.
+ constexpr tmillis_t min_interval_ms = 33; // ~30 FPS
+ constexpr tmillis_t max_interval_ms = 140; // ~7 FPS
+
+ const auto interval_range = max_interval_ms - min_interval_ms;
+ const auto interval = max_interval_ms - static_cast(clamped_visibility * static_cast(interval_range));
+
+ return std::clamp(interval, min_interval_ms, max_interval_ms);
+ }
}
void render_fallback(RGBMatrixBase *canvas)
@@ -292,23 +306,44 @@ void update_canvas(RGBMatrixBase *matrix, FrameCanvas *&first_offscreen_canvas,
scene->before_transition_stop();
tmillis_t transition_start_ms = GetTimeInMillis();
+ tmillis_t last_current_render_ms = transition_start_ms;
+ tmillis_t last_next_render_ms = transition_start_ms;
+
+ auto current_continue = scene->render(first_offscreen_canvas);
+ auto next_continue = next_scene->render(second_offscreen_canvas);
+
while (true)
{
const auto now_ms = GetTimeInMillis();
- const auto current_continue = scene->render(first_offscreen_canvas);
- const auto next_continue = next_scene->render(second_offscreen_canvas);
+ const auto elapsed_transition = now_ms - transition_start_ms;
+ const auto alpha_progress = std::clamp(
+ static_cast(elapsed_transition) / static_cast(std::max(1, transition_duration)),
+ 0.0f,
+ 1.0f);
+
+ const auto current_visibility = 1.0f - alpha_progress;
+ const auto next_visibility = alpha_progress;
+
+ const auto current_render_interval_ms = render_interval_ms_from_visibility(current_visibility);
+ const auto next_render_interval_ms = render_interval_ms_from_visibility(next_visibility);
+
+ if ((now_ms - last_current_render_ms) >= current_render_interval_ms)
+ {
+ current_continue = scene->render(first_offscreen_canvas);
+ last_current_render_ms = now_ms;
+ }
+
+ if ((now_ms - last_next_render_ms) >= next_render_interval_ms)
+ {
+ next_continue = next_scene->render(second_offscreen_canvas);
+ last_next_render_ms = now_ms;
+ }
if (!current_continue || !next_continue || interrupt_received || exit_canvas_update)
{
trace("Exiting scene early.");
break;
}
-
- const auto elapsed_transition = now_ms - transition_start_ms;
- const auto alpha_progress = std::clamp(
- static_cast(elapsed_transition) / static_cast(std::max(1, transition_duration)),
- 0.0f,
- 1.0f);
apply_transition_frame(composite_offscreen_canvas,
first_offscreen_canvas,
second_offscreen_canvas,
diff --git a/src_matrix/server/other_routes.cpp b/src_matrix/server/other_routes.cpp
index ba9db186..7b4ff712 100644
--- a/src_matrix/server/other_routes.cpp
+++ b/src_matrix/server/other_routes.cpp
@@ -23,6 +23,13 @@ std::unique_ptr Server::add_other_routes(std::unique_ptrhttp_get("/web", [](auto req, auto)
+ {
+ auto response = req->create_response(restinio::status_see_other())
+ .append_header(restinio::http_field::location, "/web/");
+ Server::add_cors_headers(response);
+ return response.done(); });
+
// Static file serving
router->http_get("/web/:path(.*)", [](auto req, auto params)
{
diff --git a/src_matrix/server/preset_management.cpp b/src_matrix/server/preset_management.cpp
index 42368923..e625dcb7 100644
--- a/src_matrix/server/preset_management.cpp
+++ b/src_matrix/server/preset_management.cpp
@@ -1,6 +1,7 @@
#include "preset_management.h"
#include "shared/matrix/utils/shared.h"
#include "shared/matrix/server/server_utils.h"
+#include "shared/matrix/utils/uuid.h"
#include "nlohmann/json.hpp"
#include
@@ -52,14 +53,6 @@ std::unique_ptr Server::add_preset_routes(std::unique_ptrhttp_post("/add_preset", [](auto req, auto) {
const auto qp = restinio::parse_query(req->header().query());
- if (!qp.has("id")) {
- return reply_with_error(req, "Id not given");
- }
- const std::string id{qp["id"]};
- if (id == "") {
- return reply_with_error(req, "Id empty");
- }
-
spdlog::debug("Adding preset...");
string str_body = req->body();
json j;
@@ -72,8 +65,31 @@ std::unique_ptr Server::add_preset_routes(std::unique_ptr>();
+
+ std::string id;
+ if (qp.has("id")) {
+ id = qp["id"];
+ } else {
+ do {
+ id = uuid::generate_uuid_v4();
+ } while (config->get_presets().contains(id));
+ }
+
+ if (id.empty()) {
+ return reply_with_error(req, "Id empty");
+ }
+
+ if (pr->display_name.empty()) {
+ pr->display_name = j.value("display_name", id);
+ }
+
config->set_presets(id, pr);
- return reply_with_json(req, {{"success", "Preset has been added"}});
+ config->save();
+ return reply_with_json(req, {
+ {"success", "Preset has been added"},
+ {"id", id},
+ {"display_name", pr->display_name}
+ });
} catch (exception &ex) {
spdlog::warn("Invalid preset with {}", ex.what());
return reply_with_error(req, "Could not serialize json");
@@ -99,6 +115,15 @@ std::unique_ptr Server::add_preset_routes(std::unique_ptr>();
+
+ if (pr->display_name.empty()) {
+ const auto presets = config->get_presets();
+ const auto existing = presets.find(id);
+ if (existing != presets.end() && existing->second) {
+ pr->display_name = existing->second->display_name;
+ }
+ }
+
config->set_presets(id, pr);
config->save();
return reply_with_json(req, {
@@ -111,6 +136,46 @@ std::unique_ptr Server::add_preset_routes(std::unique_ptrhttp_post("/preset_display_name", [](auto req, auto) {
+ const auto qp = restinio::parse_query(req->header().query());
+ if (!qp.has("id")) {
+ return reply_with_error(req, "Id not given");
+ }
+
+ const std::string id{qp["id"]};
+ if (id.empty()) {
+ return reply_with_error(req, "Id empty");
+ }
+
+ json j;
+ try {
+ j = json::parse(req->body());
+ } catch (exception &ex) {
+ spdlog::warn("Invalid json payload {}", ex.what());
+ return reply_with_error(req, "Invalid json payload");
+ }
+
+ if (!j.contains("display_name") || !j["display_name"].is_string()) {
+ return reply_with_error(req, "display_name not given");
+ }
+
+ const std::string display_name = j["display_name"].get();
+ if (display_name.empty()) {
+ return reply_with_error(req, "display_name empty");
+ }
+
+ if (!config->set_preset_display_name(id, display_name)) {
+ return reply_with_error(req, "Preset not found");
+ }
+
+ config->save();
+ return reply_with_json(req, {
+ {"success", "Preset display name updated"},
+ {"id", id},
+ {"display_name", display_name}
+ });
+ });
+
// DELETE routes
router->http_delete("/preset", [](auto req, auto) {
const auto qp = restinio::parse_query(req->header().query());