#define SDL_MAIN_USE_CALLBACKS 1 #include #include #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 , 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); 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(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); Uint64 nowTicks = SDL_GetTicks(); static Uint64 lastTicks = nowTicks; float dt = static_cast(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); 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(); }