Compare commits
20
Commits
1.0
...
5369532ab4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5369532ab4 | ||
|
|
42b915bd25 | ||
|
|
d60a8bd7fb | ||
|
|
ca7b8b7e6f | ||
|
|
bf2d4d1f89 | ||
|
|
2917d33866 | ||
|
|
3f6201182e | ||
|
|
d84307bd0e | ||
|
|
43cd93bbd7 | ||
|
|
92ba9f39fb | ||
|
|
400c6a782b | ||
|
|
9b0bd517c6 | ||
|
|
babb74c7ad | ||
|
|
47785cd4b9 | ||
|
|
15583edef8 | ||
|
|
63265cc22f | ||
|
|
9e73c82641 | ||
|
|
f2a40cd019 | ||
|
|
5caf1d6ef8 | ||
|
|
e17e881c00 |
@@ -0,0 +1,82 @@
|
|||||||
|
name: build-dev
|
||||||
|
on:
|
||||||
|
# Every push to dev: build, run the offline test suite, publish to the
|
||||||
|
# dev Attic cache.
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
jobs:
|
||||||
|
nix-build:
|
||||||
|
runs-on: build-server
|
||||||
|
steps:
|
||||||
|
# actions/checkout@v4 is unreliable on host-mode runners (its files are
|
||||||
|
# copied into the workspace instead of the repo), so clone explicitly.
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
||||||
|
git config --global http.extraheader "Authorization: token ${{ github.token }}"
|
||||||
|
git clone --branch "${{ gitea.ref_name }}" \
|
||||||
|
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
test -f flake.nix
|
||||||
|
git log --oneline -1
|
||||||
|
- name: Inspect workspace
|
||||||
|
run: |
|
||||||
|
pwd
|
||||||
|
ls -la
|
||||||
|
git rev-parse --show-toplevel 2>&1 || true
|
||||||
|
git remote -v 2>&1 || true
|
||||||
|
git describe --tags --always 2>&1 || true
|
||||||
|
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
|
||||||
|
# Dev builds fingerprint as the short commit hash.
|
||||||
|
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
|
||||||
|
# builtins.getEnv.
|
||||||
|
- name: Compute version
|
||||||
|
run: |
|
||||||
|
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||||
|
# checks is keyed per-system (checks.<system>.default); nix build's
|
||||||
|
# #attr shorthand can't resolve it, so resolve the current system inline.
|
||||||
|
# --impure lets the flake read APP_VERSION via builtins.getEnv
|
||||||
|
# (pure evals always get the package defaults).
|
||||||
|
- name: Build package
|
||||||
|
run: |
|
||||||
|
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||||
|
--impure --out-link result-pkg
|
||||||
|
# The offline test suite runs in the check derivation's checkPhase.
|
||||||
|
# This is a separate build/output from the package itself.
|
||||||
|
- name: Build + offline test suite
|
||||||
|
run: |
|
||||||
|
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||||
|
--impure --out-link result-checks
|
||||||
|
- name: Live portal tests
|
||||||
|
env:
|
||||||
|
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
|
||||||
|
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
|
||||||
|
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
|
||||||
|
run: |
|
||||||
|
nix develop --command bash -c '
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
cmake --build build --target test_api_client test_license_service
|
||||||
|
./build/test_api_client
|
||||||
|
./build/test_license_service
|
||||||
|
'
|
||||||
|
# Dev closure (the actual package, not the check output) lands in the
|
||||||
|
# dev cache.
|
||||||
|
- name: Push to Attic dev cache
|
||||||
|
env:
|
||||||
|
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
|
||||||
|
run: |
|
||||||
|
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
||||||
|
attic push radiantwave:radiantwave-dev ./result-pkg
|
||||||
|
|
||||||
|
# Publish the manifest kiosks poll for live app updates. `storePath`
|
||||||
|
# must match exactly the closure pushed to the dev Attic cache above
|
||||||
|
# (readlink resolves the result-pkg out-link symlink to its real
|
||||||
|
# /nix/store path), so a kiosk can `nix copy --from` it without ever
|
||||||
|
# building from source. Upload mirrors the ISO scp pattern to the
|
||||||
|
# fileserver via the build-server's `radiantwave-infra` SSH alias.
|
||||||
|
- name: Publish manifest
|
||||||
|
run: |
|
||||||
|
STOREPATH=$(readlink -f ./result-pkg)
|
||||||
|
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
|
||||||
|
cat manifest.json
|
||||||
|
scp manifest.json \
|
||||||
|
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
name: build
|
name: build-main
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Manual only for now. Select the branch to build when dispatching:
|
# Manual only. Dispatch against main: build, run the offline test suite,
|
||||||
# main -> APP_VERSION is the current git tag
|
# publish to the release Attic cache.
|
||||||
# dev -> APP_VERSION is the current commit hash
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,8 +11,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# actions/checkout@v4 is unreliable on host-mode runners (its files are
|
# actions/checkout@v4 is unreliable on host-mode runners (its files are
|
||||||
# copied into the workspace instead of the repo), so clone explicitly.
|
# copied into the workspace instead of the repo), so clone explicitly.
|
||||||
# A full clone (no --depth) is required so `git describe --tags` on main
|
# A full clone (no --depth) is required so `git describe --tags` resolves
|
||||||
# resolves the real nearest tag instead of falling back to a hash.
|
# the real nearest tag instead of falling back to a hash.
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
||||||
@@ -32,26 +31,27 @@ jobs:
|
|||||||
git describe --tags --always 2>&1 || true
|
git describe --tags --always 2>&1 || true
|
||||||
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
|
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
|
||||||
|
|
||||||
# Compute the version string baked into the binary's Home footer:
|
# Release builds report the current git tag.
|
||||||
# main reports the git tag, dev reports the commit hash fingerprint.
|
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
|
||||||
# The key stays in the build step's environment (never echoed).
|
# builtins.getEnv.
|
||||||
- name: Compute version
|
- name: Compute version
|
||||||
id: version
|
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ gitea.ref_name }}" = "main" ]; then
|
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
|
||||||
APP_VERSION="$(git describe --tags --always)"
|
|
||||||
else
|
|
||||||
APP_VERSION="$(git rev-parse --short HEAD)"
|
|
||||||
fi
|
|
||||||
echo "Branch: ${{ gitea.ref_name }} -> APP_VERSION=$APP_VERSION"
|
|
||||||
|
|
||||||
# checks is keyed per-system (checks.<system>.default); nix build's
|
# checks is keyed per-system (checks.<system>.default); nix build's
|
||||||
# #attr shorthand can't resolve it, so resolve the current system inline.
|
# #attr shorthand can't resolve it, so resolve the current system inline.
|
||||||
# --impure lets the flake read APP_VERSION via builtins.getEnv
|
# --impure lets the flake read APP_VERSION via builtins.getEnv
|
||||||
# (pure evals always get the package defaults). The offline test suite
|
# (pure evals always get the package defaults).
|
||||||
# runs in the package's checkPhase.
|
- name: Build package
|
||||||
|
run: |
|
||||||
|
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||||
|
--impure --out-link result-pkg
|
||||||
|
# The offline test suite runs in the check derivation's checkPhase.
|
||||||
|
# This is a separate build/output from the package itself.
|
||||||
- name: Build + offline test suite
|
- name: Build + offline test suite
|
||||||
run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure
|
run: |
|
||||||
|
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||||
|
--impure --out-link result-checks
|
||||||
|
|
||||||
- name: Live portal tests
|
- name: Live portal tests
|
||||||
env:
|
env:
|
||||||
@@ -66,10 +66,11 @@ jobs:
|
|||||||
./build/test_license_service
|
./build/test_license_service
|
||||||
'
|
'
|
||||||
|
|
||||||
- name: Push to Attic cache
|
# Release closure (the actual package, not the check output) lands in the
|
||||||
|
# release cache.
|
||||||
|
- name: Push to Attic release cache
|
||||||
|
env:
|
||||||
|
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
|
||||||
run: |
|
run: |
|
||||||
nix profile install github:zhaofengli/attic#attic-client \
|
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
||||||
--extra-experimental-features "nix-command flakes"
|
attic push radiantwave:radiantwave ./result-pkg
|
||||||
|
|
||||||
attic login radiantwave https://attic.radiantwavetech.com "${{ secrets.ATTIC_TOKEN }}"
|
|
||||||
attic push radiantwave ./result-kiosk ./result-android ./result-commercial
|
|
||||||
+8
-1
@@ -112,7 +112,14 @@ add_executable(test_provisioning
|
|||||||
tests/test_provisioning.cpp
|
tests/test_provisioning.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(test_provisioning PRIVATE src)
|
target_include_directories(test_provisioning PRIVATE src)
|
||||||
target_link_libraries(test_provisioning PRIVATE SDL3::SDL3)
|
# provisioning.h pulls in app_state.h, so the test compiles against the SDL3,
|
||||||
|
# SDL3_mixer, and SQLite headers transitively (app_state.h -> audio_manager.h ->
|
||||||
|
# SDL3_mixer; app_state.h -> database_manager.h -> sqlite3.h).
|
||||||
|
target_link_libraries(test_provisioning PRIVATE
|
||||||
|
SDL3::SDL3
|
||||||
|
SDL3_mixer::SDL3_mixer
|
||||||
|
SQLite3::SQLite3
|
||||||
|
)
|
||||||
|
|
||||||
add_executable(test_assets
|
add_executable(test_assets
|
||||||
tests/test_assets.cpp
|
tests/test_assets.cpp
|
||||||
|
|||||||
@@ -112,6 +112,18 @@ in
|
|||||||
export WLR_RENDERER=pixman
|
export WLR_RENDERER=pixman
|
||||||
export WLR_NO_HARDWARE_CURSORS=1
|
export WLR_NO_HARDWARE_CURSORS=1
|
||||||
|
|
||||||
|
# Prefer the PipeWire audio backend so SDL enumerates every sink
|
||||||
|
# (built-in analog, HDMI, and the loopback virtual speaker) instead of
|
||||||
|
# silently falling back to an ALSA-only device view that hides the
|
||||||
|
# PipeWire sinks and makes the device selector show a single entry.
|
||||||
|
export SDL_AUDIODRIVER=pipewire
|
||||||
|
if [ -n "$${XDG_RUNTIME_DIR:-}" ]; then
|
||||||
|
for i in {1..20}; do
|
||||||
|
[ -S "$XDG_RUNTIME_DIR/pipewire-0" ] && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1
|
cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct AudioManifestEntry {
|
|||||||
// and keeps the database's audio_files table in sync with what is actually
|
// and keeps the database's audio_files table in sync with what is actually
|
||||||
// on disk.
|
// on disk.
|
||||||
//
|
//
|
||||||
// Fetches new_checksums.md5, compares each entry against the local file's MD5,
|
// Fetches checksums.md5, compares each entry against the local file's MD5,
|
||||||
// and re-downloads anything that is missing or whose checksum differs.
|
// and re-downloads anything that is missing or whose checksum differs.
|
||||||
// Files present on disk but absent from the manifest are deleted, and the
|
// Files present on disk but absent from the manifest are deleted, and the
|
||||||
// audio_files table is reconciled on every pass (and after each individual
|
// audio_files table is reconciled on every pass (and after each individual
|
||||||
@@ -68,7 +68,7 @@ public:
|
|||||||
|
|
||||||
static constexpr const char *AUDIO_BASE_URL =
|
static constexpr const char *AUDIO_BASE_URL =
|
||||||
"https://files.radiantwavetech.com/audio/";
|
"https://files.radiantwavetech.com/audio/";
|
||||||
static constexpr const char *MANIFEST_FILENAME = "new_checksums.md5";
|
static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void run();
|
void run();
|
||||||
@@ -88,7 +88,7 @@ private:
|
|||||||
db::DatabaseManager *db_ = nullptr;
|
db::DatabaseManager *db_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parses new_checksums.md5 manifest text into entries. Blank lines and lines
|
// Parses checksums.md5 manifest text into entries. Blank lines and lines
|
||||||
// starting with '#' are ignored. Lines that don't match a known format are
|
// starting with '#' are ignored. Lines that don't match a known format are
|
||||||
// skipped. Returns false if no valid entries were found.
|
// skipped. Returns false if no valid entries were found.
|
||||||
bool parseManifest(const std::string &text,
|
bool parseManifest(const std::string &text,
|
||||||
|
|||||||
+388
-31
@@ -32,6 +32,7 @@ namespace radiantwave::audio
|
|||||||
if (lower.find("analog") != std::string::npos ||
|
if (lower.find("analog") != std::string::npos ||
|
||||||
lower.find("built-in") != std::string::npos ||
|
lower.find("built-in") != std::string::npos ||
|
||||||
lower.find("builtin") != std::string::npos ||
|
lower.find("builtin") != std::string::npos ||
|
||||||
|
lower.find("virtual") != std::string::npos ||
|
||||||
lower.find("realtek") != std::string::npos ||
|
lower.find("realtek") != std::string::npos ||
|
||||||
lower.find("hda") != std::string::npos ||
|
lower.find("hda") != std::string::npos ||
|
||||||
lower.find("speaker") != std::string::npos ||
|
lower.find("speaker") != std::string::npos ||
|
||||||
@@ -76,7 +77,6 @@ namespace radiantwave::audio
|
|||||||
enabled_ = cfg.audio.enabled;
|
enabled_ = cfg.audio.enabled;
|
||||||
currentDevice_ = cfg.audio.output_device;
|
currentDevice_ = cfg.audio.output_device;
|
||||||
currentTrack_ = cfg.audio.current_track;
|
currentTrack_ = cfg.audio.current_track;
|
||||||
|
|
||||||
if (!MIX_Init())
|
if (!MIX_Init())
|
||||||
{
|
{
|
||||||
std::string err = SDL_GetError();
|
std::string err = SDL_GetError();
|
||||||
@@ -86,17 +86,68 @@ namespace radiantwave::audio
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create mixer device with default audio device
|
// Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
|
||||||
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
|
// Pulse, dummy, ...) and what playback devices it actually sees. This is
|
||||||
|
// the first place a "only the built-in speakers show up" report points;
|
||||||
|
// a single device here means SDL itself never enumerated the HDMI sink.
|
||||||
|
const char *driver = SDL_GetCurrentAudioDriver();
|
||||||
|
std::string driverName = driver ? driver : "(none)";
|
||||||
|
int deviceCount = 0;
|
||||||
|
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount);
|
||||||
|
std::string deviceNames;
|
||||||
|
if (devices)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < deviceCount; ++i)
|
||||||
|
{
|
||||||
|
if (i)
|
||||||
|
{
|
||||||
|
deviceNames += ", ";
|
||||||
|
}
|
||||||
|
const char *n = SDL_GetAudioDeviceName(devices[i]);
|
||||||
|
deviceNames += n ? n : "(unnamed)";
|
||||||
|
}
|
||||||
|
SDL_free(devices);
|
||||||
|
}
|
||||||
|
logger::Logger::instance().info(
|
||||||
|
"Audio",
|
||||||
|
"SDL audio driver: " + driverName + "; " +
|
||||||
|
std::to_string(deviceCount) + " playback device(s) [" +
|
||||||
|
(deviceNames.empty() ? "(none)" : deviceNames) + "]");
|
||||||
|
|
||||||
|
// Try to open the mixer on the device previously persisted in config;
|
||||||
|
// fall back to the system default when the configured device is gone.
|
||||||
|
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
|
if (!currentDevice_.empty())
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||||
|
if (devices)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
{
|
||||||
|
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||||
|
if (name && currentDevice_ == name)
|
||||||
|
{
|
||||||
|
targetId = devices[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SDL_free(devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixer_ = MIX_CreateMixerDevice(targetId, nullptr);
|
||||||
if (!mixer_)
|
if (!mixer_)
|
||||||
{
|
{
|
||||||
std::string err = SDL_GetError();
|
std::string err = SDL_GetError();
|
||||||
logger::Logger::instance().warning("Audio",
|
logger::Logger::instance().warning("Audio",
|
||||||
"Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
|
"Could not open audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
|
||||||
initialized_ = false;
|
initialized_ = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentDeviceId_ = targetId;
|
||||||
|
|
||||||
slots_[0].track = MIX_CreateTrack(mixer_);
|
slots_[0].track = MIX_CreateTrack(mixer_);
|
||||||
slots_[1].track = MIX_CreateTrack(mixer_);
|
slots_[1].track = MIX_CreateTrack(mixer_);
|
||||||
initialized_ = true;
|
initialized_ = true;
|
||||||
@@ -135,6 +186,40 @@ namespace radiantwave::audio
|
|||||||
return enabled_ ? (volume_ / 100.0f) : 0.0f;
|
return enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SDL_PropertiesID AudioManager::createInfiniteLoopOptions()
|
||||||
|
{
|
||||||
|
// MIX_PlayTrack()'s options default to a single pass; requesting
|
||||||
|
// infinite loops is what keeps a track going until it is explicitly
|
||||||
|
// faded out or the app shuts down.
|
||||||
|
SDL_PropertiesID props = SDL_CreateProperties();
|
||||||
|
if (props)
|
||||||
|
{
|
||||||
|
SDL_SetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, -1);
|
||||||
|
}
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioManager::playTrackLooping(int slotIndex)
|
||||||
|
{
|
||||||
|
if (!initialized_ || !mixer_ || slotIndex < 0 || slotIndex >= 2 ||
|
||||||
|
!slots_[slotIndex].track)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_PropertiesID props = createInfiniteLoopOptions();
|
||||||
|
if (!props)
|
||||||
|
{
|
||||||
|
logger::Logger::instance().error(
|
||||||
|
"Audio", "Failed to create playback options for " +
|
||||||
|
displayNameFor(currentTrack_));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = MIX_PlayTrack(slots_[slotIndex].track, props);
|
||||||
|
SDL_DestroyProperties(props);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
void AudioManager::shutdown()
|
void AudioManager::shutdown()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
@@ -181,18 +266,62 @@ namespace radiantwave::audio
|
|||||||
{
|
{
|
||||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||||
std::string raw = name ? name : "";
|
std::string raw = name ? name : "";
|
||||||
list.push_back({raw, mapToFriendlyDeviceName(raw)});
|
AudioDeviceItem item;
|
||||||
|
item.raw_name = raw;
|
||||||
|
item.friendly_name = mapToFriendlyDeviceName(raw);
|
||||||
|
item.id = devices[i];
|
||||||
|
list.push_back(item);
|
||||||
}
|
}
|
||||||
SDL_free(devices);
|
SDL_free(devices);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
list.push_back({"", "Built-In Speakers"});
|
if (devices)
|
||||||
|
{
|
||||||
|
SDL_free(devices);
|
||||||
|
}
|
||||||
|
const char *driver = SDL_GetCurrentAudioDriver();
|
||||||
|
// Zero enumerated devices is a symptom, not a real output. Surface
|
||||||
|
// the truth (a single "System Default" entry that still routes to
|
||||||
|
// whatever the OS default is) instead of pretending a fabricated
|
||||||
|
// device, and say which backend was active so kiosk logs show why.
|
||||||
|
logger::Logger::instance().debug(
|
||||||
|
"Audio",
|
||||||
|
"No playback devices enumerated (audio driver: " +
|
||||||
|
std::string(driver ? driver : "none") +
|
||||||
|
"); falling back to the system default");
|
||||||
|
list.push_back({"", "System Default", 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id)
|
||||||
|
{
|
||||||
|
if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||||
|
std::string found;
|
||||||
|
if (devices)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
{
|
||||||
|
if (devices[i] == id)
|
||||||
|
{
|
||||||
|
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||||
|
found = name ? name : "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SDL_free(devices);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
void AudioManager::setVolume(int volume_0_to_100)
|
void AudioManager::setVolume(int volume_0_to_100)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
@@ -253,14 +382,76 @@ namespace radiantwave::audio
|
|||||||
bool AudioManager::switchDevice(const std::string &raw_device_name)
|
bool AudioManager::switchDevice(const std::string &raw_device_name)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
currentDevice_ = raw_device_name;
|
|
||||||
|
|
||||||
logger::Logger::instance().debug("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
|
// Resolve the config's raw name to an SDL device id so the mixer is
|
||||||
|
// actually reopened on the requested device (""/unresolvable = default).
|
||||||
|
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
|
if (!raw_device_name.empty())
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||||
|
if (devices)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
{
|
||||||
|
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||||
|
if (name && raw_device_name == name)
|
||||||
|
{
|
||||||
|
targetId = devices[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SDL_free(devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return switchDeviceById(targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioManager::switchDeviceById(SDL_AudioDeviceID id)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
|
||||||
|
// 0 is the synthetic "default / built-in" entry from getPlaybackDevices().
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No mixer yet: just record the choice for initialize().
|
||||||
|
if (!mixer_ || !initialized_)
|
||||||
|
{
|
||||||
|
currentDeviceId_ = id;
|
||||||
|
currentDevice_ = resolvePlaybackDeviceName(id);
|
||||||
|
if (configManager_)
|
||||||
|
{
|
||||||
|
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
|
||||||
|
{ cfg.audio.output_device = name; });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recreateMixerOnDevice(id))
|
||||||
|
{
|
||||||
|
logger::Logger::instance().error("Audio",
|
||||||
|
"Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDeviceId_ = id;
|
||||||
|
// After recreation SDL may have normalized the id (e.g. default).
|
||||||
|
if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
|
||||||
|
{
|
||||||
|
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
|
}
|
||||||
|
currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_);
|
||||||
|
|
||||||
|
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_));
|
||||||
|
|
||||||
if (configManager_)
|
if (configManager_)
|
||||||
{
|
{
|
||||||
configManager_->update([raw_device_name](config::AppConfig &cfg)
|
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
|
||||||
{ cfg.audio.output_device = raw_device_name; });
|
{ cfg.audio.output_device = name; });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -272,6 +463,150 @@ namespace radiantwave::audio
|
|||||||
return currentDevice_;
|
return currentDevice_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
return currentDeviceId_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioManager::handleDeviceEvent(const SDL_Event &event)
|
||||||
|
{
|
||||||
|
if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED &&
|
||||||
|
event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (!mixer_ || !initialized_)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only playback device events are relevant to the active output.
|
||||||
|
if (event.adevice.recording)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SDL_AudioDeviceID changedId = event.adevice.which;
|
||||||
|
|
||||||
|
// If a specific device (not the default) was removed, fail over to the
|
||||||
|
// system default so playback keeps going on whatever remains.
|
||||||
|
if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED &&
|
||||||
|
currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK &&
|
||||||
|
changedId == currentDeviceId_)
|
||||||
|
{
|
||||||
|
logger::Logger::instance().warning("Audio",
|
||||||
|
"Active audio output device removed; failing over to the system default");
|
||||||
|
if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK))
|
||||||
|
{
|
||||||
|
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
|
currentDevice_ = "";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A device arrived or a non-active device left: nothing to remap, but
|
||||||
|
// the audio settings page refreshes its list on its own timer.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with mutex_ held. Creates a fresh mixer on the given
|
||||||
|
// device, recreating the two track slots and resuming the active track if
|
||||||
|
// one was playing.
|
||||||
|
//
|
||||||
|
// The new mixer is created BEFORE the current one is destroyed. Reversing
|
||||||
|
// the order (destroy-then-reopen) quits the whole SDL audio subsystem the
|
||||||
|
// moment the last device closes: MIX_DestroyMixer() follows up with
|
||||||
|
// SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry
|
||||||
|
// and renumbers every enumerated device id on the way back up. The id this
|
||||||
|
// switch was asked to open is then stale and the reopen fails with
|
||||||
|
// "Invalid audio device instance ID". Keeping the current device open while
|
||||||
|
// the replacement opens also means a failed switch leaves the current
|
||||||
|
// output playing instead of going permanently mute.
|
||||||
|
bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId)
|
||||||
|
{
|
||||||
|
// Capture the playback state we want to preserve across the switch.
|
||||||
|
std::string resumeTrack = currentTrack_;
|
||||||
|
float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f
|
||||||
|
? slots_[activeSlot_].fadeInDuration
|
||||||
|
: 0.0f;
|
||||||
|
|
||||||
|
// Open the replacement mixer on the target device first, while the
|
||||||
|
// current device (and SDL's device registry) is still alive.
|
||||||
|
MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr);
|
||||||
|
if (!newMixer)
|
||||||
|
{
|
||||||
|
std::string err = SDL_GetError();
|
||||||
|
logger::Logger::instance().error(
|
||||||
|
"Audio",
|
||||||
|
"Could not open audio output device "
|
||||||
|
+ mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId))
|
||||||
|
+ "; keeping the current device (MIX_CreateMixerDevice error: "
|
||||||
|
+ err + ")");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
MIX_Track *newTracks[2];
|
||||||
|
for (int i = 0; i < 2; ++i)
|
||||||
|
{
|
||||||
|
newTracks[i] = MIX_CreateTrack(newMixer);
|
||||||
|
}
|
||||||
|
if (!newTracks[0] || !newTracks[1])
|
||||||
|
{
|
||||||
|
MIX_DestroyMixer(newMixer);
|
||||||
|
logger::Logger::instance().error(
|
||||||
|
"Audio", "Could not create mixer tracks; keeping the current device (SDL error: "
|
||||||
|
+ std::string(SDL_GetError()) + ")");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear down the existing mixer (its tracks die with it) and adopt the
|
||||||
|
// new one.
|
||||||
|
for (int i = 0; i < 2; ++i)
|
||||||
|
{
|
||||||
|
slots_[i].track = nullptr;
|
||||||
|
if (slots_[i].audio)
|
||||||
|
{
|
||||||
|
MIX_DestroyAudio(slots_[i].audio);
|
||||||
|
slots_[i].audio = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mixer_)
|
||||||
|
{
|
||||||
|
MIX_DestroyMixer(mixer_);
|
||||||
|
mixer_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
mixer_ = newMixer;
|
||||||
|
slots_[0].track = newTracks[0];
|
||||||
|
slots_[1].track = newTracks[1];
|
||||||
|
|
||||||
|
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||||
|
MIX_SetMixerGain(mixer_, gain);
|
||||||
|
|
||||||
|
// Resume the previously active track at full gain (or restart its
|
||||||
|
// fade-in if it was still ramping).
|
||||||
|
if (!resumeTrack.empty())
|
||||||
|
{
|
||||||
|
if (loadTrackInto(activeSlot_, resumeTrack))
|
||||||
|
{
|
||||||
|
if (fadeSeconds > 0.0f)
|
||||||
|
{
|
||||||
|
startFadeIn(activeSlot_, fadeSeconds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
||||||
|
playTrackLooping(activeSlot_); // Loop playback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
|
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
|
||||||
{
|
{
|
||||||
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
|
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
|
||||||
@@ -322,7 +657,7 @@ namespace radiantwave::audio
|
|||||||
slot.fadeInDuration = seconds;
|
slot.fadeInDuration = seconds;
|
||||||
slot.fadeInElapsed = 0.0f;
|
slot.fadeInElapsed = 0.0f;
|
||||||
MIX_SetTrackGain(slot.track, 0.0f);
|
MIX_SetTrackGain(slot.track, 0.0f);
|
||||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
playTrackLooping(slotIndex); // Loop playback
|
||||||
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
|
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +671,7 @@ namespace radiantwave::audio
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
||||||
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback
|
playTrackLooping(activeSlot_); // Loop playback
|
||||||
|
|
||||||
// Explicit user selection: persist the manual track so it survives a
|
// Explicit user selection: persist the manual track so it survives a
|
||||||
// restart and is used whenever dynamic audio is turned off. Dynamic
|
// restart and is used whenever dynamic audio is turned off. Dynamic
|
||||||
@@ -373,10 +708,11 @@ namespace radiantwave::audio
|
|||||||
slot.fadeInElapsed = 0.0f;
|
slot.fadeInElapsed = 0.0f;
|
||||||
slot.fadeOutRemaining = 0.0f;
|
slot.fadeOutRemaining = 0.0f;
|
||||||
MIX_SetTrackGain(slot.track, 1.0f);
|
MIX_SetTrackGain(slot.track, 1.0f);
|
||||||
if (!MIX_TrackPlaying(slot.track))
|
// Always restart: MIX_PlayTrack() re-starts a playing track too, so
|
||||||
{
|
// this cancels any in-flight stop-fade inherited from a quick
|
||||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
// pop->push within the fade window. Gating on MIX_TrackPlaying()
|
||||||
}
|
// left a fading track to finish its fade and go silently stopped.
|
||||||
|
playTrackLooping(activeSlot_); // Loop playback
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,10 +747,14 @@ namespace radiantwave::audio
|
|||||||
if (!loadTrackInto(otherSlot, wav_filename))
|
if (!loadTrackInto(otherSlot, wav_filename))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Fade out whatever is currently playing on the active slot.
|
// Fade out whatever is currently playing on the active slot. Clear any
|
||||||
|
// in-flight fade-in bookkeeping first so update() stops ramping this
|
||||||
|
// slot's gain up while the mixer fades it down.
|
||||||
TrackSlot &active = slots_[activeSlot_];
|
TrackSlot &active = slots_[activeSlot_];
|
||||||
if (active.audio)
|
if (active.audio)
|
||||||
{
|
{
|
||||||
|
active.fadeInDuration = 0.0f;
|
||||||
|
active.fadeInElapsed = 0.0f;
|
||||||
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
|
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
|
||||||
active.fadeOutRemaining = fade;
|
active.fadeOutRemaining = fade;
|
||||||
}
|
}
|
||||||
@@ -442,6 +782,10 @@ namespace radiantwave::audio
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The mixer owns the ramp now; drop any pending fade-in so the
|
||||||
|
// per-frame update() cannot fight the fade-out.
|
||||||
|
slot.fadeInDuration = 0.0f;
|
||||||
|
slot.fadeInElapsed = 0.0f;
|
||||||
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
|
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
|
||||||
slot.fadeOutRemaining = fade;
|
slot.fadeOutRemaining = fade;
|
||||||
faded = true;
|
faded = true;
|
||||||
@@ -476,7 +820,29 @@ namespace radiantwave::audio
|
|||||||
{
|
{
|
||||||
TrackSlot &slot = slots_[i];
|
TrackSlot &slot = slots_[i];
|
||||||
if (!slot.track)
|
if (!slot.track)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slot mid stop-fade belongs to the mixer: skip both the fade-in
|
||||||
|
// ramp and the active-gain pin so nothing drives its gain up while
|
||||||
|
// the mixer ramps it down (this also keeps a quit/pop fade clean).
|
||||||
|
if (slot.fadeOutRemaining > 0.0f)
|
||||||
|
{
|
||||||
|
slot.fadeOutRemaining -= dt;
|
||||||
|
if (slot.fadeOutRemaining <= 0.0f)
|
||||||
|
{
|
||||||
|
slot.fadeOutRemaining = 0.0f;
|
||||||
|
// Free the audio once the fade-out has finished; the track
|
||||||
|
// object itself is reused.
|
||||||
|
if (slot.audio)
|
||||||
|
{
|
||||||
|
MIX_DestroyAudio(slot.audio);
|
||||||
|
slot.audio = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (slot.fadeInDuration > 0.0f)
|
if (slot.fadeInDuration > 0.0f)
|
||||||
{
|
{
|
||||||
@@ -493,21 +859,12 @@ namespace radiantwave::audio
|
|||||||
// Keep the active track at full per-track gain; the mixer gain
|
// Keep the active track at full per-track gain; the mixer gain
|
||||||
// carries the user's volume / mute state.
|
// carries the user's volume / mute state.
|
||||||
MIX_SetTrackGain(slot.track, 1.0f);
|
MIX_SetTrackGain(slot.track, 1.0f);
|
||||||
}
|
// Self-heal: playback must continue indefinitely, so restart a
|
||||||
|
// track that stopped unexpectedly (there is no pause state and
|
||||||
if (slot.fadeOutRemaining > 0.0f)
|
// nothing else should legitimately stop the active track).
|
||||||
|
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
|
||||||
{
|
{
|
||||||
slot.fadeOutRemaining -= dt;
|
playTrackLooping(i);
|
||||||
if (slot.fadeOutRemaining <= 0.0f)
|
|
||||||
{
|
|
||||||
slot.fadeOutRemaining = 0.0f;
|
|
||||||
// Free the audio once the fade-out has finished; the track
|
|
||||||
// object itself is reused.
|
|
||||||
if (slot.audio)
|
|
||||||
{
|
|
||||||
MIX_DestroyAudio(slot.audio);
|
|
||||||
slot.audio = nullptr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "config/config_manager.h"
|
#include "config/config_manager.h"
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
#include <SDL3_mixer/SDL_mixer.h>
|
#include <SDL3_mixer/SDL_mixer.h>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
|
|||||||
struct AudioDeviceItem {
|
struct AudioDeviceItem {
|
||||||
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
|
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
|
||||||
std::string friendly_name; // e.g. "Built-In Speakers"
|
std::string friendly_name; // e.g. "Built-In Speakers"
|
||||||
|
SDL_AudioDeviceID id = 0; // SDL playback device id (0 = synthetic fallback)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps raw technical audio device strings to user-friendly labels
|
// Maps raw technical audio device strings to user-friendly labels
|
||||||
@@ -51,7 +53,24 @@ public:
|
|||||||
|
|
||||||
// Audio device selection
|
// Audio device selection
|
||||||
bool switchDevice(const std::string& raw_device_name);
|
bool switchDevice(const std::string& raw_device_name);
|
||||||
|
bool switchDeviceById(SDL_AudioDeviceID id);
|
||||||
std::string getCurrentDevice() const;
|
std::string getCurrentDevice() const;
|
||||||
|
SDL_AudioDeviceID getCurrentDeviceId() const;
|
||||||
|
|
||||||
|
// Handle an SDL audio device event (SDL_EVENT_AUDIO_DEVICE_ADDED / REMOVED).
|
||||||
|
// Returns true if the active device was affected and the mixer was recreated.
|
||||||
|
bool handleDeviceEvent(const SDL_Event& event);
|
||||||
|
|
||||||
|
// Pure helper (no SDL audio open): find the playback device with the given
|
||||||
|
// SDL id, returning its raw name ("" when not found / id invalid). Exposed
|
||||||
|
// for tests; the real lookup is through getPlaybackDevices().
|
||||||
|
static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id);
|
||||||
|
|
||||||
|
// Builds the SDL_PropertiesID passed to MIX_PlayTrack() for tracks that
|
||||||
|
// loop forever (MIX_PROP_PLAY_LOOPS_NUMBER = -1). Exposed for tests; no
|
||||||
|
// mixer, track, or audio device is required. The caller owns the property
|
||||||
|
// set and must release it with SDL_DestroyProperties().
|
||||||
|
static SDL_PropertiesID createInfiniteLoopOptions();
|
||||||
|
|
||||||
// Playback control
|
// Playback control
|
||||||
bool playTrack(const std::string& wav_filename);
|
bool playTrack(const std::string& wav_filename);
|
||||||
@@ -84,6 +103,21 @@ private:
|
|||||||
void startFadeIn(int slotIndex, float seconds);
|
void startFadeIn(int slotIndex, float seconds);
|
||||||
std::string displayNameFor(const std::string& wav_filename) const;
|
std::string displayNameFor(const std::string& wav_filename) const;
|
||||||
|
|
||||||
|
// Starts the track on the given slot with infinite looping. The track must
|
||||||
|
// already be loaded on the slot. Calling this on a playing/stopped/fading
|
||||||
|
// track restarts it (cancelling any in-flight stop-fade).
|
||||||
|
bool playTrackLooping(int slotIndex);
|
||||||
|
|
||||||
|
// Recreates the mixer on the given playback device id (0 = system default).
|
||||||
|
// Called with mutex_ held. Preserves playback state: whichever track was
|
||||||
|
// playing on the active slot is reloaded and resumed at full gain, an
|
||||||
|
// in-progress fade is restarted. The replacement mixer is opened before the
|
||||||
|
// current one is destroyed so SDL's device ids stay valid (destroy-first
|
||||||
|
// quits the whole audio subsystem and orphans the id being opened).
|
||||||
|
// Returns false on failure without touching the current mixer, so the
|
||||||
|
// existing output keeps playing.
|
||||||
|
bool recreateMixerOnDevice(SDL_AudioDeviceID targetId);
|
||||||
|
|
||||||
config::ConfigManager* configManager_ = nullptr;
|
config::ConfigManager* configManager_ = nullptr;
|
||||||
db::DatabaseManager* db_ = nullptr;
|
db::DatabaseManager* db_ = nullptr;
|
||||||
|
|
||||||
@@ -91,6 +125,7 @@ private:
|
|||||||
bool enabled_ = true;
|
bool enabled_ = true;
|
||||||
int volume_ = 80; // 0 - 100
|
int volume_ = 80; // 0 - 100
|
||||||
std::string currentDevice_ = "";
|
std::string currentDevice_ = "";
|
||||||
|
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
std::string currentTrack_ = "";
|
std::string currentTrack_ = "";
|
||||||
|
|
||||||
MIX_Mixer* mixer_ = nullptr;
|
MIX_Mixer* mixer_ = nullptr;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
|
|||||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||||
if (!devices_.empty()) {
|
if (!devices_.empty()) {
|
||||||
currentDeviceIndex_ = modalSelectedIndex_;
|
currentDeviceIndex_ = modalSelectedIndex_;
|
||||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
applyDeviceSelection();
|
||||||
}
|
}
|
||||||
modalState_ = ModalState::None;
|
modalState_ = ModalState::None;
|
||||||
} else if (modalState_ == ModalState::TrackSelector) {
|
} else if (modalState_ == ModalState::TrackSelector) {
|
||||||
@@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
|
|||||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||||
if (!devices_.empty()) {
|
if (!devices_.empty()) {
|
||||||
currentDeviceIndex_ = modalSelectedIndex_;
|
currentDeviceIndex_ = modalSelectedIndex_;
|
||||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
applyDeviceSelection();
|
||||||
}
|
}
|
||||||
modalState_ = ModalState::None;
|
modalState_ = ModalState::None;
|
||||||
} else if (modalState_ == ModalState::TrackSelector) {
|
} else if (modalState_ == ModalState::TrackSelector) {
|
||||||
@@ -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() {
|
void AudioSettingsPage::enableDynamicAudio() {
|
||||||
if (!state_) return;
|
if (!state_) return;
|
||||||
state_->config.update([](radiantwave::config::AppConfig& cfg) {
|
state_->config.update([](radiantwave::config::AppConfig& cfg) {
|
||||||
@@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
|
|||||||
if (modalState_ == ModalState::DeviceSelector) {
|
if (modalState_ == ModalState::DeviceSelector) {
|
||||||
if (!devices_.empty()) {
|
if (!devices_.empty()) {
|
||||||
currentDeviceIndex_ = modalSelectedIndex_;
|
currentDeviceIndex_ = modalSelectedIndex_;
|
||||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
applyDeviceSelection();
|
||||||
}
|
}
|
||||||
} else if (modalState_ == ModalState::TrackSelector) {
|
} else if (modalState_ == ModalState::TrackSelector) {
|
||||||
if (modalSelectedIndex_ == 0) {
|
if (modalSelectedIndex_ == 0) {
|
||||||
@@ -518,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
|
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
|
||||||
? devices_[currentDeviceIndex_].friendly_name : "Built-In Speakers";
|
? devices_[currentDeviceIndex_].friendly_name : "System Default";
|
||||||
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
|
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
|
||||||
if (deviceText_.texture) {
|
if (deviceText_.texture) {
|
||||||
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
|
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ private:
|
|||||||
void enableDynamicAudio();
|
void enableDynamicAudio();
|
||||||
void selectTrack(int trackIdx);
|
void selectTrack(int trackIdx);
|
||||||
|
|
||||||
|
// Applies the currently selected device (devices_[currentDeviceIndex_]) via
|
||||||
|
// the audio manager and records user-facing success/failure feedback.
|
||||||
|
void applyDeviceSelection();
|
||||||
|
|
||||||
AppState* state_ = nullptr;
|
AppState* state_ = nullptr;
|
||||||
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
|
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -180,6 +180,16 @@ void Home::render(SDL_Renderer *renderer)
|
|||||||
SDL_RenderFillRect(renderer, &line);
|
SDL_RenderFillRect(renderer, &line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Large bold reminder to connect WiFi, drawn below the menu while no
|
||||||
|
// network is joined (refreshed every 5s in update()).
|
||||||
|
if (wifiNote_.texture && ssid_.empty())
|
||||||
|
{
|
||||||
|
float noteX = (fw - wifiNote_.width) / 2.0f;
|
||||||
|
float noteY = settingsY_ + settings_.height + 40.0f;
|
||||||
|
SDL_FRect noteDst{noteX, noteY, wifiNote_.width, wifiNote_.height};
|
||||||
|
SDL_RenderTexture(renderer, wifiNote_.texture, nullptr, ¬eDst);
|
||||||
|
}
|
||||||
|
|
||||||
// Bottom-right status footer (20pt, aligned to a virtual rectangle:
|
// Bottom-right status footer (20pt, aligned to a virtual rectangle:
|
||||||
// names left-aligned at the rect's left edge, values right-aligned at
|
// names left-aligned at the rect's left edge, values right-aligned at
|
||||||
// the rect's right edge; the longest name's colon sits 4 spaces from its
|
// the rect's right edge; the longest name's colon sits 4 spaces from its
|
||||||
@@ -281,10 +291,10 @@ void Home::render(SDL_Renderer *renderer)
|
|||||||
fourSpaces.destroy();
|
fourSpaces.destroy();
|
||||||
|
|
||||||
const float margin = 20.0f;
|
const float margin = 20.0f;
|
||||||
float rectRight = fw - margin;
|
float rectRight = fw - margin - 50.0f;
|
||||||
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
|
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
|
||||||
|
|
||||||
float lineY = fh - margin;
|
float lineY = fh - margin - 50.0f;
|
||||||
const float lineGap = 6.0f;
|
const float lineGap = 6.0f;
|
||||||
for (size_t i = footerNames_.size(); i-- > 0;)
|
for (size_t i = footerNames_.size(); i-- > 0;)
|
||||||
{
|
{
|
||||||
@@ -352,6 +362,15 @@ void Home::createTextures(AppState &state)
|
|||||||
|
|
||||||
TTF_CloseFont(font);
|
TTF_CloseFont(font);
|
||||||
|
|
||||||
|
font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48);
|
||||||
|
if (font)
|
||||||
|
{
|
||||||
|
TTF_SetFontStyle(font, TTF_STYLE_BOLD);
|
||||||
|
wifiNote_ = renderText(state.renderer, font, "Please connect to WiFi", white);
|
||||||
|
TTF_SetFontStyle(font, TTF_STYLE_NORMAL);
|
||||||
|
TTF_CloseFont(font);
|
||||||
|
}
|
||||||
|
|
||||||
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
|
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
|
||||||
if (bgSurface)
|
if (bgSurface)
|
||||||
{
|
{
|
||||||
@@ -410,6 +429,11 @@ void Home::destroyTextures()
|
|||||||
SDL_DestroyTexture(settings_.texture);
|
SDL_DestroyTexture(settings_.texture);
|
||||||
settings_.texture = nullptr;
|
settings_.texture = nullptr;
|
||||||
}
|
}
|
||||||
|
if (wifiNote_.texture)
|
||||||
|
{
|
||||||
|
SDL_DestroyTexture(wifiNote_.texture);
|
||||||
|
wifiNote_.texture = nullptr;
|
||||||
|
}
|
||||||
for (auto &t : footerNames_)
|
for (auto &t : footerNames_)
|
||||||
t.destroy();
|
t.destroy();
|
||||||
for (auto &t : footerValues_)
|
for (auto &t : footerValues_)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ private:
|
|||||||
TextLine play_;
|
TextLine play_;
|
||||||
TextLine settings_;
|
TextLine settings_;
|
||||||
TextLine bg_;
|
TextLine bg_;
|
||||||
|
TextLine wifiNote_;
|
||||||
|
|
||||||
AppState* state_ = nullptr;
|
AppState* state_ = nullptr;
|
||||||
float playY_ = 0;
|
float playY_ = 0;
|
||||||
|
|||||||
+38
-4
@@ -4,13 +4,47 @@
|
|||||||
|
|
||||||
namespace radiantwave {
|
namespace radiantwave {
|
||||||
|
|
||||||
|
// Design resolution every page lays out against. The renderer's logical
|
||||||
|
// presentation (LETTERBOX) scales this fixed space onto whatever
|
||||||
|
// window/backbuffer the display actually provides.
|
||||||
|
inline constexpr int kDesignWidth = 1920;
|
||||||
|
inline constexpr int kDesignHeight = 1080;
|
||||||
|
|
||||||
|
// Returns the size pages should lay out against: the renderer's logical size
|
||||||
|
// when presentation is active, otherwise the real window size. The output
|
||||||
|
// params are always written — design constants are the last-resort fallback.
|
||||||
inline void getLayoutSize(SDL_Renderer *renderer, int *w, int *h)
|
inline void getLayoutSize(SDL_Renderer *renderer, int *w, int *h)
|
||||||
{
|
{
|
||||||
|
if (!w || !h)
|
||||||
|
return;
|
||||||
|
*w = kDesignWidth;
|
||||||
|
*h = kDesignHeight;
|
||||||
|
|
||||||
|
if (!renderer)
|
||||||
|
return;
|
||||||
|
|
||||||
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
|
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
|
||||||
if (!SDL_GetRenderLogicalPresentation(renderer, w, h, &mode) ||
|
int logicalW = 0;
|
||||||
mode == SDL_LOGICAL_PRESENTATION_DISABLED || *w <= 0 || *h <= 0)
|
int logicalH = 0;
|
||||||
{
|
if (SDL_GetRenderLogicalPresentation(renderer, &logicalW, &logicalH,
|
||||||
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), w, h);
|
&mode) &&
|
||||||
|
mode != SDL_LOGICAL_PRESENTATION_DISABLED && logicalW > 0 &&
|
||||||
|
logicalH > 0) {
|
||||||
|
*w = logicalW;
|
||||||
|
*h = logicalH;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Window *window = SDL_GetRenderWindow(renderer);
|
||||||
|
if (!window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int windowW = 0;
|
||||||
|
int windowH = 0;
|
||||||
|
SDL_GetWindowSize(window, &windowW, &windowH);
|
||||||
|
if (windowW > 0 && windowH > 0) {
|
||||||
|
*w = windowW;
|
||||||
|
*h = windowH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-10
@@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
#include "app_state.h"
|
#include "app_state.h"
|
||||||
#include "home.h"
|
#include "home.h"
|
||||||
|
#include "logical_size.h"
|
||||||
#include "logger/logger.h"
|
#include "logger/logger.h"
|
||||||
#include "provisioning.h"
|
#include "provisioning.h"
|
||||||
|
#include "scroller_page.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// How long audio fades out before the app tears down on quit. Only applied
|
// How long audio fades out before the app tears down on quit. Only applied
|
||||||
@@ -32,11 +34,26 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
|
|||||||
|
|
||||||
auto *state = new AppState{};
|
auto *state = new AppState{};
|
||||||
|
|
||||||
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the
|
// Size the fullscreen window from the display's actual mode so the very
|
||||||
// compositor never has to scale the moving scroller (scaling moving text
|
// first buffer commit already matches what the compositor scanouts 1:1.
|
||||||
// causes flicker).
|
// Requesting a hardcoded size left the backbuffer at that size on
|
||||||
state->window =
|
// displays running a different mode, and frames reached the panel
|
||||||
SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN);
|
// unscaled (quarter-size on 4K, cropped on smaller panels).
|
||||||
|
const SDL_DisplayMode *displayMode =
|
||||||
|
SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
|
||||||
|
int windowWidth =
|
||||||
|
(displayMode && displayMode->w > 0) ? displayMode->w
|
||||||
|
: radiantwave::kDesignWidth;
|
||||||
|
int windowHeight =
|
||||||
|
(displayMode && displayMode->h > 0) ? displayMode->h
|
||||||
|
: radiantwave::kDesignHeight;
|
||||||
|
|
||||||
|
// Borderless desktop fullscreen: the compositor never has to scale the
|
||||||
|
// moving scroller (scaling moving text causes flicker). Deliberately no
|
||||||
|
// SDL_WINDOW_HIGH_PIXEL_DENSITY — kiosk panels are not HiDPI, so keeping
|
||||||
|
// the pixel buffer equal to the display mode keeps scanout 1:1.
|
||||||
|
state->window = SDL_CreateWindow("RadiantWave", windowWidth, windowHeight,
|
||||||
|
SDL_WINDOW_FULLSCREEN);
|
||||||
if (!state->window) {
|
if (!state->window) {
|
||||||
SDL_Log("Failed to create window: %s", SDL_GetError());
|
SDL_Log("Failed to create window: %s", SDL_GetError());
|
||||||
delete state;
|
delete state;
|
||||||
@@ -59,16 +76,38 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
|
|||||||
std::string(SDL_GetError()) + ")");
|
std::string(SDL_GetError()) + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock the renderer to a 1920x1080 logical coordinate space. All page
|
// All page layout targets the fixed design resolution; LETTERBOX maps it
|
||||||
// layout code already targets this resolution, so everything renders at
|
// onto the real window with aspect preserved (bars on non-16:9 panels).
|
||||||
// the correct size on any display — the GPU scales to native pixels.
|
if (!SDL_SetRenderLogicalPresentation(state->renderer,
|
||||||
if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080,
|
radiantwave::kDesignWidth,
|
||||||
SDL_LOGICAL_PRESENTATION_STRETCH)) {
|
radiantwave::kDesignHeight,
|
||||||
|
SDL_LOGICAL_PRESENTATION_LETTERBOX)) {
|
||||||
radiantwave::logger::Logger::instance().warning(
|
radiantwave::logger::Logger::instance().warning(
|
||||||
"Graphics", "Could not set logical render size; layout may be "
|
"Graphics", "Could not set logical render size; layout may be "
|
||||||
"wrong on HiDPI displays");
|
"wrong on HiDPI displays");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Record how the requested mode, window, pixel buffer, and render
|
||||||
|
// output actually compare — this is the evidence trail for
|
||||||
|
// display-scaling problems on field units.
|
||||||
|
int windowW = 0, windowH = 0;
|
||||||
|
SDL_GetWindowSize(state->window, &windowW, &windowH);
|
||||||
|
int pixelW = 0, pixelH = 0;
|
||||||
|
SDL_GetWindowSizeInPixels(state->window, &pixelW, &pixelH);
|
||||||
|
int outputW = 0, outputH = 0;
|
||||||
|
SDL_GetRenderOutputSize(state->renderer, &outputW, &outputH);
|
||||||
|
radiantwave::logger::Logger::instance().info(
|
||||||
|
"Graphics",
|
||||||
|
"Requested " + std::to_string(windowWidth) + "x" +
|
||||||
|
std::to_string(windowHeight) + ", window " +
|
||||||
|
std::to_string(windowW) + "x" + std::to_string(windowH) +
|
||||||
|
", pixels " + std::to_string(pixelW) + "x" +
|
||||||
|
std::to_string(pixelH) + ", render output " +
|
||||||
|
std::to_string(outputW) + "x" + std::to_string(outputH) +
|
||||||
|
", presentation LETTERBOX");
|
||||||
|
}
|
||||||
|
|
||||||
SDL_HideCursor();
|
SDL_HideCursor();
|
||||||
|
|
||||||
// Initialize configuration, database, and network manager
|
// Initialize configuration, database, and network manager
|
||||||
@@ -157,6 +196,30 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
|
|||||||
state.quitRequested = true;
|
state.quitRequested = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event->type == SDL_EVENT_AUDIO_DEVICE_ADDED ||
|
||||||
|
event->type == SDL_EVENT_AUDIO_DEVICE_REMOVED) {
|
||||||
|
// Route device hotplug to the audio manager so an active device that
|
||||||
|
// disappears is failed over to the system default automatically.
|
||||||
|
state.audio.handleDeviceEvent(*event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
|
||||||
|
radiantwave::logger::Logger::instance().info(
|
||||||
|
"Graphics", "Window resized to " +
|
||||||
|
std::to_string(event->window.data1) + "x" +
|
||||||
|
std::to_string(event->window.data2));
|
||||||
|
// Logical presentation persists across resizes; re-apply defensively
|
||||||
|
// in case backend state was reset underneath us. Skip while the
|
||||||
|
// scroller is active — it owns its own presentation (NONE, full
|
||||||
|
// window) and restores LETTERBOX in onPop.
|
||||||
|
if (!dynamic_cast<ScrollerPage *>(state.pages.current())) {
|
||||||
|
SDL_SetRenderLogicalPresentation(state.renderer,
|
||||||
|
radiantwave::kDesignWidth,
|
||||||
|
radiantwave::kDesignHeight,
|
||||||
|
SDL_LOGICAL_PRESENTATION_LETTERBOX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.keybinds.handleEvent(*event)) {
|
if (!state.keybinds.handleEvent(*event)) {
|
||||||
state.pages.handleEvent(*event);
|
state.pages.handleEvent(*event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ void ProclamationOptionsPage::rebuildItems()
|
|||||||
FlatItem::Type::SubCategory,
|
FlatItem::Type::SubCategory,
|
||||||
std::to_string(sub.id),
|
std::to_string(sub.id),
|
||||||
sub.name,
|
sub.name,
|
||||||
1, false, false, false,
|
1, true, false, false,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const auto& p : proclamations_) {
|
for (const auto& p : proclamations_) {
|
||||||
@@ -268,7 +268,7 @@ void ProclamationOptionsPage::rebuildVisible()
|
|||||||
parentVisible = true; // the always-on top-level proclamation
|
parentVisible = true; // the always-on top-level proclamation
|
||||||
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
|
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
|
||||||
parentVisible = (item.depth == 1) ||
|
parentVisible = (item.depth == 1) ||
|
||||||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded);
|
(item.depth == 2 && currentSub >= 0);
|
||||||
}
|
}
|
||||||
if (parentVisible) visible_.push_back(i);
|
if (parentVisible) visible_.push_back(i);
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ void ProclamationOptionsPage::toggleItem()
|
|||||||
if (visible_.empty()) return;
|
if (visible_.empty()) return;
|
||||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
|
|
||||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::RootCategory) {
|
||||||
item.expanded = !item.expanded;
|
item.expanded = !item.expanded;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
return;
|
return;
|
||||||
@@ -388,7 +388,7 @@ void ProclamationOptionsPage::collapseCurrent()
|
|||||||
{
|
{
|
||||||
if (visible_.empty()) return;
|
if (visible_.empty()) return;
|
||||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::RootCategory) {
|
||||||
if (item.expanded) {
|
if (item.expanded) {
|
||||||
item.expanded = false;
|
item.expanded = false;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
@@ -400,7 +400,7 @@ void ProclamationOptionsPage::expandCurrent()
|
|||||||
{
|
{
|
||||||
if (visible_.empty()) return;
|
if (visible_.empty()) return;
|
||||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::RootCategory) {
|
||||||
if (!item.expanded) {
|
if (!item.expanded) {
|
||||||
item.expanded = true;
|
item.expanded = true;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
@@ -526,8 +526,7 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
|
|||||||
if (searchActive_) return;
|
if (searchActive_) return;
|
||||||
if (!visible_.empty()) {
|
if (!visible_.empty()) {
|
||||||
const FlatItem& item = items_[visible_[highlightIndex_]];
|
const FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
if ((item.type == FlatItem::Type::RootCategory ||
|
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
|
||||||
item.type == FlatItem::Type::SubCategory) && item.expanded) {
|
|
||||||
collapseCurrent();
|
collapseCurrent();
|
||||||
} else {
|
} else {
|
||||||
moveToParent();
|
moveToParent();
|
||||||
@@ -655,14 +654,18 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
|
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Compute the widest row so the block can be centered.
|
// Stable width: compute from every result so the list doesn't shift.
|
||||||
float maxTotalWidth = 0.0f;
|
float maxTotalWidth = 0.0f;
|
||||||
|
for (const auto& r : searchResults_) {
|
||||||
|
int w = 0, h = 0;
|
||||||
|
TTF_GetStringSize(fontItem_, proclamations_[r].title.c_str(), 0, &w, &h);
|
||||||
|
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<float> textWidths(static_cast<size_t>(listSize));
|
std::vector<float> textWidths(static_cast<size_t>(listSize));
|
||||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
|
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
|
||||||
const std::string& label = proclamations_[searchResults_[i]].title;
|
|
||||||
int w = 0, h = 0;
|
int w = 0, h = 0;
|
||||||
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h);
|
||||||
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
|
|
||||||
textWidths[i] = static_cast<float>(w);
|
textWidths[i] = static_cast<float>(w);
|
||||||
}
|
}
|
||||||
float sharedX = (fw - maxTotalWidth) / 2.0f;
|
float sharedX = (fw - maxTotalWidth) / 2.0f;
|
||||||
@@ -695,19 +698,29 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// --- Browse tree ---
|
// --- Browse tree ---
|
||||||
// Compute the widest row so the block can be centered.
|
// Stable width: compute from every item so the list doesn't
|
||||||
|
// shift left/right when groups expand or collapse.
|
||||||
float maxTotalWidth = 0.0f;
|
float maxTotalWidth = 0.0f;
|
||||||
std::vector<float> textWidths(visible_.size());
|
for (const auto& item : items_) {
|
||||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
|
||||||
const FlatItem& item = items_[visible_[i]];
|
|
||||||
std::string label = item.label;
|
std::string label = item.label;
|
||||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::RootCategory) {
|
||||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||||
}
|
}
|
||||||
int w = 0, h = 0;
|
int w = 0, h = 0;
|
||||||
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
||||||
float total = item.depth * indent + static_cast<float>(w);
|
float total = item.depth * indent + static_cast<float>(w);
|
||||||
maxTotalWidth = std::max(maxTotalWidth, total);
|
maxTotalWidth = std::max(maxTotalWidth, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<float> textWidths(visible_.size());
|
||||||
|
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
||||||
|
const FlatItem& item = items_[visible_[i]];
|
||||||
|
std::string label = item.label;
|
||||||
|
if (item.type == FlatItem::Type::RootCategory) {
|
||||||
|
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||||
|
}
|
||||||
|
int w = 0, h = 0;
|
||||||
|
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
||||||
textWidths[i] = static_cast<float>(w);
|
textWidths[i] = static_cast<float>(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,7 +734,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
float y = listTop + rowIndex * rowPitch;
|
float y = listTop + rowIndex * rowPitch;
|
||||||
|
|
||||||
std::string label = item.label;
|
std::string label = item.label;
|
||||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::RootCategory) {
|
||||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+203
-270
@@ -2,27 +2,26 @@
|
|||||||
|
|
||||||
#include "app_state.h"
|
#include "app_state.h"
|
||||||
#include "audio/audio_downloader.h"
|
#include "audio/audio_downloader.h"
|
||||||
|
#include "logical_size.h"
|
||||||
#include "proclamation_options_page.h"
|
#include "proclamation_options_page.h"
|
||||||
#include "scramble/fibonacci_scrambler.h"
|
#include "scramble/fibonacci_scrambler.h"
|
||||||
#include "utf8_utils.h"
|
#include "utf8_utils.h"
|
||||||
#include "logical_size.h"
|
|
||||||
|
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
#include <SDL3_ttf/SDL_ttf.h>
|
#include <SDL3_ttf/SDL_ttf.h>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cstdint>
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
using radiantwave::FibonacciLinePattern;
|
using radiantwave::FibonacciLinePattern;
|
||||||
using radiantwave::scroller::ProclamationStream;
|
using radiantwave::scroller::ProclamationStream;
|
||||||
|
|
||||||
namespace
|
namespace {
|
||||||
{
|
|
||||||
|
|
||||||
constexpr float kFadeSeconds = 5.0f;
|
constexpr float kFadeSeconds = 5.0f;
|
||||||
constexpr float kVelocityMin = -3500.0f;
|
constexpr float kVelocityMin = -3500.0f;
|
||||||
@@ -30,17 +29,41 @@ namespace
|
|||||||
|
|
||||||
} // anonymous namespace
|
} // anonymous namespace
|
||||||
|
|
||||||
void ScrollerPage::onPush(AppState &state)
|
void ScrollerPage::onPush(AppState &state) {
|
||||||
{
|
|
||||||
state_ = &state;
|
state_ = &state;
|
||||||
|
|
||||||
int winW = 0;
|
// Save the current logical presentation (LETTERBOX at design resolution)
|
||||||
int winH = 0;
|
// and switch to NONE so we render into the full physical window. This
|
||||||
radiantwave::getLayoutSize(state.renderer, &winW, &winH);
|
// lets the scroller scroll the entire vertical extent of tall/rotated
|
||||||
screenW_ = static_cast<float>(winW);
|
// displays while keeping text at the same pixel scale. onPop restores
|
||||||
screenH_ = static_cast<float>(winH);
|
// the original presentation for other pages.
|
||||||
// Lines are capped at the full screen width; the widest Fibonacci lines are
|
SDL_GetRenderLogicalPresentation(state.renderer, &savedLogicalW_,
|
||||||
// sized to fill it with whole words (peak word count derived from the
|
&savedLogicalH_, &savedMode_);
|
||||||
|
|
||||||
|
// Use the renderer's actual output size in pixels, not the window size in
|
||||||
|
// points. SDL_GetWindowSize() returns screen coordinates, which can be
|
||||||
|
// smaller than the real drawable if the display has a content scale
|
||||||
|
// applied (common on some monitors/compositors) — that mismatch caused
|
||||||
|
// this page to lay out and center within a sub-region of the screen
|
||||||
|
// instead of the full physical canvas. SDL_GetRenderOutputSize() gives us
|
||||||
|
// the true pixel dimensions of whatever we're actually rendering into.
|
||||||
|
int windowW = 0;
|
||||||
|
int windowH = 0;
|
||||||
|
SDL_GetRenderOutputSize(state.renderer, &windowW, &windowH);
|
||||||
|
if (windowW <= 0) {
|
||||||
|
windowW = radiantwave::kDesignWidth;
|
||||||
|
}
|
||||||
|
if (windowH <= 0) {
|
||||||
|
windowH = radiantwave::kDesignHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_SetRenderLogicalPresentation(state.renderer, windowW, windowH,
|
||||||
|
SDL_LOGICAL_PRESENTATION_DISABLED);
|
||||||
|
|
||||||
|
screenW_ = static_cast<float>(windowW);
|
||||||
|
screenH_ = static_cast<float>(windowH);
|
||||||
|
// Lines are capped at the full screen width; the widest Fibonacci lines
|
||||||
|
// are sized to fill it with whole words (peak word count derived from the
|
||||||
// screen), independent of resolution.
|
// screen), independent of resolution.
|
||||||
maxLineWidth_ = screenW_;
|
maxLineWidth_ = screenW_;
|
||||||
|
|
||||||
@@ -58,63 +81,59 @@ void ScrollerPage::onPush(AppState &state)
|
|||||||
// plays. A frequency file that is not on disk yet falls back to the manual
|
// plays. A frequency file that is not on disk yet falls back to the manual
|
||||||
// track rather than playing nothing.
|
// track rather than playing nothing.
|
||||||
std::string track = state.config.get().audio.current_track;
|
std::string track = state.config.get().audio.current_track;
|
||||||
if (state.config.get().audio.dynamic_audio && !streams_.empty())
|
if (state.config.get().audio.dynamic_audio && !streams_.empty()) {
|
||||||
{
|
|
||||||
const ProclamationStream &stream = streams_[streamIndex_];
|
const ProclamationStream &stream = streams_[streamIndex_];
|
||||||
if (stream.solfeggio_frequency_id != 0 && !stream.audioFilename.empty() &&
|
if (stream.solfeggio_frequency_id != 0 &&
|
||||||
std::filesystem::exists(audioFilePath(stream.audioFilename)))
|
!stream.audioFilename.empty() &&
|
||||||
{
|
std::filesystem::exists(audioFilePath(stream.audioFilename))) {
|
||||||
track = stream.audioFilename;
|
track = stream.audioFilename;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (std::filesystem::exists(audioFilePath(track)))
|
if (std::filesystem::exists(audioFilePath(track))) {
|
||||||
{
|
|
||||||
state.audio.playTrackFadeIn(track, kFadeSeconds);
|
state.audio.playTrackFadeIn(track, kFadeSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]()
|
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
|
||||||
{
|
|
||||||
if (state_) {
|
if (state_) {
|
||||||
state_->pages.pop(*state_);
|
state_->pages.pop(*state_);
|
||||||
} });
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::onResume(AppState &state)
|
void ScrollerPage::onResume(AppState &state) {
|
||||||
{
|
|
||||||
state_ = &state;
|
state_ = &state;
|
||||||
createFont(state);
|
createFont(state);
|
||||||
createPatterns();
|
createPatterns();
|
||||||
rebuildWordTextures();
|
rebuildWordTextures();
|
||||||
|
|
||||||
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]()
|
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
|
||||||
{
|
|
||||||
if (state_) {
|
if (state_) {
|
||||||
state_->pages.pop(*state_);
|
state_->pages.pop(*state_);
|
||||||
} });
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::onPause(AppState &state)
|
void ScrollerPage::onPause(AppState &state) {
|
||||||
{
|
|
||||||
state.keybinds.unbindAll();
|
state.keybinds.unbindAll();
|
||||||
destroyResources();
|
destroyResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::onPop(AppState &state)
|
void ScrollerPage::onPop(AppState &state) {
|
||||||
{
|
// Restore the original logical presentation so other pages (Home,
|
||||||
|
// Settings, etc.) render in the design-resolution LETTERBOX space.
|
||||||
|
SDL_SetRenderLogicalPresentation(state.renderer, savedLogicalW_,
|
||||||
|
savedLogicalH_, savedMode_);
|
||||||
|
|
||||||
state.audio.stopTrackFadeOut(kFadeSeconds);
|
state.audio.stopTrackFadeOut(kFadeSeconds);
|
||||||
state.keybinds.unbindAll();
|
state.keybinds.unbindAll();
|
||||||
destroyResources();
|
destroyResources();
|
||||||
TTF_Quit();
|
TTF_Quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::handleEvent(const SDL_Event &)
|
void ScrollerPage::handleEvent(const SDL_Event &) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScrollerPage::update(float dt)
|
void ScrollerPage::update(float dt) {
|
||||||
{
|
if (state_) {
|
||||||
if (state_)
|
|
||||||
{
|
|
||||||
state_->audio.update(dt);
|
state_->audio.update(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,69 +141,55 @@ void ScrollerPage::update(float dt)
|
|||||||
|
|
||||||
scrollOffset_ += velocity_ * dt;
|
scrollOffset_ += velocity_ * dt;
|
||||||
float move = std::floor(scrollOffset_);
|
float move = std::floor(scrollOffset_);
|
||||||
if (move != 0.0f)
|
if (move != 0.0f) {
|
||||||
{
|
|
||||||
scrollOffset_ -= move;
|
scrollOffset_ -= move;
|
||||||
for (auto &line : lines_)
|
for (auto &line : lines_) {
|
||||||
{
|
for (auto &w : line.words) {
|
||||||
for (auto &w : line.words)
|
|
||||||
{
|
|
||||||
w.y += move;
|
w.y += move;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lines_.empty())
|
if (lines_.empty()) {
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (velocity_ > 0.0f)
|
if (velocity_ > 0.0f) {
|
||||||
{
|
|
||||||
// Lines move downward. Recycle every line that scrolled fully off the
|
// Lines move downward. Recycle every line that scrolled fully off the
|
||||||
// bottom this frame (movement is continuous, so one frame can cover
|
// bottom this frame (movement is continuous, so one frame can cover
|
||||||
// several line heights) and add a replacement just above the top.
|
// several line heights) and add a replacement just above the top.
|
||||||
while (!lines_.empty() && lines_.back().words[0].y > screenH_)
|
while (!lines_.empty() && lines_.back().words[0].y > screenH_) {
|
||||||
{
|
|
||||||
float baseY = lines_.front().words[0].y - lineHeight_;
|
float baseY = lines_.front().words[0].y - lineHeight_;
|
||||||
lines_.pop_back();
|
lines_.pop_back();
|
||||||
if (!addLineAt(baseY))
|
if (!addLineAt(baseY)) {
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (velocity_ < 0.0f) {
|
||||||
else if (velocity_ < 0.0f)
|
|
||||||
{
|
|
||||||
// Lines move upward. Recycle every line that scrolled fully off the top
|
// Lines move upward. Recycle every line that scrolled fully off the top
|
||||||
// this frame and add a replacement just below the bottom.
|
// this frame and add a replacement just below the bottom.
|
||||||
while (!lines_.empty() &&
|
while (!lines_.empty() &&
|
||||||
lines_.front().words[0].y + lines_.front().words[0].height < 0.0f)
|
lines_.front().words[0].y + lines_.front().words[0].height <
|
||||||
{
|
0.0f) {
|
||||||
float baseY = lines_.back().words[0].y + lineHeight_;
|
float baseY = lines_.back().words[0].y + lineHeight_;
|
||||||
lines_.pop_front();
|
lines_.pop_front();
|
||||||
if (!addLineAt(baseY))
|
if (!addLineAt(baseY)) {
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::render(SDL_Renderer *renderer)
|
void ScrollerPage::render(SDL_Renderer *renderer) {
|
||||||
{
|
|
||||||
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
|
||||||
SDL_RenderClear(renderer);
|
SDL_RenderClear(renderer);
|
||||||
|
|
||||||
for (const auto &line : lines_)
|
for (const auto &line : lines_) {
|
||||||
{
|
if (line.words.empty()) {
|
||||||
if (line.words.empty())
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
float startX = std::floor((screenW_ - line.width) / 2.0f);
|
float startX = std::floor((screenW_ - line.width) / 2.0f);
|
||||||
for (const auto &w : line.words)
|
for (const auto &w : line.words) {
|
||||||
{
|
|
||||||
SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b);
|
SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b);
|
||||||
SDL_FRect dst{startX + w.x, w.y, w.width, w.height};
|
SDL_FRect dst{startX + w.x, w.y, w.width, w.height};
|
||||||
SDL_RenderTexture(renderer, w.texture, nullptr, &dst);
|
SDL_RenderTexture(renderer, w.texture, nullptr, &dst);
|
||||||
@@ -192,53 +197,46 @@ void ScrollerPage::render(SDL_Renderer *renderer)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::buildStreams(AppState &state)
|
void ScrollerPage::buildStreams(AppState &state) {
|
||||||
{
|
|
||||||
streams_.clear();
|
streams_.clear();
|
||||||
fallbackPalette_.clear();
|
fallbackPalette_.clear();
|
||||||
lines_.clear();
|
lines_.clear();
|
||||||
streamIndex_ = 0;
|
streamIndex_ = 0;
|
||||||
|
|
||||||
auto proclamations = state.db.getActiveProclamations();
|
auto proclamations = state.db.getActiveProclamations();
|
||||||
if (proclamations.empty())
|
if (proclamations.empty()) {
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto &c : state.db.getPaletteForFrequencyId(5))
|
for (const auto &c :
|
||||||
{ // 528 Hz palette
|
state.db.getPaletteForFrequencyId(5)) { // 528 Hz palette
|
||||||
fallbackPalette_.push_back({c.r, c.g, c.b, c.a});
|
fallbackPalette_.push_back({c.r, c.g, c.b, c.a});
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>> palettesByFreqId;
|
std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>>
|
||||||
for (const auto &rec : proclamations)
|
palettesByFreqId;
|
||||||
{
|
for (const auto &rec : proclamations) {
|
||||||
int freqId = rec.solfeggio_frequency_id;
|
int freqId = rec.solfeggio_frequency_id;
|
||||||
if (palettesByFreqId.count(freqId) != 0)
|
if (palettesByFreqId.count(freqId) != 0) {
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
std::vector<radiantwave::scroller::StreamColor> colors;
|
std::vector<radiantwave::scroller::StreamColor> colors;
|
||||||
for (const auto &c : state.db.getPaletteForFrequencyId(freqId))
|
for (const auto &c : state.db.getPaletteForFrequencyId(freqId)) {
|
||||||
{
|
|
||||||
colors.push_back({c.r, c.g, c.b, c.a});
|
colors.push_back({c.r, c.g, c.b, c.a});
|
||||||
}
|
}
|
||||||
palettesByFreqId[freqId] = std::move(colors);
|
palettesByFreqId[freqId] = std::move(colors);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unordered_map<int, radiantwave::db::AudioFileRecord> audioByFreqId;
|
std::unordered_map<int, radiantwave::db::AudioFileRecord> audioByFreqId;
|
||||||
for (const auto &af : state.db.getAudioFiles())
|
for (const auto &af : state.db.getAudioFiles()) {
|
||||||
{
|
|
||||||
int freqId = af.solfeggio_frequency_id;
|
int freqId = af.solfeggio_frequency_id;
|
||||||
// Self-heal for databases seeded from a legacy manifest that left the
|
// Self-heal for databases seeded from a legacy manifest that left the
|
||||||
// frequency id at 0 for every row: recover the id from the filename.
|
// frequency id at 0 for every row: recover the id from the filename.
|
||||||
if (freqId == 0)
|
if (freqId == 0) {
|
||||||
{
|
|
||||||
freqId = radiantwave::audio::frequencyIdForHz(
|
freqId = radiantwave::audio::frequencyIdForHz(
|
||||||
radiantwave::audio::hzFromFilename(af.filename));
|
radiantwave::audio::hzFromFilename(af.filename));
|
||||||
}
|
}
|
||||||
if (freqId != 0)
|
if (freqId != 0) {
|
||||||
{
|
|
||||||
audioByFreqId[freqId] = af;
|
audioByFreqId[freqId] = af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,10 +246,8 @@ void ScrollerPage::buildStreams(AppState &state)
|
|||||||
DEFAULT_PROPLEAMATION_ID);
|
DEFAULT_PROPLEAMATION_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::createFont(AppState &state)
|
void ScrollerPage::createFont(AppState &state) {
|
||||||
{
|
if (font_) {
|
||||||
if (font_)
|
|
||||||
{
|
|
||||||
TTF_CloseFont(font_);
|
TTF_CloseFont(font_);
|
||||||
font_ = nullptr;
|
font_ = nullptr;
|
||||||
}
|
}
|
||||||
@@ -262,46 +258,41 @@ void ScrollerPage::createFont(AppState &state)
|
|||||||
clearCharTextures();
|
clearCharTextures();
|
||||||
|
|
||||||
fontPxSize_ = state.config.get().proclamation.font_size;
|
fontPxSize_ = state.config.get().proclamation.font_size;
|
||||||
if (fontPxSize_ <= 0)
|
if (fontPxSize_ <= 0) {
|
||||||
{
|
|
||||||
fontPxSize_ = 32;
|
fontPxSize_ = 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_);
|
font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_);
|
||||||
if (!font_)
|
if (!font_) {
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int w = 0;
|
int w = 0;
|
||||||
int h = 0;
|
int h = 0;
|
||||||
TTF_GetStringSize(font_, "Mg", 0, &w, &h);
|
TTF_GetStringSize(font_, "Mg", 0, &w, &h);
|
||||||
lineHeight_ = h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_);
|
lineHeight_ =
|
||||||
|
h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_);
|
||||||
|
|
||||||
spaceWidth_ = measureWidth(" ");
|
spaceWidth_ = measureWidth(" ");
|
||||||
if (spaceWidth_ <= 0.0f)
|
if (spaceWidth_ <= 0.0f) {
|
||||||
{
|
|
||||||
spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f;
|
spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::createPatterns()
|
void ScrollerPage::createPatterns() {
|
||||||
{
|
|
||||||
patterns_.clear();
|
patterns_.clear();
|
||||||
patterns_.reserve(streams_.size());
|
patterns_.reserve(streams_.size());
|
||||||
for (size_t i = 0; i < streams_.size(); ++i)
|
for (size_t i = 0; i < streams_.size(); ++i) {
|
||||||
{
|
|
||||||
int peak = computePeakWordCount(streams_[i].words);
|
int peak = computePeakWordCount(streams_[i].words);
|
||||||
patterns_.emplace_back(peak, maxLineWidth_, spaceWidth_,
|
patterns_.emplace_back(
|
||||||
[this](const std::string &word)
|
peak, maxLineWidth_, spaceWidth_,
|
||||||
{ return measureWidth(word); });
|
[this](const std::string &word) { return measureWidth(word); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) const
|
int ScrollerPage::computePeakWordCount(
|
||||||
{
|
const std::vector<std::string> &words) const {
|
||||||
if (words.empty() || screenW_ <= 0.0f)
|
if (words.empty() || screenW_ <= 0.0f) {
|
||||||
{
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,14 +300,12 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
|
|||||||
// spacing. The peak is the number of whole words that fit across the
|
// spacing. The peak is the number of whole words that fit across the
|
||||||
// screen, so the widest Fibonacci lines fill the width at natural spacing.
|
// screen, so the widest Fibonacci lines fill the width at natural spacing.
|
||||||
double total = 0.0;
|
double total = 0.0;
|
||||||
for (const auto &w : words)
|
for (const auto &w : words) {
|
||||||
{
|
|
||||||
total += measureWidth(w);
|
total += measureWidth(w);
|
||||||
}
|
}
|
||||||
double n = static_cast<double>(words.size());
|
double n = static_cast<double>(words.size());
|
||||||
double perWord = (total + (n - 1.0) * spaceWidth_) / n;
|
double perWord = (total + (n - 1.0) * spaceWidth_) / n;
|
||||||
if (perWord <= 0.0)
|
if (perWord <= 0.0) {
|
||||||
{
|
|
||||||
perWord = 1.0;
|
perWord = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,45 +313,36 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
|
|||||||
return fit < 1 ? 1 : fit;
|
return fit < 1 ? 1 : fit;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::destroyResources()
|
void ScrollerPage::destroyResources() {
|
||||||
{
|
|
||||||
clearCharTextures();
|
clearCharTextures();
|
||||||
|
|
||||||
for (auto &entry : wordTextures_)
|
for (auto &entry : wordTextures_) {
|
||||||
{
|
if (entry.second.texture) {
|
||||||
if (entry.second.texture)
|
|
||||||
{
|
|
||||||
SDL_DestroyTexture(entry.second.texture);
|
SDL_DestroyTexture(entry.second.texture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wordTextures_.clear();
|
wordTextures_.clear();
|
||||||
|
|
||||||
if (font_)
|
if (font_) {
|
||||||
{
|
|
||||||
TTF_CloseFont(font_);
|
TTF_CloseFont(font_);
|
||||||
font_ = nullptr;
|
font_ = nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::rebuildWordTextures()
|
void ScrollerPage::rebuildWordTextures() {
|
||||||
{
|
|
||||||
wordTextures_.clear();
|
wordTextures_.clear();
|
||||||
for (auto &line : lines_)
|
for (auto &line : lines_) {
|
||||||
{
|
|
||||||
float width = 0.0f;
|
float width = 0.0f;
|
||||||
for (auto &item : line.words)
|
for (auto &item : line.words) {
|
||||||
{
|
|
||||||
ensureWordTexture(item.text);
|
ensureWordTexture(item.text);
|
||||||
const CachedWord &cached = wordTextures_[item.text];
|
const CachedWord &cached = wordTextures_[item.text];
|
||||||
if (!cached.texture)
|
if (!cached.texture) {
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
item.texture = cached.texture;
|
item.texture = cached.texture;
|
||||||
item.width = cached.width;
|
item.width = cached.width;
|
||||||
item.height = cached.height;
|
item.height = cached.height;
|
||||||
if (width > 0.0f)
|
if (width > 0.0f) {
|
||||||
{
|
|
||||||
width += spaceWidth_;
|
width += spaceWidth_;
|
||||||
}
|
}
|
||||||
item.x = width;
|
item.x = width;
|
||||||
@@ -372,31 +352,25 @@ void ScrollerPage::rebuildWordTextures()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::prefillLines()
|
void ScrollerPage::prefillLines() {
|
||||||
{
|
if (streams_.empty() || lineHeight_ <= 0.0f) {
|
||||||
if (streams_.empty() || lineHeight_ <= 0.0f)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int n = static_cast<int>(std::ceil(screenH_ / lineHeight_)) + 2;
|
int n = static_cast<int>(std::ceil(screenH_ / lineHeight_)) + 2;
|
||||||
for (int i = 0; i < n; ++i)
|
for (int i = 0; i < n; ++i) {
|
||||||
{
|
|
||||||
float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_;
|
float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_;
|
||||||
addLineAt(baseY);
|
addLineAt(baseY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ScrollerPage::addLineAt(float baseY)
|
bool ScrollerPage::addLineAt(float baseY) {
|
||||||
{
|
if (streams_.empty() || !font_) {
|
||||||
if (streams_.empty() || !font_)
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProclamationStream &stream = streams_[streamIndex_];
|
ProclamationStream &stream = streams_[streamIndex_];
|
||||||
if (stream.words.empty())
|
if (stream.words.empty()) {
|
||||||
{
|
|
||||||
advanceStream();
|
advanceStream();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -404,7 +378,8 @@ bool ScrollerPage::addLineAt(float baseY)
|
|||||||
FibonacciLinePattern &pattern = patterns_[streamIndex_];
|
FibonacciLinePattern &pattern = patterns_[streamIndex_];
|
||||||
|
|
||||||
// Capture palette state before any stream advance.
|
// Capture palette state before any stream advance.
|
||||||
std::vector<radiantwave::scroller::StreamColor> paletteCopy = stream.palette;
|
std::vector<radiantwave::scroller::StreamColor> paletteCopy =
|
||||||
|
stream.palette;
|
||||||
int lineColorIndex = stream.colorIndex;
|
int lineColorIndex = stream.colorIndex;
|
||||||
stream.colorIndex++;
|
stream.colorIndex++;
|
||||||
|
|
||||||
@@ -414,17 +389,15 @@ bool ScrollerPage::addLineAt(float baseY)
|
|||||||
stream.wordsPlaced += placed;
|
stream.wordsPlaced += placed;
|
||||||
// First word of a fresh pass is the "start" of the proclamation; print to
|
// First word of a fresh pass is the "start" of the proclamation; print to
|
||||||
// stdout once so the console shows the current title while it plays.
|
// stdout once so the console shows the current title while it plays.
|
||||||
if (stream.wordsPlaced == placed)
|
if (stream.wordsPlaced == placed) {
|
||||||
{
|
|
||||||
std::printf("Playing %s\n", stream.name.c_str());
|
std::printf("Playing %s\n", stream.name.c_str());
|
||||||
}
|
}
|
||||||
bool advanced = false;
|
bool advanced = false;
|
||||||
while (!advanced && !stream.words.empty() && stream.wordsPlaced >= static_cast<int>(stream.words.size()))
|
while (!advanced && !stream.words.empty() &&
|
||||||
{
|
stream.wordsPlaced >= static_cast<int>(stream.words.size())) {
|
||||||
stream.wordsPlaced -= static_cast<int>(stream.words.size());
|
stream.wordsPlaced -= static_cast<int>(stream.words.size());
|
||||||
stream.completed++;
|
stream.completed++;
|
||||||
if (stream.completed >= stream.repeat)
|
if (stream.completed >= stream.repeat) {
|
||||||
{
|
|
||||||
stream.completed = 0;
|
stream.completed = 0;
|
||||||
advanceStream();
|
advanceStream();
|
||||||
advanced = true;
|
advanced = true;
|
||||||
@@ -433,12 +406,10 @@ bool ScrollerPage::addLineAt(float baseY)
|
|||||||
|
|
||||||
ScrollerLine line;
|
ScrollerLine line;
|
||||||
float width = 0.0f;
|
float width = 0.0f;
|
||||||
for (const auto &text : wordStrs)
|
for (const auto &text : wordStrs) {
|
||||||
{
|
|
||||||
ensureWordTexture(text);
|
ensureWordTexture(text);
|
||||||
const CachedWord &cached = wordTextures_[text];
|
const CachedWord &cached = wordTextures_[text];
|
||||||
if (!cached.texture)
|
if (!cached.texture) {
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
WordItem item;
|
WordItem item;
|
||||||
@@ -447,8 +418,7 @@ bool ScrollerPage::addLineAt(float baseY)
|
|||||||
item.width = cached.width;
|
item.width = cached.width;
|
||||||
item.height = cached.height;
|
item.height = cached.height;
|
||||||
item.y = baseY;
|
item.y = baseY;
|
||||||
if (width > 0.0f)
|
if (width > 0.0f) {
|
||||||
{
|
|
||||||
width += spaceWidth_;
|
width += spaceWidth_;
|
||||||
}
|
}
|
||||||
item.x = width;
|
item.x = width;
|
||||||
@@ -456,77 +426,63 @@ bool ScrollerPage::addLineAt(float baseY)
|
|||||||
line.words.push_back(item);
|
line.words.push_back(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.words.empty())
|
if (line.words.empty()) {
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
line.width = width;
|
line.width = width;
|
||||||
|
|
||||||
if (!paletteCopy.empty())
|
if (!paletteCopy.empty()) {
|
||||||
{
|
const auto &c =
|
||||||
const auto &c = paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())];
|
paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())];
|
||||||
line.r = c.r;
|
line.r = c.r;
|
||||||
line.g = c.g;
|
line.g = c.g;
|
||||||
line.b = c.b;
|
line.b = c.b;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lines_.empty() && baseY < lines_.front().words[0].y)
|
if (!lines_.empty() && baseY < lines_.front().words[0].y) {
|
||||||
{
|
|
||||||
lines_.push_front(std::move(line));
|
lines_.push_front(std::move(line));
|
||||||
}
|
} else {
|
||||||
else
|
|
||||||
{
|
|
||||||
lines_.push_back(std::move(line));
|
lines_.push_back(std::move(line));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::advanceStream()
|
void ScrollerPage::advanceStream() {
|
||||||
{
|
if (streams_.empty()) {
|
||||||
if (streams_.empty())
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
|
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
|
||||||
// Diagnostic: report the palette the incoming stream will render so the
|
// Diagnostic: report the palette the incoming stream will render so the
|
||||||
// console shows whether colors should change when this stream starts.
|
// console shows whether colors should change when this stream starts.
|
||||||
const ProclamationStream &stream = streams_[streamIndex_];
|
const ProclamationStream &stream = streams_[streamIndex_];
|
||||||
if (!stream.palette.empty())
|
if (!stream.palette.empty()) {
|
||||||
{
|
|
||||||
const auto &c = stream.palette[0];
|
const auto &c = stream.palette[0];
|
||||||
std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n",
|
std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n",
|
||||||
stream.name.c_str(), stream.solfeggio_frequency_id,
|
stream.name.c_str(), stream.solfeggio_frequency_id, c.r,
|
||||||
c.r, c.g, c.b);
|
c.g, c.b);
|
||||||
}
|
} else {
|
||||||
else
|
|
||||||
{
|
|
||||||
std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n",
|
std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n",
|
||||||
stream.name.c_str(), stream.solfeggio_frequency_id);
|
stream.name.c_str(), stream.solfeggio_frequency_id);
|
||||||
}
|
}
|
||||||
switchAudioForCurrentStream();
|
switchAudioForCurrentStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::switchAudioForCurrentStream()
|
void ScrollerPage::switchAudioForCurrentStream() {
|
||||||
{
|
if (prefilling_ || streams_.empty() || !state_) {
|
||||||
if (prefilling_ || streams_.empty() || !state_)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic audio only: with a fixed manual track selected the audio must
|
// Dynamic audio only: with a fixed manual track selected the audio must
|
||||||
// never switch as proclamations change.
|
// never switch as proclamations change.
|
||||||
if (!state_->config.get().audio.dynamic_audio)
|
if (!state_->config.get().audio.dynamic_audio) {
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProclamationStream &stream = streams_[streamIndex_];
|
const ProclamationStream &stream = streams_[streamIndex_];
|
||||||
if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty())
|
if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) {
|
||||||
{
|
|
||||||
return; // default stream: never touch audio
|
return; // default stream: never touch audio
|
||||||
}
|
}
|
||||||
if (!std::filesystem::exists(audioFilePath(stream.audioFilename)))
|
if (!std::filesystem::exists(audioFilePath(stream.audioFilename))) {
|
||||||
{
|
|
||||||
return; // keep current track; retried on the next activation
|
return; // keep current track; retried on the next activation
|
||||||
}
|
}
|
||||||
// Crossfades over kFadeSeconds; a no-op when the requested track is the
|
// Crossfades over kFadeSeconds; a no-op when the requested track is the
|
||||||
@@ -534,14 +490,11 @@ void ScrollerPage::switchAudioForCurrentStream()
|
|||||||
state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds);
|
state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::updateAutoVelocity(float dt)
|
void ScrollerPage::updateAutoVelocity(float dt) {
|
||||||
{
|
switch (velocityState_) {
|
||||||
switch (velocityState_)
|
|
||||||
{
|
|
||||||
case 0: // ramping to the negative extreme
|
case 0: // ramping to the negative extreme
|
||||||
velocity_ -= velocityStep_ * dt;
|
velocity_ -= velocityStep_ * dt;
|
||||||
if (velocity_ <= kVelocityMin)
|
if (velocity_ <= kVelocityMin) {
|
||||||
{
|
|
||||||
velocity_ = kVelocityMin;
|
velocity_ = kVelocityMin;
|
||||||
velocityTimer_ = velocityDelay_;
|
velocityTimer_ = velocityDelay_;
|
||||||
velocityState_ = 1;
|
velocityState_ = 1;
|
||||||
@@ -549,15 +502,13 @@ void ScrollerPage::updateAutoVelocity(float dt)
|
|||||||
break;
|
break;
|
||||||
case 1: // holding at the negative extreme
|
case 1: // holding at the negative extreme
|
||||||
velocityTimer_ -= dt;
|
velocityTimer_ -= dt;
|
||||||
if (velocityTimer_ <= 0.0f)
|
if (velocityTimer_ <= 0.0f) {
|
||||||
{
|
|
||||||
velocityState_ = 2;
|
velocityState_ = 2;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 2: // ramping to the positive extreme
|
case 2: // ramping to the positive extreme
|
||||||
velocity_ += velocityStep_ * dt;
|
velocity_ += velocityStep_ * dt;
|
||||||
if (velocity_ >= kVelocityMax)
|
if (velocity_ >= kVelocityMax) {
|
||||||
{
|
|
||||||
velocity_ = kVelocityMax;
|
velocity_ = kVelocityMax;
|
||||||
velocityTimer_ = velocityDelay_;
|
velocityTimer_ = velocityDelay_;
|
||||||
velocityState_ = 3;
|
velocityState_ = 3;
|
||||||
@@ -565,8 +516,7 @@ void ScrollerPage::updateAutoVelocity(float dt)
|
|||||||
break;
|
break;
|
||||||
case 3: // holding at the positive extreme
|
case 3: // holding at the positive extreme
|
||||||
velocityTimer_ -= dt;
|
velocityTimer_ -= dt;
|
||||||
if (velocityTimer_ <= 0.0f)
|
if (velocityTimer_ <= 0.0f) {
|
||||||
{
|
|
||||||
velocityState_ = 0;
|
velocityState_ = 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -576,10 +526,8 @@ void ScrollerPage::updateAutoVelocity(float dt)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::ensureWordTexture(const std::string &word)
|
void ScrollerPage::ensureWordTexture(const std::string &word) {
|
||||||
{
|
if (wordTextures_.count(word) != 0) {
|
||||||
if (wordTextures_.count(word) != 0)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,69 +550,64 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
|
|||||||
// breaking, centering, and the Fibonacci line pattern are unchanged; only
|
// breaking, centering, and the Fibonacci line pattern are unchanged; only
|
||||||
// the glyph pixels move.
|
// the glyph pixels move.
|
||||||
CachedWord cached;
|
CachedWord cached;
|
||||||
if (font_ && state_)
|
if (font_ && state_) {
|
||||||
{
|
|
||||||
std::vector<uint32_t> codePoints;
|
std::vector<uint32_t> codePoints;
|
||||||
if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty())
|
if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) {
|
||||||
{
|
|
||||||
std::vector<CharEntry *> glyphs;
|
std::vector<CharEntry *> glyphs;
|
||||||
float totalWidth = 0.0f;
|
float totalWidth = 0.0f;
|
||||||
float glyphHeight = 0.0f;
|
float glyphHeight = 0.0f;
|
||||||
for (uint32_t cp : codePoints)
|
for (uint32_t cp : codePoints) {
|
||||||
{
|
|
||||||
const std::string glyphText = radiantwave::encodeUtf8(cp);
|
const std::string glyphText = radiantwave::encodeUtf8(cp);
|
||||||
if (glyphText.empty())
|
if (glyphText.empty()) {
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ensureCharTexture(glyphText);
|
ensureCharTexture(glyphText);
|
||||||
CharEntry &entry = charTextures_[glyphText];
|
CharEntry &entry = charTextures_[glyphText];
|
||||||
if (!entry.surface)
|
if (!entry.surface) {
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
glyphs.push_back(&entry);
|
glyphs.push_back(&entry);
|
||||||
totalWidth += entry.advance;
|
totalWidth += entry.advance;
|
||||||
if (entry.glyphH > glyphHeight)
|
if (entry.glyphH > glyphHeight) {
|
||||||
{
|
|
||||||
glyphHeight = entry.glyphH;
|
glyphHeight = entry.glyphH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!glyphs.empty())
|
if (!glyphs.empty()) {
|
||||||
{
|
const int wordW =
|
||||||
const int wordW = std::max(1, static_cast<int>(std::lround(totalWidth)));
|
std::max(1, static_cast<int>(std::lround(totalWidth)));
|
||||||
const int wordH = std::max(1, static_cast<int>(std::lround(glyphHeight)));
|
const int wordH =
|
||||||
SDL_Surface *wordSurface = SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888);
|
std::max(1, static_cast<int>(std::lround(glyphHeight)));
|
||||||
if (wordSurface)
|
SDL_Surface *wordSurface =
|
||||||
{
|
SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888);
|
||||||
|
if (wordSurface) {
|
||||||
// Every glyph surface carries the font's full line height
|
// Every glyph surface carries the font's full line height
|
||||||
// (probe-verified), so y = 0 keeps every crop on the shared
|
// (probe-verified), so y = 0 keeps every crop on the shared
|
||||||
// baseline and per-word textures stay line-height tall.
|
// baseline and per-word textures stay line-height tall.
|
||||||
float x = 0.0f;
|
float x = 0.0f;
|
||||||
for (const CharEntry *entry : glyphs)
|
for (const CharEntry *entry : glyphs) {
|
||||||
{
|
|
||||||
SDL_Rect srcRect{0, 0, static_cast<int>(entry->glyphW),
|
SDL_Rect srcRect{0, 0, static_cast<int>(entry->glyphW),
|
||||||
static_cast<int>(entry->glyphH)};
|
static_cast<int>(entry->glyphH)};
|
||||||
SDL_Rect dstRect{static_cast<int>(std::lround(x)), 0,
|
SDL_Rect dstRect{static_cast<int>(std::lround(x)), 0,
|
||||||
static_cast<int>(entry->glyphW),
|
static_cast<int>(entry->glyphW),
|
||||||
static_cast<int>(entry->glyphH)};
|
static_cast<int>(entry->glyphH)};
|
||||||
SDL_BlitSurface(entry->surface, &srcRect, wordSurface, &dstRect);
|
SDL_BlitSurface(entry->surface, &srcRect, wordSurface,
|
||||||
|
&dstRect);
|
||||||
x += entry->advance;
|
x += entry->advance;
|
||||||
}
|
}
|
||||||
cached.texture = SDL_CreateTextureFromSurface(state_->renderer, wordSurface);
|
cached.texture = SDL_CreateTextureFromSurface(
|
||||||
|
state_->renderer, wordSurface);
|
||||||
// The scroller steps glyphs in whole pixels, so bilinear
|
// The scroller steps glyphs in whole pixels, so bilinear
|
||||||
// filtering can only smear them: nearest-neighbor keeps
|
// filtering can only smear them: nearest-neighbor keeps
|
||||||
// every frame pixel-sharp.
|
// every frame pixel-sharp.
|
||||||
if (cached.texture)
|
if (cached.texture) {
|
||||||
{
|
SDL_SetTextureScaleMode(cached.texture,
|
||||||
SDL_SetTextureScaleMode(cached.texture, SDL_SCALEMODE_NEAREST);
|
SDL_SCALEMODE_NEAREST);
|
||||||
}
|
}
|
||||||
cached.width = static_cast<float>(wordSurface->w);
|
cached.width = static_cast<float>(wordSurface->w);
|
||||||
cached.height = static_cast<float>(wordSurface->h);
|
cached.height = static_cast<float>(wordSurface->h);
|
||||||
SDL_DestroySurface(wordSurface);
|
SDL_DestroySurface(wordSurface);
|
||||||
if (!cached.texture)
|
if (!cached.texture) {
|
||||||
{
|
|
||||||
cached.width = 0.0f;
|
cached.width = 0.0f;
|
||||||
cached.height = 0.0f;
|
cached.height = 0.0f;
|
||||||
}
|
}
|
||||||
@@ -675,10 +618,8 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
|
|||||||
wordTextures_[word] = cached;
|
wordTextures_[word] = cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::ensureCharTexture(const std::string &glyph)
|
void ScrollerPage::ensureCharTexture(const std::string &glyph) {
|
||||||
{
|
if (charTextures_.count(glyph) != 0) {
|
||||||
if (charTextures_.count(glyph) != 0)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,25 +628,23 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
|
|||||||
// font size; createFont() clears the atlas whenever the font or its size
|
// font size; createFont() clears the atlas whenever the font or its size
|
||||||
// changes, and pause/pop clear it as part of destroyResources().
|
// changes, and pause/pop clear it as part of destroyResources().
|
||||||
CharEntry entry;
|
CharEntry entry;
|
||||||
if (font_ && state_)
|
if (font_ && state_) {
|
||||||
{
|
|
||||||
SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0,
|
SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0,
|
||||||
{255, 255, 255, 255});
|
{255, 255, 255, 255});
|
||||||
if (surface)
|
if (surface) {
|
||||||
{
|
|
||||||
const int gW = surface->w;
|
const int gW = surface->w;
|
||||||
const int gH = surface->h;
|
const int gH = surface->h;
|
||||||
const int n = radiantwave::scramble::canvasSize(gW, gH);
|
const int n = radiantwave::scramble::canvasSize(gW, gH);
|
||||||
if (n > 0)
|
if (n > 0) {
|
||||||
{
|
|
||||||
// The scrambler needs tightly packed RGBA8888; a TTF surface's
|
// The scrambler needs tightly packed RGBA8888; a TTF surface's
|
||||||
// pitch can exceed gW * 4, so copy row by row.
|
// pitch can exceed gW * 4, so copy row by row.
|
||||||
std::vector<uint8_t> source(static_cast<size_t>(gW) * gH * 4);
|
std::vector<uint8_t> source(static_cast<size_t>(gW) * gH * 4);
|
||||||
const uint8_t *pixels = static_cast<const uint8_t *>(surface->pixels);
|
const uint8_t *pixels =
|
||||||
for (int y = 0; y < gH; ++y)
|
static_cast<const uint8_t *>(surface->pixels);
|
||||||
{
|
for (int y = 0; y < gH; ++y) {
|
||||||
std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4,
|
std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4,
|
||||||
pixels + static_cast<size_t>(y) * surface->pitch,
|
pixels +
|
||||||
|
static_cast<size_t>(y) * surface->pitch,
|
||||||
static_cast<size_t>(gW) * 4);
|
static_cast<size_t>(gW) * 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,25 +653,24 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
|
|||||||
// The fixed seed makes every device render the same glyph as
|
// The fixed seed makes every device render the same glyph as
|
||||||
// the same scrambled pattern, with no key to persist or ship.
|
// the same scrambled pattern, with no key to persist or ship.
|
||||||
radiantwave::scramble::ScrambleConfig config;
|
radiantwave::scramble::ScrambleConfig config;
|
||||||
if (radiantwave::scramble::scramble(source.data(), scrambled.data(),
|
if (radiantwave::scramble::scramble(
|
||||||
gW, gH, config))
|
source.data(), scrambled.data(), gW, gH, config)) {
|
||||||
{
|
SDL_Surface *tile =
|
||||||
SDL_Surface *tile = SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888);
|
SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888);
|
||||||
if (tile)
|
if (tile) {
|
||||||
{
|
|
||||||
uint8_t *dst = static_cast<uint8_t *>(tile->pixels);
|
uint8_t *dst = static_cast<uint8_t *>(tile->pixels);
|
||||||
for (int y = 0; y < n; ++y)
|
for (int y = 0; y < n; ++y) {
|
||||||
{
|
std::memcpy(dst + static_cast<size_t>(y) *
|
||||||
std::memcpy(dst + static_cast<size_t>(y) * tile->pitch,
|
tile->pitch,
|
||||||
scrambled.data() + static_cast<size_t>(y) * n * 4,
|
scrambled.data() +
|
||||||
|
static_cast<size_t>(y) * n * 4,
|
||||||
static_cast<size_t>(n) * 4);
|
static_cast<size_t>(n) * 4);
|
||||||
}
|
}
|
||||||
entry.surface = tile;
|
entry.surface = tile;
|
||||||
entry.glyphW = static_cast<float>(gW);
|
entry.glyphW = static_cast<float>(gW);
|
||||||
entry.glyphH = static_cast<float>(gH);
|
entry.glyphH = static_cast<float>(gH);
|
||||||
entry.advance = measureWidth(glyph);
|
entry.advance = measureWidth(glyph);
|
||||||
if (entry.advance <= 0.0f)
|
if (entry.advance <= 0.0f) {
|
||||||
{
|
|
||||||
entry.advance = entry.glyphW;
|
entry.advance = entry.glyphW;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -744,22 +682,17 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
|
|||||||
charTextures_[glyph] = entry;
|
charTextures_[glyph] = entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScrollerPage::clearCharTextures()
|
void ScrollerPage::clearCharTextures() {
|
||||||
{
|
for (auto &entry : charTextures_) {
|
||||||
for (auto &entry : charTextures_)
|
if (entry.second.surface) {
|
||||||
{
|
|
||||||
if (entry.second.surface)
|
|
||||||
{
|
|
||||||
SDL_DestroySurface(entry.second.surface);
|
SDL_DestroySurface(entry.second.surface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
charTextures_.clear();
|
charTextures_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
float ScrollerPage::measureWidth(const std::string &text) const
|
float ScrollerPage::measureWidth(const std::string &text) const {
|
||||||
{
|
if (!font_) {
|
||||||
if (!font_)
|
|
||||||
{
|
|
||||||
return 0.0f;
|
return 0.0f;
|
||||||
}
|
}
|
||||||
int w = 0;
|
int w = 0;
|
||||||
@@ -768,9 +701,9 @@ float ScrollerPage::measureWidth(const std::string &text) const
|
|||||||
return static_cast<float>(w);
|
return static_cast<float>(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ScrollerPage::audioFilePath(const std::string &filename) const
|
std::string ScrollerPage::audioFilePath(const std::string &filename) const {
|
||||||
{
|
std::string path1 =
|
||||||
std::string path1 = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") +
|
std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") +
|
||||||
"/.local/radiantwave/audio/" + filename;
|
"/.local/radiantwave/audio/" + filename;
|
||||||
std::string path2 = "assets/audio/" + filename;
|
std::string path2 = "assets/audio/" + filename;
|
||||||
return std::filesystem::exists(path1) ? path1 : path2;
|
return std::filesystem::exists(path1) ? path1 : path2;
|
||||||
|
|||||||
@@ -115,4 +115,8 @@ private:
|
|||||||
float velocityDelay_ = 2.5f;
|
float velocityDelay_ = 2.5f;
|
||||||
int velocityState_ = 0;
|
int velocityState_ = 0;
|
||||||
float velocityTimer_ = 0.0f;
|
float velocityTimer_ = 0.0f;
|
||||||
|
|
||||||
|
int savedLogicalW_ = 0;
|
||||||
|
int savedLogicalH_ = 0;
|
||||||
|
SDL_RendererLogicalPresentation savedMode_ = SDL_LOGICAL_PRESENTATION_DISABLED;
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-18
@@ -19,18 +19,18 @@ namespace
|
|||||||
{
|
{
|
||||||
|
|
||||||
constexpr int MENU_ITEM_COUNT = 9;
|
constexpr int MENU_ITEM_COUNT = 9;
|
||||||
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations,
|
// 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
|
||||||
// 4 = Email Address, 5 = License Key, 6 = Date Format,
|
// 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
|
||||||
// 7 = View System Logs, 8 = Back
|
// 7 = System Logs, 8 = Back
|
||||||
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
|
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
|
||||||
"WiFi Setup",
|
|
||||||
"Audio Options",
|
|
||||||
"Time Zone Setup",
|
|
||||||
"Proclamations",
|
"Proclamations",
|
||||||
|
"Audio Options",
|
||||||
"Email Address",
|
"Email Address",
|
||||||
"License Key",
|
"License Key",
|
||||||
|
"WiFi Setup",
|
||||||
|
"Time Zone Setup",
|
||||||
"Date Format",
|
"Date Format",
|
||||||
"View System Logs",
|
"System Logs",
|
||||||
"Back to Home",
|
"Back to Home",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
|
|||||||
switch (selectedItem_)
|
switch (selectedItem_)
|
||||||
{
|
{
|
||||||
case 0:
|
case 0:
|
||||||
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
// The Proclamation list is license-gated like Play.
|
// The Proclamation list is license-gated like Play.
|
||||||
if (state_->licenseTracker.isValid())
|
if (state_->licenseTracker.isValid())
|
||||||
{
|
{
|
||||||
@@ -97,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
|
|||||||
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
|
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 1:
|
||||||
|
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
|
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 3:
|
||||||
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
|
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
|
||||||
break;
|
break;
|
||||||
|
case 4:
|
||||||
|
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
|
||||||
|
break;
|
||||||
case 6:
|
case 6:
|
||||||
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
|
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -8,9 +8,34 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
|
|||||||
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
|
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
|
||||||
EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device");
|
EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device");
|
||||||
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
|
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
|
||||||
|
EXPECT_EQ(mapToFriendlyDeviceName("Virtual Speaker"), "Built-In Speakers");
|
||||||
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
|
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(AudioTest, ResolvePlaybackDeviceNameHandlesSentinels) {
|
||||||
|
// The sentinel and 0 mean "system default" and never correspond to a real
|
||||||
|
// enumerated playback device, so they resolve to the empty (default) name.
|
||||||
|
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(0), "");
|
||||||
|
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) {
|
||||||
|
AudioManager am;
|
||||||
|
auto devices = am.getPlaybackDevices();
|
||||||
|
// Headless hosts (CI) expose no playback devices, so expect the synthetic
|
||||||
|
// fallback entry that carries id 0 (the "system default" marker).
|
||||||
|
if (devices.empty()) {
|
||||||
|
// No devices and no fallback would be a contract violation.
|
||||||
|
FAIL() << "getPlaybackDevices() returned an empty list without fallback";
|
||||||
|
}
|
||||||
|
for (const auto& d : devices) {
|
||||||
|
if (d.raw_name.empty()) {
|
||||||
|
EXPECT_EQ(d.id, 0u);
|
||||||
|
EXPECT_EQ(d.friendly_name, "System Default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST(AudioTest, AudioManagerVolumeClamping) {
|
TEST(AudioTest, AudioManagerVolumeClamping) {
|
||||||
radiantwave::config::ConfigManager config;
|
radiantwave::config::ConfigManager config;
|
||||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
|
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
|
||||||
@@ -36,3 +61,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
|
|||||||
am.shutdown();
|
am.shutdown();
|
||||||
std::filesystem::remove(temp_path);
|
std::filesystem::remove(temp_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(AudioTest, PlayOptionsLoopInfinite) {
|
||||||
|
// Regression: MIX_PlayTrack() must not default to a single pass. Tracks
|
||||||
|
// have to loop forever so playback never runs out mid-scroll; a track that
|
||||||
|
// stops (rather than attenuates) also can't be revived by raising app
|
||||||
|
// volume. Assert the options request infinite looping (-1).
|
||||||
|
SDL_PropertiesID props = AudioManager::createInfiniteLoopOptions();
|
||||||
|
ASSERT_NE(props, 0u);
|
||||||
|
EXPECT_EQ(SDL_GetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, 0), -1);
|
||||||
|
SDL_DestroyProperties(props);
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
|
|||||||
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(ScrollerStreamsTest, InterleavedListCyclesBackToFirstSelected)
|
||||||
|
{
|
||||||
|
// Three selected proclamations: the built list must be
|
||||||
|
// [S1, Default, S2, Default, S3, Default]. ScrollerPage advances through
|
||||||
|
// this list with a modulo, so once the trailing default finishes the cycle
|
||||||
|
// restarts at the FIRST selected proclamation — the "always on" default
|
||||||
|
// must never accumulate or take over the queue.
|
||||||
|
ProclamationRecord b;
|
||||||
|
b.id = "B";
|
||||||
|
b.title = "B";
|
||||||
|
b.content = "bbbb";
|
||||||
|
b.selected = true;
|
||||||
|
b.solfeggio_frequency_id = 2;
|
||||||
|
|
||||||
|
ProclamationRecord c;
|
||||||
|
c.id = "C";
|
||||||
|
c.title = "C";
|
||||||
|
c.content = "cccc";
|
||||||
|
c.selected = true;
|
||||||
|
c.solfeggio_frequency_id = 3;
|
||||||
|
|
||||||
|
ProclamationRecord d;
|
||||||
|
d.id = "D";
|
||||||
|
d.title = "D";
|
||||||
|
d.content = "dddd";
|
||||||
|
d.selected = true;
|
||||||
|
d.solfeggio_frequency_id = 4;
|
||||||
|
|
||||||
|
ProclamationRecord def;
|
||||||
|
def.id = kDefaultId;
|
||||||
|
def.title = "Default";
|
||||||
|
def.content = "dddddddddd";
|
||||||
|
def.selected = true;
|
||||||
|
def.solfeggio_frequency_id = 5;
|
||||||
|
|
||||||
|
auto streams = buildStreams({b, def, c, d}, {}, palette(), {}, kDefaultId);
|
||||||
|
|
||||||
|
ASSERT_EQ(streams.size(), 6u);
|
||||||
|
EXPECT_EQ(streams[0].name, "B");
|
||||||
|
EXPECT_EQ(streams[1].name, "Default");
|
||||||
|
EXPECT_EQ(streams[2].name, "C");
|
||||||
|
EXPECT_EQ(streams[3].name, "Default");
|
||||||
|
EXPECT_EQ(streams[4].name, "D");
|
||||||
|
EXPECT_EQ(streams[5].name, "Default");
|
||||||
|
|
||||||
|
// Every default copy is independent playback state (fresh counters), so a
|
||||||
|
// copy finishing its passes advances to the next stream rather than
|
||||||
|
// restarting "the default" forever.
|
||||||
|
for (size_t i = 1; i < streams.size(); i += 2) {
|
||||||
|
EXPECT_EQ(streams[i].name, "Default");
|
||||||
|
EXPECT_EQ(streams[i].completed, 0) << "default copy " << i;
|
||||||
|
EXPECT_EQ(streams[i].wordsPlaced, 0) << "default copy " << i;
|
||||||
|
EXPECT_EQ(streams[i].repeat, kDefaultRepeatCount) << "default copy " << i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
||||||
{
|
{
|
||||||
ProclamationRecord p1;
|
ProclamationRecord p1;
|
||||||
|
|||||||
Reference in New Issue
Block a user