27 Commits
Author SHA1 Message Date
NeilClack fee31c87b8 Add manifest upload to release workflow 2026-08-28 16:51:53 -05:00
NeilClack 5369532ab4 Merge pull request 'Fix audio device problems' (#1) from dev into main
Reviewed-on: #1
2026-08-28 21:30:12 +00:00
NeilClack 42b915bd25 fixed (bug) Audio device switch killing playback via stale SDL device id
build-dev / nix-build (push) Successful in 45s
recreateMixerOnDevice destroyed the active mixer before opening the
replacement. MIX_DestroyMixer quits SDL audio at refcount 0, tearing down
the device registry and renumbering device ids, so the cached id being
reopened went stale ('Invalid audio device instance ID') and the app went
permanently mute. Open the new mixer first while the old device is alive,
then tear the old one down; a failed switch now keeps the current output
playing instead of killing audio. Also log SDL_GetError() on open failure.
2026-08-28 16:18:36 -05:00
NeilClack d60a8bd7fb Add WiFi prompt
build-dev / nix-build (push) Successful in 46s
2026-08-28 15:35:57 -05:00
NeilClack ca7b8b7e6f Update audio pipeline with extensive logging and defualt with fallbacks
build-dev / nix-build (push) Successful in 45s
2026-08-28 15:04:42 -05:00
NeilClack bf2d4d1f89 Update build dependencies to properly link dynamically. 2026-08-28 15:03:58 -05:00
NeilClack 2917d33866 fixed (bug) Audio not looping after ending.
build-dev / nix-build (push) Successful in 46s
2026-08-28 13:55:20 -05:00
NeilClack 3f6201182e Publish live-update manifest to fileserver (dev)
build-dev / nix-build (push) Successful in 43s
After pushing the dev closure to Attic, write a small JSON manifest
(version + storePath) to the fileserver for kiosks to poll. storePath is
the readlink of the result-pkg out-link, so it matches exactly the
closure pushed to the dev Attic cache and can be fetched with
'nix copy --from' without building from source. Upload goes to
/mnt/infrastructure_data/files/radiantwave-dev/manifest.json via the
build-server's radiantwave-infra SSH alias.
2026-08-27 20:58:13 -05:00
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
NeilClack b96631c374 Add first Attic push 2026-08-21 14:52:14 -05:00
NeilClack 39eb6d7bf7 Update to new_checkshums.md5
new_checksums.md5 enables audio file naming and other audio related features.
2026-08-21 10:03:31 -05:00
NeilClack 3c9de0bee2 Fix double-scaling by locking page layout to logical presentation
Pages computed layout from SDL_GetWindowSize() (real window
coordinates) while SDL_SetRenderLogicalPresentation() independently
remapped every draw call into a 1920x1080 space. The two coordinate
systems only agreed when the window happened to be exactly 1920x1080;
at any other size, layout coordinates were transformed a second time,
so content shrank/grew quadratically toward the top-left on resize or
on non-1080p monitors. Commenting the call out masked the bug by
making rendering 1:1 with the window, but then nothing scaled: fonts
and margins stayed fixed-pixel while backgrounds cover-fit, and HiDPI
displays drew at point resolution and relied on blurry compositor
upsampling.

Restore SDL_SetRenderLogicalPresentation(1920x1080, LETTERBOX) as the
single source of scaling, and route all twelve layout sites through a
new radiantwave::getLayoutSize() helper (src/logical_size.h) that
returns the renderer's logical size, so pages always lay out against
the design resolution regardless of window or monitor geometry. Add
SDL_WINDOW_HIGH_PIXEL_DENSITY so HiDPI displays render at native pixel
density instead of being upscaled by the OS. Cached per-page textures
become resolution-independent since layout no longer depends on window
size.
2026-08-21 09:29:23 -05:00
NeilClack 8e930ef8d9 Remove WiFi gate 2026-08-18 23:37:44 -04:00
NeilClack ec6b76618c Lock compositor to 1920x1080 2026-08-17 22:46:44 -05:00
NeilClack 74245ee07d Remove API key requirement 2026-08-17 20:36:59 -05:00
43 changed files with 1942 additions and 1324 deletions
-78
View File
@@ -1,78 +0,0 @@
name: build
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
# Both branches bake the portal API key from secrets.
workflow_dispatch:
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.
# 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.
- 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"
# 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).
- 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 "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV"
echo "Branch: ${{ gitea.ref_name }} -> APP_VERSION=$APP_VERSION"
if [ -n "${{ secrets.RWT_API_KEY }}" ]; then
echo "API key: provided via secret RWT_API_KEY"
else
echo "API key: NOT set (build proceeds without an Authorization header)"
fi
# 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/API_KEY via builtins.getEnv
# (pure evals always get the package defaults). The offline test suite
# runs in the package's checkPhase.
- name: Build + offline test suite
env:
API_KEY: ${{ secrets.RWT_API_KEY }}
run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure
- name: Live portal tests
if: ${{ secrets.RWT_LIVE_API_KEY != '' }}
env:
RWT_LIVE_API_KEY: ${{ secrets.RWT_LIVE_API_KEY }}
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
'
+82
View File
@@ -0,0 +1,82 @@
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
# Publish the manifest kiosks poll for live app updates. `storePath`
# must match exactly the closure pushed to the dev Attic cache above
# (readlink resolves the result-pkg out-link symlink to its real
# /nix/store path), so a kiosk can `nix copy --from` it without ever
# building from source. Upload mirrors the ISO scp pattern to the
# fileserver via the build-server's `radiantwave-infra` SSH alias.
- name: Publish manifest
run: |
STOREPATH=$(readlink -f ./result-pkg)
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
cat manifest.json
scp manifest.json \
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json
+90
View File
@@ -0,0 +1,90 @@
name: build-main
on:
# Manual only. Dispatch against main: build, run the offline test suite,
# publish to the release Attic cache and live-update manifest.
workflow_dispatch:
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.
# 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
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"
# 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
run: |
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).
- 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
'
# 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: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg
# Publish the manifest production kiosks poll for live app updates.
# `storePath` must match exactly the closure pushed to the release Attic
# cache above (readlink resolves the result-pkg out-link symlink to its
# real /nix/store path), so a kiosk can `nix copy --from` it without ever
# building from source. Upload mirrors the ISO scp pattern to the
# fileserver via the build-server's `radiantwave-infra` SSH alias.
- name: Publish manifest
run: |
STOREPATH=$(readlink -f ./result-pkg)
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
cat manifest.json
scp manifest.json \
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave/manifest.json
+6 -24
View File
@@ -33,23 +33,8 @@ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3"
The Nix package bakes `-DAPP_VERSION=<pkg version>` via `cmakeFlags` (`version` The Nix package bakes `-DAPP_VERSION=<pkg version>` via `cmakeFlags` (`version`
defaults to `0.1.0` in `nix/package.nix`; the flake overrides it from the defaults to `0.1.0` in `nix/package.nix`; the flake overrides it from the
`APP_VERSION` env var on impure evals). `nix develop` `APP_VERSION` env var on impure evals). `nix develop`
sets `APP_VERSION=dev` and exports `API_KEY` from the ambient environment sets `APP_VERSION=dev`. CMake seeds a *fresh* configure from the
(falling back to `RWT_LIVE_API_KEY`). CMake seeds a *fresh* configure from the `APP_VERSION` env var; `-D` wins on reconfigure.
`APP_VERSION` / `API_KEY` env vars; `-D` wins on reconfigure.
The portal API key is also a build-time argument: `-DAPI_KEY="..."` (CMake) or
`apiKey` on `callPackage` (Nix). It is compiled into the binary as the
`Authorization: Bearer` token and defaults to empty (no auth header). Keep the
real value in CI secrets, never in source — rotate by rebuilding with a new
key.
Version and API key can be baked in together with a single configure:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DAPP_VERSION="1.2.3" \
-DAPI_KEY="$API_KEY"
```
- The app resolves `assets/...` relative to its working directory — launch - The app resolves `assets/...` relative to its working directory — launch
`./build/radiantwave` from the repo root. (The Nix wrapper chdirs to `./build/radiantwave` from the repo root. (The Nix wrapper chdirs to
@@ -238,14 +223,11 @@ cached.
`checks` is keyed per-system, so the workflow resolves the current system `checks` is keyed per-system, so the workflow resolves the current system
via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes
in a per-branch `APP_VERSION` — the current git tag on `main`, the current in a per-branch `APP_VERSION` — the current git tag on `main`, the current
commit hash on `dev` — plus the portal API key from the `RWT_API_KEY` commit hash on `dev`. The flake reads `APP_VERSION` from the environment via
secret. The flake reads `APP_VERSION`/`API_KEY` from the environment via
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the `builtins.getEnv`, so the build must pass `--impure` (pure evals get the
package defaults). When the `RWT_LIVE_API_KEY` secret is set it also runs package defaults). The live portal tests run from `nix develop` when the
`test_api_client` / `test_license_service` against the portal via `RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`,
`nix develop` (outside the sandbox, like a dev machine). Those live tests `RWT_LIVE_LICENSE_EMAIL`, and `RWT_LIVE_LICENSE_KEY`.
also need `RWT_LIVE_ACCOUNT_EMAIL`, `RWT_LIVE_LICENSE_EMAIL`, and
`RWT_LIVE_LICENSE_KEY`.
- `test_assets` must pass before committing — it catches missing fonts/images - `test_assets` must pass before committing — it catches missing fonts/images
that break the app at runtime. that break the app at runtime.
- `cmake --build build` builds everything; pass `--target <name>` to build one - `cmake --build build` builds everything; pass `--target <name>` to build one
+8 -16
View File
@@ -18,20 +18,6 @@ else()
"Version string shown in the Home footer (override at configure time)") "Version string shown in the Home footer (override at configure time)")
endif() endif()
# API bearer key baked into the binary and sent as `Authorization: Bearer <key>`
# on every portal request. Leave empty to send no auth header. Never commit the
# real value — inject it at CI build time from a secret, e.g.:
# cmake ... -DAPI_KEY="${{ secrets.PORTAL_API_KEY }}"
# The `nix develop` shell exports API_KEY from the ambient environment, which
# seeds a fresh configure; -D wins on reconfigure.
if(DEFINED ENV{API_KEY})
set(API_KEY "$ENV{API_KEY}" CACHE STRING
"Portal API key baked into the binary (leave empty to skip auth)")
else()
set(API_KEY "" CACHE STRING
"Portal API key baked into the binary (leave empty to skip auth)")
endif()
find_package(SDL3 REQUIRED) find_package(SDL3 REQUIRED)
find_package(SDL3_ttf REQUIRED) find_package(SDL3_ttf REQUIRED)
find_package(SDL3_image REQUIRED) find_package(SDL3_image REQUIRED)
@@ -90,7 +76,6 @@ add_executable(radiantwave
target_compile_definitions(radiantwave PRIVATE target_compile_definitions(radiantwave PRIVATE
APP_VERSION="${APP_VERSION}" APP_VERSION="${APP_VERSION}"
API_KEY="${API_KEY}"
) )
target_include_directories(radiantwave PRIVATE src) target_include_directories(radiantwave PRIVATE src)
@@ -127,7 +112,14 @@ add_executable(test_provisioning
tests/test_provisioning.cpp tests/test_provisioning.cpp
) )
target_include_directories(test_provisioning PRIVATE src) target_include_directories(test_provisioning PRIVATE src)
target_link_libraries(test_provisioning PRIVATE SDL3::SDL3) # provisioning.h pulls in app_state.h, so the test compiles against the SDL3,
# SDL3_mixer, and SQLite headers transitively (app_state.h -> audio_manager.h ->
# SDL3_mixer; app_state.h -> database_manager.h -> sqlite3.h).
target_link_libraries(test_provisioning PRIVATE
SDL3::SDL3
SDL3_mixer::SDL3_mixer
SQLite3::SQLite3
)
add_executable(test_assets add_executable(test_assets
tests/test_assets.cpp tests/test_assets.cpp
+11 -43
View File
@@ -136,45 +136,13 @@ If `APP_VERSION` is left unset the footer falls back to the project version
from `CMakeLists.txt` (`project(... VERSION ...)`); a build that defines no from `CMakeLists.txt` (`project(... VERSION ...)`); a build that defines no
version at all shows `unknown`. The Nix build sets `APP_VERSION` automatically version at all shows `unknown`. The Nix build sets `APP_VERSION` automatically
to the flake package version, so a plain `nix build` needs no extra flags. to the flake package version, so a plain `nix build` needs no extra flags.
With `--impure` the flake instead reads the `APP_VERSION` / `API_KEY` With `--impure` the flake instead reads the `APP_VERSION`
environment variables — this is how CI injects the version and portal key environment variable — this is how CI injects the version
(see the CI note under *Common Pitfalls*). Pure evals always get the package (see the CI note under *Common Pitfalls*). Pure evals always get the package
defaults, because `builtins.getEnv` cannot see the environment in pure defaults, because `builtins.getEnv` cannot see the environment in pure
evaluation mode. evaluation mode.
#### Portal API key #### Version in one configure
Every portal request sends `Authorization: Bearer <key>`. The key is baked
into the binary at build time via the `API_KEY` CMake cache variable. It is
**optional** — when unset, requests go out without an auth header. Keep the
real value out of the source tree and inject it at build time from your CI
secret store so it never lands in `git`:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DAPI_KEY="${{ secrets.PORTAL_API_KEY }}"
cmake --build build
```
Rotate the key by building with a new value and shipping the new binary. The
Nix package accepts the same value via `callPackage ./nix/package.nix {
apiKey = "…"; }`, or via the `API_KEY` environment variable with
`nix build --impure`; the flake itself never embeds one.
#### Version + API key in one configure
`APP_VERSION` and `API_KEY` are independent CMake cache variables, so pass
both — plus the build type — in a single configure:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DAPP_VERSION="1.2.3" \
-DAPI_KEY="${{ secrets.PORTAL_API_KEY }}"
cmake --build build
```
`-D` values always win over the `APP_VERSION` / `API_KEY` environment
variables, which `nix develop` seeds from the ambient environment.
### Building with Nix ### Building with Nix
@@ -187,14 +155,14 @@ nix build .#packages.x86_64-linux.default # needs a Linux machine/builders
nix build .#packages.aarch64-linux.default nix build .#packages.aarch64-linux.default
``` ```
To bake in a custom version and/or portal key, build impurely — the flake To bake in a custom version, build impurely — the flake
reads `APP_VERSION` and `API_KEY` from the environment on impure evals: reads `APP_VERSION` from the environment on impure evals:
```sh ```sh
APP_VERSION="1.2.3" API_KEY="$PORTAL_API_KEY" nix build --impure .#default APP_VERSION="1.2.3" nix build --impure .#default
``` ```
A pure `nix build` always uses the package defaults (`0.1.0`, no auth header) A pure `nix build` always uses the package defaults (`0.1.0`)
even if those variables are set in your shell, because pure evaluation cannot even if those variables are set in your shell, because pure evaluation cannot
see the environment. CI uses `--impure` to inject them. see the environment. CI uses `--impure` to inject them.
@@ -633,9 +601,9 @@ status to decide which page to show.
workflow is `.gitea/workflows/build.yml`. It is **manual-only** workflow is `.gitea/workflows/build.yml`. It is **manual-only**
(`workflow_dispatch`) — pick the branch when dispatching. `main` builds bake (`workflow_dispatch`) — pick the branch when dispatching. `main` builds bake
the current git tag as `APP_VERSION`; `dev` builds bake the current commit the current git tag as `APP_VERSION`; `dev` builds bake the current commit
hash. Both pass the portal key from the `RWT_API_KEY` secret and build with hash. Both build with `nix build --impure` (which lets the flake's env
`nix build --impure` (which lets the flake's env injection work). The injection work). The offline test suite runs in the package's `checkPhase`;
offline test suite runs in the package's `checkPhase`; the live portal tests the live portal tests run from `nix develop` when the `RWT_LIVE_*` secrets
run from `nix develop` when the `RWT_LIVE_*` secrets are set. are set.
- Asset tests (`test_assets`) must pass before committing -- they catch missing - Asset tests (`test_assets`) must pass before committing -- they catch missing
fonts and images that would break the app at runtime. fonts and images that would break the app at runtime.
+13 -20
View File
@@ -34,18 +34,16 @@
}; };
in in
{ {
# Version and API key are injected from the ambient environment on # Version is injected from the ambient environment on
# impure evals (CI sets APP_VERSION/API_KEY from the git ref and a # impure evals (CI sets APP_VERSION from the git ref). Pure evals
# secret). Pure evals (`nix flake check`, local `nix build`) can't # (`nix flake check`, local `nix build`) can't see the
# see the environment, so builtins.getEnv returns "" and the # environment, so builtins.getEnv returns "" and the package
# package falls back to its 0.1.0 default / no auth header. # falls back to its 0.1.0 default.
packages.default = packages.default =
let let
envVersion = builtins.getEnv "APP_VERSION"; envVersion = builtins.getEnv "APP_VERSION";
envApiKey = builtins.getEnv "API_KEY";
packageArgs = packageArgs =
{ apiKey = envApiKey; } nixpkgs.lib.optionalAttrs (envVersion != "") { version = envVersion; };
// (nixpkgs.lib.optionalAttrs (envVersion != "") { version = envVersion; });
in in
pkgs.callPackage ./nix/package.nix packageArgs; pkgs.callPackage ./nix/package.nix packageArgs;
@@ -53,22 +51,17 @@
inputsFrom = [ self.packages.${system}.default ]; inputsFrom = [ self.packages.${system}.default ];
packages = [ ]; packages = [ ];
env = { env = {
# APP_VERSION and API_KEY are CMake cache variables with no # APP_VERSION is a CMake cache variable with no default; on a
# default; on a fresh configure cmake seeds their cache from # fresh configure cmake seeds its cache from the environment
# environment variables of the same name, so a plain # variable of the same name, so a plain `cmake -S . -B build`
# `cmake -S . -B build` bakes them into the binary. # bakes it into the binary.
APP_VERSION = "dev"; APP_VERSION = "dev";
}; };
shellHook = '' shellHook = ''
# Never commit the real key. Export API_KEY (or the CI name echo "RadiantWave dev shell (APP_VERSION=dev)"
# RWT_LIVE_API_KEY) in your ambient environment and this shell
# picks it up; empty by default (no auth header).
export API_KEY="''${API_KEY:-''${RWT_LIVE_API_KEY:-}}"
echo "RadiantWave dev shell (APP_VERSION=dev, API_KEY=$([[ -n "$API_KEY" ]] && echo set || echo unset))"
echo " Configure: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug" echo " Configure: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug"
echo " Bake version+key: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION=\"1.2.3\" -DAPI_KEY=\"<key>\"" echo " Bake version: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION=\"1.2.3\""
echo " Nix build (env version+key, impure): APP_VERSION=... API_KEY=... nix build --impure .#default" echo " Nix build (env version, impure): APP_VERSION=... nix build --impure .#default"
echo " Build: cmake --build build" echo " Build: cmake --build build"
echo " Run: ./build/radiantwave" echo " Run: ./build/radiantwave"
echo " Tests: ./build/test_assets && ./build/test_<name>" echo " Tests: ./build/test_assets && ./build/test_<name>"
+1 -7
View File
@@ -15,9 +15,6 @@
curl, curl,
openssl, openssl,
systemdLibs, systemdLibs,
# Portal API key baked into the binary. Keep it out of the flake; pass it
# when calling callPackage from CI/your own config (see README "App version").
apiKey ? "",
# App version baked into the Home footer's Version row. CI overrides this # App version baked into the Home footer's Version row. CI overrides this
# per branch (git tag on main, commit hash on dev); local/pure builds keep # per branch (git tag on main, commit hash on dev); local/pure builds keep
# the 0.1.0 default. # the 0.1.0 default.
@@ -54,10 +51,7 @@ stdenv.mkDerivation (finalAttrs: {
# Bake the package version into the binary so the Home footer's Version row # Bake the package version into the binary so the Home footer's Version row
# shows it (CMake passes APP_VERSION through as a compile definition). # shows it (CMake passes APP_VERSION through as a compile definition).
# API_KEY is only added when a non-empty key is supplied to callPackage. cmakeFlags = [ "-DAPP_VERSION=${finalAttrs.version}" ];
cmakeFlags =
[ "-DAPP_VERSION=${finalAttrs.version}" ]
++ lib.optionals (apiKey != "") [ "-DAPI_KEY=${apiKey}" ];
# gtest is a buildInput, not native: nixpkgs builds it shared, so the # gtest is a buildInput, not native: nixpkgs builds it shared, so the
# check-phase test binaries need its lib dir baked into their RPATH. # check-phase test binaries need its lib dir baked into their RPATH.
+12
View File
@@ -112,6 +112,18 @@ in
export WLR_RENDERER=pixman export WLR_RENDERER=pixman
export WLR_NO_HARDWARE_CURSORS=1 export WLR_NO_HARDWARE_CURSORS=1
# Prefer the PipeWire audio backend so SDL enumerates every sink
# (built-in analog, HDMI, and the loopback virtual speaker) instead of
# silently falling back to an ALSA-only device view that hides the
# PipeWire sinks and makes the device selector show a single entry.
export SDL_AUDIODRIVER=pipewire
if [ -n "$${XDG_RUNTIME_DIR:-}" ]; then
for i in {1..20}; do
[ -S "$XDG_RUNTIME_DIR/pipewire-0" ] && break
sleep 0.5
done
fi
cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1 cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1
fi fi
''; '';
+7 -3
View File
@@ -66,13 +66,16 @@ public:
// Returns the current human-readable status for display (e.g. footer). // Returns the current human-readable status for display (e.g. footer).
std::string statusString() const; std::string statusString() const;
static constexpr const char* AUDIO_BASE_URL = "https://files.radiantwavetech.com/audio/"; static constexpr const char *AUDIO_BASE_URL =
"https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5"; static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
private: private:
void run(); void run();
bool fetchManifest(std::vector<AudioManifestEntry> &out); bool fetchManifest(std::vector<AudioManifestEntry> &out);
bool downloadAndVerify(const std::string& filename, const std::string& expectedMd5, const std::string& displayName); bool downloadAndVerify(const std::string &filename,
const std::string &expectedMd5,
const std::string &displayName);
void setStatus(const std::string &msg); void setStatus(const std::string &msg);
void wait(std::chrono::seconds seconds); void wait(std::chrono::seconds seconds);
@@ -88,7 +91,8 @@ private:
// Parses 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 // starting with '#' are ignored. Lines that don't match a known format are
// skipped. Returns false if no valid entries were found. // skipped. Returns false if no valid entries were found.
bool parseManifest(const std::string& text, std::vector<AudioManifestEntry>& out); bool parseManifest(const std::string &text,
std::vector<AudioManifestEntry> &out);
// Maps a frequency value in Hz to its internal solfeggio frequency id using // Maps a frequency value in Hz to its internal solfeggio frequency id using
// the same table the color palettes are seeded from (174/285->1, 396->2, // the same table the color palettes are seeded from (174/285->1, 396->2,
+388 -31
View File
@@ -32,6 +32,7 @@ namespace radiantwave::audio
if (lower.find("analog") != std::string::npos || if (lower.find("analog") != std::string::npos ||
lower.find("built-in") != std::string::npos || lower.find("built-in") != std::string::npos ||
lower.find("builtin") != std::string::npos || lower.find("builtin") != std::string::npos ||
lower.find("virtual") != std::string::npos ||
lower.find("realtek") != std::string::npos || lower.find("realtek") != std::string::npos ||
lower.find("hda") != std::string::npos || lower.find("hda") != std::string::npos ||
lower.find("speaker") != std::string::npos || lower.find("speaker") != std::string::npos ||
@@ -76,7 +77,6 @@ namespace radiantwave::audio
enabled_ = cfg.audio.enabled; enabled_ = cfg.audio.enabled;
currentDevice_ = cfg.audio.output_device; currentDevice_ = cfg.audio.output_device;
currentTrack_ = cfg.audio.current_track; currentTrack_ = cfg.audio.current_track;
if (!MIX_Init()) if (!MIX_Init())
{ {
std::string err = SDL_GetError(); std::string err = SDL_GetError();
@@ -86,17 +86,68 @@ namespace radiantwave::audio
return false; return false;
} }
// Create mixer device with default audio device // Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr); // Pulse, dummy, ...) and what playback devices it actually sees. This is
// the first place a "only the built-in speakers show up" report points;
// a single device here means SDL itself never enumerated the HDMI sink.
const char *driver = SDL_GetCurrentAudioDriver();
std::string driverName = driver ? driver : "(none)";
int deviceCount = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount);
std::string deviceNames;
if (devices)
{
for (int i = 0; i < deviceCount; ++i)
{
if (i)
{
deviceNames += ", ";
}
const char *n = SDL_GetAudioDeviceName(devices[i]);
deviceNames += n ? n : "(unnamed)";
}
SDL_free(devices);
}
logger::Logger::instance().info(
"Audio",
"SDL audio driver: " + driverName + "; " +
std::to_string(deviceCount) + " playback device(s) [" +
(deviceNames.empty() ? "(none)" : deviceNames) + "]");
// 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_) if (!mixer_)
{ {
std::string err = SDL_GetError(); std::string err = SDL_GetError();
logger::Logger::instance().warning("Audio", 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; initialized_ = false;
return false; return false;
} }
currentDeviceId_ = targetId;
slots_[0].track = MIX_CreateTrack(mixer_); slots_[0].track = MIX_CreateTrack(mixer_);
slots_[1].track = MIX_CreateTrack(mixer_); slots_[1].track = MIX_CreateTrack(mixer_);
initialized_ = true; initialized_ = true;
@@ -135,6 +186,40 @@ namespace radiantwave::audio
return enabled_ ? (volume_ / 100.0f) : 0.0f; return enabled_ ? (volume_ / 100.0f) : 0.0f;
} }
SDL_PropertiesID AudioManager::createInfiniteLoopOptions()
{
// MIX_PlayTrack()'s options default to a single pass; requesting
// infinite loops is what keeps a track going until it is explicitly
// faded out or the app shuts down.
SDL_PropertiesID props = SDL_CreateProperties();
if (props)
{
SDL_SetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, -1);
}
return props;
}
bool AudioManager::playTrackLooping(int slotIndex)
{
if (!initialized_ || !mixer_ || slotIndex < 0 || slotIndex >= 2 ||
!slots_[slotIndex].track)
{
return false;
}
SDL_PropertiesID props = createInfiniteLoopOptions();
if (!props)
{
logger::Logger::instance().error(
"Audio", "Failed to create playback options for " +
displayNameFor(currentTrack_));
return false;
}
bool ok = MIX_PlayTrack(slots_[slotIndex].track, props);
SDL_DestroyProperties(props);
return ok;
}
void AudioManager::shutdown() void AudioManager::shutdown()
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
@@ -181,18 +266,62 @@ namespace radiantwave::audio
{ {
const char *name = SDL_GetAudioDeviceName(devices[i]); const char *name = SDL_GetAudioDeviceName(devices[i]);
std::string raw = name ? name : ""; 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); SDL_free(devices);
} }
else else
{ {
list.push_back({"", "Built-In Speakers"}); if (devices)
{
SDL_free(devices);
}
const char *driver = SDL_GetCurrentAudioDriver();
// Zero enumerated devices is a symptom, not a real output. Surface
// the truth (a single "System Default" entry that still routes to
// whatever the OS default is) instead of pretending a fabricated
// device, and say which backend was active so kiosk logs show why.
logger::Logger::instance().debug(
"Audio",
"No playback devices enumerated (audio driver: " +
std::string(driver ? driver : "none") +
"); falling back to the system default");
list.push_back({"", "System Default", 0});
} }
return list; 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) void AudioManager::setVolume(int volume_0_to_100)
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
@@ -253,14 +382,76 @@ namespace radiantwave::audio
bool AudioManager::switchDevice(const std::string &raw_device_name) bool AudioManager::switchDevice(const std::string &raw_device_name)
{ {
std::lock_guard<std::mutex> lock(mutex_); 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_) if (configManager_)
{ {
configManager_->update([raw_device_name](config::AppConfig &cfg) configManager_->update([name = currentDevice_](config::AppConfig &cfg)
{ cfg.audio.output_device = raw_device_name; }); { cfg.audio.output_device = name; });
} }
return true; return true;
@@ -272,6 +463,150 @@ namespace radiantwave::audio
return currentDevice_; 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. Creates a fresh mixer on the given
// device, recreating the two track slots and resuming the active track if
// one was playing.
//
// The new mixer is created BEFORE the current one is destroyed. Reversing
// the order (destroy-then-reopen) quits the whole SDL audio subsystem the
// moment the last device closes: MIX_DestroyMixer() follows up with
// SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry
// and renumbers every enumerated device id on the way back up. The id this
// switch was asked to open is then stale and the reopen fails with
// "Invalid audio device instance ID". Keeping the current device open while
// the replacement opens also means a failed switch leaves the current
// output playing instead of going permanently mute.
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;
// Open the replacement mixer on the target device first, while the
// current device (and SDL's device registry) is still alive.
MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr);
if (!newMixer)
{
std::string err = SDL_GetError();
logger::Logger::instance().error(
"Audio",
"Could not open audio output device "
+ mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId))
+ "; keeping the current device (MIX_CreateMixerDevice error: "
+ err + ")");
return false;
}
MIX_Track *newTracks[2];
for (int i = 0; i < 2; ++i)
{
newTracks[i] = MIX_CreateTrack(newMixer);
}
if (!newTracks[0] || !newTracks[1])
{
MIX_DestroyMixer(newMixer);
logger::Logger::instance().error(
"Audio", "Could not create mixer tracks; keeping the current device (SDL error: "
+ std::string(SDL_GetError()) + ")");
return false;
}
// Tear down the existing mixer (its tracks die with it) and adopt the
// new one.
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_ = newMixer;
slots_[0].track = newTracks[0];
slots_[1].track = newTracks[1];
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);
playTrackLooping(activeSlot_); // Loop playback
}
}
}
return true;
}
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename) bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
{ {
if (!initialized_ || !mixer_ || !slots_[slotIndex].track) if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
@@ -322,7 +657,7 @@ namespace radiantwave::audio
slot.fadeInDuration = seconds; slot.fadeInDuration = seconds;
slot.fadeInElapsed = 0.0f; slot.fadeInElapsed = 0.0f;
MIX_SetTrackGain(slot.track, 0.0f); MIX_SetTrackGain(slot.track, 0.0f);
MIX_PlayTrack(slot.track, 0); // Loop playback playTrackLooping(slotIndex); // Loop playback
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)"); logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
} }
@@ -336,7 +671,7 @@ namespace radiantwave::audio
return false; return false;
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f); MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback playTrackLooping(activeSlot_); // Loop playback
// Explicit user selection: persist the manual track so it survives a // Explicit user selection: persist the manual track so it survives a
// restart and is used whenever dynamic audio is turned off. Dynamic // restart and is used whenever dynamic audio is turned off. Dynamic
@@ -373,10 +708,11 @@ namespace radiantwave::audio
slot.fadeInElapsed = 0.0f; slot.fadeInElapsed = 0.0f;
slot.fadeOutRemaining = 0.0f; slot.fadeOutRemaining = 0.0f;
MIX_SetTrackGain(slot.track, 1.0f); MIX_SetTrackGain(slot.track, 1.0f);
if (!MIX_TrackPlaying(slot.track)) // Always restart: MIX_PlayTrack() re-starts a playing track too, so
{ // this cancels any in-flight stop-fade inherited from a quick
MIX_PlayTrack(slot.track, 0); // Loop playback // pop->push within the fade window. Gating on MIX_TrackPlaying()
} // left a fading track to finish its fade and go silently stopped.
playTrackLooping(activeSlot_); // Loop playback
return true; return true;
} }
@@ -411,10 +747,14 @@ namespace radiantwave::audio
if (!loadTrackInto(otherSlot, wav_filename)) if (!loadTrackInto(otherSlot, wav_filename))
return false; return false;
// Fade out whatever is currently playing on the active slot. // Fade out whatever is currently playing on the active slot. Clear any
// in-flight fade-in bookkeeping first so update() stops ramping this
// slot's gain up while the mixer fades it down.
TrackSlot &active = slots_[activeSlot_]; TrackSlot &active = slots_[activeSlot_];
if (active.audio) if (active.audio)
{ {
active.fadeInDuration = 0.0f;
active.fadeInElapsed = 0.0f;
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0))); MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
active.fadeOutRemaining = fade; active.fadeOutRemaining = fade;
} }
@@ -442,6 +782,10 @@ namespace radiantwave::audio
{ {
continue; continue;
} }
// The mixer owns the ramp now; drop any pending fade-in so the
// per-frame update() cannot fight the fade-out.
slot.fadeInDuration = 0.0f;
slot.fadeInElapsed = 0.0f;
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0))); MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
slot.fadeOutRemaining = fade; slot.fadeOutRemaining = fade;
faded = true; faded = true;
@@ -476,7 +820,29 @@ namespace radiantwave::audio
{ {
TrackSlot &slot = slots_[i]; TrackSlot &slot = slots_[i];
if (!slot.track) if (!slot.track)
{
continue; continue;
}
// A slot mid stop-fade belongs to the mixer: skip both the fade-in
// ramp and the active-gain pin so nothing drives its gain up while
// the mixer ramps it down (this also keeps a quit/pop fade clean).
if (slot.fadeOutRemaining > 0.0f)
{
slot.fadeOutRemaining -= dt;
if (slot.fadeOutRemaining <= 0.0f)
{
slot.fadeOutRemaining = 0.0f;
// Free the audio once the fade-out has finished; the track
// object itself is reused.
if (slot.audio)
{
MIX_DestroyAudio(slot.audio);
slot.audio = nullptr;
}
}
continue;
}
if (slot.fadeInDuration > 0.0f) if (slot.fadeInDuration > 0.0f)
{ {
@@ -493,21 +859,12 @@ namespace radiantwave::audio
// Keep the active track at full per-track gain; the mixer gain // Keep the active track at full per-track gain; the mixer gain
// carries the user's volume / mute state. // carries the user's volume / mute state.
MIX_SetTrackGain(slot.track, 1.0f); MIX_SetTrackGain(slot.track, 1.0f);
} // Self-heal: playback must continue indefinitely, so restart a
// track that stopped unexpectedly (there is no pause state and
if (slot.fadeOutRemaining > 0.0f) // nothing else should legitimately stop the active track).
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
{ {
slot.fadeOutRemaining -= dt; playTrackLooping(i);
if (slot.fadeOutRemaining <= 0.0f)
{
slot.fadeOutRemaining = 0.0f;
// Free the audio once the fade-out has finished; the track
// object itself is reused.
if (slot.audio)
{
MIX_DestroyAudio(slot.audio);
slot.audio = nullptr;
}
} }
} }
} }
+35
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "config/config_manager.h" #include "config/config_manager.h"
#include <SDL3/SDL.h>
#include <SDL3_mixer/SDL_mixer.h> #include <SDL3_mixer/SDL_mixer.h>
#include <mutex> #include <mutex>
#include <string> #include <string>
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
struct AudioDeviceItem { struct AudioDeviceItem {
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
std::string friendly_name; // e.g. "Built-In Speakers" 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 // Maps raw technical audio device strings to user-friendly labels
@@ -51,7 +53,24 @@ public:
// Audio device selection // Audio device selection
bool switchDevice(const std::string& raw_device_name); bool switchDevice(const std::string& raw_device_name);
bool switchDeviceById(SDL_AudioDeviceID id);
std::string getCurrentDevice() const; 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);
// Builds the SDL_PropertiesID passed to MIX_PlayTrack() for tracks that
// loop forever (MIX_PROP_PLAY_LOOPS_NUMBER = -1). Exposed for tests; no
// mixer, track, or audio device is required. The caller owns the property
// set and must release it with SDL_DestroyProperties().
static SDL_PropertiesID createInfiniteLoopOptions();
// Playback control // Playback control
bool playTrack(const std::string& wav_filename); bool playTrack(const std::string& wav_filename);
@@ -84,6 +103,21 @@ private:
void startFadeIn(int slotIndex, float seconds); void startFadeIn(int slotIndex, float seconds);
std::string displayNameFor(const std::string& wav_filename) const; std::string displayNameFor(const std::string& wav_filename) const;
// Starts the track on the given slot with infinite looping. The track must
// already be loaded on the slot. Calling this on a playing/stopped/fading
// track restarts it (cancelling any in-flight stop-fade).
bool playTrackLooping(int slotIndex);
// 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. The replacement mixer is opened before the
// current one is destroyed so SDL's device ids stay valid (destroy-first
// quits the whole audio subsystem and orphans the id being opened).
// Returns false on failure without touching the current mixer, so the
// existing output keeps playing.
bool recreateMixerOnDevice(SDL_AudioDeviceID targetId);
config::ConfigManager* configManager_ = nullptr; config::ConfigManager* configManager_ = nullptr;
db::DatabaseManager* db_ = nullptr; db::DatabaseManager* db_ = nullptr;
@@ -91,6 +125,7 @@ private:
bool enabled_ = true; bool enabled_ = true;
int volume_ = 80; // 0 - 100 int volume_ = 80; // 0 - 100
std::string currentDevice_ = ""; std::string currentDevice_ = "";
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
std::string currentTrack_ = ""; std::string currentTrack_ = "";
MIX_Mixer* mixer_ = nullptr; MIX_Mixer* mixer_ = nullptr;
+19 -5
View File
@@ -1,5 +1,6 @@
#include "audio_settings_page.h" #include "audio_settings_page.h"
#include "app_state.h" #include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -76,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) { } else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) { if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_; currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); applyDeviceSelection();
} }
modalState_ = ModalState::None; modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) { } else if (modalState_ == ModalState::TrackSelector) {
@@ -155,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) { } else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) { if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_; currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); applyDeviceSelection();
} }
modalState_ = ModalState::None; modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) { } else if (modalState_ == ModalState::TrackSelector) {
@@ -279,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 - kept the current output";
}
refreshDeviceAndTrackLists();
}
void AudioSettingsPage::enableDynamicAudio() { void AudioSettingsPage::enableDynamicAudio() {
if (!state_) return; if (!state_) return;
state_->config.update([](radiantwave::config::AppConfig& cfg) { state_->config.update([](radiantwave::config::AppConfig& cfg) {
@@ -323,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
if (modalState_ == ModalState::DeviceSelector) { if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) { if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_; currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); applyDeviceSelection();
} }
} else if (modalState_ == ModalState::TrackSelector) { } else if (modalState_ == ModalState::TrackSelector) {
if (modalSelectedIndex_ == 0) { if (modalSelectedIndex_ == 0) {
@@ -477,7 +491,7 @@ void AudioSettingsPage::renderModal(SDL_Renderer* renderer, float fw, float fh,
void AudioSettingsPage::render(SDL_Renderer* renderer) { void AudioSettingsPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
@@ -517,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
} }
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size())) std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
? devices_[currentDeviceIndex_].friendly_name : "Built-In Speakers"; ? devices_[currentDeviceIndex_].friendly_name : "System Default";
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white); deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
if (deviceText_.texture) { if (deviceText_.texture) {
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height}; SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
+4
View File
@@ -36,6 +36,10 @@ private:
void enableDynamicAudio(); void enableDynamicAudio();
void selectTrack(int trackIdx); 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; AppState* state_ = nullptr;
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
+2 -1
View File
@@ -1,6 +1,7 @@
#include "date_format_page.h" #include "date_format_page.h"
#include "app_state.h" #include "app_state.h"
#include "date_format.h" #include "date_format.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <ctime> #include <ctime>
@@ -131,7 +132,7 @@ void DateFormatPage::update(float dt)
void DateFormatPage::render(SDL_Renderer* renderer) void DateFormatPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -3,6 +3,7 @@
#include "db/db_keys.h" #include "db/db_keys.h"
#include "provisioning.h" #include "provisioning.h"
#include "validation.h" #include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
namespace { namespace {
@@ -164,7 +165,7 @@ void EmailAddressPage::exitPage(AppState& state)
void EmailAddressPage::render(SDL_Renderer* renderer) void EmailAddressPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+28 -3
View File
@@ -8,6 +8,7 @@
#include "audio/audio_downloader.h" #include "audio/audio_downloader.h"
#include "db/db_keys.h" #include "db/db_keys.h"
#include "network/proclamation_sync.h" #include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <ctime> #include <ctime>
@@ -114,7 +115,7 @@ void Home::update(float dt)
void Home::render(SDL_Renderer *renderer) void Home::render(SDL_Renderer *renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
@@ -179,6 +180,16 @@ void Home::render(SDL_Renderer *renderer)
SDL_RenderFillRect(renderer, &line); SDL_RenderFillRect(renderer, &line);
} }
// Large bold reminder to connect WiFi, drawn below the menu while no
// network is joined (refreshed every 5s in update()).
if (wifiNote_.texture && ssid_.empty())
{
float noteX = (fw - wifiNote_.width) / 2.0f;
float noteY = settingsY_ + settings_.height + 40.0f;
SDL_FRect noteDst{noteX, noteY, wifiNote_.width, wifiNote_.height};
SDL_RenderTexture(renderer, wifiNote_.texture, nullptr, &noteDst);
}
// Bottom-right status footer (20pt, aligned to a virtual rectangle: // Bottom-right status footer (20pt, aligned to a virtual rectangle:
// names left-aligned at the rect's left edge, values right-aligned at // names left-aligned at the rect's left edge, values right-aligned at
// the rect's right edge; the longest name's colon sits 4 spaces from its // the rect's right edge; the longest name's colon sits 4 spaces from its
@@ -280,10 +291,10 @@ void Home::render(SDL_Renderer *renderer)
fourSpaces.destroy(); fourSpaces.destroy();
const float margin = 20.0f; const float margin = 20.0f;
float rectRight = fw - margin; float rectRight = fw - margin - 50.0f;
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW); float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
float lineY = fh - margin; float lineY = fh - margin - 50.0f;
const float lineGap = 6.0f; const float lineGap = 6.0f;
for (size_t i = footerNames_.size(); i-- > 0;) for (size_t i = footerNames_.size(); i-- > 0;)
{ {
@@ -351,6 +362,15 @@ void Home::createTextures(AppState &state)
TTF_CloseFont(font); TTF_CloseFont(font);
font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48);
if (font)
{
TTF_SetFontStyle(font, TTF_STYLE_BOLD);
wifiNote_ = renderText(state.renderer, font, "Please connect to WiFi", white);
TTF_SetFontStyle(font, TTF_STYLE_NORMAL);
TTF_CloseFont(font);
}
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png"); SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
if (bgSurface) if (bgSurface)
{ {
@@ -409,6 +429,11 @@ void Home::destroyTextures()
SDL_DestroyTexture(settings_.texture); SDL_DestroyTexture(settings_.texture);
settings_.texture = nullptr; settings_.texture = nullptr;
} }
if (wifiNote_.texture)
{
SDL_DestroyTexture(wifiNote_.texture);
wifiNote_.texture = nullptr;
}
for (auto &t : footerNames_) for (auto &t : footerNames_)
t.destroy(); t.destroy();
for (auto &t : footerValues_) for (auto &t : footerValues_)
+1
View File
@@ -34,6 +34,7 @@ private:
TextLine play_; TextLine play_;
TextLine settings_; TextLine settings_;
TextLine bg_; TextLine bg_;
TextLine wifiNote_;
AppState* state_ = nullptr; AppState* state_ = nullptr;
float playY_ = 0; float playY_ = 0;
+2 -1
View File
@@ -1,6 +1,7 @@
#include "license_blocked_page.h" #include "license_blocked_page.h"
#include "app_state.h" #include "app_state.h"
#include "home.h" #include "home.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
void LicenseBlockedPage::onPush(AppState& state) void LicenseBlockedPage::onPush(AppState& state)
@@ -71,7 +72,7 @@ void LicenseBlockedPage::update(float dt)
void LicenseBlockedPage::render(SDL_Renderer* renderer) void LicenseBlockedPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+3 -2
View File
@@ -4,6 +4,7 @@
#include "network/license_service.h" #include "network/license_service.h"
#include "provisioning.h" #include "provisioning.h"
#include "validation.h" #include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
void LicenseKeyPage::onPush(AppState& state) void LicenseKeyPage::onPush(AppState& state)
@@ -97,7 +98,7 @@ void LicenseKeyPage::submit()
// When the email is missing the key is saved but the check is skipped. // When the email is missing the key is saved but the check is skipped.
std::string errorMessage; std::string errorMessage;
const radiantwave::license::Result result = radiantwave::license::validateStoredLicense( const radiantwave::license::Result result = radiantwave::license::validateStoredLicense(
state_->db, "", &errorMessage); state_->db, &errorMessage);
switch (result) { switch (result) {
case radiantwave::license::Result::Ok: case radiantwave::license::Result::Ok:
@@ -172,7 +173,7 @@ void LicenseKeyPage::exitPage(AppState& state)
void LicenseKeyPage::render(SDL_Renderer* renderer) void LicenseKeyPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "log_viewer_page.h" #include "log_viewer_page.h"
#include "app_state.h" #include "app_state.h"
#include "timezone_helper.h" #include "timezone_helper.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <sstream> #include <sstream>
@@ -162,7 +163,7 @@ void LogViewerPage::update(float dt) {
void LogViewerPage::render(SDL_Renderer* renderer) { void LogViewerPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <SDL3/SDL.h>
namespace radiantwave {
// 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)
{
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
+139 -56
View File
@@ -4,47 +4,64 @@
#include "app_state.h" #include "app_state.h"
#include "home.h" #include "home.h"
#include "provisioning.h" #include "logical_size.h"
#include "logger/logger.h" #include "logger/logger.h"
#include "provisioning.h"
#include "scroller_page.h"
namespace namespace {
{
// How long audio fades out before the app tears down on quit. Only applied // How long audio fades out before the app tears down on quit. Only applied
// when audio is actually playing; otherwise quitting is immediate. // when audio is actually playing; otherwise quitting is immediate.
constexpr float kQuitFadeSeconds = 5.0f; constexpr float kQuitFadeSeconds = 5.0f;
} // anonymous namespace } // anonymous namespace
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
{
// Initialize text logger // Initialize text logger
radiantwave::logger::Logger::instance().initialize(); radiantwave::logger::Logger::instance().initialize();
radiantwave::logger::Logger::instance().info("System", "RadiantWave Kiosk starting up"); radiantwave::logger::Logger::instance().info(
"System", "RadiantWave Kiosk starting up");
if (!SDL_Init(SDL_INIT_VIDEO)) if (!SDL_Init(SDL_INIT_VIDEO)) {
{
std::string err = SDL_GetError(); std::string err = SDL_GetError();
radiantwave::logger::Logger::instance().fatal("Graphics", radiantwave::logger::Logger::instance().fatal(
"Screen display initialization failed. Please verify graphics hardware and display connections (SDL_Init error: " + err + ")"); "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()); SDL_Log("Failed to initialize SDL: %s", err.c_str());
return SDL_APP_FAILURE; return SDL_APP_FAILURE;
} }
auto *state = new AppState{}; auto *state = new AppState{};
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the // Size the fullscreen window from the display's actual mode so the very
// compositor never has to scale the moving scroller (scaling moving text // first buffer commit already matches what the compositor scanouts 1:1.
// causes flicker). // Requesting a hardcoded size left the backbuffer at that size on
state->window = SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN); // displays running a different mode, and frames reached the panel
if (!state->window) // 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()); SDL_Log("Failed to create window: %s", SDL_GetError());
delete state; delete state;
return SDL_APP_FAILURE; return SDL_APP_FAILURE;
} }
state->renderer = SDL_CreateRenderer(state->window, nullptr); state->renderer = SDL_CreateRenderer(state->window, nullptr);
if (!state->renderer) if (!state->renderer) {
{
SDL_Log("Failed to create renderer: %s", SDL_GetError()); SDL_Log("Failed to create renderer: %s", SDL_GetError());
delete state; delete state;
return SDL_APP_FAILURE; return SDL_APP_FAILURE;
@@ -53,56 +70,105 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
// Present on the display's refresh. Without VSync the kiosk panel tears // Present on the display's refresh. Without VSync the kiosk panel tears
// fast-scrolling text into horizontal bands that flicker as the text moves. // fast-scrolling text into horizontal bands that flicker as the text moves.
if (!SDL_SetRenderVSync(state->renderer, 1)) { if (!SDL_SetRenderVSync(state->renderer, 1)) {
radiantwave::logger::Logger::instance().warning("Graphics", radiantwave::logger::Logger::instance().warning(
"VSync could not be enabled; scrolling text may tear (VSync error: " + std::string(SDL_GetError()) + ")"); "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(); SDL_HideCursor();
// Initialize configuration, database, and network manager // Initialize configuration, database, and network manager
if (!state->config.initialize()) { if (!state->config.initialize()) {
radiantwave::logger::Logger::instance().warning("Config", radiantwave::logger::Logger::instance().warning(
"Settings file could not be loaded. Default settings will be used (Config initialization failed at ~/.local/radiantwave/config.yaml)"); "Config", "Settings file could not be loaded. Default settings "
"will be used (Config initialization failed at "
"~/.local/radiantwave/config.yaml)");
} else { } else {
radiantwave::logger::Logger::instance().info("Config", "Configuration initialized successfully"); radiantwave::logger::Logger::instance().info(
"Config", "Configuration initialized successfully");
} }
if (!state->db.initialize()) { if (!state->db.initialize()) {
radiantwave::logger::Logger::instance().error("Database", radiantwave::logger::Logger::instance().error(
"Database storage unavailable. App features may be limited (Database initialization failed at ~/.local/radiantwave/radiantwave.db)"); "Database", "Database storage unavailable. App features may be "
"limited (Database initialization failed at "
"~/.local/radiantwave/radiantwave.db)");
} else { } else {
radiantwave::logger::Logger::instance().info("Database", "Database initialized and schema migrated"); radiantwave::logger::Logger::instance().info(
"Database", "Database initialized and schema migrated");
} }
if (!state->network.initialize()) { if (!state->network.initialize()) {
radiantwave::logger::Logger::instance().warning("Network", radiantwave::logger::Logger::instance().warning(
"WiFi network management is unavailable. Check if NetworkManager is running (D-Bus connection to org.freedesktop.NetworkManager failed)"); "Network", "WiFi network management is unavailable. Check if "
"NetworkManager is running (D-Bus connection to "
"org.freedesktop.NetworkManager failed)");
} else { } else {
radiantwave::logger::Logger::instance().info("Network", "NetworkManager service connected"); radiantwave::logger::Logger::instance().info(
"Network", "NetworkManager service connected");
} }
if (!state->audio.initialize(state->config)) { if (!state->audio.initialize(state->config)) {
radiantwave::logger::Logger::instance().warning("Audio", radiantwave::logger::Logger::instance().warning(
"Audio playback is unavailable. Check sound hardware connections (SDL3_mixer initialization failed)"); "Audio", "Audio playback is unavailable. Check sound hardware "
"connections (SDL3_mixer initialization failed)");
} else { } else {
radiantwave::logger::Logger::instance().info("Audio", "Audio subsystem initialized"); radiantwave::logger::Logger::instance().info(
"Audio", "Audio subsystem initialized");
} }
// Let playback logs report user-facing display names from the database. // Let playback logs report user-facing display names from the database.
state->audio.attachDatabase(state->db); state->audio.attachDatabase(state->db);
// Background download of audio assets. The worker keeps retrying until // Background download of audio assets. The worker keeps retrying until
// internet is available, so downloads start automatically on connect. // internet is available, so downloads start automatically on connect.
// The downloaded files are reconciled into the database's audio_files table. // The downloaded files are reconciled into the database's audio_files
// table.
state->audioDownloader.start(state->db); state->audioDownloader.start(state->db);
// Background license validation. Home triggers a refresh whenever it is // Background license validation. Home triggers a refresh whenever it is
// displayed; the result gates access to Play and the Proclamation options. // displayed; the result gates access to Play and the Proclamation options.
state->licenseTracker.start(state->db); state->licenseTracker.start(state->db);
state->config.onChanged([state](const radiantwave::config::AppConfig& new_config) { state->config.onChanged(
[state](const radiantwave::config::AppConfig &new_config) {
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d", SDL_Log("Live config update received: volume=%d, dynamic_audio=%d",
new_config.audio.volume, new_config.audio.dynamic_audio ? 1 : 0); new_config.audio.volume,
state->db.logEvent("INFO", "Config", "Live configuration hot-reloaded"); 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->keybinds.bind(SDLK_UP, "menu_up", [state]() {
@@ -113,9 +179,9 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
}); });
// Drive the First-Run walkthrough: shows the WiFi page when the internet // Drive the First-Run walkthrough: shows the WiFi page when the internet
// is unreachable, then the email and license pages when those are missing, // is unreachable <DEPRECATED>, then the email and license pages when those
// finishing on Home. The internet check is a real connectivity probe // are missing, finishing on Home. The internet check is a real connectivity
// (Ethernet, WiFi, or macOS all work), so it never traps a connected // probe (Ethernet, WiFi, or macOS all work), so it never traps a connected
// device on the WiFi page. // device on the WiFi page.
radiantwave::provisioning::begin(*state); radiantwave::provisioning::begin(*state);
@@ -123,27 +189,47 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
{
auto &state = *static_cast<AppState *>(appstate); auto &state = *static_cast<AppState *>(appstate);
if (event->type == SDL_EVENT_QUIT) if (event->type == SDL_EVENT_QUIT) {
{
state.quitRequested = true; 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)) { if (!state.keybinds.handleEvent(*event)) {
state.pages.handleEvent(*event); state.pages.handleEvent(*event);
} }
if (state.quitRequested && state.quitFadeRemaining <= 0.0f) if (state.quitRequested && state.quitFadeRemaining <= 0.0f) {
{
// Graceful shutdown: start the audio fade-out and keep the loop // Graceful shutdown: start the audio fade-out and keep the loop
// running until it completes, then let SDL_AppQuit tear everything // running until it completes, then let SDL_AppQuit tear everything
// down. When nothing is playing there is nothing to fade and quitting // down. When nothing is playing there is nothing to fade and quitting
// is immediate. // is immediate.
if (state.audio.stopTrackFadeOut(kQuitFadeSeconds)) if (state.audio.stopTrackFadeOut(kQuitFadeSeconds)) {
{
state.quitFadeRemaining = kQuitFadeSeconds; state.quitFadeRemaining = kQuitFadeSeconds;
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
@@ -153,23 +239,21 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
SDL_AppResult SDL_AppIterate(void *appstate) SDL_AppResult SDL_AppIterate(void *appstate) {
{
auto &state = *static_cast<AppState *>(appstate); auto &state = *static_cast<AppState *>(appstate);
Uint64 nowTicks = SDL_GetTicks(); Uint64 nowTicks = SDL_GetTicks();
static Uint64 lastTicks = nowTicks; static Uint64 lastTicks = nowTicks;
float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f; float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f;
if (dt > 0.1f) dt = 0.1f; if (dt > 0.1f)
dt = 0.1f;
lastTicks = nowTicks; lastTicks = nowTicks;
state.pages.update(dt); state.pages.update(dt);
if (state.quitFadeRemaining > 0.0f) if (state.quitFadeRemaining > 0.0f) {
{
state.quitFadeRemaining -= dt; state.quitFadeRemaining -= dt;
if (state.quitFadeRemaining <= 0.0f) if (state.quitFadeRemaining <= 0.0f) {
{
return SDL_APP_SUCCESS; return SDL_APP_SUCCESS;
} }
} }
@@ -183,8 +267,7 @@ SDL_AppResult SDL_AppIterate(void *appstate)
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
void SDL_AppQuit(void *appstate, SDL_AppResult result) void SDL_AppQuit(void *appstate, SDL_AppResult result) {
{
auto *state = static_cast<AppState *>(appstate); auto *state = static_cast<AppState *>(appstate);
if (state) { if (state) {
state->audioDownloader.shutdown(); state->audioDownloader.shutdown();
+13 -26
View File
@@ -4,10 +4,6 @@
#include <curl/curl.h> #include <curl/curl.h>
#include <string> #include <string>
#ifndef API_KEY
#define API_KEY ""
#endif
namespace radiantwave::api { namespace radiantwave::api {
namespace { namespace {
@@ -31,7 +27,7 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
} }
// Shared curl setup + perform. Returns a populated HttpResponse. // Shared curl setup + perform. Returns a populated HttpResponse.
HttpResponse performRequest(const std::string& url, const std::string& apiKey, HttpResponse performRequest(const std::string& url,
bool post, const std::string& jsonBody) { bool post, const std::string& jsonBody) {
HttpResponse response; HttpResponse response;
@@ -47,12 +43,6 @@ HttpResponse performRequest(const std::string& url, const std::string& apiKey,
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
struct curl_slist* headers = nullptr; struct curl_slist* headers = nullptr;
// An explicit apiKey wins; otherwise fall back to the key baked in at
// build time via -DAPI_KEY (empty string when unset).
const std::string effectiveKey = apiKey.empty() ? API_KEY : apiKey;
if (!effectiveKey.empty()) {
headers = curl_slist_append(headers, ("Authorization: Bearer " + effectiveKey).c_str());
}
if (post) { if (post) {
headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POST, 1L);
@@ -79,13 +69,12 @@ HttpResponse performRequest(const std::string& url, const std::string& apiKey,
// ─── HTTP helpers ────────────────────────────────────────────────────── // ─── HTTP helpers ──────────────────────────────────────────────────────
HttpResponse httpGet(const std::string& path, const std::string& apiKey) { HttpResponse httpGet(const std::string& path) {
return performRequest(urlFor(path), apiKey, false, ""); return performRequest(urlFor(path), false, "");
} }
HttpResponse httpPost(const std::string& path, const std::string& jsonBody, HttpResponse httpPost(const std::string& path, const std::string& jsonBody) {
const std::string& apiKey) { return performRequest(urlFor(path), true, jsonBody);
return performRequest(urlFor(path), apiKey, true, jsonBody);
} }
// ─── GET /frequencies ────────────────────────────────────────────────── // ─── GET /frequencies ──────────────────────────────────────────────────
@@ -112,8 +101,8 @@ bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out)
return true; return true;
} }
bool fetchFrequencies(std::vector<Frequency>& out, const std::string& apiKey) { bool fetchFrequencies(std::vector<Frequency>& out) {
HttpResponse response = httpGet(PATH_FREQUENCIES, apiKey); HttpResponse response = httpGet(PATH_FREQUENCIES);
if (!response.transportOk || response.status < 200 || response.status >= 300) { if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false; return false;
} }
@@ -169,8 +158,8 @@ bool parseColorPalettesJson(const std::string& body, std::vector<ColorPalette>&
return true; return true;
} }
bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKey) { bool fetchColorPalettes(std::vector<ColorPalette>& out) {
HttpResponse response = httpGet(PATH_COLOR_PALETTES, apiKey); HttpResponse response = httpGet(PATH_COLOR_PALETTES);
if (!response.transportOk || response.status < 200 || response.status >= 300) { if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false; return false;
} }
@@ -196,13 +185,12 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) {
return !out.key.empty(); return !out.key.empty();
} }
LicenseResult validateLicense(const std::string& key, const std::string& email, LicenseResult validateLicense(const std::string& key, const std::string& email) {
const std::string& apiKey) {
LicenseResult result; LicenseResult result;
const std::string jsonBody = const std::string jsonBody =
"{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}"; "{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}";
HttpResponse response = httpPost(PATH_LICENSE, jsonBody, apiKey); HttpResponse response = httpPost(PATH_LICENSE, jsonBody);
result.status = response.status; result.status = response.status;
if (!response.transportOk) { if (!response.transportOk) {
@@ -233,8 +221,7 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
return true; return true;
} }
AccountResult createAccount(const std::string& email, const std::string& name, AccountResult createAccount(const std::string& email, const std::string& name) {
const std::string& apiKey) {
AccountResult result; AccountResult result;
std::string jsonBody = std::string jsonBody =
@@ -244,7 +231,7 @@ AccountResult createAccount(const std::string& email, const std::string& name,
} }
jsonBody += "}"; jsonBody += "}";
HttpResponse response = httpPost(PATH_CREATE_ACCOUNT, jsonBody, apiKey); HttpResponse response = httpPost(PATH_CREATE_ACCOUNT, jsonBody);
result.status = response.status; result.status = response.status;
if (!response.transportOk) { if (!response.transportOk) {
+7 -14
View File
@@ -24,14 +24,9 @@ struct HttpResponse {
bool transportOk = false; // true when the request completed without a transport error bool transportOk = false; // true when the request completed without a transport error
}; };
// Performs a GET/POST against `path` (relative to API_BASE_URL). When `apiKey` is // Performs a GET/POST against `path` (relative to API_BASE_URL).
// non-empty it is sent as `Authorization: Bearer <apiKey>`. When it is empty (or HttpResponse httpGet(const std::string& path);
// omitted) the request falls back to the key baked in at build time via the HttpResponse httpPost(const std::string& path, const std::string& jsonBody);
// `API_KEY` compile definition (see CMakeLists.txt); with neither set, no auth
// header is sent.
HttpResponse httpGet(const std::string& path, const std::string& apiKey = "");
HttpResponse httpPost(const std::string& path, const std::string& jsonBody,
const std::string& apiKey = "");
// ─── GET /frequencies ────────────────────────────────────────────────── // ─── GET /frequencies ──────────────────────────────────────────────────
@@ -45,7 +40,7 @@ struct Frequency {
bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out); bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out);
// Fetch the solfeggio frequencies. Returns true only on HTTP 2xx + successful parse. // Fetch the solfeggio frequencies. Returns true only on HTTP 2xx + successful parse.
bool fetchFrequencies(std::vector<Frequency>& out, const std::string& apiKey = ""); bool fetchFrequencies(std::vector<Frequency>& out);
// ─── GET /color-palettes ─────────────────────────────────────────────── // ─── GET /color-palettes ───────────────────────────────────────────────
@@ -75,7 +70,7 @@ bool parseColorPalettesJson(const std::string& body, std::vector<ColorPalette>&
// NOTE: the portal does not expose this route yet. This is readiness code — it // NOTE: the portal does not expose this route yet. This is readiness code — it
// is deliberately not called anywhere in the app until the route ships, so // is deliberately not called anywhere in the app until the route ships, so
// palettes can be updated from the portal rather than code releases. // palettes can be updated from the portal rather than code releases.
bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKey = ""); bool fetchColorPalettes(std::vector<ColorPalette>& out);
// ─── POST /license ───────────────────────────────────────────────────── // ─── POST /license ─────────────────────────────────────────────────────
@@ -98,8 +93,7 @@ struct LicenseResult {
// Validate a license key / email pair against the portal. Never throws; failures // Validate a license key / email pair against the portal. Never throws; failures
// (network, HTTP error status) are reported through the returned LicenseResult. // (network, HTTP error status) are reported through the returned LicenseResult.
// A 2xx response means the license is valid and the subscription is active. // A 2xx response means the license is valid and the subscription is active.
LicenseResult validateLicense(const std::string& key, const std::string& email, LicenseResult validateLicense(const std::string& key, const std::string& email);
const std::string& apiKey = "");
// ─── POST /automations/create/account ────────────────────────────────── // ─── POST /automations/create/account ──────────────────────────────────
@@ -122,7 +116,6 @@ struct AccountResult {
// Creates (or reuses) a user account and issues a license key. The key is emailed // Creates (or reuses) a user account and issues a license key. The key is emailed
// to the user and is NOT part of the response. `name` may be empty. // to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& name = "", AccountResult createAccount(const std::string& email, const std::string& name = "");
const std::string& apiKey = "");
} // namespace radiantwave::api } // namespace radiantwave::api
+1 -2
View File
@@ -17,7 +17,6 @@ long long nowEpochSeconds() {
} // namespace } // namespace
Result validateStoredLicense(radiantwave::db::DatabaseManager& db, Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey,
std::string* outErrorMessage) { std::string* outErrorMessage) {
if (outErrorMessage) outErrorMessage->clear(); if (outErrorMessage) outErrorMessage->clear();
@@ -28,7 +27,7 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
} }
const radiantwave::api::LicenseResult result = const radiantwave::api::LicenseResult result =
radiantwave::api::validateLicense(key, email, apiKey); radiantwave::api::validateLicense(key, email);
if (result.status == 0) { if (result.status == 0) {
if (outErrorMessage) *outErrorMessage = "network error"; if (outErrorMessage) *outErrorMessage = "network error";
-1
View File
@@ -26,7 +26,6 @@ constexpr long long GRACE_PERIOD_SECONDS = 7LL * 24 * 60 * 60;
// //
// `outErrorMessage` is an optional out-param. // `outErrorMessage` is an optional out-param.
Result validateStoredLicense(radiantwave::db::DatabaseManager& db, Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "",
std::string* outErrorMessage = nullptr); std::string* outErrorMessage = nullptr);
// Records the current time as the "last verified" stamp of the license. Called // Records the current time as the "last verified" stamp of the license. Called
+4 -6
View File
@@ -190,10 +190,9 @@ bool parseProclamationPalettesJson(const std::string& body,
return true; return true;
} }
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db) {
const std::string& apiKey) {
std::vector<api::ColorPalette> palettes; std::vector<api::ColorPalette> palettes;
if (!api::fetchColorPalettes(palettes, apiKey)) { if (!api::fetchColorPalettes(palettes)) {
return false; return false;
} }
@@ -211,9 +210,8 @@ bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db,
return db.replaceColorPalettes(records); return db.replaceColorPalettes(records);
} }
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db) {
const std::string& apiKey) { api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS);
api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS, apiKey);
if (!response.transportOk || response.status < 200 || response.status >= 300) { if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false; return false;
} }
+2 -4
View File
@@ -37,13 +37,11 @@ bool parseProclamationPalettesJson(const std::string& body,
// NOTE: not yet wired into the app flow — the portal does not expose the route // NOTE: not yet wired into the app flow — the portal does not expose the route
// yet. Readiness code: palettes should be updated from the portal, not code // yet. Readiness code: palettes should be updated from the portal, not code
// releases, so callers should treat this as a future sync step. // releases, so callers should treat this as a future sync step.
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db);
const std::string& apiKey = "");
// Alternative to syncColorPalettesFromApi: fetch proclamations and replace the // Alternative to syncColorPalettesFromApi: fetch proclamations and replace the
// local palette table using the palettes embedded in each proclamation. // local palette table using the palettes embedded in each proclamation.
// NOTE: not yet wired into the app flow; see syncColorPalettesFromApi. // NOTE: not yet wired into the app flow; see syncColorPalettesFromApi.
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db);
const std::string& apiKey = "");
} // namespace radiantwave::sync } // namespace radiantwave::sync
+32 -18
View File
@@ -1,6 +1,7 @@
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "app_state.h" #include "app_state.h"
#include "network/proclamation_sync.h" #include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -197,7 +198,7 @@ void ProclamationOptionsPage::rebuildItems()
FlatItem::Type::SubCategory, FlatItem::Type::SubCategory,
std::to_string(sub.id), std::to_string(sub.id),
sub.name, sub.name,
1, false, false, false, 1, true, false, false,
}); });
for (const auto& p : proclamations_) { for (const auto& p : proclamations_) {
@@ -267,7 +268,7 @@ void ProclamationOptionsPage::rebuildVisible()
parentVisible = true; // the always-on top-level proclamation parentVisible = true; // the always-on top-level proclamation
} else if (currentRoot >= 0 && items_[currentRoot].expanded) { } else if (currentRoot >= 0 && items_[currentRoot].expanded) {
parentVisible = (item.depth == 1) || parentVisible = (item.depth == 1) ||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded); (item.depth == 2 && currentSub >= 0);
} }
if (parentVisible) visible_.push_back(i); if (parentVisible) visible_.push_back(i);
} }
@@ -282,7 +283,7 @@ void ProclamationOptionsPage::toggleItem()
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; 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; item.expanded = !item.expanded;
rebuildVisible(); rebuildVisible();
return; return;
@@ -387,7 +388,7 @@ void ProclamationOptionsPage::collapseCurrent()
{ {
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; 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) { if (item.expanded) {
item.expanded = false; item.expanded = false;
rebuildVisible(); rebuildVisible();
@@ -399,7 +400,7 @@ void ProclamationOptionsPage::expandCurrent()
{ {
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; 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) { if (!item.expanded) {
item.expanded = true; item.expanded = true;
rebuildVisible(); rebuildVisible();
@@ -525,8 +526,7 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
if (searchActive_) return; if (searchActive_) return;
if (!visible_.empty()) { if (!visible_.empty()) {
const FlatItem& item = items_[visible_[highlightIndex_]]; const FlatItem& item = items_[visible_[highlightIndex_]];
if ((item.type == FlatItem::Type::RootCategory || if (item.type == FlatItem::Type::RootCategory && item.expanded) {
item.type == FlatItem::Type::SubCategory) && item.expanded) {
collapseCurrent(); collapseCurrent();
} else { } else {
moveToParent(); moveToParent();
@@ -552,7 +552,7 @@ void ProclamationOptionsPage::update(float dt)
void ProclamationOptionsPage::render(SDL_Renderer* renderer) void ProclamationOptionsPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
@@ -654,14 +654,18 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst); SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
} }
} else { } 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; 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)); std::vector<float> textWidths(static_cast<size_t>(listSize));
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) { for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
const std::string& label = proclamations_[searchResults_[i]].title;
int w = 0, h = 0; int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
textWidths[i] = static_cast<float>(w); textWidths[i] = static_cast<float>(w);
} }
float sharedX = (fw - maxTotalWidth) / 2.0f; float sharedX = (fw - maxTotalWidth) / 2.0f;
@@ -694,19 +698,29 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
} }
} else { } else {
// --- Browse tree --- // --- 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; float maxTotalWidth = 0.0f;
std::vector<float> textWidths(visible_.size()); for (const auto& item : items_) {
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; 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; label = (item.expanded ? "[-] " : "[+] ") + label;
} }
int w = 0, h = 0; int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w); float total = item.depth * indent + static_cast<float>(w);
maxTotalWidth = std::max(maxTotalWidth, total); 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); textWidths[i] = static_cast<float>(w);
} }
@@ -720,7 +734,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
float y = listTop + rowIndex * rowPitch; float y = listTop + rowIndex * rowPitch;
std::string label = item.label; 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; label = (item.expanded ? "[-] " : "[+] ") + label;
} }
+18 -16
View File
@@ -1,19 +1,18 @@
#include "provisioning.h" #include "provisioning.h"
#include "wifi_setup_page.h"
#include "email_address_page.h" #include "email_address_page.h"
#include "license_key_page.h"
#include "home.h" #include "home.h"
#include "license_key_page.h"
#include "logger/logger.h" #include "logger/logger.h"
#include "wifi_setup_page.h"
namespace radiantwave::provisioning { namespace radiantwave::provisioning {
// Push the page for a walkthrough step, or Home when the walkthrough is done. // Push the page for a walkthrough step, or Home when the walkthrough is done.
static void pushStep(AppState& state, Step step) static void pushStep(AppState &state, Step step) {
{
switch (step) { switch (step) {
case Step::Internet: // case Step::Internet:
state.pages.push(state, std::make_unique<WiFiSetupPage>()); // state.pages.push(state, std::make_unique<WiFiSetupPage>());
break; // break;
case Step::Email: case Step::Email:
state.pages.push(state, std::make_unique<EmailAddressPage>()); state.pages.push(state, std::make_unique<EmailAddressPage>());
break; break;
@@ -27,8 +26,7 @@ static void pushStep(AppState& state, Step step)
} }
} }
void begin(AppState& state) void begin(AppState &state) {
{
// Without the database there is nowhere to persist email/license, so the // Without the database there is nowhere to persist email/license, so the
// walkthrough would trap the user on pages that can never be completed. // walkthrough would trap the user on pages that can never be completed.
if (!state.db.isOpen()) { if (!state.db.isOpen()) {
@@ -38,14 +36,17 @@ void begin(AppState& state)
} }
const bool internetUp = state.network.hasInternet(); const bool internetUp = state.network.hasInternet();
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty(); const bool hasEmail =
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty(); !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense =
!state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
if (indicatesFirstRun(internetUp, hasEmail, hasLicense)) { if (indicatesFirstRun(internetUp, hasEmail, hasLicense)) {
// Placeholder: a brand-new device. Later this flag will launch the // Placeholder: a brand-new device. Later this flag will launch the
// first-run welcome walkthrough (TOS, quick user guide, ...). // first-run welcome walkthrough (TOS, quick user guide, ...).
state.firstRunWalkthrough = true; state.firstRunWalkthrough = true;
radiantwave::logger::Logger::instance().info("Provisioning", radiantwave::logger::Logger::instance().info(
"Provisioning",
"First-Run detected (no internet, no email, no license); " "First-Run detected (no internet, no email, no license); "
"first-run welcome walkthrough is not yet implemented"); "first-run welcome walkthrough is not yet implemented");
} }
@@ -55,8 +56,7 @@ void begin(AppState& state)
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense)); pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
} }
void advance(AppState& state) void advance(AppState &state) {
{
if (state.pages.depth() > 0) { if (state.pages.depth() > 0) {
state.pages.pop(state); state.pages.pop(state);
} }
@@ -70,8 +70,10 @@ void advance(AppState& state)
state.walkthroughInternetUp = internetUp; state.walkthroughInternetUp = internetUp;
} }
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty(); const bool hasEmail =
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty(); !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense =
!state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
state.provisioningMode = true; state.provisioningMode = true;
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense)); pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
+8 -8
View File
@@ -24,21 +24,21 @@
namespace radiantwave::provisioning { namespace radiantwave::provisioning {
// Prerequisite steps in walkthrough priority order. // Prerequisite steps in walkthrough priority order.
enum class Step { Done, Internet, Email, License }; enum class Step { Done, Email, License };
// First unsatisfied step, given the current conditions. // First unsatisfied step, given the current conditions.
inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense) inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense) {
{ // if (!internetUp) return Step::Internet;
if (!internetUp) return Step::Internet; if (!hasEmail)
if (!hasEmail) return Step::Email; return Step::Email;
if (!hasLicense) return Step::License; if (!hasLicense)
return Step::License;
return Step::Done; return Step::Done;
} }
// True when nothing is configured (no internet, no email, no license). // True when nothing is configured (no internet, no email, no license).
// Signals a brand-new, never-provisioned device. // Signals a brand-new, never-provisioned device.
inline bool indicatesFirstRun(bool internetUp, bool hasEmail, bool hasLicense) inline bool indicatesFirstRun(bool internetUp, bool hasEmail, bool hasLicense) {
{
return !internetUp && !hasEmail && !hasLicense; return !internetUp && !hasEmail && !hasLicense;
} }
+203 -269
View File
@@ -2,6 +2,7 @@
#include "app_state.h" #include "app_state.h"
#include "audio/audio_downloader.h" #include "audio/audio_downloader.h"
#include "logical_size.h"
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h" #include "scramble/fibonacci_scrambler.h"
#include "utf8_utils.h" #include "utf8_utils.h"
@@ -10,18 +11,17 @@
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <cstdint>
#include <filesystem> #include <filesystem>
#include <vector> #include <vector>
using radiantwave::FibonacciLinePattern; using radiantwave::FibonacciLinePattern;
using radiantwave::scroller::ProclamationStream; using radiantwave::scroller::ProclamationStream;
namespace namespace {
{
constexpr float kFadeSeconds = 5.0f; constexpr float kFadeSeconds = 5.0f;
constexpr float kVelocityMin = -3500.0f; constexpr float kVelocityMin = -3500.0f;
@@ -29,17 +29,41 @@ namespace
} // anonymous namespace } // anonymous namespace
void ScrollerPage::onPush(AppState &state) void ScrollerPage::onPush(AppState &state) {
{
state_ = &state; state_ = &state;
int winW = 0; // Save the current logical presentation (LETTERBOX at design resolution)
int winH = 0; // and switch to NONE so we render into the full physical window. This
SDL_GetWindowSize(state.window, &winW, &winH); // lets the scroller scroll the entire vertical extent of tall/rotated
screenW_ = static_cast<float>(winW); // displays while keeping text at the same pixel scale. onPop restores
screenH_ = static_cast<float>(winH); // the original presentation for other pages.
// Lines are capped at the full screen width; the widest Fibonacci lines are SDL_GetRenderLogicalPresentation(state.renderer, &savedLogicalW_,
// sized to fill it with whole words (peak word count derived from the &savedLogicalH_, &savedMode_);
// Use the renderer's actual output size in pixels, not the window size in
// points. SDL_GetWindowSize() returns screen coordinates, which can be
// smaller than the real drawable if the display has a content scale
// applied (common on some monitors/compositors) — that mismatch caused
// this page to lay out and center within a sub-region of the screen
// instead of the full physical canvas. SDL_GetRenderOutputSize() gives us
// the true pixel dimensions of whatever we're actually rendering into.
int windowW = 0;
int windowH = 0;
SDL_GetRenderOutputSize(state.renderer, &windowW, &windowH);
if (windowW <= 0) {
windowW = radiantwave::kDesignWidth;
}
if (windowH <= 0) {
windowH = radiantwave::kDesignHeight;
}
SDL_SetRenderLogicalPresentation(state.renderer, windowW, windowH,
SDL_LOGICAL_PRESENTATION_DISABLED);
screenW_ = static_cast<float>(windowW);
screenH_ = static_cast<float>(windowH);
// Lines are capped at the full screen width; the widest Fibonacci lines
// are sized to fill it with whole words (peak word count derived from the
// screen), independent of resolution. // screen), independent of resolution.
maxLineWidth_ = screenW_; maxLineWidth_ = screenW_;
@@ -57,63 +81,59 @@ void ScrollerPage::onPush(AppState &state)
// plays. A frequency file that is not on disk yet falls back to the manual // plays. A frequency file that is not on disk yet falls back to the manual
// track rather than playing nothing. // track rather than playing nothing.
std::string track = state.config.get().audio.current_track; std::string track = state.config.get().audio.current_track;
if (state.config.get().audio.dynamic_audio && !streams_.empty()) if (state.config.get().audio.dynamic_audio && !streams_.empty()) {
{
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id != 0 && !stream.audioFilename.empty() && if (stream.solfeggio_frequency_id != 0 &&
std::filesystem::exists(audioFilePath(stream.audioFilename))) !stream.audioFilename.empty() &&
{ std::filesystem::exists(audioFilePath(stream.audioFilename))) {
track = stream.audioFilename; track = stream.audioFilename;
} }
} }
if (std::filesystem::exists(audioFilePath(track))) if (std::filesystem::exists(audioFilePath(track))) {
{
state.audio.playTrackFadeIn(track, kFadeSeconds); state.audio.playTrackFadeIn(track, kFadeSeconds);
} }
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
{
if (state_) { if (state_) {
state_->pages.pop(*state_); state_->pages.pop(*state_);
} }); }
});
} }
void ScrollerPage::onResume(AppState &state) void ScrollerPage::onResume(AppState &state) {
{
state_ = &state; state_ = &state;
createFont(state); createFont(state);
createPatterns(); createPatterns();
rebuildWordTextures(); rebuildWordTextures();
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
{
if (state_) { if (state_) {
state_->pages.pop(*state_); state_->pages.pop(*state_);
} }); }
});
} }
void ScrollerPage::onPause(AppState &state) void ScrollerPage::onPause(AppState &state) {
{
state.keybinds.unbindAll(); state.keybinds.unbindAll();
destroyResources(); destroyResources();
} }
void ScrollerPage::onPop(AppState &state) void ScrollerPage::onPop(AppState &state) {
{ // Restore the original logical presentation so other pages (Home,
// Settings, etc.) render in the design-resolution LETTERBOX space.
SDL_SetRenderLogicalPresentation(state.renderer, savedLogicalW_,
savedLogicalH_, savedMode_);
state.audio.stopTrackFadeOut(kFadeSeconds); state.audio.stopTrackFadeOut(kFadeSeconds);
state.keybinds.unbindAll(); state.keybinds.unbindAll();
destroyResources(); destroyResources();
TTF_Quit(); TTF_Quit();
} }
void ScrollerPage::handleEvent(const SDL_Event &) void ScrollerPage::handleEvent(const SDL_Event &) {}
{
}
void ScrollerPage::update(float dt) void ScrollerPage::update(float dt) {
{ if (state_) {
if (state_)
{
state_->audio.update(dt); state_->audio.update(dt);
} }
@@ -121,69 +141,55 @@ void ScrollerPage::update(float dt)
scrollOffset_ += velocity_ * dt; scrollOffset_ += velocity_ * dt;
float move = std::floor(scrollOffset_); float move = std::floor(scrollOffset_);
if (move != 0.0f) if (move != 0.0f) {
{
scrollOffset_ -= move; scrollOffset_ -= move;
for (auto &line : lines_) for (auto &line : lines_) {
{ for (auto &w : line.words) {
for (auto &w : line.words)
{
w.y += move; w.y += move;
} }
} }
} }
if (lines_.empty()) if (lines_.empty()) {
{
return; return;
} }
if (velocity_ > 0.0f) if (velocity_ > 0.0f) {
{
// Lines move downward. Recycle every line that scrolled fully off the // Lines move downward. Recycle every line that scrolled fully off the
// bottom this frame (movement is continuous, so one frame can cover // bottom this frame (movement is continuous, so one frame can cover
// several line heights) and add a replacement just above the top. // several line heights) and add a replacement just above the top.
while (!lines_.empty() && lines_.back().words[0].y > screenH_) while (!lines_.empty() && lines_.back().words[0].y > screenH_) {
{
float baseY = lines_.front().words[0].y - lineHeight_; float baseY = lines_.front().words[0].y - lineHeight_;
lines_.pop_back(); lines_.pop_back();
if (!addLineAt(baseY)) if (!addLineAt(baseY)) {
{
break; break;
} }
} }
} } else if (velocity_ < 0.0f) {
else if (velocity_ < 0.0f)
{
// Lines move upward. Recycle every line that scrolled fully off the top // Lines move upward. Recycle every line that scrolled fully off the top
// this frame and add a replacement just below the bottom. // this frame and add a replacement just below the bottom.
while (!lines_.empty() && while (!lines_.empty() &&
lines_.front().words[0].y + lines_.front().words[0].height < 0.0f) lines_.front().words[0].y + lines_.front().words[0].height <
{ 0.0f) {
float baseY = lines_.back().words[0].y + lineHeight_; float baseY = lines_.back().words[0].y + lineHeight_;
lines_.pop_front(); lines_.pop_front();
if (!addLineAt(baseY)) if (!addLineAt(baseY)) {
{
break; break;
} }
} }
} }
} }
void ScrollerPage::render(SDL_Renderer *renderer) void ScrollerPage::render(SDL_Renderer *renderer) {
{
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer); SDL_RenderClear(renderer);
for (const auto &line : lines_) for (const auto &line : lines_) {
{ if (line.words.empty()) {
if (line.words.empty())
{
continue; continue;
} }
float startX = std::floor((screenW_ - line.width) / 2.0f); float startX = std::floor((screenW_ - line.width) / 2.0f);
for (const auto &w : line.words) for (const auto &w : line.words) {
{
SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b); SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b);
SDL_FRect dst{startX + w.x, w.y, w.width, w.height}; SDL_FRect dst{startX + w.x, w.y, w.width, w.height};
SDL_RenderTexture(renderer, w.texture, nullptr, &dst); SDL_RenderTexture(renderer, w.texture, nullptr, &dst);
@@ -191,53 +197,46 @@ void ScrollerPage::render(SDL_Renderer *renderer)
} }
} }
void ScrollerPage::buildStreams(AppState &state) void ScrollerPage::buildStreams(AppState &state) {
{
streams_.clear(); streams_.clear();
fallbackPalette_.clear(); fallbackPalette_.clear();
lines_.clear(); lines_.clear();
streamIndex_ = 0; streamIndex_ = 0;
auto proclamations = state.db.getActiveProclamations(); auto proclamations = state.db.getActiveProclamations();
if (proclamations.empty()) if (proclamations.empty()) {
{
return; return;
} }
for (const auto &c : state.db.getPaletteForFrequencyId(5)) for (const auto &c :
{ // 528 Hz palette state.db.getPaletteForFrequencyId(5)) { // 528 Hz palette
fallbackPalette_.push_back({c.r, c.g, c.b, c.a}); fallbackPalette_.push_back({c.r, c.g, c.b, c.a});
} }
std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>> palettesByFreqId; std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>>
for (const auto &rec : proclamations) palettesByFreqId;
{ for (const auto &rec : proclamations) {
int freqId = rec.solfeggio_frequency_id; int freqId = rec.solfeggio_frequency_id;
if (palettesByFreqId.count(freqId) != 0) if (palettesByFreqId.count(freqId) != 0) {
{
continue; continue;
} }
std::vector<radiantwave::scroller::StreamColor> colors; std::vector<radiantwave::scroller::StreamColor> colors;
for (const auto &c : state.db.getPaletteForFrequencyId(freqId)) for (const auto &c : state.db.getPaletteForFrequencyId(freqId)) {
{
colors.push_back({c.r, c.g, c.b, c.a}); colors.push_back({c.r, c.g, c.b, c.a});
} }
palettesByFreqId[freqId] = std::move(colors); palettesByFreqId[freqId] = std::move(colors);
} }
std::unordered_map<int, radiantwave::db::AudioFileRecord> audioByFreqId; std::unordered_map<int, radiantwave::db::AudioFileRecord> audioByFreqId;
for (const auto &af : state.db.getAudioFiles()) for (const auto &af : state.db.getAudioFiles()) {
{
int freqId = af.solfeggio_frequency_id; int freqId = af.solfeggio_frequency_id;
// Self-heal for databases seeded from a legacy manifest that left the // Self-heal for databases seeded from a legacy manifest that left the
// frequency id at 0 for every row: recover the id from the filename. // frequency id at 0 for every row: recover the id from the filename.
if (freqId == 0) if (freqId == 0) {
{
freqId = radiantwave::audio::frequencyIdForHz( freqId = radiantwave::audio::frequencyIdForHz(
radiantwave::audio::hzFromFilename(af.filename)); radiantwave::audio::hzFromFilename(af.filename));
} }
if (freqId != 0) if (freqId != 0) {
{
audioByFreqId[freqId] = af; audioByFreqId[freqId] = af;
} }
} }
@@ -247,10 +246,8 @@ void ScrollerPage::buildStreams(AppState &state)
DEFAULT_PROPLEAMATION_ID); DEFAULT_PROPLEAMATION_ID);
} }
void ScrollerPage::createFont(AppState &state) void ScrollerPage::createFont(AppState &state) {
{ if (font_) {
if (font_)
{
TTF_CloseFont(font_); TTF_CloseFont(font_);
font_ = nullptr; font_ = nullptr;
} }
@@ -261,46 +258,41 @@ void ScrollerPage::createFont(AppState &state)
clearCharTextures(); clearCharTextures();
fontPxSize_ = state.config.get().proclamation.font_size; fontPxSize_ = state.config.get().proclamation.font_size;
if (fontPxSize_ <= 0) if (fontPxSize_ <= 0) {
{
fontPxSize_ = 32; fontPxSize_ = 32;
} }
font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_); font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_);
if (!font_) if (!font_) {
{
return; return;
} }
int w = 0; int w = 0;
int h = 0; int h = 0;
TTF_GetStringSize(font_, "Mg", 0, &w, &h); TTF_GetStringSize(font_, "Mg", 0, &w, &h);
lineHeight_ = h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_); lineHeight_ =
h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_);
spaceWidth_ = measureWidth(" "); spaceWidth_ = measureWidth(" ");
if (spaceWidth_ <= 0.0f) if (spaceWidth_ <= 0.0f) {
{
spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f; spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f;
} }
} }
void ScrollerPage::createPatterns() void ScrollerPage::createPatterns() {
{
patterns_.clear(); patterns_.clear();
patterns_.reserve(streams_.size()); patterns_.reserve(streams_.size());
for (size_t i = 0; i < streams_.size(); ++i) for (size_t i = 0; i < streams_.size(); ++i) {
{
int peak = computePeakWordCount(streams_[i].words); int peak = computePeakWordCount(streams_[i].words);
patterns_.emplace_back(peak, maxLineWidth_, spaceWidth_, patterns_.emplace_back(
[this](const std::string &word) peak, maxLineWidth_, spaceWidth_,
{ return measureWidth(word); }); [this](const std::string &word) { return measureWidth(word); });
} }
} }
int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) const int ScrollerPage::computePeakWordCount(
{ const std::vector<std::string> &words) const {
if (words.empty() || screenW_ <= 0.0f) if (words.empty() || screenW_ <= 0.0f) {
{
return 1; return 1;
} }
@@ -308,14 +300,12 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
// spacing. The peak is the number of whole words that fit across the // spacing. The peak is the number of whole words that fit across the
// screen, so the widest Fibonacci lines fill the width at natural spacing. // screen, so the widest Fibonacci lines fill the width at natural spacing.
double total = 0.0; double total = 0.0;
for (const auto &w : words) for (const auto &w : words) {
{
total += measureWidth(w); total += measureWidth(w);
} }
double n = static_cast<double>(words.size()); double n = static_cast<double>(words.size());
double perWord = (total + (n - 1.0) * spaceWidth_) / n; double perWord = (total + (n - 1.0) * spaceWidth_) / n;
if (perWord <= 0.0) if (perWord <= 0.0) {
{
perWord = 1.0; perWord = 1.0;
} }
@@ -323,45 +313,36 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
return fit < 1 ? 1 : fit; return fit < 1 ? 1 : fit;
} }
void ScrollerPage::destroyResources() void ScrollerPage::destroyResources() {
{
clearCharTextures(); clearCharTextures();
for (auto &entry : wordTextures_) for (auto &entry : wordTextures_) {
{ if (entry.second.texture) {
if (entry.second.texture)
{
SDL_DestroyTexture(entry.second.texture); SDL_DestroyTexture(entry.second.texture);
} }
} }
wordTextures_.clear(); wordTextures_.clear();
if (font_) if (font_) {
{
TTF_CloseFont(font_); TTF_CloseFont(font_);
font_ = nullptr; font_ = nullptr;
} }
} }
void ScrollerPage::rebuildWordTextures() void ScrollerPage::rebuildWordTextures() {
{
wordTextures_.clear(); wordTextures_.clear();
for (auto &line : lines_) for (auto &line : lines_) {
{
float width = 0.0f; float width = 0.0f;
for (auto &item : line.words) for (auto &item : line.words) {
{
ensureWordTexture(item.text); ensureWordTexture(item.text);
const CachedWord &cached = wordTextures_[item.text]; const CachedWord &cached = wordTextures_[item.text];
if (!cached.texture) if (!cached.texture) {
{
continue; continue;
} }
item.texture = cached.texture; item.texture = cached.texture;
item.width = cached.width; item.width = cached.width;
item.height = cached.height; item.height = cached.height;
if (width > 0.0f) if (width > 0.0f) {
{
width += spaceWidth_; width += spaceWidth_;
} }
item.x = width; item.x = width;
@@ -371,31 +352,25 @@ void ScrollerPage::rebuildWordTextures()
} }
} }
void ScrollerPage::prefillLines() void ScrollerPage::prefillLines() {
{ if (streams_.empty() || lineHeight_ <= 0.0f) {
if (streams_.empty() || lineHeight_ <= 0.0f)
{
return; return;
} }
int n = static_cast<int>(std::ceil(screenH_ / lineHeight_)) + 2; int n = static_cast<int>(std::ceil(screenH_ / lineHeight_)) + 2;
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i) {
{
float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_; float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_;
addLineAt(baseY); addLineAt(baseY);
} }
} }
bool ScrollerPage::addLineAt(float baseY) bool ScrollerPage::addLineAt(float baseY) {
{ if (streams_.empty() || !font_) {
if (streams_.empty() || !font_)
{
return false; return false;
} }
ProclamationStream &stream = streams_[streamIndex_]; ProclamationStream &stream = streams_[streamIndex_];
if (stream.words.empty()) if (stream.words.empty()) {
{
advanceStream(); advanceStream();
return false; return false;
} }
@@ -403,7 +378,8 @@ bool ScrollerPage::addLineAt(float baseY)
FibonacciLinePattern &pattern = patterns_[streamIndex_]; FibonacciLinePattern &pattern = patterns_[streamIndex_];
// Capture palette state before any stream advance. // Capture palette state before any stream advance.
std::vector<radiantwave::scroller::StreamColor> paletteCopy = stream.palette; std::vector<radiantwave::scroller::StreamColor> paletteCopy =
stream.palette;
int lineColorIndex = stream.colorIndex; int lineColorIndex = stream.colorIndex;
stream.colorIndex++; stream.colorIndex++;
@@ -413,17 +389,15 @@ bool ScrollerPage::addLineAt(float baseY)
stream.wordsPlaced += placed; stream.wordsPlaced += placed;
// First word of a fresh pass is the "start" of the proclamation; print to // First word of a fresh pass is the "start" of the proclamation; print to
// stdout once so the console shows the current title while it plays. // stdout once so the console shows the current title while it plays.
if (stream.wordsPlaced == placed) if (stream.wordsPlaced == placed) {
{
std::printf("Playing %s\n", stream.name.c_str()); std::printf("Playing %s\n", stream.name.c_str());
} }
bool advanced = false; bool advanced = false;
while (!advanced && !stream.words.empty() && stream.wordsPlaced >= static_cast<int>(stream.words.size())) while (!advanced && !stream.words.empty() &&
{ stream.wordsPlaced >= static_cast<int>(stream.words.size())) {
stream.wordsPlaced -= static_cast<int>(stream.words.size()); stream.wordsPlaced -= static_cast<int>(stream.words.size());
stream.completed++; stream.completed++;
if (stream.completed >= stream.repeat) if (stream.completed >= stream.repeat) {
{
stream.completed = 0; stream.completed = 0;
advanceStream(); advanceStream();
advanced = true; advanced = true;
@@ -432,12 +406,10 @@ bool ScrollerPage::addLineAt(float baseY)
ScrollerLine line; ScrollerLine line;
float width = 0.0f; float width = 0.0f;
for (const auto &text : wordStrs) for (const auto &text : wordStrs) {
{
ensureWordTexture(text); ensureWordTexture(text);
const CachedWord &cached = wordTextures_[text]; const CachedWord &cached = wordTextures_[text];
if (!cached.texture) if (!cached.texture) {
{
continue; continue;
} }
WordItem item; WordItem item;
@@ -446,8 +418,7 @@ bool ScrollerPage::addLineAt(float baseY)
item.width = cached.width; item.width = cached.width;
item.height = cached.height; item.height = cached.height;
item.y = baseY; item.y = baseY;
if (width > 0.0f) if (width > 0.0f) {
{
width += spaceWidth_; width += spaceWidth_;
} }
item.x = width; item.x = width;
@@ -455,77 +426,63 @@ bool ScrollerPage::addLineAt(float baseY)
line.words.push_back(item); line.words.push_back(item);
} }
if (line.words.empty()) if (line.words.empty()) {
{
return false; return false;
} }
line.width = width; line.width = width;
if (!paletteCopy.empty()) if (!paletteCopy.empty()) {
{ const auto &c =
const auto &c = paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())]; paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())];
line.r = c.r; line.r = c.r;
line.g = c.g; line.g = c.g;
line.b = c.b; line.b = c.b;
} }
if (!lines_.empty() && baseY < lines_.front().words[0].y) if (!lines_.empty() && baseY < lines_.front().words[0].y) {
{
lines_.push_front(std::move(line)); lines_.push_front(std::move(line));
} } else {
else
{
lines_.push_back(std::move(line)); lines_.push_back(std::move(line));
} }
return true; return true;
} }
void ScrollerPage::advanceStream() void ScrollerPage::advanceStream() {
{ if (streams_.empty()) {
if (streams_.empty())
{
return; return;
} }
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size()); streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
// Diagnostic: report the palette the incoming stream will render so the // Diagnostic: report the palette the incoming stream will render so the
// console shows whether colors should change when this stream starts. // console shows whether colors should change when this stream starts.
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
if (!stream.palette.empty()) if (!stream.palette.empty()) {
{
const auto &c = stream.palette[0]; const auto &c = stream.palette[0];
std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n", std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n",
stream.name.c_str(), stream.solfeggio_frequency_id, stream.name.c_str(), stream.solfeggio_frequency_id, c.r,
c.r, c.g, c.b); c.g, c.b);
} } else {
else
{
std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n", std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n",
stream.name.c_str(), stream.solfeggio_frequency_id); stream.name.c_str(), stream.solfeggio_frequency_id);
} }
switchAudioForCurrentStream(); switchAudioForCurrentStream();
} }
void ScrollerPage::switchAudioForCurrentStream() void ScrollerPage::switchAudioForCurrentStream() {
{ if (prefilling_ || streams_.empty() || !state_) {
if (prefilling_ || streams_.empty() || !state_)
{
return; return;
} }
// Dynamic audio only: with a fixed manual track selected the audio must // Dynamic audio only: with a fixed manual track selected the audio must
// never switch as proclamations change. // never switch as proclamations change.
if (!state_->config.get().audio.dynamic_audio) if (!state_->config.get().audio.dynamic_audio) {
{
return; return;
} }
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) {
{
return; // default stream: never touch audio return; // default stream: never touch audio
} }
if (!std::filesystem::exists(audioFilePath(stream.audioFilename))) if (!std::filesystem::exists(audioFilePath(stream.audioFilename))) {
{
return; // keep current track; retried on the next activation return; // keep current track; retried on the next activation
} }
// Crossfades over kFadeSeconds; a no-op when the requested track is the // Crossfades over kFadeSeconds; a no-op when the requested track is the
@@ -533,14 +490,11 @@ void ScrollerPage::switchAudioForCurrentStream()
state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds); state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds);
} }
void ScrollerPage::updateAutoVelocity(float dt) void ScrollerPage::updateAutoVelocity(float dt) {
{ switch (velocityState_) {
switch (velocityState_)
{
case 0: // ramping to the negative extreme case 0: // ramping to the negative extreme
velocity_ -= velocityStep_ * dt; velocity_ -= velocityStep_ * dt;
if (velocity_ <= kVelocityMin) if (velocity_ <= kVelocityMin) {
{
velocity_ = kVelocityMin; velocity_ = kVelocityMin;
velocityTimer_ = velocityDelay_; velocityTimer_ = velocityDelay_;
velocityState_ = 1; velocityState_ = 1;
@@ -548,15 +502,13 @@ void ScrollerPage::updateAutoVelocity(float dt)
break; break;
case 1: // holding at the negative extreme case 1: // holding at the negative extreme
velocityTimer_ -= dt; velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f) if (velocityTimer_ <= 0.0f) {
{
velocityState_ = 2; velocityState_ = 2;
} }
break; break;
case 2: // ramping to the positive extreme case 2: // ramping to the positive extreme
velocity_ += velocityStep_ * dt; velocity_ += velocityStep_ * dt;
if (velocity_ >= kVelocityMax) if (velocity_ >= kVelocityMax) {
{
velocity_ = kVelocityMax; velocity_ = kVelocityMax;
velocityTimer_ = velocityDelay_; velocityTimer_ = velocityDelay_;
velocityState_ = 3; velocityState_ = 3;
@@ -564,8 +516,7 @@ void ScrollerPage::updateAutoVelocity(float dt)
break; break;
case 3: // holding at the positive extreme case 3: // holding at the positive extreme
velocityTimer_ -= dt; velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f) if (velocityTimer_ <= 0.0f) {
{
velocityState_ = 0; velocityState_ = 0;
} }
break; break;
@@ -575,10 +526,8 @@ void ScrollerPage::updateAutoVelocity(float dt)
} }
} }
void ScrollerPage::ensureWordTexture(const std::string &word) void ScrollerPage::ensureWordTexture(const std::string &word) {
{ if (wordTextures_.count(word) != 0) {
if (wordTextures_.count(word) != 0)
{
return; return;
} }
@@ -601,69 +550,64 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
// breaking, centering, and the Fibonacci line pattern are unchanged; only // breaking, centering, and the Fibonacci line pattern are unchanged; only
// the glyph pixels move. // the glyph pixels move.
CachedWord cached; CachedWord cached;
if (font_ && state_) if (font_ && state_) {
{
std::vector<uint32_t> codePoints; std::vector<uint32_t> codePoints;
if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) {
{
std::vector<CharEntry *> glyphs; std::vector<CharEntry *> glyphs;
float totalWidth = 0.0f; float totalWidth = 0.0f;
float glyphHeight = 0.0f; float glyphHeight = 0.0f;
for (uint32_t cp : codePoints) for (uint32_t cp : codePoints) {
{
const std::string glyphText = radiantwave::encodeUtf8(cp); const std::string glyphText = radiantwave::encodeUtf8(cp);
if (glyphText.empty()) if (glyphText.empty()) {
{
continue; continue;
} }
ensureCharTexture(glyphText); ensureCharTexture(glyphText);
CharEntry &entry = charTextures_[glyphText]; CharEntry &entry = charTextures_[glyphText];
if (!entry.surface) if (!entry.surface) {
{
continue; continue;
} }
glyphs.push_back(&entry); glyphs.push_back(&entry);
totalWidth += entry.advance; totalWidth += entry.advance;
if (entry.glyphH > glyphHeight) if (entry.glyphH > glyphHeight) {
{
glyphHeight = entry.glyphH; glyphHeight = entry.glyphH;
} }
} }
if (!glyphs.empty()) if (!glyphs.empty()) {
{ const int wordW =
const int wordW = std::max(1, static_cast<int>(std::lround(totalWidth))); std::max(1, static_cast<int>(std::lround(totalWidth)));
const int wordH = std::max(1, static_cast<int>(std::lround(glyphHeight))); const int wordH =
SDL_Surface *wordSurface = SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888); std::max(1, static_cast<int>(std::lround(glyphHeight)));
if (wordSurface) SDL_Surface *wordSurface =
{ SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888);
if (wordSurface) {
// Every glyph surface carries the font's full line height // Every glyph surface carries the font's full line height
// (probe-verified), so y = 0 keeps every crop on the shared // (probe-verified), so y = 0 keeps every crop on the shared
// baseline and per-word textures stay line-height tall. // baseline and per-word textures stay line-height tall.
float x = 0.0f; float x = 0.0f;
for (const CharEntry *entry : glyphs) for (const CharEntry *entry : glyphs) {
{
SDL_Rect srcRect{0, 0, static_cast<int>(entry->glyphW), SDL_Rect srcRect{0, 0, static_cast<int>(entry->glyphW),
static_cast<int>(entry->glyphH)}; static_cast<int>(entry->glyphH)};
SDL_Rect dstRect{static_cast<int>(std::lround(x)), 0, SDL_Rect dstRect{static_cast<int>(std::lround(x)), 0,
static_cast<int>(entry->glyphW), static_cast<int>(entry->glyphW),
static_cast<int>(entry->glyphH)}; static_cast<int>(entry->glyphH)};
SDL_BlitSurface(entry->surface, &srcRect, wordSurface, &dstRect); SDL_BlitSurface(entry->surface, &srcRect, wordSurface,
&dstRect);
x += entry->advance; x += entry->advance;
} }
cached.texture = SDL_CreateTextureFromSurface(state_->renderer, wordSurface); cached.texture = SDL_CreateTextureFromSurface(
state_->renderer, wordSurface);
// The scroller steps glyphs in whole pixels, so bilinear // The scroller steps glyphs in whole pixels, so bilinear
// filtering can only smear them: nearest-neighbor keeps // filtering can only smear them: nearest-neighbor keeps
// every frame pixel-sharp. // every frame pixel-sharp.
if (cached.texture) if (cached.texture) {
{ SDL_SetTextureScaleMode(cached.texture,
SDL_SetTextureScaleMode(cached.texture, SDL_SCALEMODE_NEAREST); SDL_SCALEMODE_NEAREST);
} }
cached.width = static_cast<float>(wordSurface->w); cached.width = static_cast<float>(wordSurface->w);
cached.height = static_cast<float>(wordSurface->h); cached.height = static_cast<float>(wordSurface->h);
SDL_DestroySurface(wordSurface); SDL_DestroySurface(wordSurface);
if (!cached.texture) if (!cached.texture) {
{
cached.width = 0.0f; cached.width = 0.0f;
cached.height = 0.0f; cached.height = 0.0f;
} }
@@ -674,10 +618,8 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
wordTextures_[word] = cached; wordTextures_[word] = cached;
} }
void ScrollerPage::ensureCharTexture(const std::string &glyph) void ScrollerPage::ensureCharTexture(const std::string &glyph) {
{ if (charTextures_.count(glyph) != 0) {
if (charTextures_.count(glyph) != 0)
{
return; return;
} }
@@ -686,25 +628,23 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
// font size; createFont() clears the atlas whenever the font or its size // font size; createFont() clears the atlas whenever the font or its size
// changes, and pause/pop clear it as part of destroyResources(). // changes, and pause/pop clear it as part of destroyResources().
CharEntry entry; CharEntry entry;
if (font_ && state_) if (font_ && state_) {
{
SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0, SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0,
{255, 255, 255, 255}); {255, 255, 255, 255});
if (surface) if (surface) {
{
const int gW = surface->w; const int gW = surface->w;
const int gH = surface->h; const int gH = surface->h;
const int n = radiantwave::scramble::canvasSize(gW, gH); const int n = radiantwave::scramble::canvasSize(gW, gH);
if (n > 0) if (n > 0) {
{
// The scrambler needs tightly packed RGBA8888; a TTF surface's // The scrambler needs tightly packed RGBA8888; a TTF surface's
// pitch can exceed gW * 4, so copy row by row. // pitch can exceed gW * 4, so copy row by row.
std::vector<uint8_t> source(static_cast<size_t>(gW) * gH * 4); std::vector<uint8_t> source(static_cast<size_t>(gW) * gH * 4);
const uint8_t *pixels = static_cast<const uint8_t *>(surface->pixels); const uint8_t *pixels =
for (int y = 0; y < gH; ++y) static_cast<const uint8_t *>(surface->pixels);
{ for (int y = 0; y < gH; ++y) {
std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4, std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4,
pixels + static_cast<size_t>(y) * surface->pitch, pixels +
static_cast<size_t>(y) * surface->pitch,
static_cast<size_t>(gW) * 4); static_cast<size_t>(gW) * 4);
} }
@@ -713,25 +653,24 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
// The fixed seed makes every device render the same glyph as // The fixed seed makes every device render the same glyph as
// the same scrambled pattern, with no key to persist or ship. // the same scrambled pattern, with no key to persist or ship.
radiantwave::scramble::ScrambleConfig config; radiantwave::scramble::ScrambleConfig config;
if (radiantwave::scramble::scramble(source.data(), scrambled.data(), if (radiantwave::scramble::scramble(
gW, gH, config)) source.data(), scrambled.data(), gW, gH, config)) {
{ SDL_Surface *tile =
SDL_Surface *tile = SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888); SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888);
if (tile) if (tile) {
{
uint8_t *dst = static_cast<uint8_t *>(tile->pixels); uint8_t *dst = static_cast<uint8_t *>(tile->pixels);
for (int y = 0; y < n; ++y) for (int y = 0; y < n; ++y) {
{ std::memcpy(dst + static_cast<size_t>(y) *
std::memcpy(dst + static_cast<size_t>(y) * tile->pitch, tile->pitch,
scrambled.data() + static_cast<size_t>(y) * n * 4, scrambled.data() +
static_cast<size_t>(y) * n * 4,
static_cast<size_t>(n) * 4); static_cast<size_t>(n) * 4);
} }
entry.surface = tile; entry.surface = tile;
entry.glyphW = static_cast<float>(gW); entry.glyphW = static_cast<float>(gW);
entry.glyphH = static_cast<float>(gH); entry.glyphH = static_cast<float>(gH);
entry.advance = measureWidth(glyph); entry.advance = measureWidth(glyph);
if (entry.advance <= 0.0f) if (entry.advance <= 0.0f) {
{
entry.advance = entry.glyphW; entry.advance = entry.glyphW;
} }
} }
@@ -743,22 +682,17 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
charTextures_[glyph] = entry; charTextures_[glyph] = entry;
} }
void ScrollerPage::clearCharTextures() void ScrollerPage::clearCharTextures() {
{ for (auto &entry : charTextures_) {
for (auto &entry : charTextures_) if (entry.second.surface) {
{
if (entry.second.surface)
{
SDL_DestroySurface(entry.second.surface); SDL_DestroySurface(entry.second.surface);
} }
} }
charTextures_.clear(); charTextures_.clear();
} }
float ScrollerPage::measureWidth(const std::string &text) const float ScrollerPage::measureWidth(const std::string &text) const {
{ if (!font_) {
if (!font_)
{
return 0.0f; return 0.0f;
} }
int w = 0; int w = 0;
@@ -767,9 +701,9 @@ float ScrollerPage::measureWidth(const std::string &text) const
return static_cast<float>(w); return static_cast<float>(w);
} }
std::string ScrollerPage::audioFilePath(const std::string &filename) const std::string ScrollerPage::audioFilePath(const std::string &filename) const {
{ std::string path1 =
std::string path1 = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") +
"/.local/radiantwave/audio/" + filename; "/.local/radiantwave/audio/" + filename;
std::string path2 = "assets/audio/" + filename; std::string path2 = "assets/audio/" + filename;
return std::filesystem::exists(path1) ? path1 : path2; return std::filesystem::exists(path1) ? path1 : path2;
+4
View File
@@ -115,4 +115,8 @@ private:
float velocityDelay_ = 2.5f; float velocityDelay_ = 2.5f;
int velocityState_ = 0; int velocityState_ = 0;
float velocityTimer_ = 0.0f; float velocityTimer_ = 0.0f;
int savedLogicalW_ = 0;
int savedLogicalH_ = 0;
SDL_RendererLogicalPresentation savedMode_ = SDL_LOGICAL_PRESENTATION_DISABLED;
}; };
+20 -19
View File
@@ -10,6 +10,7 @@
#include "email_address_page.h" #include "email_address_page.h"
#include "license_key_page.h" #include "license_key_page.h"
#include "date_format_page.h" #include "date_format_page.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <algorithm> #include <algorithm>
@@ -18,18 +19,18 @@ namespace
{ {
constexpr int MENU_ITEM_COUNT = 9; constexpr int MENU_ITEM_COUNT = 9;
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations, // 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
// 4 = Email Address, 5 = License Key, 6 = Date Format, // 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
// 7 = View System Logs, 8 = Back // 7 = System Logs, 8 = Back
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = { constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
"WiFi Setup",
"Audio Options",
"Time Zone Setup",
"Proclamations", "Proclamations",
"Audio Options",
"Email Address", "Email Address",
"License Key", "License Key",
"WiFi Setup",
"Time Zone Setup",
"Date Format", "Date Format",
"View System Logs", "System Logs",
"Back to Home", "Back to Home",
}; };
@@ -77,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
switch (selectedItem_) switch (selectedItem_)
{ {
case 0: 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. // The Proclamation list is license-gated like Play.
if (state_->licenseTracker.isValid()) if (state_->licenseTracker.isValid())
{ {
@@ -96,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>()); state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
} }
break; break;
case 4: case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<EmailAddressPage>()); state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
break; break;
case 5: case 3:
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>()); state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
break; 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: case 6:
state_->pages.push(*state_, std::make_unique<DateFormatPage>()); state_->pages.push(*state_, std::make_unique<DateFormatPage>());
break; break;
@@ -128,7 +129,7 @@ void Settings::update(float dt)
void Settings::render(SDL_Renderer *renderer) void Settings::render(SDL_Renderer *renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,5 +1,6 @@
#include "timezone_page.h" #include "timezone_page.h"
#include "app_state.h" #include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <ctime> #include <ctime>
@@ -199,7 +200,7 @@ void TimeZonePage::update(float dt) {
void TimeZonePage::render(SDL_Renderer* renderer) { void TimeZonePage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "wifi_setup_page.h" #include "wifi_setup_page.h"
#include "app_state.h" #include "app_state.h"
#include "provisioning.h" #include "provisioning.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <exception> #include <exception>
@@ -301,7 +302,7 @@ void WiFiSetupPage::update(float dt) {
void WiFiSetupPage::render(SDL_Renderer* renderer) { void WiFiSetupPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+9 -14
View File
@@ -19,11 +19,6 @@ std::string env(const char* name) {
return value ? std::string(value) : std::string(); return value ? std::string(value) : std::string();
} }
// Returns the optional live API key, or "" when none was provided.
std::string liveApiKey() {
return env("RW_LIVE_API_KEY");
}
} // namespace } // namespace
// ─── Offline: parse functions ───────────────────────────────────────── // ─── Offline: parse functions ─────────────────────────────────────────
@@ -163,7 +158,7 @@ TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
TEST(ApiClientLiveTest, FetchFrequenciesLive) { TEST(ApiClientLiveTest, FetchFrequenciesLive) {
std::vector<Frequency> out; std::vector<Frequency> out;
EXPECT_TRUE(fetchFrequencies(out, liveApiKey())); EXPECT_TRUE(fetchFrequencies(out));
ASSERT_GE(out.size(), 7u); ASSERT_GE(out.size(), 7u);
bool foundSolarPlexus = false; bool foundSolarPlexus = false;
@@ -180,7 +175,7 @@ TEST(ApiClientLiveTest, FetchFrequenciesLive) {
// ─── Live: proclamation + category parsers against the real API ──────── // ─── Live: proclamation + category parsers against the real API ────────
TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) { TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS, liveApiKey()); HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS);
ASSERT_TRUE(procsResponse.transportOk); ASSERT_TRUE(procsResponse.transportOk);
ASSERT_EQ(procsResponse.status, 200); ASSERT_EQ(procsResponse.status, 200);
@@ -193,7 +188,7 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
EXPECT_FALSE(proclamations[0].id.empty()); EXPECT_FALSE(proclamations[0].id.empty());
} }
HttpResponse catsResponse = httpGet(PATH_CATEGORIES, liveApiKey()); HttpResponse catsResponse = httpGet(PATH_CATEGORIES);
ASSERT_TRUE(catsResponse.transportOk); ASSERT_TRUE(catsResponse.transportOk);
ASSERT_EQ(catsResponse.status, 200); ASSERT_EQ(catsResponse.status, 200);
@@ -205,14 +200,14 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
// ─── Live: POST /license error paths (no credentials required) ───────── // ─── Live: POST /license error paths (no credentials required) ─────────
TEST(ApiClientLiveTest, ValidateLicenseMissingKeyLive) { TEST(ApiClientLiveTest, ValidateLicenseMissingKeyLive) {
LicenseResult result = validateLicense("", "test@example.com", liveApiKey()); LicenseResult result = validateLicense("", "test@example.com");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license_key is required"); EXPECT_EQ(result.error_message, "license_key is required");
} }
TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) { TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "", liveApiKey()); LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email_address is required"); EXPECT_EQ(result.error_message, "email_address is required");
@@ -220,7 +215,7 @@ TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
TEST(ApiClientLiveTest, ValidateLicenseUnknownKeyLive) { TEST(ApiClientLiveTest, ValidateLicenseUnknownKeyLive) {
LicenseResult result = LicenseResult result =
validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com", liveApiKey()); validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license key not found"); EXPECT_EQ(result.error_message, "license key not found");
@@ -235,7 +230,7 @@ TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set"; GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set";
} }
LicenseResult result = validateLicense(key, email, liveApiKey()); LicenseResult result = validateLicense(key, email);
ASSERT_TRUE(result.ok()) << "status=" << result.status ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message; << " error=" << result.error_message;
EXPECT_EQ(result.info.key, key); EXPECT_EQ(result.info.key, key);
@@ -245,7 +240,7 @@ TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
// ─── Live: POST /automations/create/account error paths (harmless) ───── // ─── Live: POST /automations/create/account error paths (harmless) ─────
TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) { TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) {
AccountResult result = createAccount("", "", liveApiKey()); AccountResult result = createAccount("", "");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email is required"); EXPECT_EQ(result.error_message, "email is required");
@@ -260,7 +255,7 @@ TEST(ApiClientLiveTest, CreateAccountSuccessLive) {
GTEST_SKIP() << "RW_LIVE_ACCOUNT_EMAIL not set"; GTEST_SKIP() << "RW_LIVE_ACCOUNT_EMAIL not set";
} }
AccountResult result = createAccount(email, "RadiantWave Test", liveApiKey()); AccountResult result = createAccount(email, "RadiantWave Test");
ASSERT_TRUE(result.ok()) << "status=" << result.status ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message; << " error=" << result.error_message;
EXPECT_TRUE(result.body.success); EXPECT_TRUE(result.body.success);
+36
View File
@@ -8,9 +8,34 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor"); EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device"); EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device");
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio"); EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
EXPECT_EQ(mapToFriendlyDeviceName("Virtual Speaker"), "Built-In Speakers");
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output"); 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);
EXPECT_EQ(d.friendly_name, "System Default");
}
}
}
TEST(AudioTest, AudioManagerVolumeClamping) { TEST(AudioTest, AudioManagerVolumeClamping) {
radiantwave::config::ConfigManager config; radiantwave::config::ConfigManager config;
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string(); std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
@@ -36,3 +61,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
am.shutdown(); am.shutdown();
std::filesystem::remove(temp_path); std::filesystem::remove(temp_path);
} }
TEST(AudioTest, PlayOptionsLoopInfinite) {
// Regression: MIX_PlayTrack() must not default to a single pass. Tracks
// have to loop forever so playback never runs out mid-scroll; a track that
// stops (rather than attenuates) also can't be revived by raising app
// volume. Assert the options request infinite looping (-1).
SDL_PropertiesID props = AudioManager::createInfiniteLoopOptions();
ASSERT_NE(props, 0u);
EXPECT_EQ(SDL_GetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, 0), -1);
SDL_DestroyProperties(props);
}
+1 -1
View File
@@ -175,7 +175,7 @@ TEST(ColorPalettesLiveTest, FetchColorPalettesLive) {
} }
std::vector<ColorPalette> palettes; std::vector<ColorPalette> palettes;
ASSERT_TRUE(fetchColorPalettes(palettes, env("RW_LIVE_API_KEY"))); ASSERT_TRUE(fetchColorPalettes(palettes));
EXPECT_FALSE(palettes.empty()); EXPECT_FALSE(palettes.empty());
for (const auto& p : palettes) { for (const auto& p : palettes) {
EXPECT_GE(p.colors.size(), 1u); EXPECT_GE(p.colors.size(), 1u);
+4 -4
View File
@@ -48,7 +48,7 @@ TEST(LicenseServiceTest, NotConfiguredWhenNoEmail) {
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
std::string errorMessage = "stale"; std::string errorMessage = "stale";
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage), EXPECT_EQ(validateStoredLicense(tmp.db, &errorMessage),
Result::NotConfigured); Result::NotConfigured);
EXPECT_EQ(errorMessage, ""); EXPECT_EQ(errorMessage, "");
} }
@@ -57,7 +57,7 @@ TEST(LicenseServiceTest, NotConfiguredWhenNoLicense) {
TempDb tmp; TempDb tmp;
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com"); tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
EXPECT_EQ(validateStoredLicense(tmp.db, "", nullptr), EXPECT_EQ(validateStoredLicense(tmp.db, nullptr),
Result::NotConfigured); Result::NotConfigured);
} }
@@ -117,7 +117,7 @@ TEST(LicenseServiceLiveTest, ValidatesAndGrantsSubscribedState) {
tmp.db.setKV(KV_EMAIL_ADDRESS, email); tmp.db.setKV(KV_EMAIL_ADDRESS, email);
std::string errorMessage; std::string errorMessage;
const Result result = validateStoredLicense(tmp.db, "", &errorMessage); const Result result = validateStoredLicense(tmp.db, &errorMessage);
EXPECT_EQ(result, Result::Ok) << "error=" << errorMessage; EXPECT_EQ(result, Result::Ok) << "error=" << errorMessage;
// A successful validation is the "subscribed" flag; no subscription-level // A successful validation is the "subscribed" flag; no subscription-level
@@ -134,7 +134,7 @@ TEST(LicenseServiceLiveTest, InvalidPairReportsInvalid) {
tmp.db.setKV(KV_EMAIL_ADDRESS, "nobody@example.com"); tmp.db.setKV(KV_EMAIL_ADDRESS, "nobody@example.com");
std::string errorMessage; std::string errorMessage;
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage), EXPECT_EQ(validateStoredLicense(tmp.db, &errorMessage),
Result::Invalid); Result::Invalid);
EXPECT_FALSE(errorMessage.empty()); EXPECT_FALSE(errorMessage.empty());
} }
+8 -22
View File
@@ -1,50 +1,36 @@
#include "test_harness.h"
#include "../src/provisioning.h" #include "../src/provisioning.h"
#include "test_harness.h"
using radiantwave::provisioning::firstMissingStep; using radiantwave::provisioning::firstMissingStep;
using radiantwave::provisioning::indicatesFirstRun; using radiantwave::provisioning::indicatesFirstRun;
using radiantwave::provisioning::Step; using radiantwave::provisioning::Step;
TEST(first_missing_step_all_satisfied) TEST(first_missing_step_all_satisfied) {
{
ASSERT_EQ(firstMissingStep(true, true, true), Step::Done); ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
} }
TEST(first_missing_step_no_internet) TEST(first_missing_step_no_email) {
{
// Internet is checked before email/license regardless of the others.
ASSERT_EQ(firstMissingStep(false, false, false), Step::Internet);
ASSERT_EQ(firstMissingStep(false, true, true), Step::Internet);
}
TEST(first_missing_step_no_email)
{
ASSERT_EQ(firstMissingStep(true, false, false), Step::Email); ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
ASSERT_EQ(firstMissingStep(true, false, true), Step::Email); ASSERT_EQ(firstMissingStep(true, false, true), Step::Email);
} }
TEST(first_missing_step_no_license) TEST(first_missing_step_no_license) {
{
ASSERT_EQ(firstMissingStep(true, true, false), Step::License); ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
} }
TEST(first_run_all_missing) TEST(first_run_all_missing) {
{
ASSERT_TRUE(indicatesFirstRun(false, false, false)); ASSERT_TRUE(indicatesFirstRun(false, false, false));
} }
TEST(first_run_has_internet) TEST(first_run_has_internet) {
{
ASSERT_FALSE(indicatesFirstRun(true, false, false)); ASSERT_FALSE(indicatesFirstRun(true, false, false));
} }
TEST(first_run_has_email) TEST(first_run_has_email) {
{
ASSERT_FALSE(indicatesFirstRun(false, true, false)); ASSERT_FALSE(indicatesFirstRun(false, true, false));
} }
TEST(first_run_has_license) TEST(first_run_has_license) {
{
ASSERT_FALSE(indicatesFirstRun(false, false, true)); ASSERT_FALSE(indicatesFirstRun(false, false, true));
} }
+56
View File
@@ -94,6 +94,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav"); EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
} }
TEST(ScrollerStreamsTest, InterleavedListCyclesBackToFirstSelected)
{
// Three selected proclamations: the built list must be
// [S1, Default, S2, Default, S3, Default]. ScrollerPage advances through
// this list with a modulo, so once the trailing default finishes the cycle
// restarts at the FIRST selected proclamation — the "always on" default
// must never accumulate or take over the queue.
ProclamationRecord b;
b.id = "B";
b.title = "B";
b.content = "bbbb";
b.selected = true;
b.solfeggio_frequency_id = 2;
ProclamationRecord c;
c.id = "C";
c.title = "C";
c.content = "cccc";
c.selected = true;
c.solfeggio_frequency_id = 3;
ProclamationRecord d;
d.id = "D";
d.title = "D";
d.content = "dddd";
d.selected = true;
d.solfeggio_frequency_id = 4;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "dddddddddd";
def.selected = true;
def.solfeggio_frequency_id = 5;
auto streams = buildStreams({b, def, c, d}, {}, palette(), {}, kDefaultId);
ASSERT_EQ(streams.size(), 6u);
EXPECT_EQ(streams[0].name, "B");
EXPECT_EQ(streams[1].name, "Default");
EXPECT_EQ(streams[2].name, "C");
EXPECT_EQ(streams[3].name, "Default");
EXPECT_EQ(streams[4].name, "D");
EXPECT_EQ(streams[5].name, "Default");
// Every default copy is independent playback state (fresh counters), so a
// copy finishing its passes advances to the next stream rather than
// restarting "the default" forever.
for (size_t i = 1; i < streams.size(); i += 2) {
EXPECT_EQ(streams[i].name, "Default");
EXPECT_EQ(streams[i].completed, 0) << "default copy " << i;
EXPECT_EQ(streams[i].wordsPlaced, 0) << "default copy " << i;
EXPECT_EQ(streams[i].repeat, kDefaultRepeatCount) << "default copy " << i;
}
}
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected) TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
{ {
ProclamationRecord p1; ProclamationRecord p1;