13 Commits
Author SHA1 Message Date
NeilClack d84307bd0e fixed (bug) Audio output device not switching
build-dev / nix-build (push) Successful in 47s
2026-08-27 18:45:57 -05:00
NeilClack 43cd93bbd7 Switch from window-size to render-output-size 2026-08-25 11:30:20 -05:00
NeilClack 92ba9f39fb Switch from window-size to render-output-size 2026-08-25 11:28:02 -05:00
NeilClack 400c6a782b Scroller: use full window height on rotated/tall displays
build-dev / nix-build (push) Successful in 41s
Override the LETTERBOX logical presentation while the scroller is
active so it renders into the full physical window.  On a 1920x6480
rotated display the scroller now scrolls through all 6480 pixels of
vertical space instead of being confined to the 1920x1080 letterbox
region.  Text scale is unchanged (same font size, same pixel
rasterization); more lines are simply visible at once.

- onPush: save current logical presentation, switch to DISABLED with
  real window dimensions
- onPop: restore the saved LETTERBOX presentation
- resize handler in main.cpp: skip re-apply while scroller is active
  (it owns its own presentation and restores on pop)
2026-08-25 10:55:53 -05:00
NeilClack 9b0bd517c6 pad footer 2026-08-25 10:33:04 -05:00
NeilClack babb74c7ad Fix attic upload
build-dev / nix-build (push) Successful in 40s
2026-08-25 07:55:32 -05:00
NeilClack 47785cd4b9 Proclamations: sub-cats always expanded, stable list width
build-dev / nix-build (push) Successful in 43s
- Sub-categories start expanded and cannot be collapsed
- Only root categories toggle open/closed
- LEFT on a sub-category is ignored (navigates to parent for depth-2)
- [+]/[-] indicators only shown on root categories
- Bounding box width computed over all items to prevent
  horizontal shifting when groups expand or collapse
