Kiosk: apply schedule engine in playback and add System Settings toggle
ScrollerPage now resolves the active Block from ScheduleManager (reusing the existing stream-switch path), honoring dynamicAudio true/false and falling back to local selection for gaps, no schedule, or toggle off. Adds a 'Schedule' On/Off entry (default On) in System Settings persisted in config.yaml, and config plumbing (proclamation.schedule_enabled). All on-device schedule times remain 12h AM/PM.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
#include "network/network_manager.h"
|
||||
#include "network/license_status.h"
|
||||
#include "network/system_name_sync.h"
|
||||
#include "schedule/schedule_manager.h"
|
||||
#include "audio/audio_manager.h"
|
||||
#include "audio/audio_downloader.h"
|
||||
|
||||
@@ -21,6 +22,7 @@ struct AppState {
|
||||
radiantwave::network::NetworkManager network;
|
||||
radiantwave::license::LicenseStatusTracker licenseTracker;
|
||||
radiantwave::systemname::SystemNameSync systemNameSync;
|
||||
radiantwave::schedule::ScheduleManager scheduleManager;
|
||||
radiantwave::audio::AudioManager audio;
|
||||
radiantwave::audio::AudioDownloader audioDownloader;
|
||||
bool quitRequested = false;
|
||||
|
||||
@@ -17,6 +17,7 @@ struct ProclamationConfig {
|
||||
int font_size = 32; // font size in pt
|
||||
std::string current_id = ""; // currently active proclamation ID
|
||||
bool schedule_active = false; // follow the portal's listening schedule
|
||||
bool schedule_enabled = true; // local Schedule on/off toggle (default on)
|
||||
};
|
||||
|
||||
struct AppConfig {
|
||||
|
||||
@@ -253,6 +253,9 @@ void ConfigManager::loadAndMergeDefaults() {
|
||||
if (proc_node.hasChild("schedule_active")) {
|
||||
merged.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(defaults.proclamation.schedule_active);
|
||||
}
|
||||
if (proc_node.hasChild("schedule_enabled")) {
|
||||
merged.proclamation.schedule_enabled = proc_node.getChild("schedule_enabled").asBool(defaults.proclamation.schedule_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -234,6 +234,9 @@ AppConfig parseConfigFromYaml(const std::string& yamlContent) {
|
||||
if (proc_node.hasChild("schedule_active")) {
|
||||
config.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(config.proclamation.schedule_active);
|
||||
}
|
||||
if (proc_node.hasChild("schedule_enabled")) {
|
||||
config.proclamation.schedule_enabled = proc_node.getChild("schedule_enabled").asBool(config.proclamation.schedule_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -260,6 +263,7 @@ std::string serializeConfigToYaml(const AppConfig& config) {
|
||||
ss << " font_size: " << config.proclamation.font_size << "\n";
|
||||
ss << " current_id: \"" << config.proclamation.current_id << "\"\n";
|
||||
ss << " schedule_active: " << (config.proclamation.schedule_active ? "true" : "false") << "\n";
|
||||
ss << " schedule_enabled: " << (config.proclamation.schedule_enabled ? "true" : "false") << "\n";
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
@@ -166,6 +166,11 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
|
||||
// timer; license validation also applies the name (see license_service).
|
||||
state->systemNameSync.start(state->db);
|
||||
|
||||
// Background Schedule sync. Loads the on-disk cache immediately, then pulls
|
||||
// on boot, on a 5-minute timer, and on demand. Playback reads it locally.
|
||||
state->scheduleManager.setEnabled(state->config.get().proclamation.schedule_enabled);
|
||||
state->scheduleManager.start(state->db, state->config.get().timezone);
|
||||
|
||||
state->config.onChanged(
|
||||
[state](const radiantwave::config::AppConfig &new_config) {
|
||||
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d",
|
||||
@@ -275,6 +280,7 @@ void SDL_AppQuit(void *appstate, SDL_AppResult result) {
|
||||
auto *state = static_cast<AppState *>(appstate);
|
||||
if (state) {
|
||||
state->audioDownloader.shutdown();
|
||||
state->scheduleManager.shutdown();
|
||||
state->systemNameSync.shutdown();
|
||||
state->licenseTracker.shutdown();
|
||||
state->audio.shutdown();
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#include "schedule_toggle_page.h"
|
||||
#include "app_state.h"
|
||||
#include "config/app_config.h"
|
||||
#include "logical_size.h"
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
void ScheduleTogglePage::onPush(AppState& state)
|
||||
{
|
||||
state_ = &state;
|
||||
TTF_Init();
|
||||
createFonts();
|
||||
enabled_ = state.config.get().proclamation.schedule_enabled;
|
||||
statusMessage_ = "Press ENTER to turn the Schedule on or off";
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::onResume(AppState& state)
|
||||
{
|
||||
state_ = &state;
|
||||
createFonts();
|
||||
enabled_ = state.config.get().proclamation.schedule_enabled;
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::onPause(AppState& state)
|
||||
{
|
||||
state.keybinds.unbindAll();
|
||||
destroyFonts();
|
||||
destroyTextures();
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::onPop(AppState& state)
|
||||
{
|
||||
destroyFonts();
|
||||
destroyTextures();
|
||||
TTF_Quit();
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::createFonts()
|
||||
{
|
||||
destroyFonts();
|
||||
fontTitle_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 52);
|
||||
fontHeader_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 28);
|
||||
fontBody_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 32);
|
||||
fontSmall_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 22);
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::destroyFonts()
|
||||
{
|
||||
if (fontTitle_) { TTF_CloseFont(fontTitle_); fontTitle_ = nullptr; }
|
||||
if (fontHeader_) { TTF_CloseFont(fontHeader_); fontHeader_ = nullptr; }
|
||||
if (fontBody_) { TTF_CloseFont(fontBody_); fontBody_ = nullptr; }
|
||||
if (fontSmall_) { TTF_CloseFont(fontSmall_); fontSmall_ = nullptr; }
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::destroyTextures()
|
||||
{
|
||||
titleText_.destroy();
|
||||
stateText_.destroy();
|
||||
statusText_.destroy();
|
||||
hintText_.destroy();
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::toggle()
|
||||
{
|
||||
if (!state_) return;
|
||||
const bool next = !enabled_;
|
||||
const bool ok = state_->config.update([next](radiantwave::config::AppConfig& cfg) {
|
||||
cfg.proclamation.schedule_enabled = next;
|
||||
});
|
||||
if (ok) {
|
||||
enabled_ = next;
|
||||
statusMessage_ = enabled_ ? "Schedule enabled" : "Schedule disabled";
|
||||
// Push the new state to the engine and nudge a refresh. While off the
|
||||
// engine takes no action but keeps its cache updating.
|
||||
state_->scheduleManager.setEnabled(enabled_);
|
||||
state_->scheduleManager.requestRefresh();
|
||||
} else {
|
||||
statusMessage_ = "Failed to save";
|
||||
}
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::handleEvent(const SDL_Event& event)
|
||||
{
|
||||
if (!state_) return;
|
||||
if (event.type == SDL_EVENT_KEY_DOWN) {
|
||||
SDL_Keycode key = event.key.key;
|
||||
if (key == SDLK_RETURN || key == SDLK_KP_ENTER) {
|
||||
toggle();
|
||||
} else if (key == SDLK_ESCAPE) {
|
||||
state_->pages.pop(*state_);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::update(float dt)
|
||||
{
|
||||
}
|
||||
|
||||
void ScheduleTogglePage::render(SDL_Renderer* renderer)
|
||||
{
|
||||
int winW, winH;
|
||||
radiantwave::getLayoutSize(renderer, &winW, &winH);
|
||||
float fw = static_cast<float>(winW);
|
||||
float fh = static_cast<float>(winH);
|
||||
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255);
|
||||
SDL_FRect bgRect{0, 0, fw, fh};
|
||||
SDL_RenderFillRect(renderer, &bgRect);
|
||||
|
||||
if (!fontTitle_ || !fontHeader_ || !fontBody_ || !fontSmall_) return;
|
||||
|
||||
SDL_Color white = {255, 255, 255, 255};
|
||||
SDL_Color green = {110, 220, 130, 255};
|
||||
SDL_Color gray = {180, 180, 180, 255};
|
||||
|
||||
float currentY = 40.0f;
|
||||
|
||||
titleText_.ensure(renderer, fontTitle_, "Schedule", white);
|
||||
if (titleText_.texture) {
|
||||
float x = (fw - titleText_.width) / 2.0f;
|
||||
SDL_FRect dst{x, currentY, titleText_.width, titleText_.height};
|
||||
SDL_RenderTexture(renderer, titleText_.texture, nullptr, &dst);
|
||||
currentY += titleText_.height + 50.0f;
|
||||
}
|
||||
|
||||
std::string value = enabled_ ? "On" : "Off";
|
||||
stateText_.ensure(renderer, fontBody_, "Schedule: " + value,
|
||||
enabled_ ? green : gray);
|
||||
if (stateText_.texture) {
|
||||
float x = (fw - stateText_.width) / 2.0f;
|
||||
SDL_FRect dst{x, currentY, stateText_.width, stateText_.height};
|
||||
SDL_RenderTexture(renderer, stateText_.texture, nullptr, &dst);
|
||||
currentY += stateText_.height + 40.0f;
|
||||
}
|
||||
|
||||
if (!statusMessage_.empty()) {
|
||||
statusText_.ensure(renderer, fontHeader_, statusMessage_, gray);
|
||||
if (statusText_.texture) {
|
||||
float x = (fw - statusText_.width) / 2.0f;
|
||||
SDL_FRect dst{x, currentY, statusText_.width, statusText_.height};
|
||||
SDL_RenderTexture(renderer, statusText_.texture, nullptr, &dst);
|
||||
}
|
||||
}
|
||||
|
||||
hintText_.ensure(renderer, fontSmall_,
|
||||
"[ENTER] Toggle [ESC] Back", gray);
|
||||
if (hintText_.texture) {
|
||||
float x = (fw - hintText_.width) / 2.0f;
|
||||
SDL_FRect dst{x, fh - 40.0f, hintText_.width, hintText_.height};
|
||||
SDL_RenderTexture(renderer, hintText_.texture, nullptr, &dst);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include "page.h"
|
||||
#include "text_cache.h"
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <string>
|
||||
|
||||
// System Settings: local Schedule on/off toggle. Default On. When Off the
|
||||
// schedule engine takes no action, but the cache keeps updating. Persisted
|
||||
// locally in config.yaml (proclamation.schedule_enabled).
|
||||
class ScheduleTogglePage : public Page {
|
||||
public:
|
||||
void onPush(AppState& state) override;
|
||||
void onResume(AppState& state) override;
|
||||
void onPause(AppState& state) override;
|
||||
void onPop(AppState& state) override;
|
||||
void handleEvent(const SDL_Event& event) override;
|
||||
void update(float dt) override;
|
||||
void render(SDL_Renderer* renderer) override;
|
||||
|
||||
private:
|
||||
void createFonts();
|
||||
void destroyFonts();
|
||||
void destroyTextures();
|
||||
void toggle();
|
||||
|
||||
AppState* state_ = nullptr;
|
||||
bool enabled_ = true;
|
||||
std::string statusMessage_;
|
||||
|
||||
TTF_Font* fontTitle_ = nullptr;
|
||||
TTF_Font* fontHeader_ = nullptr;
|
||||
TTF_Font* fontBody_ = nullptr;
|
||||
TTF_Font* fontSmall_ = nullptr;
|
||||
|
||||
CachedText titleText_;
|
||||
CachedText stateText_;
|
||||
CachedText statusText_;
|
||||
CachedText hintText_;
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
#pragma once
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fibonacci affine image scrambling (RadiantWave scroller text obfuscation)
|
||||
//
|
||||
// This module implements the Fibonacci affine transformation as a reversible
|
||||
// image permutation. The RadiantWave scroller uses it to render proclamation
|
||||
// text as scrambled glyphs: every code point's glyph is permuted by the map
|
||||
// below so letterforms are destroyed and the text is never readable on screen,
|
||||
// while the exact inverse recovers the original glyph given the same key.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// [Published: Williams 2007] -- concepts from the published research
|
||||
//
|
||||
// Ben Williams, "Some properties of digital image scrambling methods using
|
||||
// Fibonacci numbers", MATH491 Summer Research Project 2006-2007, Department
|
||||
// of Mathematics and Statistics, University of Canterbury, 2007 (mirrored in
|
||||
// this repository as algorithm.md).
|
||||
//
|
||||
// Mathematical foundations:
|
||||
// - The Fibonacci matrix (Definition 2.2) is
|
||||
// F = [ 0 1 ]
|
||||
// [ 1 1 ]
|
||||
// so consecutive Fibonacci numbers satisfy u(n+1) = F.u(n). It squares to
|
||||
// the Arnold matrix F^2 = [[1,1],[1,2]] (Remark 3).
|
||||
// - The Fibonacci affine transformation (Definition 4.1) acts on the square
|
||||
// grid Z_m^2 = {0..m-1}^2 with modular arithmetic (Section 3):
|
||||
// T(x) = F.x + b (mod m), b in Z_m^2.
|
||||
// - An affine map x -> A.x + b is a bijection of Z_m^2 iff gcd(det A, m) = 1
|
||||
// (Theorem 3.1 / Corollary 3.2), and it is periodic under the same
|
||||
// condition (Theorem 3.4).
|
||||
// - det F = -1, which is coprime to every m. By Proposition 4.1 the
|
||||
// Fibonacci affine transformation is therefore a permutation of Z_m^2 for
|
||||
// EVERY modulus m: no constraint whatsoever on the grid size.
|
||||
// - The paper decrypts by re-applying T until the period recurs. Section 5
|
||||
// notes the period of F mod m can reach 6m, so that route is slow; the
|
||||
// paper never uses the inverse transform.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// [Adapted beyond the paper] -- generalization from algorithm.md's appendix
|
||||
// (lines 491-500)
|
||||
//
|
||||
// Two extensions turn the published square-only transform into an
|
||||
// arbitrary-size scrambler with a fast, exact round trip:
|
||||
//
|
||||
// 1. Arbitrary image sizes. Any W x H image is embedded in a square canvas
|
||||
// of side
|
||||
// N = canvasSize(W, H) = max(W, H)
|
||||
// and T acts on Z_N^2. Because det F = -1 is invertible for every N, no
|
||||
// Fibonacci-number, power-of-two, or other size constraint applies. (The
|
||||
// size restriction in the paper was specific to Zou's scaled transform,
|
||||
// Definition 4.3, not to the plain Fibonacci affine map.)
|
||||
//
|
||||
// 2. Exact inverse instead of period iteration. Since
|
||||
// F^-1 = [ -1 1 ]
|
||||
// [ 1 0 ]
|
||||
// exists for every modulus, decryption applies the inverse directly and
|
||||
// costs exactly as many rounds as encryption -- not up to 6N applications
|
||||
// of T. This also turns the key into (round count, offset sequence):
|
||||
// scramble applies the per-round offsets b1..bt in order, and unscramble
|
||||
// applies the inverse in reverse order.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// [This implementation] -- project-specific engineering decisions
|
||||
//
|
||||
// Gather-form permutation. Each round is a single gather pass:
|
||||
// scramble: dst[y] = src[preimage(y)], preimage(y) = T^-1(y)
|
||||
// unscramble: dst[y] = src[image(y)], image(y) = T(y)
|
||||
// Every output pixel is produced from exactly one input pixel, so there are
|
||||
// never holes or collisions, and the padding convention is filled directly
|
||||
// where a preimage falls outside the source. (The paper describes the
|
||||
// forward scatter -- source pixel at x moves to T(x); gather is the
|
||||
// equivalent output-directed formulation.)
|
||||
//
|
||||
// Composition semantics. Scrambling applies the inverse maps in round order
|
||||
// (T1^-1, T2^-1, ..., Tt^-1); unscrambling applies the forward maps in
|
||||
// reverse order (Tt, ..., T1). The composition telescopes:
|
||||
// T1^-1( T2^-1( ... Tt^-1( Tt( ... T2( T1(y) ) ... ) ) ... ) ) = y
|
||||
// so the round trip is exact in exactly `rounds` passes, regardless of the
|
||||
// transform's period. Padding is created in round 1 and then permuted like
|
||||
// any other pixel; because the composition is a bijection, each W x H output
|
||||
// position of the final reverse pass maps to a canvas position holding a real
|
||||
// source pixel, so no padding value ever reaches the recovered W x H region.
|
||||
// (Intermediate reverse passes do read padding pixels -- they move the full
|
||||
// N x N canvas -- but they are routed out of the W x H output.)
|
||||
//
|
||||
// Key derivation. The key is the (seed, rounds) pair. Each round derives its
|
||||
// offset vector b = (b0, b1) from `seed` via splitmix64 -- two consecutive
|
||||
// pure-integer draws reduced mod N:
|
||||
// b0 = splitmix64(seed) mod N, b1 = splitmix64(seed) mod N.
|
||||
// Pure integer arithmetic makes the offsets bit-identical on every platform,
|
||||
// so a compile-time seed produces identical scrambling everywhere. The
|
||||
// scroller fixes the seed at compile time (kDefaultSeed), meaning the same
|
||||
// glyph scrambles identically on every device and every run, and no key
|
||||
// state is ever persisted or transmitted.
|
||||
//
|
||||
// Padding convention. During scramble the W x H source sits in the natural
|
||||
// top-left embedding of the N x N canvas. Canvas positions whose preimage
|
||||
// falls outside [0, W) x [0, H) are written as transparent black
|
||||
// (0, 0, 0, 0), which is invisible on a black background.
|
||||
// Because the map is a bijection, the W x H source pixels land on W x H
|
||||
// distinct canvas positions, and the remaining N^2 - W*H positions are
|
||||
// padding. unscramble() reads the whole N x N canvas and writes only the
|
||||
// W x H region, reproducing the source exactly. Display uses the matching
|
||||
// crop-window convention: render the top-left W x H sub-rectangle of the
|
||||
// scrambled canvas.
|
||||
//
|
||||
// Whole pixels. Each RGBA8888 pixel, alpha included, is permuted as a unit,
|
||||
// so a round trip preserves alpha exactly.
|
||||
//
|
||||
// In-place hazard / scratch. A gather pass is not safe in place: within one
|
||||
// pass, dst[y] = src[p(y)] reads source position p(y) after an earlier write
|
||||
// at that same position may have replaced it (whenever p(y) < y in iteration
|
||||
// order). Every pass therefore reads and writes distinct buffers. Two
|
||||
// ping-pong scratch buffers of N*N*4 bytes are reused across rounds, so read
|
||||
// and write never alias while memory stays bounded.
|
||||
//
|
||||
// Numerics. All coordinate products are accumulated in int64_t and reduced
|
||||
// mod N, which is exact for any N whose squares fit comfortably in int64_t
|
||||
// (N <= 2^30). Rounds below 1 are clamped to 1; N == 1 (a 1x1 image) yields
|
||||
// the identity map, which is correct. In practice N is the side of a glyph
|
||||
// tile, at most a few hundred pixels.
|
||||
//
|
||||
// Format & portability. The module is SDL-free and pure C++17: it operates
|
||||
// on tightly packed RGBA8888 pixels (4 bytes per pixel, row stride = width *
|
||||
// 4) and keeps no state between calls. Any caller can adopt it; the scroller
|
||||
// uses it per glyph (see scroller_page.cpp).
|
||||
//
|
||||
// Why CPU. Glyph tiles are tiny and every distinct code point's tile is
|
||||
// scrambled at most once per font size and then cached, so the total cost is
|
||||
// a one-time, amortized O(N^2 * rounds) integer permute per character. A GPU
|
||||
// path would need per-texture compute passes or shader support that buys
|
||||
// nothing here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace radiantwave {
|
||||
namespace scramble {
|
||||
|
||||
// Fixed compile-time seed so every device renders the same glyph scrambling.
|
||||
// The value is splitmix64's golden-ratio constant; any constant would do, the
|
||||
// requirement is only that it never changes across runs and platforms.
|
||||
inline constexpr uint64_t kDefaultSeed = 0x9E3779B97F4A7C15ULL;
|
||||
|
||||
// Default round count. Each round applies the affine map once; more rounds mix
|
||||
// pixels across a larger fraction of the canvas. Values below 1 are clamped to
|
||||
// 1 by the API.
|
||||
inline constexpr int kDefaultRounds = 4;
|
||||
|
||||
// Per-call configuration: how many rounds and which key (seed).
|
||||
struct ScrambleConfig
|
||||
{
|
||||
int rounds = kDefaultRounds;
|
||||
uint64_t seed = kDefaultSeed;
|
||||
};
|
||||
|
||||
// Square canvas side N for a W x H image: N = max(W, H).
|
||||
// Returns 0 when width or height is < 1 (the scramble/unscramble entry points
|
||||
// reject N == 0).
|
||||
int canvasSize(int width, int height);
|
||||
|
||||
// Scramble a W x H RGBA8888 image (tightly packed, stride = width * 4) into
|
||||
// an N x N RGBA8888 canvas where N = canvasSize(width, height). `dst` must
|
||||
// hold at least N*N*4 bytes. Out-of-canvas source coordinates are written as
|
||||
// transparent black per the padding convention above. `src` and `dst` must not
|
||||
// alias.
|
||||
//
|
||||
// Returns false (writing nothing to `dst`) when width/height < 1, when either
|
||||
// pointer is null, or when N exceeds the int64-safe bound (2^30).
|
||||
bool scramble(const uint8_t* src, uint8_t* dst, int width, int height,
|
||||
const ScrambleConfig& config);
|
||||
|
||||
// Exact inverse of scramble(): reads the N x N RGBA8888 canvas (stride =
|
||||
// N * 4) and writes back the top-left W x H RGBA8888 region (stride = width *
|
||||
// 4). `dst` must hold at least W*H*4 bytes. Must be called with the same
|
||||
// (width, height, config) as the matching scramble() -- the key is the same
|
||||
// tuple. `src` and `dst` must not alias.
|
||||
//
|
||||
// Returns false under the same conditions as scramble().
|
||||
bool unscramble(const uint8_t* src, uint8_t* dst, int width, int height,
|
||||
const ScrambleConfig& config);
|
||||
|
||||
} // namespace scramble
|
||||
} // namespace radiantwave
|
||||
+39
-26
@@ -214,15 +214,32 @@ void ScrollerPage::buildStreams(AppState &state) {
|
||||
streamIndex_ = 0;
|
||||
scheduleChangePending_ = false;
|
||||
|
||||
// Schedule state: only an active schedule with a currently-matching block
|
||||
// changes the play list. The block boundary is handled in advanceStream(),
|
||||
// so rebuilding mid-playback never truncates the current proclamation.
|
||||
// Schedule state: resolve the active Block from the schedule manager (which
|
||||
// owns the fetched/cached payload and the local on/off toggle). The block
|
||||
// boundary is handled in advanceStream(), so rebuilding mid-playback never
|
||||
// truncates the current proclamation.
|
||||
const radiantwave::config::AppConfig cfg = state.config.get();
|
||||
scheduleActive_ = cfg.proclamation.schedule_active;
|
||||
timezone_ = cfg.timezone;
|
||||
scheduleBlocks_ = scheduleActive_ ? state.db.getScheduleBlocks()
|
||||
: std::vector<radiantwave::db::ScheduleBlockRecord>{};
|
||||
activeBlockIndex_ = currentScheduleBlockIndex();
|
||||
// Keep the engine's local toggle in step with config (it may have been
|
||||
// changed in System Settings or hot-reloaded).
|
||||
state.scheduleManager.setEnabled(cfg.proclamation.schedule_enabled);
|
||||
|
||||
const int now = radiantwave::timezone::currentLocalMinutes(timezone_);
|
||||
const radiantwave::schedule::Weekday weekday =
|
||||
radiantwave::schedule::weekdayFromIndex(
|
||||
radiantwave::timezone::currentLocalWeekday(timezone_));
|
||||
const radiantwave::schedule::Decision decision =
|
||||
state.scheduleManager.activeBlock(now, weekday);
|
||||
scheduleActive_ = state.scheduleManager.enabled();
|
||||
if (decision.hasBlock) {
|
||||
activeBlockProclamationIds_ = decision.block.proclamation_ids;
|
||||
activeBlockDynamicAudio_ = decision.block.dynamic_audio;
|
||||
activeBlockIndex_ = 1; // any non-negative marker: a block is active
|
||||
} else {
|
||||
activeBlockProclamationIds_.clear();
|
||||
activeBlockDynamicAudio_ = false;
|
||||
activeBlockIndex_ = -1;
|
||||
}
|
||||
|
||||
auto proclamations = state.db.getActiveProclamations();
|
||||
if (proclamations.empty()) {
|
||||
@@ -264,9 +281,8 @@ void ScrollerPage::buildStreams(AppState &state) {
|
||||
|
||||
const std::vector<std::string>* scheduledIds = nullptr;
|
||||
std::vector<std::string> scheduledOrder;
|
||||
if (activeBlockIndex_ >= 0 &&
|
||||
activeBlockIndex_ < static_cast<int>(scheduleBlocks_.size())) {
|
||||
scheduledOrder = scheduleBlocks_[activeBlockIndex_].proclamation_ids;
|
||||
if (activeBlockIndex_ >= 0 && !activeBlockProclamationIds_.empty()) {
|
||||
scheduledOrder = activeBlockProclamationIds_;
|
||||
scheduledIds = &scheduledOrder;
|
||||
}
|
||||
|
||||
@@ -275,26 +291,25 @@ void ScrollerPage::buildStreams(AppState &state) {
|
||||
DEFAULT_PROPLEAMATION_ID, scheduledIds);
|
||||
}
|
||||
|
||||
int ScrollerPage::currentScheduleBlockIndex() const {
|
||||
if (!scheduleActive_ || scheduleBlocks_.empty()) {
|
||||
return -1;
|
||||
bool ScrollerPage::scheduleBlockActiveNow() const {
|
||||
if (!state_) {
|
||||
return false;
|
||||
}
|
||||
const int now = radiantwave::timezone::currentLocalMinutes(timezone_);
|
||||
for (size_t i = 0; i < scheduleBlocks_.size(); ++i) {
|
||||
const radiantwave::db::ScheduleBlockRecord &block = scheduleBlocks_[i];
|
||||
if (radiantwave::scroller::minuteInScheduleBlock(
|
||||
now, block.start_minutes, block.end_minutes, block.overnight)) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
const radiantwave::schedule::Weekday weekday =
|
||||
radiantwave::schedule::weekdayFromIndex(
|
||||
radiantwave::timezone::currentLocalWeekday(timezone_));
|
||||
return state_->scheduleManager.activeBlock(now, weekday).hasBlock;
|
||||
}
|
||||
|
||||
void ScrollerPage::pollScheduleChange() {
|
||||
if (!scheduleActive_) {
|
||||
// The manager owns the local toggle; when off, there is never an active block.
|
||||
if (!state_) {
|
||||
return;
|
||||
}
|
||||
if (currentScheduleBlockIndex() != activeBlockIndex_) {
|
||||
const bool activeNow = scheduleBlockActiveNow();
|
||||
const bool hadBlock = activeBlockIndex_ >= 0;
|
||||
if (activeNow != hadBlock) {
|
||||
scheduleChangePending_ = true;
|
||||
}
|
||||
}
|
||||
@@ -303,9 +318,7 @@ bool ScrollerPage::effectiveDynamicAudio() const {
|
||||
// dynamicAudio=true on the active block forces dynamic audio, overriding
|
||||
// whatever the device selected. false means "don't override": the device's
|
||||
// own selection (dynamic or a fixed track) applies as usual.
|
||||
if (scheduleActive_ && activeBlockIndex_ >= 0 &&
|
||||
activeBlockIndex_ < static_cast<int>(scheduleBlocks_.size()) &&
|
||||
scheduleBlocks_[activeBlockIndex_].dynamic_audio) {
|
||||
if (scheduleActive_ && activeBlockIndex_ >= 0 && activeBlockDynamicAudio_) {
|
||||
return true;
|
||||
}
|
||||
return state_ ? state_->config.get().audio.dynamic_audio : false;
|
||||
|
||||
+9
-8
@@ -83,9 +83,8 @@ private:
|
||||
bool addLineAt(float baseY);
|
||||
void advanceStream();
|
||||
void switchAudioForCurrentStream();
|
||||
// Re-reads the schedule from the DB/config and records which block (if any)
|
||||
// matches the clock right now. Returns the matching index, or -1.
|
||||
int currentScheduleBlockIndex() const;
|
||||
// Re-reads the schedule and records whether a block is active right now.
|
||||
bool scheduleBlockActiveNow() const;
|
||||
// If the active block changed, latch a pending switch that is applied at the
|
||||
// next proclamation boundary so the current proclamation is never cut off.
|
||||
void pollScheduleChange();
|
||||
@@ -108,12 +107,14 @@ private:
|
||||
int streamIndex_ = 0;
|
||||
bool prefilling_ = false;
|
||||
|
||||
// Schedule playback. `scheduleActive_` mirrors config; `scheduleBlocks_` is
|
||||
// the cached schedule; `activeBlockIndex_` is the block currently playing
|
||||
// (-1 = normal mode / no matching block). A change is latched in
|
||||
// `scheduleChangePending_` and applied when the current proclamation ends.
|
||||
// Schedule playback. `scheduleActive_` mirrors config; the active Block is
|
||||
// resolved from ScheduleManager each poll; `activeBlockIndex_` is the block
|
||||
// currently playing (-1 = normal mode / no matching block). A change is
|
||||
// latched in `scheduleChangePending_` and applied when the current
|
||||
// proclamation ends.
|
||||
std::string timezone_;
|
||||
std::vector<radiantwave::db::ScheduleBlockRecord> scheduleBlocks_;
|
||||
std::vector<std::string> activeBlockProclamationIds_;
|
||||
bool activeBlockDynamicAudio_ = false;
|
||||
bool scheduleActive_ = false;
|
||||
int activeBlockIndex_ = -1;
|
||||
bool scheduleChangePending_ = false;
|
||||
|
||||
@@ -7,6 +7,7 @@ std::vector<SystemSettingEntry> systemSettingEntries() {
|
||||
{"Email Address", SystemSettingId::EmailAddress},
|
||||
{"License Key", SystemSettingId::LicenseKey},
|
||||
{"System Name", SystemSettingId::SystemName},
|
||||
{"Schedule", SystemSettingId::Schedule},
|
||||
{"WiFi Setup", SystemSettingId::WiFiSetup},
|
||||
{"Time Zone Setup", SystemSettingId::Timezone},
|
||||
{"Date Format", SystemSettingId::DateFormat},
|
||||
|
||||
@@ -10,6 +10,7 @@ enum class SystemSettingId {
|
||||
EmailAddress,
|
||||
LicenseKey,
|
||||
SystemName,
|
||||
Schedule,
|
||||
WiFiSetup,
|
||||
Timezone,
|
||||
DateFormat,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "timezone_page.h"
|
||||
#include "wifi_setup_page.h"
|
||||
#include "system_name_page.h"
|
||||
#include "schedule_toggle_page.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <algorithm>
|
||||
@@ -80,6 +81,9 @@ void SystemSettingsPage::activateSelected(AppState &state)
|
||||
case radiantwave::settings::SystemSettingId::SystemName:
|
||||
state.pages.push(state, std::make_unique<SystemNamePage>());
|
||||
break;
|
||||
case radiantwave::settings::SystemSettingId::Schedule:
|
||||
state.pages.push(state, std::make_unique<ScheduleTogglePage>());
|
||||
break;
|
||||
case radiantwave::settings::SystemSettingId::WiFiSetup:
|
||||
state.pages.push(state, std::make_unique<WiFiSetupPage>());
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user