Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a043ec84d8 | ||
|
|
9fead922c4 | ||
|
|
2b1040d3db | ||
|
|
cf991c34af | ||
|
|
b56601bb93 | ||
|
|
72ba15891d | ||
|
|
356629a5a1 | ||
|
|
6019be6359 | ||
|
|
fee31c87b8 | ||
|
|
5369532ab4 | ||
|
|
42b915bd25 | ||
|
|
d60a8bd7fb | ||
|
|
ca7b8b7e6f | ||
|
|
bf2d4d1f89 | ||
|
|
2917d33866 | ||
|
|
3f6201182e | ||
|
|
d84307bd0e | ||
|
|
43cd93bbd7 | ||
|
|
92ba9f39fb |
@@ -66,3 +66,17 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ name: build-main
|
||||
|
||||
on:
|
||||
# Manual only. Dispatch against main: build, run the offline test suite,
|
||||
# publish to the release Attic cache.
|
||||
# publish to the release Attic cache and live-update manifest.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -74,3 +74,17 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -102,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass.
|
||||
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
|
||||
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-1
@@ -112,7 +112,14 @@ add_executable(test_provisioning
|
||||
tests/test_provisioning.cpp
|
||||
)
|
||||
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
|
||||
tests/test_assets.cpp
|
||||
|
||||
@@ -484,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
|
||||
| ------- | --------- |
|
||||
| app_config | Key-value store: license, email, audio device, volume |
|
||||
| logs | Application logs |
|
||||
| proclamations | Synced proclamation text |
|
||||
| proclamation_categories | Category metadata |
|
||||
| proclamations | Synced proclamation text (category_id + group_id) |
|
||||
| proclamation_categories | Flat category metadata |
|
||||
| proclamation_groups | Tag-like groups that organize proclamations within a category |
|
||||
| solfeggio_frequencies | 9 frequency presets |
|
||||
| audio_files | Downloaded audio files |
|
||||
|
||||
|
||||
@@ -112,6 +112,18 @@ in
|
||||
export WLR_RENDERER=pixman
|
||||
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
|
||||
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.
|
||||
+388
-31
@@ -32,6 +32,7 @@ namespace radiantwave::audio
|
||||
if (lower.find("analog") != std::string::npos ||
|
||||
lower.find("built-in") != std::string::npos ||
|
||||
lower.find("builtin") != std::string::npos ||
|
||||
lower.find("virtual") != std::string::npos ||
|
||||
lower.find("realtek") != std::string::npos ||
|
||||
lower.find("hda") != std::string::npos ||
|
||||
lower.find("speaker") != std::string::npos ||
|
||||
@@ -76,7 +77,6 @@ namespace radiantwave::audio
|
||||
enabled_ = cfg.audio.enabled;
|
||||
currentDevice_ = cfg.audio.output_device;
|
||||
currentTrack_ = cfg.audio.current_track;
|
||||
|
||||
if (!MIX_Init())
|
||||
{
|
||||
std::string err = SDL_GetError();
|
||||
@@ -86,17 +86,68 @@ namespace radiantwave::audio
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create mixer device with default audio device
|
||||
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
|
||||
// Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
|
||||
// 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_)
|
||||
{
|
||||
std::string err = SDL_GetError();
|
||||
logger::Logger::instance().warning("Audio",
|
||||
"Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
|
||||
"Could not open audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
|
||||
initialized_ = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
currentDeviceId_ = targetId;
|
||||
|
||||
slots_[0].track = MIX_CreateTrack(mixer_);
|
||||
slots_[1].track = MIX_CreateTrack(mixer_);
|
||||
initialized_ = true;
|
||||
@@ -135,6 +186,40 @@ namespace radiantwave::audio
|
||||
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()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -181,18 +266,62 @@ namespace radiantwave::audio
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
std::string raw = name ? name : "";
|
||||
list.push_back({raw, mapToFriendlyDeviceName(raw)});
|
||||
AudioDeviceItem item;
|
||||
item.raw_name = raw;
|
||||
item.friendly_name = mapToFriendlyDeviceName(raw);
|
||||
item.id = devices[i];
|
||||
list.push_back(item);
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
else
|
||||
{
|
||||
list.push_back({"", "Built-In Speakers"});
|
||||
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;
|
||||
}
|
||||
|
||||
std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id)
|
||||
{
|
||||
if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||
std::string found;
|
||||
if (devices)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
if (devices[i] == id)
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
found = name ? name : "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
void AudioManager::setVolume(int volume_0_to_100)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -253,14 +382,76 @@ namespace radiantwave::audio
|
||||
bool AudioManager::switchDevice(const std::string &raw_device_name)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
currentDevice_ = raw_device_name;
|
||||
|
||||
logger::Logger::instance().debug("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
|
||||
// Resolve the config's raw name to an SDL device id so the mixer is
|
||||
// actually reopened on the requested device (""/unresolvable = default).
|
||||
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
if (!raw_device_name.empty())
|
||||
{
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||
if (devices)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
if (name && raw_device_name == name)
|
||||
{
|
||||
targetId = devices[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
}
|
||||
|
||||
return switchDeviceById(targetId);
|
||||
}
|
||||
|
||||
bool AudioManager::switchDeviceById(SDL_AudioDeviceID id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
// 0 is the synthetic "default / built-in" entry from getPlaybackDevices().
|
||||
if (id == 0)
|
||||
{
|
||||
id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
}
|
||||
|
||||
// No mixer yet: just record the choice for initialize().
|
||||
if (!mixer_ || !initialized_)
|
||||
{
|
||||
currentDeviceId_ = id;
|
||||
currentDevice_ = resolvePlaybackDeviceName(id);
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
|
||||
{ cfg.audio.output_device = name; });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!recreateMixerOnDevice(id))
|
||||
{
|
||||
logger::Logger::instance().error("Audio",
|
||||
"Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id)));
|
||||
return false;
|
||||
}
|
||||
|
||||
currentDeviceId_ = id;
|
||||
// After recreation SDL may have normalized the id (e.g. default).
|
||||
if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
|
||||
{
|
||||
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
}
|
||||
currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_);
|
||||
|
||||
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_));
|
||||
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([raw_device_name](config::AppConfig &cfg)
|
||||
{ cfg.audio.output_device = raw_device_name; });
|
||||
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
|
||||
{ cfg.audio.output_device = name; });
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -272,6 +463,150 @@ namespace radiantwave::audio
|
||||
return currentDevice_;
|
||||
}
|
||||
|
||||
SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return currentDeviceId_;
|
||||
}
|
||||
|
||||
bool AudioManager::handleDeviceEvent(const SDL_Event &event)
|
||||
{
|
||||
if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED &&
|
||||
event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!mixer_ || !initialized_)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only playback device events are relevant to the active output.
|
||||
if (event.adevice.recording)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const SDL_AudioDeviceID changedId = event.adevice.which;
|
||||
|
||||
// If a specific device (not the default) was removed, fail over to the
|
||||
// system default so playback keeps going on whatever remains.
|
||||
if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED &&
|
||||
currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK &&
|
||||
changedId == currentDeviceId_)
|
||||
{
|
||||
logger::Logger::instance().warning("Audio",
|
||||
"Active audio output device removed; failing over to the system default");
|
||||
if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK))
|
||||
{
|
||||
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
currentDevice_ = "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A device arrived or a non-active device left: nothing to remap, but
|
||||
// the audio settings page refreshes its list on its own timer.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be called with mutex_ held. 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)
|
||||
{
|
||||
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
|
||||
@@ -322,7 +657,7 @@ namespace radiantwave::audio
|
||||
slot.fadeInDuration = seconds;
|
||||
slot.fadeInElapsed = 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)");
|
||||
}
|
||||
|
||||
@@ -336,7 +671,7 @@ namespace radiantwave::audio
|
||||
return false;
|
||||
|
||||
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
|
||||
// restart and is used whenever dynamic audio is turned off. Dynamic
|
||||
@@ -373,10 +708,11 @@ namespace radiantwave::audio
|
||||
slot.fadeInElapsed = 0.0f;
|
||||
slot.fadeOutRemaining = 0.0f;
|
||||
MIX_SetTrackGain(slot.track, 1.0f);
|
||||
if (!MIX_TrackPlaying(slot.track))
|
||||
{
|
||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
||||
}
|
||||
// Always restart: MIX_PlayTrack() re-starts a playing track too, so
|
||||
// this cancels any in-flight stop-fade inherited from a quick
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -411,10 +747,14 @@ namespace radiantwave::audio
|
||||
if (!loadTrackInto(otherSlot, wav_filename))
|
||||
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_];
|
||||
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)));
|
||||
active.fadeOutRemaining = fade;
|
||||
}
|
||||
@@ -442,6 +782,10 @@ namespace radiantwave::audio
|
||||
{
|
||||
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)));
|
||||
slot.fadeOutRemaining = fade;
|
||||
faded = true;
|
||||
@@ -476,7 +820,29 @@ namespace radiantwave::audio
|
||||
{
|
||||
TrackSlot &slot = slots_[i];
|
||||
if (!slot.track)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -493,21 +859,12 @@ namespace radiantwave::audio
|
||||
// Keep the active track at full per-track gain; the mixer gain
|
||||
// carries the user's volume / mute state.
|
||||
MIX_SetTrackGain(slot.track, 1.0f);
|
||||
}
|
||||
|
||||
if (slot.fadeOutRemaining > 0.0f)
|
||||
{
|
||||
slot.fadeOutRemaining -= dt;
|
||||
if (slot.fadeOutRemaining <= 0.0f)
|
||||
// Self-heal: playback must continue indefinitely, so restart a
|
||||
// track that stopped unexpectedly (there is no pause state and
|
||||
// nothing else should legitimately stop the active track).
|
||||
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
|
||||
{
|
||||
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;
|
||||
}
|
||||
playTrackLooping(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "config/config_manager.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
|
||||
struct AudioDeviceItem {
|
||||
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
|
||||
std::string friendly_name; // e.g. "Built-In Speakers"
|
||||
SDL_AudioDeviceID id = 0; // SDL playback device id (0 = synthetic fallback)
|
||||
};
|
||||
|
||||
// Maps raw technical audio device strings to user-friendly labels
|
||||
@@ -51,7 +53,24 @@ public:
|
||||
|
||||
// Audio device selection
|
||||
bool switchDevice(const std::string& raw_device_name);
|
||||
bool switchDeviceById(SDL_AudioDeviceID id);
|
||||
std::string getCurrentDevice() const;
|
||||
SDL_AudioDeviceID getCurrentDeviceId() const;
|
||||
|
||||
// Handle an SDL audio device event (SDL_EVENT_AUDIO_DEVICE_ADDED / REMOVED).
|
||||
// Returns true if the active device was affected and the mixer was recreated.
|
||||
bool handleDeviceEvent(const SDL_Event& event);
|
||||
|
||||
// Pure helper (no SDL audio open): find the playback device with the given
|
||||
// SDL id, returning its raw name ("" when not found / id invalid). Exposed
|
||||
// for tests; the real lookup is through getPlaybackDevices().
|
||||
static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id);
|
||||
|
||||
// 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
|
||||
bool playTrack(const std::string& wav_filename);
|
||||
@@ -84,6 +103,21 @@ private:
|
||||
void startFadeIn(int slotIndex, float seconds);
|
||||
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;
|
||||
db::DatabaseManager* db_ = nullptr;
|
||||
|
||||
@@ -91,6 +125,7 @@ private:
|
||||
bool enabled_ = true;
|
||||
int volume_ = 80; // 0 - 100
|
||||
std::string currentDevice_ = "";
|
||||
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
std::string currentTrack_ = "";
|
||||
|
||||
MIX_Mixer* mixer_ = nullptr;
|
||||
|
||||
@@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
|
||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||
if (!devices_.empty()) {
|
||||
currentDeviceIndex_ = modalSelectedIndex_;
|
||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
||||
applyDeviceSelection();
|
||||
}
|
||||
modalState_ = ModalState::None;
|
||||
} else if (modalState_ == ModalState::TrackSelector) {
|
||||
@@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
|
||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||
if (!devices_.empty()) {
|
||||
currentDeviceIndex_ = modalSelectedIndex_;
|
||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
||||
applyDeviceSelection();
|
||||
}
|
||||
modalState_ = ModalState::None;
|
||||
} else if (modalState_ == ModalState::TrackSelector) {
|
||||
@@ -280,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() {
|
||||
}
|
||||
}
|
||||
|
||||
void AudioSettingsPage::applyDeviceSelection() {
|
||||
if (!state_) return;
|
||||
if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast<int>(devices_.size())) return;
|
||||
|
||||
const bool ok = state_->audio.switchDeviceById(devices_[currentDeviceIndex_].id);
|
||||
if (ok) {
|
||||
statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name;
|
||||
} else {
|
||||
statusMessage_ = "Device unavailable - kept the current output";
|
||||
}
|
||||
refreshDeviceAndTrackLists();
|
||||
}
|
||||
|
||||
void AudioSettingsPage::enableDynamicAudio() {
|
||||
if (!state_) return;
|
||||
state_->config.update([](radiantwave::config::AppConfig& cfg) {
|
||||
@@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
|
||||
if (modalState_ == ModalState::DeviceSelector) {
|
||||
if (!devices_.empty()) {
|
||||
currentDeviceIndex_ = modalSelectedIndex_;
|
||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
||||
applyDeviceSelection();
|
||||
}
|
||||
} else if (modalState_ == ModalState::TrackSelector) {
|
||||
if (modalSelectedIndex_ == 0) {
|
||||
@@ -518,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
|
||||
}
|
||||
|
||||
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);
|
||||
if (deviceText_.texture) {
|
||||
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
|
||||
|
||||
@@ -36,6 +36,10 @@ private:
|
||||
void enableDynamicAudio();
|
||||
void selectTrack(int trackIdx);
|
||||
|
||||
// Applies the currently selected device (devices_[currentDeviceIndex_]) via
|
||||
// the audio manager and records user-facing success/failure feedback.
|
||||
void applyDeviceSelection();
|
||||
|
||||
AppState* state_ = nullptr;
|
||||
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
|
||||
|
||||
|
||||
+153
-18
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
|
||||
is_active INTEGER DEFAULT 1,
|
||||
selected INTEGER DEFAULT 0,
|
||||
category_id INTEGER DEFAULT 0,
|
||||
group_id INTEGER DEFAULT 0,
|
||||
solfeggio_frequency_id INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
|
||||
CREATE TABLE IF NOT EXISTS proclamation_categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
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
|
||||
);
|
||||
|
||||
@@ -182,6 +188,7 @@ bool DatabaseManager::runMigrations() {
|
||||
// Back-fill columns for databases created before these fields existed.
|
||||
ensureColumn("proclamations", "selected", "selected 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("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
||||
// 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 "
|
||||
"('subscription_level_id','subscription_level_name','subscription_level_description');");
|
||||
}
|
||||
if (ok) {
|
||||
ok = migrateSubCategoriesToGroups();
|
||||
}
|
||||
if (ok) {
|
||||
seedColorPalettes();
|
||||
}
|
||||
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() {
|
||||
static const char* kSeedSql =
|
||||
"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, 7, record.selected ? 1 : 0);
|
||||
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) {
|
||||
@@ -345,12 +413,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
||||
if (!db_) return false;
|
||||
|
||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||
"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;
|
||||
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.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -384,7 +454,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
|
||||
if (!db_) return result;
|
||||
|
||||
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;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -414,8 +484,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
|
||||
"VALUES (?, ?, ?, ?);";
|
||||
const char* insSql = "INSERT INTO proclamation_categories (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);
|
||||
@@ -425,8 +495,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
||||
for (const auto& cat : records) {
|
||||
sqlite3_bind_int(stmt, 1, cat.id);
|
||||
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 3, cat.parent_id);
|
||||
sqlite3_bind_int(stmt, 4, cat.sort_order);
|
||||
sqlite3_bind_int(stmt, 3, 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) {
|
||||
ok = false;
|
||||
break;
|
||||
@@ -499,12 +611,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
|
||||
// Upsert each proclamation, preserving the stashed selection state.
|
||||
if (ok) {
|
||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||
"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;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -555,7 +667,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
|
||||
if (!db_) return result;
|
||||
|
||||
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;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return result;
|
||||
@@ -574,7 +686,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
||||
std::vector<ProclamationCategoryRecord> 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;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -585,8 +697,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
||||
ProclamationCategoryRecord rec;
|
||||
rec.id = sqlite3_column_int(stmt, 0);
|
||||
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, 3);
|
||||
rec.sort_order = sqlite3_column_int(stmt, 2);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,23 @@ struct ProclamationRecord {
|
||||
bool is_active = true;
|
||||
bool selected = false;
|
||||
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
|
||||
};
|
||||
|
||||
struct ProclamationCategoryRecord {
|
||||
int id = 0;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -90,6 +100,9 @@ public:
|
||||
// Full sync: replaces all categories with the given set.
|
||||
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`,
|
||||
// preserves the existing `selected` state, and forces the proclamation
|
||||
// identified by `defaultProclamationId` to remain selected.
|
||||
@@ -102,6 +115,9 @@ public:
|
||||
// Fetch all categories ordered by sort_order then id.
|
||||
std::vector<ProclamationCategoryRecord> getAllCategories();
|
||||
|
||||
// Fetch all groups ordered by sort_order then id.
|
||||
std::vector<ProclamationGroupRecord> getAllGroups();
|
||||
|
||||
// Toggle a single proclamation's selected flag.
|
||||
bool setProclamationSelected(const std::string& id, bool selected);
|
||||
|
||||
@@ -144,6 +160,7 @@ public:
|
||||
private:
|
||||
std::string resolvePath(const std::string& raw_path) const;
|
||||
bool runMigrations();
|
||||
bool migrateSubCategoriesToGroups();
|
||||
bool executeSql(const std::string& sql);
|
||||
bool columnExists(const std::string& table, const std::string& column);
|
||||
bool ensureColumn(const std::string& table, const std::string& column,
|
||||
|
||||
@@ -180,6 +180,16 @@ void Home::render(SDL_Renderer *renderer)
|
||||
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:
|
||||
// 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
|
||||
@@ -352,6 +362,15 @@ void Home::createTextures(AppState &state)
|
||||
|
||||
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");
|
||||
if (bgSurface)
|
||||
{
|
||||
@@ -410,6 +429,11 @@ void Home::destroyTextures()
|
||||
SDL_DestroyTexture(settings_.texture);
|
||||
settings_.texture = nullptr;
|
||||
}
|
||||
if (wifiNote_.texture)
|
||||
{
|
||||
SDL_DestroyTexture(wifiNote_.texture);
|
||||
wifiNote_.texture = nullptr;
|
||||
}
|
||||
for (auto &t : footerNames_)
|
||||
t.destroy();
|
||||
for (auto &t : footerValues_)
|
||||
|
||||
@@ -34,6 +34,7 @@ private:
|
||||
TextLine play_;
|
||||
TextLine settings_;
|
||||
TextLine bg_;
|
||||
TextLine wifiNote_;
|
||||
|
||||
AppState* state_ = nullptr;
|
||||
float playY_ = 0;
|
||||
|
||||
@@ -196,6 +196,13 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
|
||||
state.quitRequested = true;
|
||||
}
|
||||
|
||||
if (event->type == SDL_EVENT_AUDIO_DEVICE_ADDED ||
|
||||
event->type == SDL_EVENT_AUDIO_DEVICE_REMOVED) {
|
||||
// Route device hotplug to the audio manager so an active device that
|
||||
// disappears is failed over to the system default automatically.
|
||||
state.audio.handleDeviceEvent(*event);
|
||||
}
|
||||
|
||||
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
|
||||
radiantwave::logger::Logger::instance().info(
|
||||
"Graphics", "Window resized to " +
|
||||
|
||||
@@ -12,6 +12,7 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
|
||||
// Paths relative to API_BASE_URL.
|
||||
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
|
||||
constexpr const char* PATH_CATEGORIES = "/categories";
|
||||
constexpr const char* PATH_GROUPS = "/groups";
|
||||
constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
||||
constexpr const char* PATH_LICENSE = "/license";
|
||||
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
|
||||
|
||||
@@ -66,6 +66,7 @@ bool parseProclamationsJson(const std::string& body,
|
||||
rec.title = stripHtml(entry.stringField("name"));
|
||||
rec.content = stripHtml(entry.stringField("content"));
|
||||
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"));
|
||||
out.push_back(std::move(rec));
|
||||
}
|
||||
@@ -88,7 +89,28 @@ bool parseCategoriesJson(const std::string& body,
|
||||
if (rec.id == 0) continue;
|
||||
|
||||
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"));
|
||||
out.push_back(std::move(rec));
|
||||
}
|
||||
@@ -114,16 +136,28 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
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::ProclamationCategoryRecord> categories;
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
|
||||
!parseCategoriesJson(categoriesResponse.body, categories)) {
|
||||
!parseCategoriesJson(categoriesResponse.body, categories) ||
|
||||
!parseGroupsJson(groupsResponse.body, groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!db.replaceCategories(categories)) {
|
||||
return false;
|
||||
}
|
||||
if (!db.replaceGroups(groups)) {
|
||||
return false;
|
||||
}
|
||||
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,13 @@ bool parseProclamationsJson(const std::string& body,
|
||||
bool parseCategoriesJson(const std::string& body,
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
|
||||
|
||||
// Fetches proclamations + categories 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
|
||||
// Parse the JSON payload returned by the groups endpoint.
|
||||
bool parseGroupsJson(const std::string& body,
|
||||
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.
|
||||
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
const std::string& defaultProclamationId);
|
||||
|
||||
@@ -148,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
|
||||
{
|
||||
proclamations_ = state_->db.getAllProclamations();
|
||||
categories_ = state_->db.getAllCategories();
|
||||
groups_ = state_->db.getAllGroups();
|
||||
|
||||
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
|
||||
for (auto& p : proclamations_) {
|
||||
@@ -181,42 +182,55 @@ void ProclamationOptionsPage::rebuildItems()
|
||||
0, false, true, true,
|
||||
});
|
||||
|
||||
for (const auto& root : categories_) {
|
||||
if (root.parent_id != 0) continue;
|
||||
// Groups that actually exist, so a proclamation pointing at a group the
|
||||
// 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({
|
||||
FlatItem::Type::RootCategory,
|
||||
std::to_string(root.id),
|
||||
root.name,
|
||||
FlatItem::Type::Category,
|
||||
std::to_string(category.id),
|
||||
category.name,
|
||||
0, false, false, false,
|
||||
});
|
||||
|
||||
for (const auto& sub : categories_) {
|
||||
if (sub.parent_id != root.id) continue;
|
||||
|
||||
items_.push_back({
|
||||
FlatItem::Type::SubCategory,
|
||||
std::to_string(sub.id),
|
||||
sub.name,
|
||||
1, true, false, false,
|
||||
});
|
||||
|
||||
// Groups are tag-like and global. A group is shown under a category
|
||||
// only when it has at least one proclamation in that category, so
|
||||
// impossible pairings (e.g. "Physical / Prayers") never render.
|
||||
for (const auto& group : groups_) {
|
||||
std::vector<const radiantwave::db::ProclamationRecord*> members;
|
||||
for (const auto& p : proclamations_) {
|
||||
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({
|
||||
FlatItem::Type::Proclamation,
|
||||
p.id,
|
||||
p.title,
|
||||
2, false, p.selected, false,
|
||||
p->id,
|
||||
p->title,
|
||||
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_) {
|
||||
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({
|
||||
FlatItem::Type::Proclamation,
|
||||
p.id,
|
||||
@@ -235,29 +249,29 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree walk: track the currently-open root and sub category (items_ is
|
||||
// already ordered root -> sub -> proclamation, so each category dominates
|
||||
// the items until the next category of equal or shallower depth).
|
||||
int currentRoot = -1; // items_ index of the current root category
|
||||
int currentSub = -1; // items_ index of the current sub-category
|
||||
// Tree walk: track the currently-open category and group (items_ is
|
||||
// already ordered category -> group -> proclamation, so each category
|
||||
// dominates the items until the next category).
|
||||
int currentCategory = -1; // items_ index of the current category
|
||||
int currentGroup = -1; // items_ index of the current group
|
||||
|
||||
for (size_t i = 0; i < items_.size(); ++i) {
|
||||
const FlatItem& item = items_[i];
|
||||
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
currentRoot = static_cast<int>(i);
|
||||
currentSub = -1;
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
currentCategory = static_cast<int>(i);
|
||||
currentGroup = -1;
|
||||
visible_.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type == FlatItem::Type::SubCategory) {
|
||||
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded;
|
||||
if (rootOpen) {
|
||||
currentSub = static_cast<int>(i);
|
||||
if (item.type == FlatItem::Type::Group) {
|
||||
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
|
||||
if (categoryOpen) {
|
||||
currentGroup = static_cast<int>(i);
|
||||
visible_.push_back(i);
|
||||
} else {
|
||||
currentSub = -1;
|
||||
currentGroup = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -266,9 +280,10 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
bool parentVisible = false;
|
||||
if (item.depth == 0) {
|
||||
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) ||
|
||||
(item.depth == 2 && currentSub >= 0);
|
||||
(item.depth == 2 && currentGroup >= 0 &&
|
||||
items_[currentGroup].expanded);
|
||||
}
|
||||
if (parentVisible) visible_.push_back(i);
|
||||
}
|
||||
@@ -283,7 +298,7 @@ void ProclamationOptionsPage::toggleItem()
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
item.expanded = !item.expanded;
|
||||
rebuildVisible();
|
||||
return;
|
||||
@@ -388,7 +403,7 @@ void ProclamationOptionsPage::collapseCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
if (item.expanded) {
|
||||
item.expanded = false;
|
||||
rebuildVisible();
|
||||
@@ -400,7 +415,7 @@ void ProclamationOptionsPage::expandCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
if (!item.expanded) {
|
||||
item.expanded = true;
|
||||
rebuildVisible();
|
||||
@@ -417,8 +432,8 @@ void ProclamationOptionsPage::moveToParent()
|
||||
int start = static_cast<int>(visible_[highlightIndex_]);
|
||||
for (int i = start - 1; i >= 0; --i) {
|
||||
const FlatItem& candidate = items_[i];
|
||||
if ((candidate.type == FlatItem::Type::RootCategory ||
|
||||
candidate.type == FlatItem::Type::SubCategory) &&
|
||||
if ((candidate.type == FlatItem::Type::Category ||
|
||||
candidate.type == FlatItem::Type::Group) &&
|
||||
candidate.depth == item.depth - 1) {
|
||||
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
||||
if (visible_[vi] == static_cast<size_t>(i)) {
|
||||
@@ -526,7 +541,8 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
|
||||
if (searchActive_) return;
|
||||
if (!visible_.empty()) {
|
||||
const FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
|
||||
if ((item.type == FlatItem::Type::Category ||
|
||||
item.type == FlatItem::Type::Group) && item.expanded) {
|
||||
collapseCurrent();
|
||||
} else {
|
||||
moveToParent();
|
||||
@@ -567,6 +583,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
SDL_Color white = {255, 255, 255, 255};
|
||||
SDL_Color gray = {170, 170, 170, 255};
|
||||
SDL_Color categoryBlue = {100, 180, 255, 255};
|
||||
SDL_Color groupBlue = {160, 210, 255, 255};
|
||||
SDL_Color selectedOrange = {255, 170, 60, 255};
|
||||
|
||||
float titleY = 50.0f;
|
||||
@@ -703,7 +720,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
float maxTotalWidth = 0.0f;
|
||||
for (const auto& item : items_) {
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
int w = 0, h = 0;
|
||||
@@ -716,7 +733,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
||||
const FlatItem& item = items_[visible_[i]];
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
int w = 0, h = 0;
|
||||
@@ -734,13 +751,15 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
float y = listTop + rowIndex * rowPitch;
|
||||
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
|
||||
SDL_Color color = white;
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
color = categoryBlue;
|
||||
} else if (item.type == FlatItem::Type::Group) {
|
||||
color = groupBlue;
|
||||
} else if (item.selected || item.isDefault) {
|
||||
color = selectedOrange;
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ public:
|
||||
|
||||
private:
|
||||
struct FlatItem {
|
||||
enum class Type { RootCategory, SubCategory, Proclamation };
|
||||
enum class Type { Category, Group, 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
|
||||
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation
|
||||
bool expanded = false; // categories only
|
||||
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
|
||||
bool expanded = false; // categories and groups only
|
||||
bool selected = false; // proclamations only
|
||||
bool isDefault = false; // the always-on top-level proclamation
|
||||
};
|
||||
@@ -67,6 +67,7 @@ private:
|
||||
AppState* state_ = nullptr;
|
||||
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
|
||||
std::vector<FlatItem> items_;
|
||||
std::vector<size_t> visible_; // indices into items_, in display order
|
||||
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
|
||||
|
||||
+620
-716
File diff suppressed because it is too large
Load Diff
+27
-10
@@ -142,16 +142,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
|
||||
EXPECT_EQ(out[1].category_id, 32);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
|
||||
const std::string body =
|
||||
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
|
||||
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
|
||||
TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
|
||||
const std::string categoriesBody =
|
||||
R"([{"id":4,"name":"Spiritual","sortOrder":0},
|
||||
{"id":5,"name":"General","sortOrder":1}])";
|
||||
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> out;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
|
||||
EXPECT_EQ(out[1].parent_id, 4);
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
|
||||
ASSERT_EQ(categories.size(), 2u);
|
||||
EXPECT_EQ(categories[0].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) ───────────────────
|
||||
@@ -194,7 +203,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
|
||||
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> 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) ─────────
|
||||
|
||||
@@ -8,9 +8,34 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
|
||||
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("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("Virtual Speaker"), "Built-In Speakers");
|
||||
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) {
|
||||
radiantwave::config::ConfigManager config;
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
|
||||
@@ -36,3 +61,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
|
||||
am.shutdown();
|
||||
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);
|
||||
}
|
||||
|
||||
+100
-4
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
|
||||
DbFixture fx("test_rw_db_cats.db");
|
||||
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
cats.push_back({3, "Root", 0, 1});
|
||||
cats.push_back({7, "Child", 3, 2});
|
||||
cats.push_back({3, "Spiritual", 1});
|
||||
cats.push_back({7, "Physical", 2});
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
||||
|
||||
auto all = fx.db.getAllCategories();
|
||||
ASSERT_EQ(all.size(), 2u);
|
||||
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].parent_id, 3);
|
||||
EXPECT_EQ(all[1].name, "Physical");
|
||||
EXPECT_EQ(all[1].sort_order, 2);
|
||||
|
||||
// Replacing wipes previous categories.
|
||||
ASSERT_TRUE(fx.db.replaceCategories({}));
|
||||
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) {
|
||||
DbFixture fx("test_rw_db_audio.db");
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ using namespace radiantwave::sync;
|
||||
|
||||
TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||
const std::string body =
|
||||
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2},
|
||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
|
||||
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,"groupId":0,"solfeggioFrequencyId":0},
|
||||
{"notAnObject":true}])";
|
||||
|
||||
std::vector<ProclamationRecord> out;
|
||||
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||
EXPECT_EQ(out[0].title, "Peace");
|
||||
EXPECT_EQ(out[0].content, "I am peace.");
|
||||
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[1].id, "p2");
|
||||
EXPECT_EQ(out[1].title, "Joy & Calm");
|
||||
EXPECT_EQ(out[1].content, "text");
|
||||
EXPECT_EQ(out[1].category_id, 0);
|
||||
EXPECT_EQ(out[1].group_id, 0);
|
||||
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
|
||||
}
|
||||
|
||||
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||
// Categories are flat now; a legacy parentId field is simply ignored.
|
||||
const std::string body =
|
||||
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
|
||||
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
|
||||
R"([{"id":3,"name":"Spiritual","sortOrder":1},
|
||||
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
|
||||
|
||||
std::vector<ProclamationCategoryRecord> out;
|
||||
EXPECT_TRUE(parseCategoriesJson(body, out));
|
||||
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||
|
||||
EXPECT_EQ(out[0].id, 3);
|
||||
EXPECT_EQ(out[0].name, "Spiritual");
|
||||
EXPECT_EQ(out[0].parent_id, 0);
|
||||
EXPECT_EQ(out[0].sort_order, 1);
|
||||
|
||||
EXPECT_EQ(out[1].id, 7);
|
||||
EXPECT_EQ(out[1].name, "Inner");
|
||||
EXPECT_EQ(out[1].parent_id, 3);
|
||||
EXPECT_EQ(out[1].name, "Physical");
|
||||
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) {
|
||||
std::vector<ProclamationRecord> procs;
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
std::vector<ProclamationGroupRecord> groups;
|
||||
|
||||
EXPECT_FALSE(parseProclamationsJson("not json", procs));
|
||||
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.
|
||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
|
||||
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) {
|
||||
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
||||
EXPECT_EQ(procs[0].id, "ok");
|
||||
|
||||
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);
|
||||
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) {
|
||||
|
||||
@@ -94,6 +94,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
|
||||
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)
|
||||
{
|
||||
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