Add schedule sync and schedule-aware playback

This commit is contained in:
NeilClack
2026-09-11 19:14:55 -05:00
parent a043ec84d8
commit f37beaf07e
23 changed files with 893 additions and 32 deletions
+85 -7
View File
@@ -5,6 +5,7 @@
#include "logical_size.h"
#include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h"
#include "timezone_helper.h"
#include "utf8_utils.h"
#include <SDL3/SDL.h>
@@ -72,6 +73,7 @@ void ScrollerPage::onPush(AppState &state) {
createFont(state);
createPatterns();
lines_.clear();
prefilling_ = true;
prefillLines();
prefilling_ = false;
@@ -79,9 +81,10 @@ void ScrollerPage::onPush(AppState &state) {
// Start audio. In dynamic mode the current stream's solfeggio-frequency
// track wins (when its file exists); otherwise the configured manual track
// plays. A frequency file that is not on disk yet falls back to the manual
// track rather than playing nothing.
// track rather than playing nothing. A schedule block with dynamicAudio=true
// forces dynamic audio for its duration; false leaves the device setting be.
std::string track = state.config.get().audio.current_track;
if (state.config.get().audio.dynamic_audio && !streams_.empty()) {
if (effectiveDynamicAudio() && !streams_.empty()) {
const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id != 0 &&
!stream.audioFilename.empty() &&
@@ -137,6 +140,14 @@ void ScrollerPage::update(float dt) {
state_->audio.update(dt);
}
// Block boundaries are minute-granular, so a one-second poll is plenty and
// avoids mutating the process timezone every frame.
schedulePollTimer_ -= dt;
if (schedulePollTimer_ <= 0.0f) {
schedulePollTimer_ = 1.0f;
pollScheduleChange();
}
updateAutoVelocity(dt);
scrollOffset_ += velocity_ * dt;
@@ -200,8 +211,18 @@ void ScrollerPage::render(SDL_Renderer *renderer) {
void ScrollerPage::buildStreams(AppState &state) {
streams_.clear();
fallbackPalette_.clear();
lines_.clear();
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.
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();
auto proclamations = state.db.getActiveProclamations();
if (proclamations.empty()) {
@@ -241,9 +262,53 @@ 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;
scheduledIds = &scheduledOrder;
}
streams_ = radiantwave::scroller::buildStreams(
proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId,
DEFAULT_PROPLEAMATION_ID);
DEFAULT_PROPLEAMATION_ID, scheduledIds);
}
int ScrollerPage::currentScheduleBlockIndex() const {
if (!scheduleActive_ || scheduleBlocks_.empty()) {
return -1;
}
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;
}
void ScrollerPage::pollScheduleChange() {
if (!scheduleActive_) {
return;
}
if (currentScheduleBlockIndex() != activeBlockIndex_) {
scheduleChangePending_ = true;
}
}
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) {
return true;
}
return state_ ? state_->config.get().audio.dynamic_audio : false;
}
void ScrollerPage::createFont(AppState &state) {
@@ -451,7 +516,19 @@ void ScrollerPage::advanceStream() {
if (streams_.empty()) {
return;
}
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
if (scheduleChangePending_ && state_) {
// A schedule block changed while the previous proclamation was playing.
// Apply it only now, at a stream boundary, so the current proclamation
// finishes instead of being cut off.
buildStreams(*state_);
createPatterns();
streamIndex_ = 0;
} else {
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
}
if (streams_.empty()) {
return;
}
// Diagnostic: report the palette the incoming stream will render so the
// console shows whether colors should change when this stream starts.
const ProclamationStream &stream = streams_[streamIndex_];
@@ -473,8 +550,9 @@ void ScrollerPage::switchAudioForCurrentStream() {
}
// Dynamic audio only: with a fixed manual track selected the audio must
// never switch as proclamations change.
if (!state_->config.get().audio.dynamic_audio) {
// never switch as proclamations change. An active schedule block's
// dynamicAudio flag overrides the device-wide setting.
if (!effectiveDynamicAudio()) {
return;
}