Add Continue Reading menu and remember last book folder (#129)

## Summary

* **What is the goal of this PR?**

Add a "Continue Reading" feature to improve user experience when
returning to a previously opened book.

* **What changes are included?**

- Add dynamic "Continue: <book name>" menu item in Home screen when a
book was previously opened

- File browser now starts from the folder of the last opened book
instead of always starting from root directory
- Menu dynamically shows 3 or 4 items based on reading history:
  - Without history: `Browse`, `File transfer`, `Settings`
- With history: `Continue: <book>`, `Browse`, `File transfer`,
`Settings`

## Additional Context

* This feature leverages the existing `APP_STATE.openEpubPath` which
already persists the last opened book path
* The Continue Reading menu only appears if the book file still exists
on the SD card
* Book name in the menu is truncated to 25 characters with "..." suffix
if too long
* If the last book's folder was deleted, the file browser gracefully
falls back to root directory
* No new dependencies or significant memory overhead - reuses existing
state management
This commit is contained in:
Eunchurn Park
2025-12-26 09:55:23 +09:00
committed by GitHub
parent e3c1e28b8f
commit b77af16caa
9 changed files with 140 additions and 37 deletions

View File

@@ -4,22 +4,24 @@
#include <InputManager.h>
#include <SD.h>
#include "CrossPointState.h"
#include "config.h"
namespace {
constexpr int menuItemCount = 3;
}
void HomeActivity::taskTrampoline(void* param) {
auto* self = static_cast<HomeActivity*>(param);
self->displayTaskLoop();
}
int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; }
void HomeActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Check if we have a book to continue reading
hasContinueReading = !APP_STATE.openEpubPath.empty() && SD.exists(APP_STATE.openEpubPath.c_str());
selectorIndex = 0;
// Trigger first update
@@ -52,7 +54,22 @@ void HomeActivity::loop() {
const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
const int menuCount = getMenuItemCount();
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (hasContinueReading) {
// Menu: Continue Reading, Browse, File transfer, Settings
if (selectorIndex == 0) {
onContinueReading();
} else if (selectorIndex == 1) {
onReaderOpen();
} else if (selectorIndex == 2) {
onFileTransferOpen();
} else if (selectorIndex == 3) {
onSettingsOpen();
}
} else {
// Menu: Browse, File transfer, Settings
if (selectorIndex == 0) {
onReaderOpen();
} else if (selectorIndex == 1) {
@@ -60,11 +77,12 @@ void HomeActivity::loop() {
} else if (selectorIndex == 2) {
onSettingsOpen();
}
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount;
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuItemCount;
selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true;
}
}
@@ -89,9 +107,41 @@ void HomeActivity::render() const {
// Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30);
renderer.drawText(UI_FONT_ID, 20, 60, "Read", selectorIndex != 0);
renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1);
renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2);
int menuY = 60;
int menuIndex = 0;
if (hasContinueReading) {
// Extract filename from path for display
std::string bookName = APP_STATE.openEpubPath;
const size_t lastSlash = bookName.find_last_of('/');
if (lastSlash != std::string::npos) {
bookName = bookName.substr(lastSlash + 1);
}
// Remove .epub extension
if (bookName.length() > 5 && bookName.substr(bookName.length() - 5) == ".epub") {
bookName.resize(bookName.length() - 5);
}
// Truncate if too long
if (bookName.length() > 25) {
bookName.resize(22);
bookName += "...";
}
std::string continueLabel = "Continue: " + bookName;
renderer.drawText(UI_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex);
menuY += 30;
menuIndex++;
}
renderer.drawText(UI_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex);
menuY += 30;
menuIndex++;
renderer.drawText(UI_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex);
menuY += 30;
menuIndex++;
renderer.drawText(UI_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex);
renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right");

View File

