Add adjustable sleep timer and file browser settings

Implemented the adjustable sleep timer feature as requested. Updated the settings storage to include refresh interval and default folder preferences. Modified the state file version to accommodate new fields for last browsed folder.
This commit is contained in:
altsysrq 2026-01-03 23:24:21 -06:00
parent 0054981eab
commit 8ca01e9ede
13 changed files with 350 additions and 13 deletions

View File

@ -41,6 +41,8 @@ This project is **not affiliated with Xteink**; it's built as a community projec
- [ ] Full UTF support
- [x] Screen rotation
- [x] Bluetooth LE Support
- [x] Adjustable sleep timer
- [x] Set default folder
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.

View File

@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 13;
constexpr uint8_t SETTINGS_COUNT = 16;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
@ -40,6 +40,9 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, bluetoothEnabled);
serialization::writePod(outputFile, useCoverArtPicker);
serialization::writePod(outputFile, autoSleepMinutes);
serialization::writePod(outputFile, refreshInterval);
serialization::writePod(outputFile, defaultFolder);
serialization::writeString(outputFile, customDefaultFolder);
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -92,6 +95,12 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, autoSleepMinutes);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, refreshInterval);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, defaultFolder);
if (++settingsRead >= fileSettingsCount) break;
serialization::readString(inputFile, customDefaultFolder);
if (++settingsRead >= fileSettingsCount) break;
} while (false);
inputFile.close();

View File

