Compare commits
37
Commits
e401bd4ec5
..
1.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a043ec84d8 | ||
|
|
9fead922c4 | ||
|
|
2b1040d3db | ||
|
|
cf991c34af | ||
|
|
b56601bb93 | ||
|
|
72ba15891d | ||
|
|
356629a5a1 | ||
|
|
6019be6359 | ||
|
|
fee31c87b8 | ||
|
|
5369532ab4 | ||
|
|
42b915bd25 | ||
|
|
d60a8bd7fb | ||
|
|
ca7b8b7e6f | ||
|
|
bf2d4d1f89 | ||
|
|
2917d33866 | ||
|
|
3f6201182e | ||
|
|
d84307bd0e | ||
|
|
43cd93bbd7 | ||
|
|
92ba9f39fb | ||
|
|
400c6a782b | ||
|
|
9b0bd517c6 | ||
|
|
babb74c7ad | ||
|
|
47785cd4b9 | ||
|
|
15583edef8 | ||
|
|
63265cc22f | ||
|
|
9e73c82641 | ||
|
|
f2a40cd019 | ||
|
|
5caf1d6ef8 | ||
|
|
e17e881c00 | ||
|
|
b96631c374 | ||
|
|
39eb6d7bf7 | ||
|
|
3c9de0bee2 | ||
|
|
8e930ef8d9 | ||
|
|
ec6b76618c | ||
|
|
74245ee07d | ||
|
|
359ae905d7 | ||
|
|
2b60002df3 |
@@ -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
|
|
||||||
'
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -117,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass.
|
|||||||
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
|
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
|
||||||
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
|
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
|
||||||
from it.
|
from it.
|
||||||
|
- Proclamations carry a flat `category_id` plus a tag-like `group_id`
|
||||||
|
(`proclamation_groups`). The Options page lists every category and, under it,
|
||||||
|
only the groups that have at least one member in that category; ungrouped
|
||||||
|
proclamations sit directly under the category. `runMigrations()` performs a
|
||||||
|
one-time v1→v2 migration (`PRAGMA user_version`) that converts legacy
|
||||||
|
`proclamation_categories.parent_id` sub-categories into groups and remaps
|
||||||
|
their proclamations, preserving selections. No network is needed for it, so
|
||||||
|
offline kiosks migrate on startup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -238,14 +231,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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -516,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
|
|||||||
| ------- | --------- |
|
| ------- | --------- |
|
||||||
| app_config | Key-value store: license, email, audio device, volume |
|
| app_config | Key-value store: license, email, audio device, volume |
|
||||||
| logs | Application logs |
|
| logs | Application logs |
|
||||||
| proclamations | Synced proclamation text |
|
| proclamations | Synced proclamation text (category_id + group_id) |
|
||||||
| proclamation_categories | Category metadata |
|
| proclamation_categories | Flat category metadata |
|
||||||
|
| proclamation_groups | Tag-like groups that organize proclamations within a category |
|
||||||
| solfeggio_frequencies | 9 frequency presets |
|
| solfeggio_frequencies | 9 frequency presets |
|
||||||
| audio_files | Downloaded audio files |
|
| audio_files | Downloaded audio files |
|
||||||
|
|
||||||
@@ -633,9 +602,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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
'';
|
'';
|
||||||
|
|||||||
+467
@@ -0,0 +1,467 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Radiant Wave Portal API (v2)
|
||||||
|
version: 2.0.0
|
||||||
|
description: |
|
||||||
|
Read API used by the Radiant Wave kiosk plus the automation onboarding
|
||||||
|
webhook.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
The v2 API is **not** API-key gated. The former bearer-token middleware in
|
||||||
|
`src/hooks.server.ts` is present but disabled, so v2 routes are served
|
||||||
|
without a global API key.
|
||||||
|
|
||||||
|
Two endpoints carry their own credential semantics:
|
||||||
|
|
||||||
|
- `GET /api/v2/proclamations` optionally accepts the paired
|
||||||
|
`x-license-key` / `x-email` headers to scope results to an entitled user.
|
||||||
|
Supplying neither returns the full catalogue; supplying only one, or a
|
||||||
|
pair that does not match, is rejected.
|
||||||
|
- `POST /api/v2/automations/create/account` is protected by an HMAC-SHA256
|
||||||
|
request signature (see the operation description).
|
||||||
|
servers:
|
||||||
|
- url: https://portal.radiantwavetech.com
|
||||||
|
description: Production
|
||||||
|
- url: http://localhost:5173
|
||||||
|
description: Local development
|
||||||
|
tags:
|
||||||
|
- name: Catalogue
|
||||||
|
description: Reference data for categories, groups, frequencies and palettes.
|
||||||
|
- name: Proclamations
|
||||||
|
description: Proclamation content, optionally scoped by license entitlement.
|
||||||
|
- name: License
|
||||||
|
description: License key validation.
|
||||||
|
- name: Automations
|
||||||
|
description: Signed machine-to-machine webhooks.
|
||||||
|
paths:
|
||||||
|
/api/v2/categories:
|
||||||
|
get:
|
||||||
|
tags: [Catalogue]
|
||||||
|
summary: List proclamation categories
|
||||||
|
operationId: listCategories
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: All proclamation categories.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Category'
|
||||||
|
/api/v2/proclamation-categories:
|
||||||
|
get:
|
||||||
|
tags: [Catalogue]
|
||||||
|
summary: List proclamation categories (dedicated alias)
|
||||||
|
description: Alias of `/api/v2/categories`; returns the identical payload.
|
||||||
|
operationId: listProclamationCategories
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: All proclamation categories.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Category'
|
||||||
|
/api/v2/groups:
|
||||||
|
get:
|
||||||
|
tags: [Catalogue]
|
||||||
|
summary: List proclamation groups
|
||||||
|
description: Groups are ordered by `sortOrder` ascending.
|
||||||
|
operationId: listGroups
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: All proclamation groups.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Group'
|
||||||
|
/api/v2/proclamation-groups:
|
||||||
|
get:
|
||||||
|
tags: [Catalogue]
|
||||||
|
summary: List proclamation groups (dedicated alias)
|
||||||
|
description: Alias of `/api/v2/groups`; returns the identical payload.
|
||||||
|
operationId: listProclamationGroups
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: All proclamation groups.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Group'
|
||||||
|
/api/v2/frequencies:
|
||||||
|
get:
|
||||||
|
tags: [Catalogue]
|
||||||
|
summary: List canonical solfeggio frequencies
|
||||||
|
description: |
|
||||||
|
Returns the nine canonical frequencies (ids 1-9) the kiosk expects.
|
||||||
|
These are static values, not the portal database autoincrement ids.
|
||||||
|
operationId: listFrequencies
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The nine canonical frequencies.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
minItems: 9
|
||||||
|
maxItems: 9
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Frequency'
|
||||||
|
/api/v2/color-palettes:
|
||||||
|
get:
|
||||||
|
tags: [Catalogue]
|
||||||
|
summary: List color palettes per canonical frequency
|
||||||
|
description: |
|
||||||
|
Returns one 7-color palette for each canonical frequency id (1-9).
|
||||||
|
Palette rows stored in the database take precedence; ids without a row
|
||||||
|
fall back to the built-in canonical palette.
|
||||||
|
operationId: listColorPalettes
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Nine palettes, one per canonical frequency id.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
minItems: 9
|
||||||
|
maxItems: 9
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ColorPalette'
|
||||||
|
/api/v2/proclamations:
|
||||||
|
get:
|
||||||
|
tags: [Proclamations]
|
||||||
|
summary: List proclamations
|
||||||
|
description: |
|
||||||
|
Without entitlement headers, returns the full proclamation catalogue.
|
||||||
|
|
||||||
|
With both `x-license-key` and `x-email`, returns only the proclamations
|
||||||
|
assigned to the matching user. Both headers must be present together and
|
||||||
|
the key must belong to the user.
|
||||||
|
|
||||||
|
Proclamations whose frequency has no canonical id (for example 936 Hz)
|
||||||
|
are omitted from the response.
|
||||||
|
operationId: listProclamations
|
||||||
|
parameters:
|
||||||
|
- in: header
|
||||||
|
name: x-license-key
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: License key. Must be supplied together with `x-email`.
|
||||||
|
- in: header
|
||||||
|
name: x-email
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
required: false
|
||||||
|
description: Account email. Must be supplied together with `x-license-key`.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Proclamation records with canonical frequency ids and embedded palettes.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Proclamation'
|
||||||
|
'400':
|
||||||
|
description: |
|
||||||
|
Missing entitlement or the supplied key/email pair is invalid.
|
||||||
|
Message is `not-authorized`, or
|
||||||
|
`this key doesn't appear to belong to you` when the key does not
|
||||||
|
belong to the specified user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
/api/v2/license:
|
||||||
|
post:
|
||||||
|
tags: [License]
|
||||||
|
summary: Validate a license key
|
||||||
|
description: |
|
||||||
|
Validates that the license key exists, is active, and belongs to the
|
||||||
|
account matching `email_address`. A successful validation records
|
||||||
|
`lastValidatedAt` on the key.
|
||||||
|
operationId: validateLicense
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LicenseRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The validated key and its owner email.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LicenseResponse'
|
||||||
|
'400':
|
||||||
|
description: |
|
||||||
|
Validation failure or malformed body. Possible messages:
|
||||||
|
`content-type must be application/json`, `invalid JSON body`,
|
||||||
|
`request body is required`, `license_key is required`,
|
||||||
|
`email_address is required`, `license key not found`,
|
||||||
|
`license key is not active`, `user does not exist`,
|
||||||
|
`key does not belong to specified user`.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
'413':
|
||||||
|
description: The request body exceeded the 64 KiB limit.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
'429':
|
||||||
|
description: Rate limit exceeded (10 requests per minute per IP).
|
||||||
|
headers:
|
||||||
|
Retry-After:
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Seconds to wait before retrying.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
/api/v2/automations/create/account:
|
||||||
|
post:
|
||||||
|
tags: [Automations]
|
||||||
|
summary: Create an account and license key (signed webhook)
|
||||||
|
description: |
|
||||||
|
Machine-to-machine onboarding webhook. The request must be signed with
|
||||||
|
HMAC-SHA256 using `API_V2_ONBOARDING_SECRET`.
|
||||||
|
|
||||||
|
The canonical string that is signed is the newline-joined concatenation
|
||||||
|
of:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST
|
||||||
|
<path>
|
||||||
|
<x-timestamp>
|
||||||
|
<x-nonce>
|
||||||
|
<raw request body>
|
||||||
|
```
|
||||||
|
|
||||||
|
where `<path>` is `/api/v2/automations/create/account`. The signature is
|
||||||
|
the lowercase hex digest.
|
||||||
|
|
||||||
|
On success, creates the user if they do not already exist, generates a
|
||||||
|
license key, emails it, and returns the user id. Invalid or missing
|
||||||
|
signatures return `401 { "error": "invalid signature" }` (the same
|
||||||
|
response is used for every signature failure, including replay and
|
||||||
|
timestamp skew).
|
||||||
|
operationId: createAutomationAccount
|
||||||
|
parameters:
|
||||||
|
- in: header
|
||||||
|
name: x-timestamp
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unix epoch seconds. Must be within 300 seconds of the server clock.
|
||||||
|
- in: header
|
||||||
|
name: x-nonce
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unique value per request. Replays within the skew window are rejected.
|
||||||
|
- in: header
|
||||||
|
name: x-signature
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[0-9a-f]{64}$'
|
||||||
|
description: Lowercase hex HMAC-SHA256 of the canonical request string.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Account and license key created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingResponse'
|
||||||
|
'400':
|
||||||
|
description: |
|
||||||
|
Malformed or invalid request. Possible messages:
|
||||||
|
`request body is required`, `invalid JSON body`, or
|
||||||
|
`email is required`.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
'401':
|
||||||
|
description: Signature missing, malformed, expired, replayed, or incorrect.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [error]
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
example: invalid signature
|
||||||
|
'413':
|
||||||
|
description: The request body exceeded the 64 KiB limit.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
'429':
|
||||||
|
description: |
|
||||||
|
Rate limit exceeded. Limits are 5 requests per minute per IP and
|
||||||
|
3 requests per 24 hours per email address.
|
||||||
|
headers:
|
||||||
|
Retry-After:
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Seconds to wait before retrying.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
'500':
|
||||||
|
description: The account could not be created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Category:
|
||||||
|
type: object
|
||||||
|
required: [id, name, sortOrder]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
sortOrder:
|
||||||
|
type: integer
|
||||||
|
Group:
|
||||||
|
type: object
|
||||||
|
required: [id, name, sortOrder]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
sortOrder:
|
||||||
|
type: integer
|
||||||
|
Frequency:
|
||||||
|
type: object
|
||||||
|
required: [id, name, value]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 9
|
||||||
|
description: Canonical id used by the kiosk.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: integer
|
||||||
|
description: Frequency in Hz.
|
||||||
|
ColorPalette:
|
||||||
|
type: object
|
||||||
|
required: [solfeggioFrequencyId, colors]
|
||||||
|
properties:
|
||||||
|
solfeggioFrequencyId:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 9
|
||||||
|
colors:
|
||||||
|
type: array
|
||||||
|
description: Seven hex colors, ordered light to dark.
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
pattern: '^#[0-9A-Fa-f]{6}$'
|
||||||
|
Proclamation:
|
||||||
|
type: object
|
||||||
|
required: [id, name, content, categoryId, groupId, solfeggioFrequencyId]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
description: HTML content.
|
||||||
|
categoryId:
|
||||||
|
type: integer
|
||||||
|
groupId:
|
||||||
|
type: integer
|
||||||
|
solfeggioFrequencyId:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 9
|
||||||
|
colorPalette:
|
||||||
|
type: array
|
||||||
|
description: Present only when a palette exists for the canonical frequency id.
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
pattern: '^#[0-9A-Fa-f]{6}$'
|
||||||
|
LicenseRequest:
|
||||||
|
type: object
|
||||||
|
required: [license_key, email_address]
|
||||||
|
properties:
|
||||||
|
license_key:
|
||||||
|
type: string
|
||||||
|
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||||
|
example: ABCD-EFGH-IJKL-MNOP
|
||||||
|
email_address:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 254
|
||||||
|
LicenseResponse:
|
||||||
|
type: object
|
||||||
|
required: [key, email_address]
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
example: ABCD-EFGH-IJKL-MNOP
|
||||||
|
email_address:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
OnboardingRequest:
|
||||||
|
type: object
|
||||||
|
required: [email]
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 254
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 120
|
||||||
|
description: Optional display name. Defaults to the email local-part.
|
||||||
|
OnboardingResponse:
|
||||||
|
type: object
|
||||||
|
required: [success, message, user_id]
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Account and license key created
|
||||||
|
user_id:
|
||||||
|
type: string
|
||||||
|
ErrorMessage:
|
||||||
|
type: object
|
||||||
|
required: [message]
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# Portal API v2 — current schema
|
|
||||||
|
|
||||||
The kiosk talks to `https://portal.radiantwavetech.com/api/v2`. This document
|
|
||||||
describes the current contract after the move to an **active/inactive license
|
|
||||||
model**. There are no subscription levels: a license is either **valid**
|
|
||||||
(subscribed) or not, and the kiosk treats a successful `POST /license`
|
|
||||||
validation as the device's "subscribed" flag.
|
|
||||||
|
|
||||||
Base URL: `https://portal.radiantwavetech.com/api/v2`
|
|
||||||
|
|
||||||
Authentication: `Authorization: Bearer sk_<hex>` on every request (the API key
|
|
||||||
is baked into the kiosk binary at build time).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `POST /license`
|
|
||||||
|
|
||||||
Validates a license key / email pair.
|
|
||||||
|
|
||||||
Request:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v2/license
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer sk_<hex>
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"license_key": "XXXX-XXXX-XXXX-XXXX",
|
|
||||||
"email_address": "user@example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response (200):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"key": "XXXX-XXXX-XXXX-XXXX",
|
|
||||||
"email_address": "user@example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Contract (kiosk `parseLicenseJson`):
|
|
||||||
|
|
||||||
- `key`: string, **required non-empty** (empty key ⇒ parse failure).
|
|
||||||
- `email_address`: string.
|
|
||||||
- Any other fields (e.g. the legacy `subscription_level`) are ignored. A 2xx
|
|
||||||
response means the license is valid and the subscription is **active**; the
|
|
||||||
kiosk stores no subscription state other than the `license_last_verified`
|
|
||||||
stamp that drives its 7-day offline grace window.
|
|
||||||
|
|
||||||
Errors:
|
|
||||||
|
|
||||||
| Code | Body | Meaning |
|
|
||||||
|------|------|---------|
|
|
||||||
| 200 | success object | License valid and owned by the email |
|
|
||||||
| 400 | `{"message": "license_key is required"}` | Missing `license_key` |
|
|
||||||
| 400 | `{"message": "email_address is required"}` | Missing `email_address` |
|
|
||||||
| 400 | `{"message": "license key not found"}` | No key with that value |
|
|
||||||
| 400 | `{"message": "license key is not active"}` | Key exists but inactive |
|
|
||||||
| 400 | `{"message": "user does not exist"}` | No user with that email |
|
|
||||||
| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user |
|
|
||||||
| 401 | `{"error": "..."}` | Missing/invalid API key |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `GET /proclamations`
|
|
||||||
|
|
||||||
Lists every proclamation. There is **no `subscriptionLevelId`** — any validated
|
|
||||||
license may access any proclamation.
|
|
||||||
|
|
||||||
Response (200):
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "b40fdcb9-703c-420e-af32-a081964c6f08",
|
|
||||||
"name": "Peace & Relaxation",
|
|
||||||
"content": "<p>The text content, HTML formatted</p>",
|
|
||||||
"categoryId": 5,
|
|
||||||
"solfeggioFrequencyId": 5,
|
|
||||||
"colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`):
|
|
||||||
|
|
||||||
- `id`: string UUID, **required non-empty** (empty ids are skipped).
|
|
||||||
- `name`: string, used as the display title. Kiosk strips HTML from it.
|
|
||||||
- `content`: string, **HTML** (the kiosk strips tags and unescapes entities
|
|
||||||
before rendering).
|
|
||||||
- `categoryId`: int, maps to a `/categories` id.
|
|
||||||
- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)**.
|
|
||||||
- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings. When
|
|
||||||
absent, the kiosk falls back to `/color-palettes`, then to its own 528 Hz
|
|
||||||
seed.
|
|
||||||
- Legacy `subscriptionLevelId` fields in the payload are ignored.
|
|
||||||
|
|
||||||
Errors:
|
|
||||||
|
|
||||||
| Code | Body | Meaning |
|
|
||||||
|------|------|---------|
|
|
||||||
| 200 | array of proclamation records | Success |
|
|
||||||
| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist |
|
|
||||||
| 401 | `{"error": "..."}` | Missing/invalid API key |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `GET /categories`
|
|
||||||
|
|
||||||
Lists the proclamation categories.
|
|
||||||
|
|
||||||
Response (200):
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"name": "Spiritual",
|
|
||||||
"parentId": 0,
|
|
||||||
"sortOrder": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Contract (kiosk `parseCategoriesJson`):
|
|
||||||
|
|
||||||
- `id`: int, **required non-zero** (zero ids are skipped).
|
|
||||||
- `name`: string.
|
|
||||||
- `parentId`: int, `null` maps to 0 (root).
|
|
||||||
- `sortOrder`: int.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `POST /automations/create/account`
|
|
||||||
|
|
||||||
Creates (or reuses) a user account and issues a license key. **No
|
|
||||||
`subscription_level` is sent** — every created license is a standard active
|
|
||||||
license.
|
|
||||||
|
|
||||||
Request:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v2/automations/create/account
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer sk_<hex>
|
|
||||||
X-Timestamp: <unix seconds>
|
|
||||||
X-Nonce: <random uuid>
|
|
||||||
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "user@example.com",
|
|
||||||
"name": "Optional Display Name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `email` (required, string).
|
|
||||||
- `name` (optional, string; defaults to the local part of `email`).
|
|
||||||
|
|
||||||
Response (200):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Account and license key created",
|
|
||||||
"user_id": "uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors:
|
|
||||||
|
|
||||||
| Code | Body | Meaning |
|
|
||||||
|------|------|---------|
|
|
||||||
| 200 | success object | Account (+ license key) created |
|
|
||||||
| 400 | `{"message": "email is required"}` | Missing/empty `email` |
|
|
||||||
| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature |
|
|
||||||
| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email |
|
|
||||||
| 500 | `{"message": "Failed to create account"}` | User creation failed |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Readiness routes (not yet wired into the app)
|
|
||||||
|
|
||||||
- `GET /color-palettes` → `[{solfeggioFrequencyId, colors[7]}]`
|
|
||||||
- `GET /frequencies` → canonical id 1–9 + `value` in Hz
|
|
||||||
|
|
||||||
These exist for future sync steps; the kiosk does not call them yet.
|
|
||||||
@@ -53,12 +53,12 @@ public:
|
|||||||
AudioDownloader() = default;
|
AudioDownloader() = default;
|
||||||
~AudioDownloader();
|
~AudioDownloader();
|
||||||
|
|
||||||
AudioDownloader(const AudioDownloader&) = delete;
|
AudioDownloader(const AudioDownloader &) = delete;
|
||||||
AudioDownloader& operator=(const AudioDownloader&) = delete;
|
AudioDownloader &operator=(const AudioDownloader &) = delete;
|
||||||
|
|
||||||
// Spawns the background thread. Safe to call repeatedly; only one worker
|
// Spawns the background thread. Safe to call repeatedly; only one worker
|
||||||
// runs at a time. The manifest is reconciled against `db` on every pass.
|
// runs at a time. The manifest is reconciled against `db` on every pass.
|
||||||
void start(db::DatabaseManager& db);
|
void start(db::DatabaseManager &db);
|
||||||
|
|
||||||
// Signals the worker to stop and joins the thread.
|
// Signals the worker to stop and joins the thread.
|
||||||
void shutdown();
|
void shutdown();
|
||||||
@@ -66,15 +66,18 @@ 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 =
|
||||||
static constexpr const char* MANIFEST_FILENAME = "checksums.md5";
|
"https://files.radiantwavetech.com/audio/";
|
||||||
|
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);
|
||||||
|
|
||||||
mutable std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
@@ -82,13 +85,14 @@ private:
|
|||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
std::atomic<bool> stop_{false};
|
std::atomic<bool> stop_{false};
|
||||||
std::string status_;
|
std::string status_;
|
||||||
db::DatabaseManager* db_ = nullptr;
|
db::DatabaseManager *db_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -100,9 +104,9 @@ int frequencyIdForHz(int hz);
|
|||||||
// "396Hz.wav" -> 396. Requires the digits to be followed by "Hz"
|
// "396Hz.wav" -> 396. Requires the digits to be followed by "Hz"
|
||||||
// (case-insensitive); returns 0 when the name has no frequency (e.g.
|
// (case-insensitive); returns 0 when the name has no frequency (e.g.
|
||||||
// "AlphaWaves.wav", "OriginalAudio.wav").
|
// "AlphaWaves.wav", "OriginalAudio.wav").
|
||||||
int hzFromFilename(const std::string& filename);
|
int hzFromFilename(const std::string &filename);
|
||||||
|
|
||||||
// Computes the lowercase MD5 hex of a file, or an empty string on failure.
|
// Computes the lowercase MD5 hex of a file, or an empty string on failure.
|
||||||
std::string computeFileMd5(const std::string& path);
|
std::string computeFileMd5(const std::string &path);
|
||||||
|
|
||||||
} // namespace radiantwave::audio
|
} // namespace radiantwave::audio
|
||||||
|
|||||||
+388
-31
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+153
-18
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
|
|||||||
is_active INTEGER DEFAULT 1,
|
is_active INTEGER DEFAULT 1,
|
||||||
selected INTEGER DEFAULT 0,
|
selected INTEGER DEFAULT 0,
|
||||||
category_id INTEGER DEFAULT 0,
|
category_id INTEGER DEFAULT 0,
|
||||||
|
group_id INTEGER DEFAULT 0,
|
||||||
solfeggio_frequency_id INTEGER DEFAULT 0,
|
solfeggio_frequency_id INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
|
|||||||
CREATE TABLE IF NOT EXISTS proclamation_categories (
|
CREATE TABLE IF NOT EXISTS proclamation_categories (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
parent_id INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proclamation_groups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
sort_order INTEGER DEFAULT 0
|
sort_order INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -182,6 +188,7 @@ bool DatabaseManager::runMigrations() {
|
|||||||
// Back-fill columns for databases created before these fields existed.
|
// Back-fill columns for databases created before these fields existed.
|
||||||
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
|
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
|
||||||
ensureColumn("proclamations", "category_id", "category_id INTEGER DEFAULT 0");
|
ensureColumn("proclamations", "category_id", "category_id INTEGER DEFAULT 0");
|
||||||
|
ensureColumn("proclamations", "group_id", "group_id INTEGER DEFAULT 0");
|
||||||
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
||||||
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
||||||
// Clean up the pre-active/inactive-license subscription level keys.
|
// Clean up the pre-active/inactive-license subscription level keys.
|
||||||
@@ -189,12 +196,72 @@ bool DatabaseManager::runMigrations() {
|
|||||||
executeSql("DELETE FROM system_kv WHERE key IN "
|
executeSql("DELETE FROM system_kv WHERE key IN "
|
||||||
"('subscription_level_id','subscription_level_name','subscription_level_description');");
|
"('subscription_level_id','subscription_level_name','subscription_level_description');");
|
||||||
}
|
}
|
||||||
|
if (ok) {
|
||||||
|
ok = migrateSubCategoriesToGroups();
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
seedColorPalettes();
|
seedColorPalettes();
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schema migration v2: sub-categories -> tag-like groups.
|
||||||
|
//
|
||||||
|
// Pre-v2 databases modelled a two-level category tree via
|
||||||
|
// `proclamation_categories.parent_id`, and proclamations pointed at either a
|
||||||
|
// root or a sub-category. v2 flattens categories and turns each former
|
||||||
|
// sub-category into a group, remapping the proclamations that referenced it to
|
||||||
|
// its parent category + the new group. Selection flags are never touched, so
|
||||||
|
// the user's picks survive the upgrade. `PRAGMA user_version` records the
|
||||||
|
// migration so it runs exactly once; the whole thing is one transaction so a
|
||||||
|
// failure leaves the database untouched. No network is required, so this
|
||||||
|
// always runs at startup, including on offline kiosks.
|
||||||
|
bool DatabaseManager::migrateSubCategoriesToGroups() {
|
||||||
|
if (!db_) return false;
|
||||||
|
|
||||||
|
int userVersion = 0;
|
||||||
|
{
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_, "PRAGMA user_version;", -1, &stmt, nullptr) == SQLITE_OK) {
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
userVersion = sqlite3_column_int(stmt, 0);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userVersion >= 2) {
|
||||||
|
return true; // already migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh databases (created by the v2 schema) have no legacy parent_id
|
||||||
|
// column, so there is nothing to convert: just stamp the version.
|
||||||
|
if (!columnExists("proclamation_categories", "parent_id")) {
|
||||||
|
return executeSql("PRAGMA user_version = 2;");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool migrated = executeSql(
|
||||||
|
"BEGIN;"
|
||||||
|
"INSERT OR IGNORE INTO proclamation_groups (id, name, sort_order) "
|
||||||
|
" SELECT id, name, sort_order FROM proclamation_categories WHERE parent_id != 0;"
|
||||||
|
"UPDATE proclamations "
|
||||||
|
" SET group_id = category_id, "
|
||||||
|
" category_id = COALESCE("
|
||||||
|
" (SELECT parent_id FROM proclamation_categories "
|
||||||
|
" WHERE id = proclamations.category_id), "
|
||||||
|
" category_id) "
|
||||||
|
" WHERE category_id IN "
|
||||||
|
" (SELECT id FROM proclamation_categories WHERE parent_id != 0);"
|
||||||
|
"DELETE FROM proclamation_categories WHERE parent_id != 0;"
|
||||||
|
"PRAGMA user_version = 2;"
|
||||||
|
"COMMIT;");
|
||||||
|
|
||||||
|
if (!migrated) {
|
||||||
|
executeSql("ROLLBACK;");
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
void DatabaseManager::seedColorPalettes() {
|
void DatabaseManager::seedColorPalettes() {
|
||||||
static const char* kSeedSql =
|
static const char* kSeedSql =
|
||||||
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
|
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
|
||||||
@@ -337,7 +404,8 @@ void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const Proclamat
|
|||||||
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
|
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
|
||||||
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
|
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
|
||||||
sqlite3_bind_int(stmt, 8, record.category_id);
|
sqlite3_bind_int(stmt, 8, record.category_id);
|
||||||
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
|
sqlite3_bind_int(stmt, 9, record.group_id);
|
||||||
|
sqlite3_bind_int(stmt, 10, record.solfeggio_frequency_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
||||||
@@ -345,12 +413,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
|||||||
if (!db_) return false;
|
if (!db_) return false;
|
||||||
|
|
||||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||||
"is_active=excluded.is_active, selected=excluded.selected, "
|
"is_active=excluded.is_active, selected=excluded.selected, "
|
||||||
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
"category_id=excluded.category_id, group_id=excluded.group_id, "
|
||||||
|
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||||
|
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -374,7 +443,8 @@ static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCo
|
|||||||
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
|
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
|
||||||
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
|
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
|
||||||
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
|
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
|
||||||
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
|
rec.group_id = sqlite3_column_int(stmt, firstCol + 8);
|
||||||
|
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 9);
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +454,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
|
|||||||
if (!db_) return result;
|
if (!db_) return result;
|
||||||
|
|
||||||
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
||||||
"selected, category_id, solfeggio_frequency_id "
|
"selected, category_id, group_id, solfeggio_frequency_id "
|
||||||
"FROM proclamations WHERE is_active = 1;";
|
"FROM proclamations WHERE is_active = 1;";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -414,8 +484,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
|
const char* insSql = "INSERT INTO proclamation_categories (id, name, sort_order) "
|
||||||
"VALUES (?, ?, ?, ?);";
|
"VALUES (?, ?, ?);";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||||
@@ -425,8 +495,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
|||||||
for (const auto& cat : records) {
|
for (const auto& cat : records) {
|
||||||
sqlite3_bind_int(stmt, 1, cat.id);
|
sqlite3_bind_int(stmt, 1, cat.id);
|
||||||
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
sqlite3_bind_int(stmt, 3, cat.parent_id);
|
sqlite3_bind_int(stmt, 3, cat.sort_order);
|
||||||
sqlite3_bind_int(stmt, 4, cat.sort_order);
|
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sqlite3_reset(stmt);
|
||||||
|
sqlite3_clear_bindings(stmt);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
|
||||||
|
} else {
|
||||||
|
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DatabaseManager::replaceGroups(const std::vector<ProclamationGroupRecord>& records) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (!db_) return false;
|
||||||
|
|
||||||
|
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = true;
|
||||||
|
if (sqlite3_exec(db_, "DELETE FROM proclamation_groups;", nullptr, nullptr, nullptr) != SQLITE_OK) {
|
||||||
|
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* insSql = "INSERT INTO proclamation_groups (id, name, sort_order) "
|
||||||
|
"VALUES (?, ?, ?);";
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
|
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& group : records) {
|
||||||
|
sqlite3_bind_int(stmt, 1, group.id);
|
||||||
|
sqlite3_bind_text(stmt, 2, group.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_int(stmt, 3, group.sort_order);
|
||||||
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||||
ok = false;
|
ok = false;
|
||||||
break;
|
break;
|
||||||
@@ -499,12 +611,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
|
|||||||
// Upsert each proclamation, preserving the stashed selection state.
|
// Upsert each proclamation, preserving the stashed selection state.
|
||||||
if (ok) {
|
if (ok) {
|
||||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||||
"is_active=excluded.is_active, category_id=excluded.category_id, "
|
"is_active=excluded.is_active, category_id=excluded.category_id, "
|
||||||
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
"group_id=excluded.group_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||||
|
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -555,7 +667,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
|
|||||||
if (!db_) return result;
|
if (!db_) return result;
|
||||||
|
|
||||||
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
||||||
"selected, category_id, solfeggio_frequency_id FROM proclamations;";
|
"selected, category_id, group_id, solfeggio_frequency_id FROM proclamations;";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
return result;
|
return result;
|
||||||
@@ -574,7 +686,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
|||||||
std::vector<ProclamationCategoryRecord> result;
|
std::vector<ProclamationCategoryRecord> result;
|
||||||
if (!db_) return result;
|
if (!db_) return result;
|
||||||
|
|
||||||
const char* sql = "SELECT id, name, parent_id, sort_order FROM proclamation_categories "
|
const char* sql = "SELECT id, name, sort_order FROM proclamation_categories "
|
||||||
"ORDER BY sort_order ASC, id ASC;";
|
"ORDER BY sort_order ASC, id ASC;";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -585,8 +697,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
|||||||
ProclamationCategoryRecord rec;
|
ProclamationCategoryRecord rec;
|
||||||
rec.id = sqlite3_column_int(stmt, 0);
|
rec.id = sqlite3_column_int(stmt, 0);
|
||||||
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||||
rec.parent_id = sqlite3_column_int(stmt, 2);
|
rec.sort_order = sqlite3_column_int(stmt, 2);
|
||||||
rec.sort_order = sqlite3_column_int(stmt, 3);
|
result.push_back(rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ProclamationGroupRecord> DatabaseManager::getAllGroups() {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
std::vector<ProclamationGroupRecord> result;
|
||||||
|
if (!db_) return result;
|
||||||
|
|
||||||
|
const char* sql = "SELECT id, name, sort_order FROM proclamation_groups "
|
||||||
|
"ORDER BY sort_order ASC, id ASC;";
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
ProclamationGroupRecord rec;
|
||||||
|
rec.id = sqlite3_column_int(stmt, 0);
|
||||||
|
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||||
|
rec.sort_order = sqlite3_column_int(stmt, 2);
|
||||||
result.push_back(rec);
|
result.push_back(rec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,23 @@ struct ProclamationRecord {
|
|||||||
bool is_active = true;
|
bool is_active = true;
|
||||||
bool selected = false;
|
bool selected = false;
|
||||||
int category_id = 0; // maps to ProclamationCategoryRecord::id
|
int category_id = 0; // maps to ProclamationCategoryRecord::id
|
||||||
|
int group_id = 0; // maps to ProclamationGroupRecord::id (0 = ungrouped)
|
||||||
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
|
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ProclamationCategoryRecord {
|
struct ProclamationCategoryRecord {
|
||||||
int id = 0;
|
int id = 0;
|
||||||
std::string name;
|
std::string name;
|
||||||
int parent_id = 0; // 0 = root category
|
int sort_order = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A group is a tag-like organizer: it is not owned by a category. A
|
||||||
|
// proclamation belongs to one category and may additionally belong to one
|
||||||
|
// group. Within the UI, a group appears under a category only when at least
|
||||||
|
// one proclamation pairs that (category_id, group_id).
|
||||||
|
struct ProclamationGroupRecord {
|
||||||
|
int id = 0;
|
||||||
|
std::string name;
|
||||||
int sort_order = 0;
|
int sort_order = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +100,9 @@ public:
|
|||||||
// Full sync: replaces all categories with the given set.
|
// Full sync: replaces all categories with the given set.
|
||||||
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
|
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
|
||||||
|
|
||||||
|
// Full sync: replaces all groups with the given set.
|
||||||
|
bool replaceGroups(const std::vector<ProclamationGroupRecord>& records);
|
||||||
|
|
||||||
// Full sync: upserts proclamations, deletes any not in `records`,
|
// Full sync: upserts proclamations, deletes any not in `records`,
|
||||||
// preserves the existing `selected` state, and forces the proclamation
|
// preserves the existing `selected` state, and forces the proclamation
|
||||||
// identified by `defaultProclamationId` to remain selected.
|
// identified by `defaultProclamationId` to remain selected.
|
||||||
@@ -102,6 +115,9 @@ public:
|
|||||||
// Fetch all categories ordered by sort_order then id.
|
// Fetch all categories ordered by sort_order then id.
|
||||||
std::vector<ProclamationCategoryRecord> getAllCategories();
|
std::vector<ProclamationCategoryRecord> getAllCategories();
|
||||||
|
|
||||||
|
// Fetch all groups ordered by sort_order then id.
|
||||||
|
std::vector<ProclamationGroupRecord> getAllGroups();
|
||||||
|
|
||||||
// Toggle a single proclamation's selected flag.
|
// Toggle a single proclamation's selected flag.
|
||||||
bool setProclamationSelected(const std::string& id, bool selected);
|
bool setProclamationSelected(const std::string& id, bool selected);
|
||||||
|
|
||||||
@@ -144,6 +160,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::string resolvePath(const std::string& raw_path) const;
|
std::string resolvePath(const std::string& raw_path) const;
|
||||||
bool runMigrations();
|
bool runMigrations();
|
||||||
|
bool migrateSubCategoriesToGroups();
|
||||||
bool executeSql(const std::string& sql);
|
bool executeSql(const std::string& sql);
|
||||||
bool columnExists(const std::string& table, const std::string& column);
|
bool columnExists(const std::string& table, const std::string& column);
|
||||||
bool ensureColumn(const std::string& table, const std::string& column,
|
bool ensureColumn(const std::string& table, const std::string& column,
|
||||||
|
|||||||
@@ -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
@@ -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, ¬eDst);
|
||||||
|
}
|
||||||
|
|
||||||
// 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_)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+142
-59
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
|
|||||||
// Paths relative to API_BASE_URL.
|
// Paths relative to API_BASE_URL.
|
||||||
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
|
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
|
||||||
constexpr const char* PATH_CATEGORIES = "/categories";
|
constexpr const char* PATH_CATEGORIES = "/categories";
|
||||||
|
constexpr const char* PATH_GROUPS = "/groups";
|
||||||
constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
||||||
constexpr const char* PATH_LICENSE = "/license";
|
constexpr const char* PATH_LICENSE = "/license";
|
||||||
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
|
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
|
||||||
@@ -24,14 +25,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 +41,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 +71,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 +94,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 +117,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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ bool parseProclamationsJson(const std::string& body,
|
|||||||
rec.title = stripHtml(entry.stringField("name"));
|
rec.title = stripHtml(entry.stringField("name"));
|
||||||
rec.content = stripHtml(entry.stringField("content"));
|
rec.content = stripHtml(entry.stringField("content"));
|
||||||
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
|
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
|
||||||
|
rec.group_id = static_cast<int>(entry.numberField("groupId"));
|
||||||
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
|
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
|
||||||
out.push_back(std::move(rec));
|
out.push_back(std::move(rec));
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,28 @@ bool parseCategoriesJson(const std::string& body,
|
|||||||
if (rec.id == 0) continue;
|
if (rec.id == 0) continue;
|
||||||
|
|
||||||
rec.name = entry.stringField("name");
|
rec.name = entry.stringField("name");
|
||||||
rec.parent_id = static_cast<int>(entry.numberField("parentId"));
|
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
||||||
|
out.push_back(std::move(rec));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseGroupsJson(const std::string& body,
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord>& out) {
|
||||||
|
json::JsonValue root;
|
||||||
|
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.clear();
|
||||||
|
for (const auto& entry : root.array) {
|
||||||
|
if (entry.type != json::JsonValue::Type::Object) continue;
|
||||||
|
|
||||||
|
radiantwave::db::ProclamationGroupRecord rec;
|
||||||
|
rec.id = static_cast<int>(entry.numberField("id"));
|
||||||
|
if (rec.id == 0) continue;
|
||||||
|
|
||||||
|
rec.name = entry.stringField("name");
|
||||||
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
||||||
out.push_back(std::move(rec));
|
out.push_back(std::move(rec));
|
||||||
}
|
}
|
||||||
@@ -114,16 +136,28 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api::HttpResponse groupsResponse =
|
||||||
|
api::httpGet(api::PATH_GROUPS);
|
||||||
|
if (!groupsResponse.transportOk ||
|
||||||
|
groupsResponse.status < 200 || groupsResponse.status >= 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<radiantwave::db::ProclamationRecord> proclamations;
|
std::vector<radiantwave::db::ProclamationRecord> proclamations;
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||||
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
|
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
|
||||||
!parseCategoriesJson(categoriesResponse.body, categories)) {
|
!parseCategoriesJson(categoriesResponse.body, categories) ||
|
||||||
|
!parseGroupsJson(groupsResponse.body, groups)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db.replaceCategories(categories)) {
|
if (!db.replaceCategories(categories)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!db.replaceGroups(groups)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -190,10 +224,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 +244,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ bool parseProclamationsJson(const std::string& body,
|
|||||||
bool parseCategoriesJson(const std::string& body,
|
bool parseCategoriesJson(const std::string& body,
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
|
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
|
||||||
|
|
||||||
// Fetches proclamations + categories from the API and stores them in the DB,
|
// Parse the JSON payload returned by the groups endpoint.
|
||||||
// preserving any existing selection and forcing `defaultProclamationId` to
|
bool parseGroupsJson(const std::string& body,
|
||||||
// stay selected. Returns true only if the API sync succeeded; on failure the
|
std::vector<radiantwave::db::ProclamationGroupRecord>& out);
|
||||||
|
|
||||||
|
// Fetches proclamations + categories + groups from the API and stores them in
|
||||||
|
// the DB, preserving any existing selection and forcing `defaultProclamationId`
|
||||||
|
// to stay selected. Returns true only if the API sync succeeded; on failure the
|
||||||
// database is left untouched so the app can fall back to cached data.
|
// database is left untouched so the app can fall back to cached data.
|
||||||
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||||
const std::string& defaultProclamationId);
|
const std::string& defaultProclamationId);
|
||||||
@@ -37,13 +41,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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -147,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
|
|||||||
{
|
{
|
||||||
proclamations_ = state_->db.getAllProclamations();
|
proclamations_ = state_->db.getAllProclamations();
|
||||||
categories_ = state_->db.getAllCategories();
|
categories_ = state_->db.getAllCategories();
|
||||||
|
groups_ = state_->db.getAllGroups();
|
||||||
|
|
||||||
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
|
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
|
||||||
for (auto& p : proclamations_) {
|
for (auto& p : proclamations_) {
|
||||||
@@ -180,42 +182,55 @@ void ProclamationOptionsPage::rebuildItems()
|
|||||||
0, false, true, true,
|
0, false, true, true,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const auto& root : categories_) {
|
// Groups that actually exist, so a proclamation pointing at a group the
|
||||||
if (root.parent_id != 0) continue;
|
// portal has since removed falls back to the ungrouped bucket.
|
||||||
|
auto groupExists = [this](int groupId) {
|
||||||
|
return std::any_of(groups_.begin(), groups_.end(),
|
||||||
|
[groupId](const auto& g) { return g.id == groupId; });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& category : categories_) {
|
||||||
items_.push_back({
|
items_.push_back({
|
||||||
FlatItem::Type::RootCategory,
|
FlatItem::Type::Category,
|
||||||
std::to_string(root.id),
|
std::to_string(category.id),
|
||||||
root.name,
|
category.name,
|
||||||
0, false, false, false,
|
0, false, false, false,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const auto& sub : categories_) {
|
// Groups are tag-like and global. A group is shown under a category
|
||||||
if (sub.parent_id != root.id) continue;
|
// only when it has at least one proclamation in that category, so
|
||||||
|
// impossible pairings (e.g. "Physical / Prayers") never render.
|
||||||
items_.push_back({
|
for (const auto& group : groups_) {
|
||||||
FlatItem::Type::SubCategory,
|
std::vector<const radiantwave::db::ProclamationRecord*> members;
|
||||||
std::to_string(sub.id),
|
|
||||||
sub.name,
|
|
||||||
1, false, false, false,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const auto& p : proclamations_) {
|
for (const auto& p : proclamations_) {
|
||||||
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
||||||
if (p.category_id != sub.id) continue;
|
if (p.category_id != category.id || p.group_id != group.id) continue;
|
||||||
|
members.push_back(&p);
|
||||||
|
}
|
||||||
|
if (members.empty()) continue;
|
||||||
|
|
||||||
|
items_.push_back({
|
||||||
|
FlatItem::Type::Group,
|
||||||
|
std::to_string(group.id),
|
||||||
|
group.name,
|
||||||
|
1, true, false, false,
|
||||||
|
});
|
||||||
|
for (const auto* p : members) {
|
||||||
items_.push_back({
|
items_.push_back({
|
||||||
FlatItem::Type::Proclamation,
|
FlatItem::Type::Proclamation,
|
||||||
p.id,
|
p->id,
|
||||||
p.title,
|
p->title,
|
||||||
2, false, p.selected, false,
|
2, false, p->selected, false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proclamations assigned directly to the root category.
|
// Proclamations with no group (or a stale group id) sit directly under
|
||||||
|
// the category.
|
||||||
for (const auto& p : proclamations_) {
|
for (const auto& p : proclamations_) {
|
||||||
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
||||||
if (p.category_id != root.id) continue;
|
if (p.category_id != category.id) continue;
|
||||||
|
if (p.group_id != 0 && groupExists(p.group_id)) continue;
|
||||||
items_.push_back({
|
items_.push_back({
|
||||||
FlatItem::Type::Proclamation,
|
FlatItem::Type::Proclamation,
|
||||||
p.id,
|
p.id,
|
||||||
@@ -234,29 +249,29 @@ void ProclamationOptionsPage::rebuildVisible()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tree walk: track the currently-open root and sub category (items_ is
|
// Tree walk: track the currently-open category and group (items_ is
|
||||||
// already ordered root -> sub -> proclamation, so each category dominates
|
// already ordered category -> group -> proclamation, so each category
|
||||||
// the items until the next category of equal or shallower depth).
|
// dominates the items until the next category).
|
||||||
int currentRoot = -1; // items_ index of the current root category
|
int currentCategory = -1; // items_ index of the current category
|
||||||
int currentSub = -1; // items_ index of the current sub-category
|
int currentGroup = -1; // items_ index of the current group
|
||||||
|
|
||||||
for (size_t i = 0; i < items_.size(); ++i) {
|
for (size_t i = 0; i < items_.size(); ++i) {
|
||||||
const FlatItem& item = items_[i];
|
const FlatItem& item = items_[i];
|
||||||
|
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category) {
|
||||||
currentRoot = static_cast<int>(i);
|
currentCategory = static_cast<int>(i);
|
||||||
currentSub = -1;
|
currentGroup = -1;
|
||||||
visible_.push_back(i);
|
visible_.push_back(i);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::Group) {
|
||||||
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded;
|
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
|
||||||
if (rootOpen) {
|
if (categoryOpen) {
|
||||||
currentSub = static_cast<int>(i);
|
currentGroup = static_cast<int>(i);
|
||||||
visible_.push_back(i);
|
visible_.push_back(i);
|
||||||
} else {
|
} else {
|
||||||
currentSub = -1;
|
currentGroup = -1;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -265,9 +280,10 @@ void ProclamationOptionsPage::rebuildVisible()
|
|||||||
bool parentVisible = false;
|
bool parentVisible = false;
|
||||||
if (item.depth == 0) {
|
if (item.depth == 0) {
|
||||||
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 (currentCategory >= 0 && items_[currentCategory].expanded) {
|
||||||
parentVisible = (item.depth == 1) ||
|
parentVisible = (item.depth == 1) ||
|
||||||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded);
|
(item.depth == 2 && currentGroup >= 0 &&
|
||||||
|
items_[currentGroup].expanded);
|
||||||
}
|
}
|
||||||
if (parentVisible) visible_.push_back(i);
|
if (parentVisible) visible_.push_back(i);
|
||||||
}
|
}
|
||||||
@@ -282,7 +298,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::Category || item.type == FlatItem::Type::Group) {
|
||||||
item.expanded = !item.expanded;
|
item.expanded = !item.expanded;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
return;
|
return;
|
||||||
@@ -387,7 +403,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::Category || item.type == FlatItem::Type::Group) {
|
||||||
if (item.expanded) {
|
if (item.expanded) {
|
||||||
item.expanded = false;
|
item.expanded = false;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
@@ -399,7 +415,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::Category || item.type == FlatItem::Type::Group) {
|
||||||
if (!item.expanded) {
|
if (!item.expanded) {
|
||||||
item.expanded = true;
|
item.expanded = true;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
@@ -416,8 +432,8 @@ void ProclamationOptionsPage::moveToParent()
|
|||||||
int start = static_cast<int>(visible_[highlightIndex_]);
|
int start = static_cast<int>(visible_[highlightIndex_]);
|
||||||
for (int i = start - 1; i >= 0; --i) {
|
for (int i = start - 1; i >= 0; --i) {
|
||||||
const FlatItem& candidate = items_[i];
|
const FlatItem& candidate = items_[i];
|
||||||
if ((candidate.type == FlatItem::Type::RootCategory ||
|
if ((candidate.type == FlatItem::Type::Category ||
|
||||||
candidate.type == FlatItem::Type::SubCategory) &&
|
candidate.type == FlatItem::Type::Group) &&
|
||||||
candidate.depth == item.depth - 1) {
|
candidate.depth == item.depth - 1) {
|
||||||
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
||||||
if (visible_[vi] == static_cast<size_t>(i)) {
|
if (visible_[vi] == static_cast<size_t>(i)) {
|
||||||
@@ -525,8 +541,8 @@ 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::Category ||
|
||||||
item.type == FlatItem::Type::SubCategory) && item.expanded) {
|
item.type == FlatItem::Type::Group) && item.expanded) {
|
||||||
collapseCurrent();
|
collapseCurrent();
|
||||||
} else {
|
} else {
|
||||||
moveToParent();
|
moveToParent();
|
||||||
@@ -552,7 +568,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);
|
||||||
|
|
||||||
@@ -567,6 +583,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
SDL_Color white = {255, 255, 255, 255};
|
SDL_Color white = {255, 255, 255, 255};
|
||||||
SDL_Color gray = {170, 170, 170, 255};
|
SDL_Color gray = {170, 170, 170, 255};
|
||||||
SDL_Color categoryBlue = {100, 180, 255, 255};
|
SDL_Color categoryBlue = {100, 180, 255, 255};
|
||||||
|
SDL_Color groupBlue = {160, 210, 255, 255};
|
||||||
SDL_Color selectedOrange = {255, 170, 60, 255};
|
SDL_Color selectedOrange = {255, 170, 60, 255};
|
||||||
|
|
||||||
float titleY = 50.0f;
|
float titleY = 50.0f;
|
||||||
@@ -654,14 +671,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 +715,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::Category || item.type == FlatItem::Type::Group) {
|
||||||
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::Category || item.type == FlatItem::Type::Group) {
|
||||||
|
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,13 +751,15 @@ 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::Category || item.type == FlatItem::Type::Group) {
|
||||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||||
}
|
}
|
||||||
|
|
||||||
SDL_Color color = white;
|
SDL_Color color = white;
|
||||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::Category) {
|
||||||
color = categoryBlue;
|
color = categoryBlue;
|
||||||
|
} else if (item.type == FlatItem::Type::Group) {
|
||||||
|
color = groupBlue;
|
||||||
} else if (item.selected || item.isDefault) {
|
} else if (item.selected || item.isDefault) {
|
||||||
color = selectedOrange;
|
color = selectedOrange;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
struct FlatItem {
|
struct FlatItem {
|
||||||
enum class Type { RootCategory, SubCategory, Proclamation };
|
enum class Type { Category, Group, Proclamation };
|
||||||
|
|
||||||
Type type = Type::Proclamation;
|
Type type = Type::Proclamation;
|
||||||
std::string id; // category id or proclamation id
|
std::string id; // category id, group id, or proclamation id
|
||||||
std::string label; // display text
|
std::string label; // display text
|
||||||
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation
|
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
|
||||||
bool expanded = false; // categories only
|
bool expanded = false; // categories and groups only
|
||||||
bool selected = false; // proclamations only
|
bool selected = false; // proclamations only
|
||||||
bool isDefault = false; // the always-on top-level proclamation
|
bool isDefault = false; // the always-on top-level proclamation
|
||||||
};
|
};
|
||||||
@@ -67,6 +67,7 @@ private:
|
|||||||
AppState* state_ = nullptr;
|
AppState* state_ = nullptr;
|
||||||
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
|
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
|
||||||
std::vector<FlatItem> items_;
|
std::vector<FlatItem> items_;
|
||||||
std::vector<size_t> visible_; // indices into items_, in display order
|
std::vector<size_t> visible_; // indices into items_, in display order
|
||||||
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
|
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
|
||||||
|
|||||||
+18
-16
@@ -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));
|
||||||
|
|||||||
+10
-10
@@ -24,33 +24,33 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startup walkthrough. Evaluates the prerequisites and pushes the first
|
// Startup walkthrough. Evaluates the prerequisites and pushes the first
|
||||||
// missing step's page (with provisioningMode set), or Home when everything
|
// missing step's page (with provisioningMode set), or Home when everything
|
||||||
// is satisfied. Home is always the end of the walkthrough.
|
// is satisfied. Home is always the end of the walkthrough.
|
||||||
void begin(AppState& state);
|
void begin(AppState &state);
|
||||||
|
|
||||||
// Called by a walkthrough page when the user finishes or exits it. Pops the
|
// Called by a walkthrough page when the user finishes or exits it. Pops the
|
||||||
// current page, re-evaluates the prerequisites, and pushes the next missing
|
// current page, re-evaluates the prerequisites, and pushes the next missing
|
||||||
// step's page (or Home). Internet is only re-probed when it was still
|
// step's page (or Home). Internet is only re-probed when it was still
|
||||||
// missing, so completing a step never blocks on a redundant check.
|
// missing, so completing a step never blocks on a redundant check.
|
||||||
void advance(AppState& state);
|
void advance(AppState &state);
|
||||||
|
|
||||||
} // namespace radiantwave::provisioning
|
} // namespace radiantwave::provisioning
|
||||||
|
|||||||
+206
-272
@@ -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,36 +11,59 @@
|
|||||||
#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;
|
||||||
constexpr float kVelocityMax = 3500.0f;
|
constexpr float kVelocityMax = 3500.0f;
|
||||||
|
|
||||||
} // 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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+36
-24
@@ -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 ─────────────────────────────────────────
|
||||||
@@ -147,23 +142,32 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
|
|||||||
EXPECT_EQ(out[1].category_id, 32);
|
EXPECT_EQ(out[1].category_id, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
|
TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
|
||||||
const std::string body =
|
const std::string categoriesBody =
|
||||||
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
|
R"([{"id":4,"name":"Spiritual","sortOrder":0},
|
||||||
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
|
{"id":5,"name":"General","sortOrder":1}])";
|
||||||
|
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> out;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
|
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
|
||||||
ASSERT_EQ(out.size(), 2u);
|
ASSERT_EQ(categories.size(), 2u);
|
||||||
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
|
EXPECT_EQ(categories[0].id, 4);
|
||||||
EXPECT_EQ(out[1].parent_id, 4);
|
EXPECT_EQ(categories[1].id, 5);
|
||||||
|
|
||||||
|
const std::string groupsBody =
|
||||||
|
R"([{"id":9,"name":"Prayers","sortOrder":0}])";
|
||||||
|
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||||
|
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsBody, groups));
|
||||||
|
ASSERT_EQ(groups.size(), 1u);
|
||||||
|
EXPECT_EQ(groups[0].id, 9);
|
||||||
|
EXPECT_EQ(groups[0].name, "Prayers");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
|
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
|
||||||
|
|
||||||
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 +184,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,26 +197,34 @@ 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);
|
||||||
|
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
|
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
|
||||||
EXPECT_GE(categories.size(), 20u);
|
EXPECT_FALSE(categories.empty());
|
||||||
|
|
||||||
|
HttpResponse groupsResponse = httpGet(PATH_GROUPS);
|
||||||
|
ASSERT_TRUE(groupsResponse.transportOk);
|
||||||
|
ASSERT_EQ(groupsResponse.status, 200);
|
||||||
|
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||||
|
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsResponse.body, groups));
|
||||||
|
EXPECT_FALSE(groups.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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 +232,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 +247,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 +257,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 +272,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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+100
-4
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
|
|||||||
DbFixture fx("test_rw_db_cats.db");
|
DbFixture fx("test_rw_db_cats.db");
|
||||||
|
|
||||||
std::vector<ProclamationCategoryRecord> cats;
|
std::vector<ProclamationCategoryRecord> cats;
|
||||||
cats.push_back({3, "Root", 0, 1});
|
cats.push_back({3, "Spiritual", 1});
|
||||||
cats.push_back({7, "Child", 3, 2});
|
cats.push_back({7, "Physical", 2});
|
||||||
|
|
||||||
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
||||||
|
|
||||||
auto all = fx.db.getAllCategories();
|
auto all = fx.db.getAllCategories();
|
||||||
ASSERT_EQ(all.size(), 2u);
|
ASSERT_EQ(all.size(), 2u);
|
||||||
EXPECT_EQ(all[0].id, 3);
|
EXPECT_EQ(all[0].id, 3);
|
||||||
EXPECT_EQ(all[0].parent_id, 0);
|
EXPECT_EQ(all[0].name, "Spiritual");
|
||||||
|
EXPECT_EQ(all[0].sort_order, 1);
|
||||||
EXPECT_EQ(all[1].id, 7);
|
EXPECT_EQ(all[1].id, 7);
|
||||||
EXPECT_EQ(all[1].parent_id, 3);
|
EXPECT_EQ(all[1].name, "Physical");
|
||||||
|
EXPECT_EQ(all[1].sort_order, 2);
|
||||||
|
|
||||||
// Replacing wipes previous categories.
|
// Replacing wipes previous categories.
|
||||||
ASSERT_TRUE(fx.db.replaceCategories({}));
|
ASSERT_TRUE(fx.db.replaceCategories({}));
|
||||||
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
|
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(DatabaseManagerTest, ReplaceAndReadGroups) {
|
||||||
|
DbFixture fx("test_rw_db_groups.db");
|
||||||
|
|
||||||
|
std::vector<ProclamationGroupRecord> groups;
|
||||||
|
groups.push_back({10, "Prayers", 2});
|
||||||
|
groups.push_back({4, "Affirmations", 1});
|
||||||
|
|
||||||
|
ASSERT_TRUE(fx.db.replaceGroups(groups));
|
||||||
|
|
||||||
|
auto all = fx.db.getAllGroups();
|
||||||
|
ASSERT_EQ(all.size(), 2u);
|
||||||
|
// Ordered by sort_order, not by id.
|
||||||
|
EXPECT_EQ(all[0].id, 4);
|
||||||
|
EXPECT_EQ(all[0].name, "Affirmations");
|
||||||
|
EXPECT_EQ(all[1].id, 10);
|
||||||
|
EXPECT_EQ(all[1].name, "Prayers");
|
||||||
|
|
||||||
|
ASSERT_TRUE(fx.db.replaceGroups({}));
|
||||||
|
EXPECT_EQ(fx.db.getAllGroups().size(), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatabaseManagerTest, MigratesLegacySubCategoriesToGroups) {
|
||||||
|
const std::string path =
|
||||||
|
(std::filesystem::temp_directory_path() / "test_rw_db_migrate_v1.db").string();
|
||||||
|
if (std::filesystem::exists(path)) {
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a pre-v2 database by hand: a two-level category tree and a
|
||||||
|
// proclamation selected inside a sub-category.
|
||||||
|
{
|
||||||
|
sqlite3* raw = nullptr;
|
||||||
|
ASSERT_EQ(sqlite3_open(path.c_str(), &raw), SQLITE_OK);
|
||||||
|
const char* legacy =
|
||||||
|
"CREATE TABLE proclamations ("
|
||||||
|
" id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL,"
|
||||||
|
" solfeggio_freq_hz REAL DEFAULT 528.0, category TEXT DEFAULT 'general',"
|
||||||
|
" is_active INTEGER DEFAULT 1, selected INTEGER DEFAULT 0,"
|
||||||
|
" category_id INTEGER DEFAULT 0, solfeggio_frequency_id INTEGER DEFAULT 0,"
|
||||||
|
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP);"
|
||||||
|
"CREATE TABLE proclamation_categories ("
|
||||||
|
" id INTEGER PRIMARY KEY, name TEXT NOT NULL,"
|
||||||
|
" parent_id INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0);"
|
||||||
|
"INSERT INTO proclamation_categories (id, name, parent_id, sort_order)"
|
||||||
|
" VALUES (3,'Spiritual',0,1),(7,'Inner',3,2);"
|
||||||
|
"INSERT INTO proclamations (id,title,content,category_id,solfeggio_frequency_id,selected)"
|
||||||
|
" VALUES ('p1','Peace','x',7,2,1),('p2','Root-level','y',3,5,0);";
|
||||||
|
char* err = nullptr;
|
||||||
|
int rc = sqlite3_exec(raw, legacy, nullptr, nullptr, &err);
|
||||||
|
if (err) sqlite3_free(err);
|
||||||
|
sqlite3_close(raw);
|
||||||
|
ASSERT_EQ(rc, SQLITE_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseManager db;
|
||||||
|
ASSERT_TRUE(db.initialize(path));
|
||||||
|
|
||||||
|
// Each former sub-category becomes a group; only the root remains a category.
|
||||||
|
auto groups = db.getAllGroups();
|
||||||
|
ASSERT_EQ(groups.size(), 1u);
|
||||||
|
EXPECT_EQ(groups[0].id, 7);
|
||||||
|
EXPECT_EQ(groups[0].name, "Inner");
|
||||||
|
|
||||||
|
auto cats = db.getAllCategories();
|
||||||
|
ASSERT_EQ(cats.size(), 1u);
|
||||||
|
EXPECT_EQ(cats[0].id, 3);
|
||||||
|
EXPECT_EQ(cats[0].name, "Spiritual");
|
||||||
|
|
||||||
|
// Proclamations pointing at the old sub-category are remapped to the root
|
||||||
|
// category + the new group; selection is preserved.
|
||||||
|
auto procs = db.getAllProclamations();
|
||||||
|
ASSERT_EQ(procs.size(), 2u);
|
||||||
|
for (const auto& p : procs) {
|
||||||
|
EXPECT_EQ(p.category_id, 3) << "proclamation " << p.id;
|
||||||
|
if (p.id == "p1") {
|
||||||
|
EXPECT_EQ(p.group_id, 7);
|
||||||
|
EXPECT_TRUE(p.selected);
|
||||||
|
} else if (p.id == "p2") {
|
||||||
|
EXPECT_EQ(p.group_id, 0);
|
||||||
|
EXPECT_FALSE(p.selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-running the migration is a no-op (user_version gate + idempotent SQL).
|
||||||
|
db.close();
|
||||||
|
ASSERT_TRUE(db.initialize(path));
|
||||||
|
EXPECT_EQ(db.getAllGroups().size(), 1u);
|
||||||
|
EXPECT_EQ(db.getAllCategories().size(), 1u);
|
||||||
|
EXPECT_EQ(db.getAllProclamations().size(), 2u);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
|
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
|
||||||
DbFixture fx("test_rw_db_audio.db");
|
DbFixture fx("test_rw_db_audio.db");
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ using namespace radiantwave::sync;
|
|||||||
|
|
||||||
TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||||
const std::string body =
|
const std::string body =
|
||||||
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2},
|
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
|
||||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
|
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
|
||||||
{"notAnObject":true}])";
|
{"notAnObject":true}])";
|
||||||
|
|
||||||
std::vector<ProclamationRecord> out;
|
std::vector<ProclamationRecord> out;
|
||||||
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
|||||||
EXPECT_EQ(out[0].title, "Peace");
|
EXPECT_EQ(out[0].title, "Peace");
|
||||||
EXPECT_EQ(out[0].content, "I am peace.");
|
EXPECT_EQ(out[0].content, "I am peace.");
|
||||||
EXPECT_EQ(out[0].category_id, 3);
|
EXPECT_EQ(out[0].category_id, 3);
|
||||||
|
EXPECT_EQ(out[0].group_id, 7);
|
||||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
|
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
|
||||||
|
|
||||||
EXPECT_EQ(out[1].id, "p2");
|
EXPECT_EQ(out[1].id, "p2");
|
||||||
EXPECT_EQ(out[1].title, "Joy & Calm");
|
EXPECT_EQ(out[1].title, "Joy & Calm");
|
||||||
EXPECT_EQ(out[1].content, "text");
|
EXPECT_EQ(out[1].content, "text");
|
||||||
EXPECT_EQ(out[1].category_id, 0);
|
EXPECT_EQ(out[1].category_id, 0);
|
||||||
|
EXPECT_EQ(out[1].group_id, 0);
|
||||||
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
|
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||||
|
// Categories are flat now; a legacy parentId field is simply ignored.
|
||||||
const std::string body =
|
const std::string body =
|
||||||
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
|
R"([{"id":3,"name":"Spiritual","sortOrder":1},
|
||||||
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
|
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
|
||||||
|
|
||||||
std::vector<ProclamationCategoryRecord> out;
|
std::vector<ProclamationCategoryRecord> out;
|
||||||
EXPECT_TRUE(parseCategoriesJson(body, out));
|
EXPECT_TRUE(parseCategoriesJson(body, out));
|
||||||
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
|||||||
|
|
||||||
EXPECT_EQ(out[0].id, 3);
|
EXPECT_EQ(out[0].id, 3);
|
||||||
EXPECT_EQ(out[0].name, "Spiritual");
|
EXPECT_EQ(out[0].name, "Spiritual");
|
||||||
EXPECT_EQ(out[0].parent_id, 0);
|
|
||||||
EXPECT_EQ(out[0].sort_order, 1);
|
EXPECT_EQ(out[0].sort_order, 1);
|
||||||
|
|
||||||
EXPECT_EQ(out[1].id, 7);
|
EXPECT_EQ(out[1].id, 7);
|
||||||
EXPECT_EQ(out[1].name, "Inner");
|
EXPECT_EQ(out[1].name, "Physical");
|
||||||
EXPECT_EQ(out[1].parent_id, 3);
|
|
||||||
EXPECT_EQ(out[1].sort_order, 2);
|
EXPECT_EQ(out[1].sort_order, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(ProclamationSyncTest, ParseGroupsJson) {
|
||||||
|
const std::string body =
|
||||||
|
R"([{"id":5,"name":"Prayers","sortOrder":0},
|
||||||
|
{"id":8,"name":"Affirmations","sortOrder":3}])";
|
||||||
|
|
||||||
|
std::vector<ProclamationGroupRecord> out;
|
||||||
|
EXPECT_TRUE(parseGroupsJson(body, out));
|
||||||
|
ASSERT_EQ(out.size(), 2u);
|
||||||
|
|
||||||
|
EXPECT_EQ(out[0].id, 5);
|
||||||
|
EXPECT_EQ(out[0].name, "Prayers");
|
||||||
|
EXPECT_EQ(out[0].sort_order, 0);
|
||||||
|
|
||||||
|
EXPECT_EQ(out[1].id, 8);
|
||||||
|
EXPECT_EQ(out[1].name, "Affirmations");
|
||||||
|
EXPECT_EQ(out[1].sort_order, 3);
|
||||||
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, RejectMalformedJson) {
|
TEST(ProclamationSyncTest, RejectMalformedJson) {
|
||||||
std::vector<ProclamationRecord> procs;
|
std::vector<ProclamationRecord> procs;
|
||||||
std::vector<ProclamationCategoryRecord> cats;
|
std::vector<ProclamationCategoryRecord> cats;
|
||||||
|
std::vector<ProclamationGroupRecord> groups;
|
||||||
|
|
||||||
EXPECT_FALSE(parseProclamationsJson("not json", procs));
|
EXPECT_FALSE(parseProclamationsJson("not json", procs));
|
||||||
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
|
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
|
||||||
@@ -69,6 +89,10 @@ TEST(ProclamationSyncTest, RejectMalformedJson) {
|
|||||||
// Non-numeric ids are tolerated (row skipped), not fatal.
|
// Non-numeric ids are tolerated (row skipped), not fatal.
|
||||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
|
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
|
||||||
EXPECT_EQ(cats.size(), 0u);
|
EXPECT_EQ(cats.size(), 0u);
|
||||||
|
|
||||||
|
EXPECT_FALSE(parseGroupsJson("garbage", groups));
|
||||||
|
EXPECT_TRUE(parseGroupsJson(R"([{"id":"x"}])", groups));
|
||||||
|
EXPECT_EQ(groups.size(), 0u);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
||||||
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
|||||||
EXPECT_EQ(procs[0].id, "ok");
|
EXPECT_EQ(procs[0].id, "ok");
|
||||||
|
|
||||||
std::vector<ProclamationCategoryRecord> cats;
|
std::vector<ProclamationCategoryRecord> cats;
|
||||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","parentId":0}])", cats));
|
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","sortOrder":1}])", cats));
|
||||||
ASSERT_EQ(cats.size(), 1u);
|
ASSERT_EQ(cats.size(), 1u);
|
||||||
EXPECT_EQ(cats[0].id, 9);
|
EXPECT_EQ(cats[0].id, 9);
|
||||||
|
|
||||||
|
std::vector<ProclamationGroupRecord> groups;
|
||||||
|
EXPECT_TRUE(parseGroupsJson(R"([{"id":0,"name":"Bad"},{"id":4,"name":"Good","sortOrder":1}])", groups));
|
||||||
|
ASSERT_EQ(groups.size(), 1u);
|
||||||
|
EXPECT_EQ(groups[0].id, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, StripHtmlEntities) {
|
TEST(ProclamationSyncTest, StripHtmlEntities) {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
|
|
||||||
Radiant Wave
|
|
||||||
User Manual
|
|
||||||
Version 1.0
|
|
||||||
|
|
||||||
Intro
|
|
||||||
Some thank you message and introduction to Radiant Waves.
|
|
||||||
|
|
||||||
Table of Contents
|
|
||||||
|
|
||||||
Setup
|
|
||||||
Connect to WiFi
|
|
||||||
Connection to the internet is required for Radiant Wave to operate.
|
|
||||||
|
|
||||||
Wifi screen appears first
|
|
||||||
UP/DOWN to navigate through detected WiFi networks
|
|
||||||
Enter to select one
|
|
||||||
Password prompt if applicable
|
|
||||||
|
|
||||||
I still need to test entering an incorrect WiFi password on a kiosk machine
|
|
||||||
Check for crashes or correct error messaging
|
|
||||||
|
|
||||||
Email Address
|
|
||||||
|
|
||||||
The next screen you’ll be presented with is the email address prompt.
|
|
||||||
|
|
||||||
When you purchased your RadiantWave system, you should have received an email at the address you provided during your purchase containing your license key.
|
|
||||||
|
|
||||||
Enter the email address where you received your license key here.
|
|
||||||
|
|
||||||
Note that you will not be able to enter an invalid email address, but you will be able to enter the incorrect email address. You will see how to change your email address later.
|
|
||||||
|
|
||||||
License Key
|
|
||||||
|
|
||||||
Grab your license key from your email, now is the time to use it!
|
|
||||||
|
|
||||||
A few notes on the License Key prompt:
|
|
||||||
You don't have to hold SHIFT or enable CAPS LOCK, because that's already done for you
|
|
||||||
You won't have to enter the dashes "-", as those are entered for you
|
|
||||||
You CAN enter an incorrect license key
|
|
||||||
You CAN fix it later
|
|
||||||
|
|
||||||
Same steps as the email address, just type out your license key and press enter.
|
|
||||||
|
|
||||||
The Main Menu
|
|
||||||
|
|
||||||
Now you're in! This is the main menu, and it doesn't do a whole lot. It's only purpose is to display some system information for you to quickly reference when needed, such as the email address and license key you currently have saved or the current system version you have running. Other than that, the Main Menu's only purpose is to provide navigation between the system settings, and the main attraction itself.
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
wifi
|
|
||||||
email
|
|
||||||
license
|
|
||||||
main menu
|
|
||||||
settings
|
|
||||||
time zone
|
|
||||||
date
|
|
||||||
logs
|
|
||||||
audio options
|
|
||||||
proclamations
|
|
||||||
play
|
|
||||||
updates
|
|
||||||
Reference in New Issue
Block a user