@@ -12,6 +12,8 @@ class HomeActivity final : public Activity {
SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0;
bool updateRequired = false;
bool hasContinueReading = false;
const std::function<void()> onContinueReading;
const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen;
@@ -19,11 +21,14 @@ class HomeActivity final : public Activity {
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
int getMenuItemCount() const;
public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onReaderOpen,
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
: Activity("Home", renderer, inputManager),
onContinueReading(onContinueReading),
onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen) {}

View File

@@ -14,6 +14,7 @@
namespace {
constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000;
constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8;
constexpr int marginRight = 10;
@@ -108,7 +109,14 @@ void EpubReaderActivity::loop() {
xSemaphoreGive(renderingMutex);
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
// Long press BACK (1s+) goes directly to home
if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) {
onGoHome();
return;
}
// Short press BACK goes to file selection
if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) {
onGoBack();
return;
}

View File

@@ -17,6 +17,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
int pagesUntilFullRefresh = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
@@ -26,8 +27,11 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
public:
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack)
: ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {}
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
: ActivityWithSubactivity("EpubReader", renderer, inputManager),
epub(std::move(epub)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -9,6 +9,7 @@
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) {
@@ -53,7 +54,7 @@ void FileSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
basepath = "/";
// basepath is set via constructor parameter (defaults to "/" if not specified)
loadFiles();
selectorIndex = 0;
@@ -83,6 +84,16 @@ void FileSelectionActivity::onExit() {
}
void FileSelectionActivity::loop() {
// Long press BACK (1s+) goes to root folder
if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= GO_HOME_MS) {
if (basepath != "/") {
basepath = "/";
loadFiles();
updateRequired = true;
}
return;
}
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
@@ -103,16 +114,18 @@ void FileSelectionActivity::loop() {
} else {
onSelect(basepath + files[selectorIndex]);
}
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
} else if (inputManager.wasReleased(InputManager::BTN_BACK)) {
// Short press: go up one directory, or go home if at root
if (inputManager.getHeldTime() < GO_HOME_MS) {
if (basepath != "/") {
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/";
loadFiles();
updateRequired = true;
} else {
// At root level, go back home
onGoHome();
}
}
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + files.size()) % files.size();

View File

@@ -27,8 +27,11 @@ class FileSelectionActivity final : public Activity {
public:
explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome)
: Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {}
const std::function<void()>& onGoHome, std::string initialPath = "/")
: Activity("FileSelection", renderer, inputManager),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onSelect(onSelect),
onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -7,6 +7,14 @@
#include "FileSelectionActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
return "/";
}
return filePath.substr(0, lastSlash);
}
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
@@ -23,6 +31,7 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
}
void ReaderActivity::onSelectEpubFile(const std::string& path) {
currentEpubPath = path; // Track current book path
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
@@ -38,25 +47,32 @@ void ReaderActivity::onSelectEpubFile(const std::string& path) {
}
}
void ReaderActivity::onGoToFileSelection() {
void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) {
exitActivity();
// If coming from a book, start in that book's folder; otherwise start from root
const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath);
enterNewActivity(new FileSelectionActivity(
renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack));
renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath));
}
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
const auto epubPath = epub->getPath();
currentEpubPath = epubPath;
exitActivity();
enterNewActivity(new EpubReaderActivity(renderer, inputManager, std::move(epub), [this] { onGoToFileSelection(); }));
enterNewActivity(new EpubReaderActivity(
renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); },
[this] { onGoBack(); }));
}
void ReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (initialEpubPath.empty()) {
onGoToFileSelection();
onGoToFileSelection(); // Start from root when entering via Browse
return;
}
currentEpubPath = initialEpubPath;
auto epub = loadEpub(initialEpubPath);
if (!epub) {
onGoBack();

View File

@@ -7,11 +7,13 @@ class Epub;
class ReaderActivity final : public ActivityWithSubactivity {
std::string initialEpubPath;
std::string currentEpubPath; // Track current book path for navigation
const std::function<void()> onGoBack;
static std::unique_ptr<Epub> loadEpub(const std::string& path);
static std::string extractFolderPath(const std::string& filePath);
void onSelectEpubFile(const std::string& path);
void onGoToFileSelection();
void onGoToFileSelection(const std::string& fromEpubPath = "");
void onGoToEpubReader(std::unique_ptr<Epub> epub);
public:

View File

@@ -142,6 +142,7 @@ void onGoToReader(const std::string& initialEpubPath) {
enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome));
}
void onGoToReaderHome() { onGoToReader(std::string()); }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); }
void onGoToFileTransfer() {
exitActivity();
@@ -155,7 +156,8 @@ void onGoToSettings() {
void onGoHome() {
exitActivity();
enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer));
enterNewActivity(new HomeActivity(renderer, inputManager, onContinueReading, onGoToReaderHome, onGoToSettings,
onGoToFileTransfer));
}
void setupDisplayAndFonts() {