This PR unifies navigation handling & adds system-wide support for continuous navigation. ## Summary Holding down a navigation button now continuously advances through items until the button is released. This removes the need for repeated press-and-release actions and makes navigation faster and smoother, especially in long menus or documents. When page-based navigation is available, it will navigate through pages. If not, it will progress through menu items or similar list-based UI elements. Additionally, this PR fixes inconsistencies in wrap-around behavior and navigation index calculations. Places where the navigation system was updated: - Home Page - Settings Pages - My Library Page - WiFi Selection Page - OPDS Browser Page - Keyboard - File Transfer Page - XTC Chapter Selector Page - EPUB Chapter Selector Page I’ve tested this on the device as much as possible and tried to match the existing behavior. Please let me know if I missed anything. Thanks 🙏  --- Following the request from @osteotek and @daveallie for system-wide support, the old PR (#379) has been closed in favor of this consolidated, system-wide implementation. --- ### AI Usage Did you use AI tools to help write this code? _**PARTIALLY**_ --------- Co-authored-by: Dave Allie <dave@daveallie.com>
291 lines
8.6 KiB
C++
291 lines
8.6 KiB
C++
#include "HomeActivity.h"
|
|
|
|
#include <Bitmap.h>
|
|
#include <Epub.h>
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <Utf8.h>
|
|
#include <Xtc.h>
|
|
|
|
#include <cstring>
|
|
#include <vector>
|
|
|
|
#include "Battery.h"
|
|
#include "CrossPointSettings.h"
|
|
#include "CrossPointState.h"
|
|
#include "MappedInputManager.h"
|
|
#include "RecentBooksStore.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
#include "util/StringUtils.h"
|
|
|
|
void HomeActivity::taskTrampoline(void* param) {
|
|
auto* self = static_cast<HomeActivity*>(param);
|
|
self->displayTaskLoop();
|
|
}
|
|
|
|
int HomeActivity::getMenuItemCount() const {
|
|
int count = 4; // My Library, Recents, File transfer, Settings
|
|
if (!recentBooks.empty()) {
|
|
count += recentBooks.size();
|
|
}
|
|
if (hasOpdsUrl) {
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
void HomeActivity::loadRecentBooks(int maxBooks) {
|
|
recentBooks.clear();
|
|
const auto& books = RECENT_BOOKS.getBooks();
|
|
recentBooks.reserve(std::min(static_cast<int>(books.size()), maxBooks));
|
|
|
|
for (const RecentBook& book : books) {
|
|
// Limit to maximum number of recent books
|
|
if (recentBooks.size() >= maxBooks) {
|
|
break;
|
|
}
|
|
|
|
// Skip if file no longer exists
|
|
if (!Storage.exists(book.path.c_str())) {
|
|
continue;
|
|
}
|
|
|
|
recentBooks.push_back(book);
|
|
}
|
|
}
|
|
|
|
void HomeActivity::loadRecentCovers(int coverHeight) {
|
|
recentsLoading = true;
|
|
bool showingLoading = false;
|
|
Rect popupRect;
|
|
|
|
int progress = 0;
|
|
for (RecentBook& book : recentBooks) {
|
|
if (!book.coverBmpPath.empty()) {
|
|
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
|
if (!Storage.exists(coverPath.c_str())) {
|
|
// If epub, try to load the metadata for title/author and cover
|
|
if (StringUtils::checkFileExtension(book.path, ".epub")) {
|
|
Epub epub(book.path, "/.crosspoint");
|
|
// Skip loading css since we only need metadata here
|
|
epub.load(false, true);
|
|
|
|
// Try to generate thumbnail image for Continue Reading card
|
|
if (!showingLoading) {
|
|
showingLoading = true;
|
|
popupRect = GUI.drawPopup(renderer, "Loading...");
|
|
}
|
|
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
|
bool success = epub.generateThumbBmp(coverHeight);
|
|
if (!success) {
|
|
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
|
book.coverBmpPath = "";
|
|
}
|
|
coverRendered = false;
|
|
updateRequired = true;
|
|
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
|
|
StringUtils::checkFileExtension(book.path, ".xtc")) {
|
|
// Handle XTC file
|
|
Xtc xtc(book.path, "/.crosspoint");
|
|
if (xtc.load()) {
|
|
// Try to generate thumbnail image for Continue Reading card
|
|
if (!showingLoading) {
|
|
showingLoading = true;
|
|
popupRect = GUI.drawPopup(renderer, "Loading...");
|
|
}
|
|
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
|
bool success = xtc.generateThumbBmp(coverHeight);
|
|
if (!success) {
|
|
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
|
book.coverBmpPath = "";
|
|
}
|
|
coverRendered = false;
|
|
updateRequired = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
progress++;
|
|
}
|
|
|
|
recentsLoaded = true;
|
|
recentsLoading = false;
|
|
}
|
|
|
|
void HomeActivity::onEnter() {
|
|
Activity::onEnter();
|
|
|
|
renderingMutex = xSemaphoreCreateMutex();
|
|
|
|
// Check if OPDS browser URL is configured
|
|
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
|
|
|
|
selectorIndex = 0;
|
|
|
|
auto metrics = UITheme::getInstance().getMetrics();
|
|
loadRecentBooks(metrics.homeRecentBooksCount);
|
|
|
|
// Trigger first update
|
|
updateRequired = true;
|
|
|
|
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
|
|
8192, // Stack size
|
|
this, // Parameters
|
|
1, // Priority
|
|
&displayTaskHandle // Task handle
|
|
);
|
|
}
|
|
|
|
void HomeActivity::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;
|
|
|
|
// Free the stored cover buffer if any
|
|
freeCoverBuffer();
|
|
}
|
|
|
|
bool HomeActivity::storeCoverBuffer() {
|
|
uint8_t* frameBuffer = renderer.getFrameBuffer();
|
|
if (!frameBuffer) {
|
|
return false;
|
|
}
|
|
|
|
// Free any existing buffer first
|
|
freeCoverBuffer();
|
|
|
|
const size_t bufferSize = GfxRenderer::getBufferSize();
|
|
coverBuffer = static_cast<uint8_t*>(malloc(bufferSize));
|
|
if (!coverBuffer) {
|
|
return false;
|
|
}
|
|
|
|
memcpy(coverBuffer, frameBuffer, bufferSize);
|
|
return true;
|
|
}
|
|
|
|
bool HomeActivity::restoreCoverBuffer() {
|
|
if (!coverBuffer) {
|
|
return false;
|
|
}
|
|
|
|
uint8_t* frameBuffer = renderer.getFrameBuffer();
|
|
if (!frameBuffer) {
|
|
return false;
|
|
}
|
|
|
|
const size_t bufferSize = GfxRenderer::getBufferSize();
|
|
memcpy(frameBuffer, coverBuffer, bufferSize);
|
|
return true;
|
|
}
|
|
|
|
void HomeActivity::freeCoverBuffer() {
|
|
if (coverBuffer) {
|
|
free(coverBuffer);
|
|
coverBuffer = nullptr;
|
|
}
|
|
coverBufferStored = false;
|
|
}
|
|
|
|
void HomeActivity::loop() {
|
|
const int menuCount = getMenuItemCount();
|
|
|
|
buttonNavigator.onNext([this, menuCount] {
|
|
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
|
updateRequired = true;
|
|
});
|
|
|
|
buttonNavigator.onPrevious([this, menuCount] {
|
|
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
|
|
updateRequired = true;
|
|
});
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
// Calculate dynamic indices based on which options are available
|
|
int idx = 0;
|
|
int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
|
const int myLibraryIdx = idx++;
|
|
const int recentsIdx = idx++;
|
|
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
|
|
const int fileTransferIdx = idx++;
|
|
const int settingsIdx = idx;
|
|
|
|
if (selectorIndex < recentBooks.size()) {
|
|
onSelectBook(recentBooks[selectorIndex].path);
|
|
} else if (menuSelectedIndex == myLibraryIdx) {
|
|
onMyLibraryOpen();
|
|
} else if (menuSelectedIndex == recentsIdx) {
|
|
onRecentsOpen();
|
|
} else if (menuSelectedIndex == opdsLibraryIdx) {
|
|
onOpdsBrowserOpen();
|
|
} else if (menuSelectedIndex == fileTransferIdx) {
|
|
onFileTransferOpen();
|
|
} else if (menuSelectedIndex == settingsIdx) {
|
|
onSettingsOpen();
|
|
}
|
|
}
|
|
}
|
|
|
|
void HomeActivity::displayTaskLoop() {
|
|
while (true) {
|
|
if (updateRequired) {
|
|
updateRequired = false;
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
render();
|
|
xSemaphoreGive(renderingMutex);
|
|
}
|
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
}
|
|
}
|
|
|
|
void HomeActivity::render() {
|
|
auto metrics = UITheme::getInstance().getMetrics();
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
renderer.clearScreen();
|
|
bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
|
|
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr);
|
|
|
|
GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight},
|
|
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
|
|
std::bind(&HomeActivity::storeCoverBuffer, this));
|
|
|
|
// Build menu items dynamically
|
|
std::vector<const char*> menuItems = {"Browse Files", "Recents", "File Transfer", "Settings"};
|
|
if (hasOpdsUrl) {
|
|
// Insert OPDS Browser after My Library
|
|
menuItems.insert(menuItems.begin() + 2, "OPDS Browser");
|
|
}
|
|
|
|
GUI.drawButtonMenu(
|
|
renderer,
|
|
Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth,
|
|
pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 +
|
|
metrics.buttonHintsHeight)},
|
|
static_cast<int>(menuItems.size()), selectorIndex - recentBooks.size(),
|
|
[&menuItems](int index) { return std::string(menuItems[index]); }, nullptr);
|
|
|
|
const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
renderer.displayBuffer();
|
|
|
|
if (!firstRenderDone) {
|
|
firstRenderDone = true;
|
|
updateRequired = true;
|
|
} else if (!recentsLoaded && !recentsLoading) {
|
|
recentsLoading = true;
|
|
loadRecentCovers(metrics.homeCoverHeight);
|
|
}
|
|
}
|