@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <iosfwd>
#include <string>
class CrossPointSettings {
private:
@ -44,6 +45,9 @@ class CrossPointSettings {
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
// Default folder options
enum DEFAULT_FOLDER { FOLDER_ROOT = 0, FOLDER_CUSTOM = 1, FOLDER_LAST_USED = 2 };
// Sleep screen settings
uint8_t sleepScreen = DARK;
// Status bar settings
@ -68,6 +72,13 @@ class CrossPointSettings {
uint8_t useCoverArtPicker = 0;
// Auto-sleep timeout (enum index: 0=2min, 1=5min, 2=10min, 3=15min, 4=20min, 5=30min, 6=60min, 7=Never)
uint8_t autoSleepMinutes = 1; // Default to 5 minutes
// Screen refresh interval (enum index: 0=1pg, 1=3pg, 2=5pg, 3=10pg, 4=15pg, 5=20pg)
uint8_t refreshInterval = 4; // Default to 15 pages (current behavior)
// Default folder for file browser (enum index: 0=Root, 1=Custom, 2=Last Used)
uint8_t defaultFolder = FOLDER_LAST_USED; // Default to last used (current behavior)
// Custom default folder path (used when defaultFolder == FOLDER_CUSTOM)
std::string customDefaultFolder = "/books";
~CrossPointSettings() = default;
@ -91,6 +102,19 @@ class CrossPointSettings {
return (autoSleepMinutes < 8) ? timeouts[autoSleepMinutes] : timeouts[2];
}
int getRefreshIntervalPages() const {
// Map enum index to pages: 0=1, 1=3, 2=5, 3=10, 4=15 (default), 5=20
constexpr int intervals[] = {1, 3, 5, 10, 15, 20};
return (refreshInterval < 6) ? intervals[refreshInterval] : 15;
}
const char* getDefaultFolderPath() const {
// Returns the configured default folder path (doesn't handle FOLDER_LAST_USED)
if (defaultFolder == FOLDER_ROOT) return "/";
if (defaultFolder == FOLDER_CUSTOM) return customDefaultFolder.c_str();
return "/"; // Fallback
}
bool saveToFile() const;
bool loadFromFile();

View File

@ -5,7 +5,7 @@
#include <Serialization.h>
namespace {
constexpr uint8_t STATE_FILE_VERSION = 1;
constexpr uint8_t STATE_FILE_VERSION = 2;
constexpr char STATE_FILE[] = "/.crosspoint/state.bin";
} // namespace
@ -19,6 +19,7 @@ bool CrossPointState::saveToFile() const {
serialization::writePod(outputFile, STATE_FILE_VERSION);
serialization::writeString(outputFile, openEpubPath);
serialization::writeString(outputFile, lastBrowsedFolder);
outputFile.close();
return true;
}
@ -31,14 +32,20 @@ bool CrossPointState::loadFromFile() {
uint8_t version;
serialization::readPod(inputFile, version);
if (version != STATE_FILE_VERSION) {
if (version == 1) {
// Version 1: only had openEpubPath
serialization::readString(inputFile, openEpubPath);
lastBrowsedFolder = "/"; // Default for old version
} else if (version == STATE_FILE_VERSION) {
// Version 2: has openEpubPath and lastBrowsedFolder
serialization::readString(inputFile, openEpubPath);
serialization::readString(inputFile, lastBrowsedFolder);
} else {
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version);
inputFile.close();
return false;
}
serialization::readString(inputFile, openEpubPath);
inputFile.close();
return true;
}

View File

@ -8,6 +8,7 @@ class CrossPointState {
public:
std::string openEpubPath;
std::string lastBrowsedFolder = "/";
~CrossPointState() = default;
// Get singleton instance

View File

@ -6,6 +6,7 @@
#include <Xtc.h>
#include "Bitmap.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "fontIds.h"
@ -103,6 +104,8 @@ void CoverArtPickerActivity::loop() {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) {
if (basepath != "/") {
basepath = "/";
APP_STATE.lastBrowsedFolder = basepath;
APP_STATE.saveToFile();
loadFiles();
updateRequired = true;
}
@ -124,6 +127,8 @@ void CoverArtPickerActivity::loop() {
if (basepath.back() != '/') basepath += "/";
if (files[selectorIndex].back() == '/') {
basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1);
APP_STATE.lastBrowsedFolder = basepath;
APP_STATE.saveToFile();
loadFiles();
updateRequired = true;
} else {
@ -135,6 +140,8 @@ void CoverArtPickerActivity::loop() {
if (basepath != "/") {
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/";
APP_STATE.lastBrowsedFolder = basepath;
APP_STATE.saveToFile();
loadFiles();
updateRequired = true;
} else {

View File

@ -13,7 +13,6 @@
#include "fontIds.h"
namespace {
constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000;
constexpr int topPadding = 5;
@ -378,7 +377,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
pagesUntilFullRefresh = SETTINGS.getRefreshIntervalPages();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;

View File

@ -3,6 +3,7 @@
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "fontIds.h"
@ -102,6 +103,8 @@ void FileSelectionActivity::loop() {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) {
if (basepath != "/") {
basepath = "/";
APP_STATE.lastBrowsedFolder = basepath;
APP_STATE.saveToFile();
loadFiles();
updateRequired = true;
}
@ -123,6 +126,8 @@ void FileSelectionActivity::loop() {
if (basepath.back() != '/') basepath += "/";
if (files[selectorIndex].back() == '/') {
basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1);
APP_STATE.lastBrowsedFolder = basepath;
APP_STATE.saveToFile();
loadFiles();
updateRequired = true;
} else {
@ -134,6 +139,8 @@ void FileSelectionActivity::loop() {
if (basepath != "/") {
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/";
APP_STATE.lastBrowsedFolder = basepath;
APP_STATE.saveToFile();
loadFiles();
updateRequired = true;
} else {

View File

@ -1,6 +1,7 @@
#include "ReaderActivity.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "CoverArtPickerActivity.h"
#include "Epub.h"
#include "EpubReaderActivity.h"
@ -92,8 +93,22 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) {
exitActivity();
// If coming from a book, start in that book's folder; otherwise start from root
const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath);
// Determine initial path based on default folder setting
std::string initialPath;
if (SETTINGS.defaultFolder == CrossPointSettings::FOLDER_LAST_USED) {
// Use last browsed folder, or fall back to book's folder if coming from a book
if (!fromBookPath.empty()) {
initialPath = extractFolderPath(fromBookPath);
} else if (!APP_STATE.lastBrowsedFolder.empty()) {
initialPath = APP_STATE.lastBrowsedFolder;
} else {
initialPath = "/";
}
} else {
// Use configured default folder (Root or Books)
initialPath = SETTINGS.getDefaultFolderPath();
}
// Check if cover art picker is enabled
if (SETTINGS.useCoverArtPicker) {

View File

@ -11,13 +11,13 @@
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "XtcReaderChapterSelectionActivity.h"
#include "fontIds.h"
namespace {
constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipPageMs = 700;
constexpr unsigned long goHomeMs = 1000;
} // namespace
@ -266,7 +266,7 @@ void XtcReaderActivity::renderPage() {
// Display BW with conditional refresh based on pagesUntilFullRefresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
pagesUntilFullRefresh = SETTINGS.getRefreshIntervalPages();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
@ -346,7 +346,7 @@ void XtcReaderActivity::renderPage() {
// Display with appropriate refresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
pagesUntilFullRefresh = SETTINGS.getRefreshIntervalPages();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;

View File

@ -0,0 +1,195 @@
#include "FolderPickerActivity.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFolderList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
return lexicographical_compare(
begin(str1), end(str1), begin(str2), end(str2),
[](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); });
});
}
void FolderPickerActivity::taskTrampoline(void* param) {
auto* self = static_cast<FolderPickerActivity*>(param);
self->displayTaskLoop();
}
void FolderPickerActivity::loadFolders() {
folders.clear();
selectorIndex = 0;
// Add option to select current folder
folders.emplace_back("[Select This Folder]");
auto root = SdMan.open(basepath.c_str());
if (!root || !root.isDirectory()) {
if (root) root.close();
return;
}
root.rewindDirectory();
char name[128];
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
file.close();
continue;
}
if (file.isDirectory()) {
folders.emplace_back(std::string(name) + "/");
}
file.close();
}
root.close();
// Sort only the actual folders (skip the first item which is "[Select This Folder]")
if (folders.size() > 1) {
std::vector<std::string> actualFolders(folders.begin() + 1, folders.end());
sortFolderList(actualFolders);
std::copy(actualFolders.begin(), actualFolders.end(), folders.begin() + 1);
}
}
void FolderPickerActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
entryTime = millis();
loadFolders();
selectorIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&FolderPickerActivity::taskTrampoline, "FolderPickerActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void FolderPickerActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
folders.clear();
}
void FolderPickerActivity::loop() {
// Ignore button presses for 200ms after entry to avoid processing the button that opened this activity
if (millis() - entryTime < 200) {
return;
}
// Long press BACK (1s+) goes to root folder
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) {
if (basepath != "/") {
basepath = "/";
loadFolders();
updateRequired = true;
}
return;
}
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (folders.empty()) {
return;
}
if (selectorIndex == 0) {
// "[Select This Folder]" option selected
onSelect(basepath);
} else if (selectorIndex < folders.size()) {
// Navigate into the selected folder
if (basepath.back() != '/') basepath += "/";
basepath += folders[selectorIndex].substr(0, folders[selectorIndex].length() - 1);
loadFolders();
updateRequired = true;
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// Short press: go up one directory, or cancel if at root
if (mappedInput.getHeldTime() < GO_HOME_MS) {
if (basepath != "/") {
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/";
loadFolders();
updateRequired = true;
} else {
onCancel();
}
}
} else if (prevReleased && !folders.empty()) {
selectorIndex = (selectorIndex + folders.size() - 1) % folders.size();
updateRequired = true;
} else if (nextReleased && !folders.empty()) {
selectorIndex = (selectorIndex + 1) % folders.size();
updateRequired = true;
}
}
void FolderPickerActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void FolderPickerActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Choose Default Folder", true, BOLD);
// Display current path
auto truncatedPath = renderer.truncatedText(SMALL_FONT_ID, basepath.c_str(), pageWidth - 40);
renderer.drawText(SMALL_FONT_ID, 20, 35, truncatedPath.c_str());
// Help text
const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
if (folders.empty()) {
renderer.drawText(UI_10_FONT_ID, 20, 60, "No subfolders. Press Select to use this folder.");
renderer.displayBuffer();
return;
}
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < folders.size() && i < pageStartIndex + PAGE_ITEMS; i++) {
auto item = renderer.truncatedText(UI_10_FONT_ID, folders[i].c_str(), renderer.getScreenWidth() - 40);
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex);
}
renderer.displayBuffer();
}

View File

@ -0,0 +1,39 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
class FolderPickerActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::string basepath = "/";
std::vector<std::string> folders;
int selectorIndex = 0;
bool updateRequired = false;
unsigned long entryTime = 0;
const std::function<void(const std::string&)> onSelect;
const std::function<void()> onCancel;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void loadFolders();
public:
explicit FolderPickerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onCancel, std::string initialPath = "/")
: Activity("FolderPicker", renderer, mappedInput),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onSelect(onSelect),
onCancel(onCancel) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -3,13 +3,14 @@
#include <GfxRenderer.h>
#include "CrossPointSettings.h"
#include "FolderPickerActivity.h"
#include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "fontIds.h"
// Define the static settings list
namespace {
constexpr int settingsCount = 14;
constexpr int settingsCount = 17;
const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
@ -34,11 +35,20 @@ const SettingInfo settingsList[settingsCount] = {
{"Bookerly", "Noto Sans", "Open Dyslexic"}},
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
{"Screen Refresh Interval",
SettingType::ENUM,
&CrossPointSettings::refreshInterval,
{"Every page", "Every 3 pages", "Every 5 pages", "Every 10 pages", "Every 15 pages", "Every 20 pages"}},
{"Cover Art Picker", SettingType::TOGGLE, &CrossPointSettings::useCoverArtPicker, {}},
{"Auto Sleep Timeout",
SettingType::ENUM,
&CrossPointSettings::autoSleepMinutes,
{"2 min", "5 min", "10 min", "15 min", "20 min", "30 min", "60 min", "Never"}},
{"Default Folder",
SettingType::ENUM,
&CrossPointSettings::defaultFolder,
{"Root", "Custom", "Last Used"}},
{"Choose Custom Folder", SettingType::ACTION, nullptr, {}},
{"Bluetooth", SettingType::TOGGLE, &CrossPointSettings::bluetoothEnabled, {}},
{"Check for updates", SettingType::ACTION, nullptr, {}},
};
@ -140,6 +150,23 @@ void SettingsActivity::toggleCurrentSetting() {
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (std::string(setting.name) == "Choose Custom Folder") {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new FolderPickerActivity(
renderer, mappedInput,
[this](const std::string& path) {
SETTINGS.customDefaultFolder = path;
SETTINGS.saveToFile();
exitActivity();
updateRequired = true;
},
[this] {
exitActivity();
updateRequired = true;
},
"/")); // Start from root directory
xSemaphoreGive(renderingMutex);
}
} else {
// Only toggle if it's a toggle type and has a value pointer
@ -189,6 +216,11 @@ void SettingsActivity::render() const {
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
valueText = settingsList[i].enumValues[value];
} else if (settingsList[i].type == SettingType::ACTION) {
// Show current value for "Choose Custom Folder"
if (std::string(settingsList[i].name) == "Choose Custom Folder") {
valueText = SETTINGS.customDefaultFolder;
}
}
const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex);