285 lines
10 KiB
C++
285 lines
10 KiB
C++
#define SDL_MAIN_USE_CALLBACKS 1
|
|
#include <SDL3/SDL.h>
|
|
#include <SDL3/SDL_main.h>
|
|
|
|
#include "app_state.h"
|
|
#include "home.h"
|
|
#include "logical_size.h"
|
|
#include "logger/logger.h"
|
|
#include "provisioning.h"
|
|
#include "scroller_page.h"
|
|
|
|
namespace {
|
|
// How long audio fades out before the app tears down on quit. Only applied
|
|
// when audio is actually playing; otherwise quitting is immediate.
|
|
constexpr float kQuitFadeSeconds = 5.0f;
|
|
} // anonymous namespace
|
|
|
|
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
|
|
// Initialize text logger
|
|
radiantwave::logger::Logger::instance().initialize();
|
|
radiantwave::logger::Logger::instance().info(
|
|
"System", "RadiantWave Kiosk starting up");
|
|
|
|
if (!SDL_Init(SDL_INIT_VIDEO)) {
|
|
std::string err = SDL_GetError();
|
|
radiantwave::logger::Logger::instance().fatal(
|
|
"Graphics",
|
|
"Screen display initialization failed. Please verify graphics "
|
|
"hardware and display connections (SDL_Init error: " +
|
|
err + ")");
|
|
SDL_Log("Failed to initialize SDL: %s", err.c_str());
|
|
return SDL_APP_FAILURE;
|
|
}
|
|
|
|
auto *state = new AppState{};
|
|
|
|
// Size the fullscreen window from the display's actual mode so the very
|
|
// first buffer commit already matches what the compositor scanouts 1:1.
|
|
// Requesting a hardcoded size left the backbuffer at that size on
|
|
// displays running a different mode, and frames reached the panel
|
|
// unscaled (quarter-size on 4K, cropped on smaller panels).
|
|
const SDL_DisplayMode *displayMode =
|
|
SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
|
|
int windowWidth =
|
|
(displayMode && displayMode->w > 0) ? displayMode->w
|
|
: radiantwave::kDesignWidth;
|
|
int windowHeight =
|
|
(displayMode && displayMode->h > 0) ? displayMode->h
|
|
: radiantwave::kDesignHeight;
|
|
|
|
// Borderless desktop fullscreen: the compositor never has to scale the
|
|
// moving scroller (scaling moving text causes flicker). Deliberately no
|
|
// SDL_WINDOW_HIGH_PIXEL_DENSITY — kiosk panels are not HiDPI, so keeping
|
|
// the pixel buffer equal to the display mode keeps scanout 1:1.
|
|
state->window = SDL_CreateWindow("RadiantWave", windowWidth, windowHeight,
|
|
SDL_WINDOW_FULLSCREEN);
|
|
if (!state->window) {
|
|
SDL_Log("Failed to create window: %s", SDL_GetError());
|
|
delete state;
|
|
return SDL_APP_FAILURE;
|
|
}
|
|
|
|
state->renderer = SDL_CreateRenderer(state->window, nullptr);
|
|
if (!state->renderer) {
|
|
SDL_Log("Failed to create renderer: %s", SDL_GetError());
|
|
delete state;
|
|
return SDL_APP_FAILURE;
|
|
}
|
|
|
|
// Present on the display's refresh. Without VSync the kiosk panel tears
|
|
// fast-scrolling text into horizontal bands that flicker as the text moves.
|
|
if (!SDL_SetRenderVSync(state->renderer, 1)) {
|
|
radiantwave::logger::Logger::instance().warning(
|
|
"Graphics", "VSync could not be enabled; scrolling text may tear "
|
|
"(VSync error: " +
|
|
std::string(SDL_GetError()) + ")");
|
|
}
|
|
|
|
// All page layout targets the fixed design resolution; LETTERBOX maps it
|
|
// onto the real window with aspect preserved (bars on non-16:9 panels).
|
|
if (!SDL_SetRenderLogicalPresentation(state->renderer,
|
|
radiantwave::kDesignWidth,
|
|
radiantwave::kDesignHeight,
|
|
SDL_LOGICAL_PRESENTATION_LETTERBOX)) {
|
|
radiantwave::logger::Logger::instance().warning(
|
|
"Graphics", "Could not set logical render size; layout may be "
|
|
"wrong on HiDPI displays");
|
|
}
|
|
|
|
{
|
|
// Record how the requested mode, window, pixel buffer, and render
|
|
// output actually compare — this is the evidence trail for
|
|
// display-scaling problems on field units.
|
|
int windowW = 0, windowH = 0;
|
|
SDL_GetWindowSize(state->window, &windowW, &windowH);
|
|
int pixelW = 0, pixelH = 0;
|
|
SDL_GetWindowSizeInPixels(state->window, &pixelW, &pixelH);
|
|
int outputW = 0, outputH = 0;
|
|
SDL_GetRenderOutputSize(state->renderer, &outputW, &outputH);
|
|
radiantwave::logger::Logger::instance().info(
|
|
"Graphics",
|
|
"Requested " + std::to_string(windowWidth) + "x" +
|
|
std::to_string(windowHeight) + ", window " +
|
|
std::to_string(windowW) + "x" + std::to_string(windowH) +
|
|
", pixels " + std::to_string(pixelW) + "x" +
|
|
std::to_string(pixelH) + ", render output " +
|
|
std::to_string(outputW) + "x" + std::to_string(outputH) +
|
|
", presentation LETTERBOX");
|
|
}
|
|
|
|
SDL_HideCursor();
|
|
|
|
// Initialize configuration, database, and network manager
|
|
if (!state->config.initialize()) {
|
|
radiantwave::logger::Logger::instance().warning(
|
|
"Config", "Settings file could not be loaded. Default settings "
|
|
"will be used (Config initialization failed at "
|
|
"~/.local/radiantwave/config.yaml)");
|
|
} else {
|
|
radiantwave::logger::Logger::instance().info(
|
|
"Config", "Configuration initialized successfully");
|
|
}
|
|
|
|
if (!state->db.initialize()) {
|
|
radiantwave::logger::Logger::instance().error(
|
|
"Database", "Database storage unavailable. App features may be "
|
|
"limited (Database initialization failed at "
|
|
"~/.local/radiantwave/radiantwave.db)");
|
|
} else {
|
|
radiantwave::logger::Logger::instance().info(
|
|
"Database", "Database initialized and schema migrated");
|
|
}
|
|
|
|
if (!state->network.initialize()) {
|
|
radiantwave::logger::Logger::instance().warning(
|
|
"Network", "WiFi network management is unavailable. Check if "
|
|
"NetworkManager is running (D-Bus connection to "
|
|
"org.freedesktop.NetworkManager failed)");
|
|
} else {
|
|
radiantwave::logger::Logger::instance().info(
|
|
"Network", "NetworkManager service connected");
|
|
}
|
|
|
|
if (!state->audio.initialize(state->config)) {
|
|
radiantwave::logger::Logger::instance().warning(
|
|
"Audio", "Audio playback is unavailable. Check sound hardware "
|
|
"connections (SDL3_mixer initialization failed)");
|
|
} else {
|
|
radiantwave::logger::Logger::instance().info(
|
|
"Audio", "Audio subsystem initialized");
|
|
}
|
|
// Let playback logs report user-facing display names from the database.
|
|
state->audio.attachDatabase(state->db);
|
|
|
|
// Background download of audio assets. The worker keeps retrying until
|
|
// internet is available, so downloads start automatically on connect.
|
|
// The downloaded files are reconciled into the database's audio_files
|
|
// table.
|
|
state->audioDownloader.start(state->db);
|
|
|
|
// Background license validation. Home triggers a refresh whenever it is
|
|
// displayed; the result gates access to Play and the Proclamation options.
|
|
state->licenseTracker.start(state->db);
|
|
|
|
state->config.onChanged(
|
|
[state](const radiantwave::config::AppConfig &new_config) {
|
|
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d",
|
|
new_config.audio.volume,
|
|
new_config.audio.dynamic_audio ? 1 : 0);
|
|
state->db.logEvent("INFO", "Config",
|
|
"Live configuration hot-reloaded");
|
|
});
|
|
|
|
state->keybinds.bind(SDLK_UP, "menu_up", [state]() {
|
|
state->currentMenuItem = (state->currentMenuItem + 1) % 2;
|
|
});
|
|
state->keybinds.bind(SDLK_DOWN, "menu_down", [state]() {
|
|
state->currentMenuItem = (state->currentMenuItem + 1) % 2;
|
|
});
|
|
|
|
// Drive the First-Run walkthrough: shows the WiFi page when the internet
|
|
// is unreachable <DEPRECATED>, then the email and license pages when those
|
|
// are missing, finishing on Home. The internet check is a real connectivity
|
|
// probe (Ethernet, WiFi, or macOS all work), so it never traps a connected
|
|
// device on the WiFi page.
|
|
radiantwave::provisioning::begin(*state);
|
|
|
|
*appstate = state;
|
|
return SDL_APP_CONTINUE;
|
|
}
|
|
|
|
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
|
|
auto &state = *static_cast<AppState *>(appstate);
|
|
|
|
if (event->type == SDL_EVENT_QUIT) {
|
|
state.quitRequested = true;
|
|
}
|
|
|
|
if (event->type == SDL_EVENT_AUDIO_DEVICE_ADDED ||
|
|
event->type == SDL_EVENT_AUDIO_DEVICE_REMOVED) {
|
|
// Route device hotplug to the audio manager so an active device that
|
|
// disappears is failed over to the system default automatically.
|
|
state.audio.handleDeviceEvent(*event);
|
|
}
|
|
|
|
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
|
|
radiantwave::logger::Logger::instance().info(
|
|
"Graphics", "Window resized to " +
|
|
std::to_string(event->window.data1) + "x" +
|
|
std::to_string(event->window.data2));
|
|
// Logical presentation persists across resizes; re-apply defensively
|
|
// in case backend state was reset underneath us. Skip while the
|
|
// scroller is active — it owns its own presentation (NONE, full
|
|
// window) and restores LETTERBOX in onPop.
|
|
if (!dynamic_cast<ScrollerPage *>(state.pages.current())) {
|
|
SDL_SetRenderLogicalPresentation(state.renderer,
|
|
radiantwave::kDesignWidth,
|
|
radiantwave::kDesignHeight,
|
|
SDL_LOGICAL_PRESENTATION_LETTERBOX);
|
|
}
|
|
}
|
|
|
|
if (!state.keybinds.handleEvent(*event)) {
|
|
state.pages.handleEvent(*event);
|
|
}
|
|
|
|
if (state.quitRequested && state.quitFadeRemaining <= 0.0f) {
|
|
// Graceful shutdown: start the audio fade-out and keep the loop
|
|
// running until it completes, then let SDL_AppQuit tear everything
|
|
// down. When nothing is playing there is nothing to fade and quitting
|
|
// is immediate.
|
|
if (state.audio.stopTrackFadeOut(kQuitFadeSeconds)) {
|
|
state.quitFadeRemaining = kQuitFadeSeconds;
|
|
return SDL_APP_CONTINUE;
|
|
}
|
|
return SDL_APP_SUCCESS;
|
|
}
|
|
|
|
return SDL_APP_CONTINUE;
|
|
}
|
|
|
|
SDL_AppResult SDL_AppIterate(void *appstate) {
|
|
auto &state = *static_cast<AppState *>(appstate);
|
|
|
|
Uint64 nowTicks = SDL_GetTicks();
|
|
static Uint64 lastTicks = nowTicks;
|
|
float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f;
|
|
if (dt > 0.1f)
|
|
dt = 0.1f;
|
|
lastTicks = nowTicks;
|
|
|
|
state.pages.update(dt);
|
|
|
|
if (state.quitFadeRemaining > 0.0f) {
|
|
state.quitFadeRemaining -= dt;
|
|
if (state.quitFadeRemaining <= 0.0f) {
|
|
return SDL_APP_SUCCESS;
|
|
}
|
|
}
|
|
|
|
SDL_SetRenderDrawColor(state.renderer, 30, 30, 46, 255);
|
|
SDL_RenderClear(state.renderer);
|
|
|
|
state.pages.render(state.renderer);
|
|
|
|
SDL_RenderPresent(state.renderer);
|
|
return SDL_APP_CONTINUE;
|
|
}
|
|
|
|
void SDL_AppQuit(void *appstate, SDL_AppResult result) {
|
|
auto *state = static_cast<AppState *>(appstate);
|
|
if (state) {
|
|
state->audioDownloader.shutdown();
|
|
state->licenseTracker.shutdown();
|
|
state->audio.shutdown();
|
|
state->network.shutdown();
|
|
state->config.shutdown();
|
|
state->db.close();
|
|
SDL_DestroyRenderer(state->renderer);
|
|
SDL_DestroyWindow(state->window);
|
|
delete state;
|
|
}
|
|
SDL_Quit();
|
|
}
|