2026-08-25 07:15:59 -05:00
NeilClack 15583edef8 Re-order settings menu 2026-08-25 06:57:43 -05:00
NeilClack 63265cc22f Dynamically size the fullscreen window; present design space via LETTERBOX
build-dev / nix-build (push) Successful in 42s
SDL_CreateWindow hardcoded a 1920x1080 backbuffer while SDL_WINDOW_FULLSCREEN
let compositors run a different output mode; the mismatched buffer reached
the panel unscaled — quarter-size in the top-left on 4K sets, cropped ("4x
too large") on smaller non-1080p panels under cage/Wayland. Logical
presentation never got to map the fixed design space because its target
(the backbuffer) was pinned to stale dimensions.

Size the fullscreen window from SDL_GetCurrentDisplayMode so the first
commit already matches scanout 1:1, switch logical presentation to LETTERBOX
(aspect-safe bars on non-16:9 panels), and route both through new
kDesignWidth/kDesignHeight constants. Harden getLayoutSize() to always write
its out params and null-check the renderer/render-window before use.

Log requested/window/pixel-buffer/render-output sizes at startup and on
every resize event so field units self-diagnose this bug class into
~/.local/radiantwave/log.txt. SDL_WINDOW_HIGH_PIXEL_DENSITY stays off on
purpose: kiosk panels are not HiDPI, and keeping the pixel buffer equal to
the display mode keeps compositor scanout unscaled.
2026-08-23 17:06:05 -05:00
NeilClack 9e73c82641 Fix attic push
build-dev / nix-build (push) Successful in 43s
2026-08-23 15:12:35 -05:00
NeilClack f2a40cd019 Remove attic-client install step
build-dev / nix-build (push) Successful in 39s
2026-08-23 12:46:08 -05:00
NeilClack 5caf1d6ef8 Move to checksums.md5
build-dev / nix-build (push) Failing after 13s
Server is scheduled to generate this file nightly
2026-08-23 12:39:05 -05:00
NeilClack e17e881c00 Split builds 2026-08-23 12:38:44 -05:00
15 changed files with 1194 additions and 786 deletions
+68
View File
@@ -0,0 +1,68 @@
name: build-dev
on:
# Every push to dev: build, run the offline test suite, publish to the
# dev Attic cache.
push:
branches: [dev]
jobs:
nix-build:
runs-on: build-server
steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly.
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix
git log --oneline -1
- name: Inspect workspace
run: |
pwd
ls -la
git rev-parse --show-toplevel 2>&1 || true
git remote -v 2>&1 || true
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Dev builds fingerprint as the short commit hash.
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
run: |
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
# checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults).
- name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
run: |
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target test_api_client test_license_service
./build/test_api_client
./build/test_license_service
'
# Dev closure (the actual package, not the check output) lands in the
# dev cache.
- name: Push to Attic dev cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave-dev ./result-pkg
@@ -1,9 +1,8 @@
name: build
name: build-main
on:
# Manual only for now. Select the branch to build when dispatching:
# main -> APP_VERSION is the current git tag
# dev -> APP_VERSION is the current commit hash
# Manual only. Dispatch against main: build, run the offline test suite,
# publish to the release Attic cache.
workflow_dispatch:
jobs:
@@ -12,8 +11,8 @@ jobs:
steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly.
# A full clone (no --depth) is required so `git describe --tags` on main
# resolves the real nearest tag instead of falling back to a hash.
# A full clone (no --depth) is required so `git describe --tags` resolves
# the real nearest tag instead of falling back to a hash.
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
@@ -32,26 +31,27 @@ jobs:
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Compute the version string baked into the binary's Home footer:
# main reports the git tag, dev reports the commit hash fingerprint.
# The key stays in the build step's environment (never echoed).
# Release builds report the current git tag.
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
id: version
run: |
if [ "${{ gitea.ref_name }}" = "main" ]; then
APP_VERSION="$(git describe --tags --always)"
else
APP_VERSION="$(git rev-parse --short HEAD)"
fi
echo "Branch: ${{ gitea.ref_name }} -> APP_VERSION=$APP_VERSION"
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
# checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults). The offline test suite
# runs in the package's checkPhase.
# (pure evals always get the package defaults).
- name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite
run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
@@ -66,10 +66,11 @@ jobs:
./build/test_license_service
'
- name: Push to Attic cache
# Release closure (the actual package, not the check output) lands in the
# release cache.
- name: Push to Attic release cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
run: |
nix profile install github:zhaofengli/attic#attic-client \
--extra-experimental-features "nix-command flakes"
attic login radiantwave https://attic.radiantwavetech.com "${{ secrets.ATTIC_TOKEN }}"
attic push radiantwave ./result-kiosk ./result-android ./result-commercial
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg
+3 -3
View File
@@ -37,7 +37,7 @@ struct AudioManifestEntry {
// and keeps the database's audio_files table in sync with what is actually
// on disk.
//
// Fetches new_checksums.md5, compares each entry against the local file's MD5,
// Fetches checksums.md5, compares each entry against the local file's MD5,
// and re-downloads anything that is missing or whose checksum differs.
// Files present on disk but absent from the manifest are deleted, and the
// audio_files table is reconciled on every pass (and after each individual
@@ -68,7 +68,7 @@ public:
static constexpr const char *AUDIO_BASE_URL =
"https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "new_checksums.md5";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
private:
void run();
@@ -88,7 +88,7 @@ private:
db::DatabaseManager *db_ = nullptr;
};
// Parses new_checksums.md5 manifest text into entries. Blank lines and lines
// Parses checksums.md5 manifest text into entries. Blank lines and lines
// starting with '#' are ignored. Lines that don't match a known format are
// skipped. Returns false if no valid entries were found.
bool parseManifest(const std::string &text,
+241 -10
View File
@@ -76,7 +76,6 @@ namespace radiantwave::audio
enabled_ = cfg.audio.enabled;
currentDevice_ = cfg.audio.output_device;
currentTrack_ = cfg.audio.current_track;
if (!MIX_Init())
{
std::string err = SDL_GetError();
@@ -86,17 +85,40 @@ namespace radiantwave::audio
return false;
}
// Create mixer device with default audio device
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
// Try to open the mixer on the device previously persisted in config;
// fall back to the system default when the configured device is gone.
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
if (!currentDevice_.empty())
{
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
if (devices)
{
for (int i = 0; i < count; ++i)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
if (name && currentDevice_ == name)
{
targetId = devices[i];
break;
}
}
SDL_free(devices);
}
}
mixer_ = MIX_CreateMixerDevice(targetId, nullptr);
if (!mixer_)
{
std::string err = SDL_GetError();
logger::Logger::instance().warning("Audio",
"Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
"Could not open audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
initialized_ = false;
return false;
}
currentDeviceId_ = targetId;
slots_[0].track = MIX_CreateTrack(mixer_);
slots_[1].track = MIX_CreateTrack(mixer_);
initialized_ = true;
@@ -181,18 +203,48 @@ namespace radiantwave::audio
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
std::string raw = name ? name : "";
list.push_back({raw, mapToFriendlyDeviceName(raw)});
AudioDeviceItem item;
item.raw_name = raw;
item.friendly_name = mapToFriendlyDeviceName(raw);
item.id = devices[i];
list.push_back(item);
}
SDL_free(devices);
}
else
{
list.push_back({"", "Built-In Speakers"});
list.push_back({"", "Built-In Speakers", 0});
}
return list;
}
std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id)
{
if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
{
return "";
}
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
std::string found;
if (devices)
{
for (int i = 0; i < count; ++i)
{
if (devices[i] == id)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
found = name ? name : "";
break;
}
}
SDL_free(devices);
}
return found;
}
void AudioManager::setVolume(int volume_0_to_100)
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -253,14 +305,76 @@ namespace radiantwave::audio
bool AudioManager::switchDevice(const std::string &raw_device_name)
{
std::lock_guard<std::mutex> lock(mutex_);
currentDevice_ = raw_device_name;
logger::Logger::instance().debug("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
// Resolve the config's raw name to an SDL device id so the mixer is
// actually reopened on the requested device (""/unresolvable = default).
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
if (!raw_device_name.empty())
{
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
if (devices)
{
for (int i = 0; i < count; ++i)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
if (name && raw_device_name == name)
{
targetId = devices[i];
break;
}
}
SDL_free(devices);
}
}
return switchDeviceById(targetId);
}
bool AudioManager::switchDeviceById(SDL_AudioDeviceID id)
{
std::lock_guard<std::mutex> lock(mutex_);
// 0 is the synthetic "default / built-in" entry from getPlaybackDevices().
if (id == 0)
{
id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
}
// No mixer yet: just record the choice for initialize().
if (!mixer_ || !initialized_)
{
currentDeviceId_ = id;
currentDevice_ = resolvePlaybackDeviceName(id);
if (configManager_)
{
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
{ cfg.audio.output_device = name; });
}
return true;
}
if (!recreateMixerOnDevice(id))
{
logger::Logger::instance().error("Audio",
"Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id)));
return false;
}
currentDeviceId_ = id;
// After recreation SDL may have normalized the id (e.g. default).
if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
{
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
}
currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_);
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_));
if (configManager_)
{
configManager_->update([raw_device_name](config::AppConfig &cfg)
{ cfg.audio.output_device = raw_device_name; });
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
{ cfg.audio.output_device = name; });
}
return true;
@@ -272,6 +386,123 @@ namespace radiantwave::audio
return currentDevice_;
}
SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const
{
std::lock_guard<std::mutex> lock(mutex_);
return currentDeviceId_;
}
bool AudioManager::handleDeviceEvent(const SDL_Event &event)
{
if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED &&
event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED)
{
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
if (!mixer_ || !initialized_)
{
return false;
}
// Only playback device events are relevant to the active output.
if (event.adevice.recording)
{
return false;
}
const SDL_AudioDeviceID changedId = event.adevice.which;
// If a specific device (not the default) was removed, fail over to the
// system default so playback keeps going on whatever remains.
if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED &&
currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK &&
changedId == currentDeviceId_)
{
logger::Logger::instance().warning("Audio",
"Active audio output device removed; failing over to the system default");
if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK))
{
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
currentDevice_ = "";
}
return true;
}
// A device arrived or a non-active device left: nothing to remap, but
// the audio settings page refreshes its list on its own timer.
return false;
}
// Must be called with mutex_ held. Destroys the current mixer and creates a
// fresh one on the given device, recreating the two track slots and
// resuming the active track if one was playing.
bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId)
{
// Capture the playback state we want to preserve across the switch.
std::string resumeTrack = currentTrack_;
float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f
? slots_[activeSlot_].fadeInDuration
: 0.0f;
// Tear down the existing mixer (its tracks die with it).
for (int i = 0; i < 2; ++i)
{
slots_[i].track = nullptr;
if (slots_[i].audio)
{
MIX_DestroyAudio(slots_[i].audio);
slots_[i].audio = nullptr;
}
}
if (mixer_)
{
MIX_DestroyMixer(mixer_);
mixer_ = nullptr;
}
mixer_ = MIX_CreateMixerDevice(targetId, nullptr);
if (!mixer_)
{
initialized_ = false;
return false;
}
slots_[0].track = MIX_CreateTrack(mixer_);
slots_[1].track = MIX_CreateTrack(mixer_);
if (!slots_[0].track || !slots_[1].track)
{
MIX_DestroyMixer(mixer_);
mixer_ = nullptr;
initialized_ = false;
return false;
}
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
MIX_SetMixerGain(mixer_, gain);
// Resume the previously active track at full gain (or restart its
// fade-in if it was still ramping).
if (!resumeTrack.empty())
{
if (loadTrackInto(activeSlot_, resumeTrack))
{
if (fadeSeconds > 0.0f)
{
startFadeIn(activeSlot_, fadeSeconds);
}
else
{
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback
}
}
}
return true;
}
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
{
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
+21
View File
@@ -1,6 +1,7 @@
#pragma once
#include "config/config_manager.h"
#include <SDL3/SDL.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <mutex>
#include <string>
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
struct AudioDeviceItem {
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
std::string friendly_name; // e.g. "Built-In Speakers"
SDL_AudioDeviceID id = 0; // SDL playback device id (0 = synthetic fallback)
};
// Maps raw technical audio device strings to user-friendly labels
@@ -51,7 +53,18 @@ public:
// Audio device selection
bool switchDevice(const std::string& raw_device_name);
bool switchDeviceById(SDL_AudioDeviceID id);
std::string getCurrentDevice() const;
SDL_AudioDeviceID getCurrentDeviceId() const;
// Handle an SDL audio device event (SDL_EVENT_AUDIO_DEVICE_ADDED / REMOVED).
// Returns true if the active device was affected and the mixer was recreated.
bool handleDeviceEvent(const SDL_Event& event);
// Pure helper (no SDL audio open): find the playback device with the given
// SDL id, returning its raw name ("" when not found / id invalid). Exposed
// for tests; the real lookup is through getPlaybackDevices().
static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id);
// Playback control
bool playTrack(const std::string& wav_filename);
@@ -84,6 +97,13 @@ private:
void startFadeIn(int slotIndex, float seconds);
std::string displayNameFor(const std::string& wav_filename) const;
// Recreates the mixer on the given playback device id (0 = system default).
// Called with mutex_ held. Preserves playback state: whichever track was
// playing on the active slot is reloaded and resumed at full gain, an
// in-progress fade is restarted. Returns false on failure (previous mixer is
// left destroyed but the app remains safe to keep running muted).
bool recreateMixerOnDevice(SDL_AudioDeviceID targetId);
config::ConfigManager* configManager_ = nullptr;
db::DatabaseManager* db_ = nullptr;
@@ -91,6 +111,7 @@ private:
bool enabled_ = true;
int volume_ = 80; // 0 - 100
std::string currentDevice_ = "";
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
std::string currentTrack_ = "";
MIX_Mixer* mixer_ = nullptr;
+16 -3
View File
@@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) {
@@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) {
@@ -280,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() {
}
}
void AudioSettingsPage::applyDeviceSelection() {
if (!state_) return;
if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast<int>(devices_.size())) return;
const bool ok = state_->audio.switchDeviceById(devices_[currentDeviceIndex_].id);
if (ok) {
statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name;
} else {
statusMessage_ = "Device unavailable - using system default";
}
refreshDeviceAndTrackLists();
}
void AudioSettingsPage::enableDynamicAudio() {
if (!state_) return;
state_->config.update([](radiantwave::config::AppConfig& cfg) {
@@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
} else if (modalState_ == ModalState::TrackSelector) {
if (modalSelectedIndex_ == 0) {
+4
View File
@@ -36,6 +36,10 @@ private:
void enableDynamicAudio();
void selectTrack(int trackIdx);
// Applies the currently selected device (devices_[currentDeviceIndex_]) via
// the audio manager and records user-facing success/failure feedback.
void applyDeviceSelection();
AppState* state_ = nullptr;
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
+2 -2
View File
@@ -281,10 +281,10 @@ void Home::render(SDL_Renderer *renderer)
fourSpaces.destroy();
const float margin = 20.0f;
float rectRight = fw - margin;
float rectRight = fw - margin - 50.0f;
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
float lineY = fh - margin;
float lineY = fh - margin - 50.0f;
const float lineGap = 6.0f;
for (size_t i = footerNames_.size(); i-- > 0;)
{
+41 -7
View File
@@ -4,14 +4,48 @@
namespace radiantwave {
inline void getLayoutSize(SDL_Renderer* renderer, int* w, int* h)
// Design resolution every page lays out against. The renderer's logical
// presentation (LETTERBOX) scales this fixed space onto whatever
// window/backbuffer the display actually provides.
inline constexpr int kDesignWidth = 1920;
inline constexpr int kDesignHeight = 1080;
// Returns the size pages should lay out against: the renderer's logical size
// when presentation is active, otherwise the real window size. The output
// params are always written — design constants are the last-resort fallback.
inline void getLayoutSize(SDL_Renderer *renderer, int *w, int *h)
{
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
if (!SDL_GetRenderLogicalPresentation(renderer, w, h, &mode) ||
mode == SDL_LOGICAL_PRESENTATION_DISABLED || *w <= 0 || *h <= 0)
{
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), w, h);
}
if (!w || !h)
return;
*w = kDesignWidth;
*h = kDesignHeight;
if (!renderer)
return;
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
int logicalW = 0;
int logicalH = 0;
if (SDL_GetRenderLogicalPresentation(renderer, &logicalW, &logicalH,
&mode) &&
mode != SDL_LOGICAL_PRESENTATION_DISABLED && logicalW > 0 &&
logicalH > 0) {
*w = logicalW;
*h = logicalH;
return;
}
SDL_Window *window = SDL_GetRenderWindow(renderer);
if (!window)
return;
int windowW = 0;
int windowH = 0;
SDL_GetWindowSize(window, &windowW, &windowH);
if (windowW > 0 && windowH > 0) {
*w = windowW;
*h = windowH;
}
}
} // namespace radiantwave
+73 -10
View File
@@ -4,8 +4,10 @@
#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
@@ -32,11 +34,26 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
auto *state = new AppState{};
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the
// compositor never has to scale the moving scroller (scaling moving text
// causes flicker).
state->window =
SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN);
// 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;
@@ -59,16 +76,38 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
std::string(SDL_GetError()) + ")");
}
// Lock the renderer to a 1920x1080 logical coordinate space. All page
// layout code already targets this resolution, so everything renders at
// the correct size on any display — the GPU scales to native pixels.
if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080,
SDL_LOGICAL_PRESENTATION_STRETCH)) {
// 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
@@ -157,6 +196,30 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
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);
}
+30 -17
View File
@@ -198,7 +198,7 @@ void ProclamationOptionsPage::rebuildItems()
FlatItem::Type::SubCategory,
std::to_string(sub.id),
sub.name,
1, false, false, false,
1, true, false, false,
});
for (const auto& p : proclamations_) {
@@ -268,7 +268,7 @@ void ProclamationOptionsPage::rebuildVisible()
parentVisible = true; // the always-on top-level proclamation
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
parentVisible = (item.depth == 1) ||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded);
(item.depth == 2 && currentSub >= 0);
}
if (parentVisible) visible_.push_back(i);
}
@@ -283,7 +283,7 @@ void ProclamationOptionsPage::toggleItem()
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::RootCategory) {
item.expanded = !item.expanded;
rebuildVisible();
return;
@@ -388,7 +388,7 @@ void ProclamationOptionsPage::collapseCurrent()
{
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::RootCategory) {
if (item.expanded) {
item.expanded = false;
rebuildVisible();
@@ -400,7 +400,7 @@ void ProclamationOptionsPage::expandCurrent()
{
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::RootCategory) {
if (!item.expanded) {
item.expanded = true;
rebuildVisible();
@@ -526,8 +526,7 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
if (searchActive_) return;
if (!visible_.empty()) {
const FlatItem& item = items_[visible_[highlightIndex_]];
if ((item.type == FlatItem::Type::RootCategory ||
item.type == FlatItem::Type::SubCategory) && item.expanded) {
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
collapseCurrent();
} else {
moveToParent();
@@ -655,14 +654,18 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
}
} else {
// Compute the widest row so the block can be centered.
// Stable width: compute from every result so the list doesn't shift.
float maxTotalWidth = 0.0f;
for (const auto& r : searchResults_) {
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, proclamations_[r].title.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
}
std::vector<float> textWidths(static_cast<size_t>(listSize));
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
const std::string& label = proclamations_[searchResults_[i]].title;
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w);
}
float sharedX = (fw - maxTotalWidth) / 2.0f;
@@ -695,19 +698,29 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
}
} else {
// --- Browse tree ---
// Compute the widest row so the block can be centered.
// Stable width: compute from every item so the list doesn't
// shift left/right when groups expand or collapse.
float maxTotalWidth = 0.0f;
std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
for (const auto& item : items_) {
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w);
maxTotalWidth = std::max(maxTotalWidth, total);
}
std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w);
}
@@ -721,7 +734,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
float y = listTop + rowIndex * rowPitch;
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
+624 -691
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -115,4 +115,8 @@ private:
float velocityDelay_ = 2.5f;
int velocityState_ = 0;
float velocityTimer_ = 0.0f;
int savedLogicalW_ = 0;
int savedLogicalH_ = 0;
SDL_RendererLogicalPresentation savedMode_ = SDL_LOGICAL_PRESENTATION_DISABLED;
};
+18 -18
View File
@@ -19,18 +19,18 @@ namespace
{
constexpr int MENU_ITEM_COUNT = 9;
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations,
// 4 = Email Address, 5 = License Key, 6 = Date Format,
// 7 = View System Logs, 8 = Back
// 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
// 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
// 7 = System Logs, 8 = Back
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
"WiFi Setup",
"Audio Options",
"Time Zone Setup",
"Proclamations",
"Audio Options",
"Email Address",
"License Key",
"WiFi Setup",
"Time Zone Setup",
"Date Format",
"View System Logs",
"System Logs",
"Back to Home",
};
@@ -78,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
switch (selectedItem_)
{
case 0:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 3:
// The Proclamation list is license-gated like Play.
if (state_->licenseTracker.isValid())
{
@@ -97,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
}
break;
case 4:
case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
break;
case 5:
case 3:
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
break;
case 4:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 5:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 6:
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
break;
+23
View File
@@ -11,6 +11,29 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
}
TEST(AudioTest, ResolvePlaybackDeviceNameHandlesSentinels) {
// The sentinel and 0 mean "system default" and never correspond to a real
// enumerated playback device, so they resolve to the empty (default) name.
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(0), "");
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK), "");
}
TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) {
AudioManager am;
auto devices = am.getPlaybackDevices();
// Headless hosts (CI) expose no playback devices, so expect the synthetic
// fallback entry that carries id 0 (the "system default" marker).
if (devices.empty()) {
// No devices and no fallback would be a contract violation.
FAIL() << "getPlaybackDevices() returned an empty list without fallback";
}
for (const auto& d : devices) {
if (d.raw_name.empty()) {
EXPECT_EQ(d.id, 0u);
}
}
}
TEST(AudioTest, AudioManagerVolumeClamping) {
radiantwave::config::ConfigManager config;
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();