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