Add audio device switching UI
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
# Audio output device picker — design suggestions
|
||||
|
||||
Goal: give the kiosk operator a way to select which audio output sink
|
||||
RadiantWave plays through, persist it across reboots, and have the choice
|
||||
actually take effect — without depending on SDL's unstable device
|
||||
enumeration.
|
||||
|
||||
## Binaries available on the kiosk system closure
|
||||
|
||||
The production kiosk closure ships these audio tools (do not assume anything
|
||||
else):
|
||||
|
||||
- `wireplumber` 0.5.15 → **`wpctl`** (status, list, set-default, set-volume,
|
||||
set-mute, settings, inspect). Installed in kiosk `systemPackages`.
|
||||
- `pipewire` 1.6.8 → `pw-cli`, **`pw-dump`** (JSON), `pw-play`, `pw-cat`,
|
||||
`pw-metadata`, `pw-link`, `pw-mon`, `pw-top`.
|
||||
- **No `pactl`/`pacmd`.** The pipewire-pulse daemon is present
|
||||
(`services.pipewire.pulse.enable = true`) but the libpulse client tools are
|
||||
not in the closure. Avoid PulseAudio tooling.
|
||||
|
||||
`wpctl` is the right tool: it is high-level, already on the system, and talks
|
||||
directly to WirePlumber in the kiosk user session.
|
||||
|
||||
## Recommended model: route through the PipeWire default sink
|
||||
|
||||
Keep the app opening only SDL's **default** device (`mix.OpenAudioDevice("")`)
|
||||
and change which sink is the default with WirePlumber. SDL's default-device
|
||||
path already follows the PipeWire default sink, so this makes "selecting an
|
||||
output device" a WirePlumber concern instead of an SDL concern.
|
||||
|
||||
Why not SDL enumeration / named opens (current dead code):
|
||||
|
||||
- The app links `sdl2-compat` (SDL2 API over SDL3) + SDL3's PipeWire driver,
|
||||
where device IDs/descriptions are unstable across PipeWire restarts
|
||||
(libsdl-org/SDL#12278).
|
||||
- `mix.OpenAudioDevice(name)` under sdl2-compat requires an exact match against
|
||||
a cached name list and, on any mismatch, `openDeviceLocked`
|
||||
(`internal/mixer/mixer.go:90-108`) silently falls back to the system default.
|
||||
- On `main`, `SwitchDevice` and `ListDevices` have **no callers** — the feature
|
||||
is unwired. Only `CurrentDevice()` is read, and its result is discarded in
|
||||
`scrollerPage.go:314-318`.
|
||||
|
||||
Routing through `wpctl set-default` uses the mechanism PipeWire/WirePlumber
|
||||
already maintain, and the kiosk's "pin all sinks to 100%" unit
|
||||
(`radiantwave-audio-init.service`) guarantees the volume stays 100% whichever
|
||||
sink ends up default.
|
||||
|
||||
## Device enumeration
|
||||
|
||||
```
|
||||
wpctl list audio sinks
|
||||
```
|
||||
|
||||
Tab-separated fields per line:
|
||||
|
||||
| # | field | example |
|
||||
|---|------------------|----------------------------|
|
||||
| 1 | object ID | `84` |
|
||||
| 2 | name/description | `Built-in Audio Analog Stereo` |
|
||||
| 3 | media/object type| `audio/sink` |
|
||||
| 4 | default marker | `*` if current default |
|
||||
|
||||
Use field 2 (the description) as the **stable, persisted key** — numeric IDs
|
||||
change across reboots, and PipeWire descriptions are what the operator
|
||||
recognizes. Field 4 gives the "current device" highlight. Field 1 feeds
|
||||
`wpctl set-default`. (See the kioskconfig `radiantwave-audio-init.service`
|
||||
for the same parse: `wpctl list audio sinks | cut -f1`.)
|
||||
|
||||
`wpctl status` is an alternative for interactive debugging; `wpctl list` is
|
||||
preferred for parsing.
|
||||
|
||||
## Switch flow
|
||||
|
||||
1. Operator picks a device in the UI.
|
||||
2. `wpctl set-default <ID>` (runs as the kiosk user in the same PipeWire
|
||||
session; no sudo).
|
||||
3. Reopen SDL's default device: `mix.CloseAudio()` then
|
||||
`mix.OpenAudioDevice("")`. The stream now follows the new default sink.
|
||||
4. Preserve volume and resume the current track/loop — reuse the existing
|
||||
`SwitchDevice` teardown/restore logic.
|
||||
5. Persist the chosen **description** to `config.SelectedAudioDevice`.
|
||||
|
||||
## Startup restore
|
||||
|
||||
`Init` (`internal/mixer/mixer.go:42-87`) currently resolves
|
||||
`SelectedAudioDevice` against SDL enumeration via `resolvePreferredDevice` /
|
||||
`matchDevice` (`internal/mixer/device.go:79-107`). Change the resolution to:
|
||||
|
||||
1. `wpctl list audio sinks` → build `ID ↔ description` map.
|
||||
2. Match the stored description (exact, then case-insensitive substring —
|
||||
same policy as `matchDevice`).
|
||||
3. On match: `wpctl set-default <ID>` (with a short retry to cover WirePlumber
|
||||
not yet exposing sinks at boot), then open the default SDL device.
|
||||
4. On no match: log and fall back to the default (the existing warning pattern
|
||||
at `mixer.go:72`). Do **not** write the fallback back to the config so a
|
||||
missing device stays easily re-selectable.
|
||||
|
||||
## API changes (`internal/mixer`)
|
||||
|
||||
Replace the SDL-name-based surface with a wpctl-backed one:
|
||||
|
||||
```go
|
||||
// Device is one selectable output sink.
|
||||
type Device struct {
|
||||
ID string // wpctl object ID (field 1)
|
||||
Description string // PipeWire node description (field 2, persisted)
|
||||
Default bool // current default sink (field 4 == "*")
|
||||
Label string // HumanizeDeviceName(Description) for display
|
||||
}
|
||||
|
||||
// ListDevices returns sinks from `wpctl list audio sinks`.
|
||||
func ListDevices() []Device
|
||||
|
||||
// SetDefault makes dev the PipeWire default sink and reopens the SDL default
|
||||
// device so playback follows it. Preserves volume and resumes the current
|
||||
// track/loop (reuse the current SwitchDevice teardown/restore).
|
||||
func SetDefault(dev Device, cfg *db.AppConfig) error
|
||||
```
|
||||
|
||||
- Keep `CurrentDevice()` returning the description of the sink that was last
|
||||
set as default (or `""` for "system default").
|
||||
- `HumanizeDeviceName` (`internal/mixer/device.go`) keeps working: the
|
||||
descriptions it already maps (hdmi/monitor/tv, headset, usb, built-in/analog)
|
||||
are exactly the PipeWire node descriptions `wpctl list` reports.
|
||||
- If keeping the SDL-based `SwitchDevice` around, document it as superseded by
|
||||
`SetDefault`; on `main` it has no callers.
|
||||
|
||||
Suggested layout:
|
||||
|
||||
- `internal/mixer/device.go` — Device type, wpctl-backed enumeration,
|
||||
humanization (edit in place).
|
||||
- `internal/mixer/devicectl_unix.go` — `exec` helpers: `listSinks()`,
|
||||
`setDefault(id)`, wrapped with `wpctl` resolved from PATH.
|
||||
- `internal/mixer/devicectl_other.go` — stub returning
|
||||
`errors.New("audio device control not supported on this platform")` if
|
||||
cross-platform builds matter (kiosk is Linux-only).
|
||||
- `internal/mixer/devicectl_test.go` — table tests parsing fixtures of
|
||||
`wpctl list audio sinks` output (multiple sinks, default marker present and
|
||||
absent, descriptions containing spaces).
|
||||
|
||||
## UI (`internal/page`)
|
||||
|
||||
New page `internal/page/AudioDevices.go`, mirroring `AudioOptions` structure
|
||||
(`StringItem` list, triangle cursor, scroll, `updateSelectedIndex`):
|
||||
|
||||
- Items = `mixer.ListDevices()`, label = `Device.Label`, with the current
|
||||
default (`Device.Default`) rendered green like the selected track in
|
||||
`AudioOptions.Render()`.
|
||||
- First item: `System Default` (opens the default device without touching
|
||||
WirePlumber defaults — a safe always-valid choice).
|
||||
- `Enter` → `mixer.SetDefault(dev, config)`; refresh highlight; log errors
|
||||
without leaving the page.
|
||||
- `Backspace` → navigate to Audio Options (mirror `AudioOptions.HandleEvent`).
|
||||
- `Update`/`Render`/`Destroy` mirror `AudioOptions`; the page is simple enough
|
||||
to copy the pattern wholesale.
|
||||
|
||||
Wiring:
|
||||
|
||||
- Add an "Output Device" entry to the Audio Options page (either a new
|
||||
`StringItem` in `AudioOptions.go` that navigates to `AudioDevices`, or a
|
||||
section header like the existing `"Audio Track"` section).
|
||||
- `AudioOptions.HandleEvent` already routes `Enter` per `focusState`; extend it
|
||||
to route the new entry, mirroring `p.Context.NavigateTo(SETTINGS)`.
|
||||
|
||||
## Packaging
|
||||
|
||||
`wpctl` currently only exists because kioskconfig puts `pkgs.wireplumber` in
|
||||
`systemPackages`. Make the app self-contained:
|
||||
|
||||
- Add `wireplumber` to the package's runtime PATH in `flake.nix` (e.g. via the
|
||||
existing `makeWrapper` that already provides `SSL_CERT_FILE`), so the app
|
||||
builds and runs correctly on any host, not just the kiosk.
|
||||
- Keep `pkgs.wireplumber` in kioskconfig `systemPackages` (it also backs the
|
||||
`radiantwave-audio-init` service) — no change needed there.
|
||||
|
||||
## Migration
|
||||
|
||||
Existing `SelectedAudioDevice` values hold raw SDL device names. On first run
|
||||
after this change, if the stored value matches no `wpctl` description, log and
|
||||
fall back to the default (existing behavior pattern) — no data migration
|
||||
required. Once a device is selected through the picker, the description is
|
||||
stored going forward.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: parse fixtures of `wpctl list audio sinks` output; verify
|
||||
`SetDefault` invokes `wpctl set-default` (fake `wpctl` on PATH).
|
||||
- Kiosk smoke test: build + deploy, `wpctl status` before/after; pick the HDMI
|
||||
sink in the UI, confirm the stream link target via `pw-dump`/`pw-top` and
|
||||
that volume reads 100% on the new sink.
|
||||
@@ -413,7 +413,9 @@ func (app *Application) initializeAudio() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := mixer.Init(config, app.refreshCache); err != nil {
|
||||
if err := mixer.Init(config, app.refreshCache, func(c *db.AppConfig) error {
|
||||
return app.DB.Save(c).Error
|
||||
}); err != nil {
|
||||
logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err)
|
||||
logger.Error("Audio will be unavailable until a device is configured")
|
||||
} else {
|
||||
|
||||
@@ -10,13 +10,13 @@ type AppConfig struct {
|
||||
EmailAddress string
|
||||
LicenseKey string
|
||||
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
|
||||
SelectedAudioDevice string // The pipewire device name
|
||||
SelectedAudioDevice string // Raw SDL audio device name from enumeration; empty string = system default. Humanized for display via mixer.HumanizeDeviceName
|
||||
InitialFontSize int // How large to create the font atlas
|
||||
StandardFontSize int // The font size used for most text rendering
|
||||
LinePattern string // "fibonacci"
|
||||
AudioEnabled bool
|
||||
DynamicAudio bool // When true, scroller switches audio per-proclamation frequency
|
||||
SavedVolume float64 // Last saved volume as a percentage
|
||||
SavedVolume float64 // In-app volume as a 0.0–1.0 fraction of the OS audio sink (kiosk pins the sink to 100%, so this is the effective output level)
|
||||
FirstRun bool // Is this the first time the application has been run?
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package mixer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
// Device describes an audio output device as seen through SDL's core
|
||||
// enumeration APIs. Name is the raw SDL device string used to open the device
|
||||
// and to persist the selection; Label is the humanized name shown to the user.
|
||||
type Device struct {
|
||||
Name string
|
||||
Label string
|
||||
}
|
||||
|
||||
// humanizeRules maps raw SDL device names to human-readable labels. Rules are
|
||||
// evaluated in order; the first rule whose keyword is a case-insensitive
|
||||
// substring of the raw name wins.
|
||||
var humanizeRules = []struct {
|
||||
keywords []string
|
||||
label string
|
||||
}{
|
||||
{[]string{"hdmi", "monitor", "tv"}, "HDMI | Monitor | TV"},
|
||||
{[]string{"headset", "headphone"}, "Headset"},
|
||||
{[]string{"usb"}, "USB"},
|
||||
{[]string{"analog", "built-in", "builtin", "internal", "speaker"}, "Internal Speakers"},
|
||||
}
|
||||
|
||||
// HumanizeDeviceName maps a raw SDL audio device name to a human-readable
|
||||
// label. Raw names come from SDL_GetAudioDeviceName, e.g. PipeWire sink
|
||||
// descriptions ("Built-in Audio Analog Stereo", "Built-in Audio Digital
|
||||
// Stereo (HDMI)") or PulseAudio sink names ("alsa_output.pci-0000_00_1f.3.
|
||||
// analog-stereo"). If no rule matches, the raw name is returned unchanged.
|
||||
func HumanizeDeviceName(raw string) string {
|
||||
lower := strings.ToLower(raw)
|
||||
for _, rule := range humanizeRules {
|
||||
for _, kw := range rule.keywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return rule.label
|
||||
}
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// enumeratePlaybackDevices returns the available playback devices from SDL's
|
||||
// core audio enumeration, each with a humanized label. It is the single place
|
||||
// that touches SDL2's device enumeration APIs; a future SDL3 migration only
|
||||
// needs to change this function and the mixer.OpenAudioDevice boundary.
|
||||
func enumeratePlaybackDevices() []Device {
|
||||
n := sdl.GetNumAudioDevices(false)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Device, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if name := sdl.GetAudioDeviceName(i, false); name != "" {
|
||||
out = append(out, Device{Name: name, Label: HumanizeDeviceName(name)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListDevices returns the list of available output devices (from core SDL).
|
||||
// Name holds the raw device string (use it with SwitchDevice/CurrentDevice);
|
||||
// Label holds the humanized display name.
|
||||
func ListDevices() []Device {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return enumeratePlaybackDevices()
|
||||
}
|
||||
|
||||
// matchDevice resolves a stored/preferred device name against enumerated
|
||||
// device names. An exact match wins; otherwise the first device whose name
|
||||
// contains the preferred string (case-insensitive) is returned, tolerating
|
||||
// PipeWire/PulseAudio node-name suffixes. Returns false when the preferred
|
||||
// name is empty or no device matches.
|
||||
func matchDevice(names []string, preferred string) (string, bool) {
|
||||
if preferred == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, name := range names {
|
||||
if name == preferred {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
lowerPref := strings.ToLower(preferred)
|
||||
for _, name := range names {
|
||||
if strings.Contains(strings.ToLower(name), lowerPref) {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// resolvePreferredDevice finds the enumeration name to open for a persisted
|
||||
// device selection. Returns false when the preferred name is empty or no
|
||||
// enumerated device matches, in which case the caller should fall back to the
|
||||
// system default.
|
||||
func resolvePreferredDevice(preferred string) (string, bool) {
|
||||
devs := enumeratePlaybackDevices()
|
||||
names := make([]string, 0, len(devs))
|
||||
for _, d := range devs {
|
||||
names = append(names, d.Name)
|
||||
}
|
||||
return matchDevice(names, preferred)
|
||||
}
|
||||
+31
-20
@@ -31,10 +31,15 @@ var (
|
||||
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
|
||||
|
||||
refreshCacheCallback func()
|
||||
|
||||
persistConfigCallback func(*db.AppConfig) error
|
||||
)
|
||||
|
||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
||||
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||
// persistConfig persists the given config (used to save the selected audio
|
||||
// device across restarts); it may be nil, in which case device selections are
|
||||
// not saved.
|
||||
func Init(cfg *db.AppConfig, onUpdate func(), persistConfig func(*db.AppConfig) error) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if inited {
|
||||
@@ -42,6 +47,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||
}
|
||||
|
||||
refreshCacheCallback = onUpdate
|
||||
persistConfigCallback = persistConfig
|
||||
|
||||
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
||||
return fmt.Errorf("SDL audio init failed: %w", err)
|
||||
@@ -52,9 +58,20 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
||||
}
|
||||
|
||||
// A stored "default" (the first-run seed) is the system default.
|
||||
deviceName := cfg.SelectedAudioDevice
|
||||
if deviceName == "default" {
|
||||
if deviceName == "" || deviceName == "default" {
|
||||
deviceName = ""
|
||||
} else {
|
||||
// Honor a persisted device selection by resolving it against the
|
||||
// currently enumerated devices, falling back to the system default if
|
||||
// it is no longer present.
|
||||
if resolved, ok := resolvePreferredDevice(deviceName); ok {
|
||||
deviceName = resolved
|
||||
} else {
|
||||
logger.Warning("Selected audio device %q not found; using system default", cfg.SelectedAudioDevice)
|
||||
deviceName = ""
|
||||
}
|
||||
}
|
||||
if err := openDeviceLocked(deviceName); err != nil {
|
||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||
@@ -178,7 +195,10 @@ func IsPlaying() bool {
|
||||
return mix.PlayingMusic()
|
||||
}
|
||||
|
||||
// SetVolume sets the SDL_mixer music volume to level (0.0–1.0).
|
||||
// SetVolume sets the SDL_mixer music volume to level (0.0–1.0), mapped onto
|
||||
// SDL_mixer's 0–128 scale (mix.MAX_VOLUME). This is relative to the OS audio
|
||||
// sink, which the kiosk shell pins to 100%, so in-app volume is 0–100% of the
|
||||
// sink output.
|
||||
func SetVolume(level float64) error {
|
||||
if level < 0 || level > 1 {
|
||||
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
||||
@@ -204,23 +224,6 @@ func ConvertVolume(volPerc int) float64 {
|
||||
return float64(volPerc) / 100.0
|
||||
}
|
||||
|
||||
// ListDevices returns the list of available output device names (from core SDL).
|
||||
func ListDevices() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
n := sdl.GetNumAudioDevices(false)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if name := sdl.GetAudioDeviceName(i, false); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentDevice returns the last requested/opened device name ("" means default).
|
||||
func CurrentDevice() string {
|
||||
return deviceName
|
||||
@@ -265,6 +268,14 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
||||
}
|
||||
|
||||
if cfg != nil {
|
||||
// Persist whatever device is actually open (openDeviceLocked falls
|
||||
// back to the system default if the requested name cannot be opened).
|
||||
cfg.SelectedAudioDevice = CurrentDevice()
|
||||
if persistConfigCallback != nil {
|
||||
if err := persistConfigCallback(cfg); err != nil {
|
||||
logger.Error("Failed to persist audio device selection: %v", err)
|
||||
}
|
||||
}
|
||||
currentVolume = cfg.SavedVolume
|
||||
v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME)))
|
||||
mix.VolumeMusic(v)
|
||||
|
||||
@@ -1,145 +1,71 @@
|
||||
package mixer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
import "testing"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
)
|
||||
|
||||
// Regression tests for critical audio bugs (v0.5)
|
||||
|
||||
// Regression: Stop() used to return early when AudioEnabled was false,
|
||||
// leaving music playing after the user toggled audio off mid-playback.
|
||||
func TestStopCleansUpWhenAudioDisabled(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = true
|
||||
deviceOpen = false
|
||||
currentPath = "/some/track.wav"
|
||||
currentLoops = -1
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
currentPath = ""
|
||||
currentLoops = 0
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
cfg := &db.AppConfig{AudioEnabled: false}
|
||||
Stop(0, cfg)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if currentPath != "" {
|
||||
t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath)
|
||||
func TestHumanizeDeviceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"hdmi description", "Built-in Audio Digital Stereo (HDMI)", "HDMI | Monitor | TV"},
|
||||
{"hdmi alsa name", "alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1", "HDMI | Monitor | TV"},
|
||||
{"monitor", "Monitor", "HDMI | Monitor | TV"},
|
||||
{"tv", "TV", "HDMI | Monitor | TV"},
|
||||
{"uppercase hdmi", "HDMI", "HDMI | Monitor | TV"},
|
||||
{"headset", "Headset", "Headset"},
|
||||
{"headphones", "WH-1000XM4 Headphones", "Headset"},
|
||||
{"bluetooth headset name", "bluez_output.00_11_22_33_44_55.headset-head-unit", "Headset"},
|
||||
{"usb description", "USB Audio", "USB"},
|
||||
{"usb alsa name", "alsa_output.usb-046d_0825-00.analog-stereo", "USB"},
|
||||
{"internal analog description", "Built-in Audio Analog Stereo", "Internal Speakers"},
|
||||
{"internal alsa name", "alsa_output.pci-0000_00_1f.3.analog-stereo", "Internal Speakers"},
|
||||
{"internal speakers label", "Internal Speakers", "Internal Speakers"},
|
||||
{"virtual speaker", "Virtual Speaker", "Internal Speakers"},
|
||||
{"no match fallback", "Custom Node 42", "Custom Node 42"},
|
||||
{"empty fallback", "", ""},
|
||||
}
|
||||
if currentLoops != 0 {
|
||||
t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := HumanizeDeviceName(tt.raw); got != tt.want {
|
||||
t.Errorf("HumanizeDeviceName(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: SwitchDevice used to convert oldDevice="" to "system default"
|
||||
// and pass that string to openDeviceLocked, which SDL does not recognize.
|
||||
func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
func TestMatchDeviceName(t *testing.T) {
|
||||
names := []string{
|
||||
"Built-in Audio Analog Stereo",
|
||||
"Built-in Audio Digital Stereo (HDMI)",
|
||||
"USB Audio",
|
||||
"Headset",
|
||||
}
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
tests := []struct {
|
||||
name string
|
||||
preferred string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"empty preferred", "", "", false},
|
||||
{"exact match", "USB Audio", "USB Audio", true},
|
||||
{"substring match", "hdmi", "Built-in Audio Digital Stereo (HDMI)", true},
|
||||
{"suffix tolerance", "Built-in Audio Digital Stereo", "Built-in Audio Digital Stereo (HDMI)", true},
|
||||
{"case insensitive", "usb audio", "USB Audio", true},
|
||||
{"no match", "Bluetooth Dongle", "", false},
|
||||
}
|
||||
|
||||
cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5}
|
||||
err := SwitchDevice("Some Device", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := matchDevice(names, tt.preferred)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("matchDevice(%q) ok = %v, want %v", tt.preferred, ok, tt.wantOK)
|
||||
}
|
||||
if ok && got != tt.want {
|
||||
t.Errorf("matchDevice(%q) = %q, want %q", tt.preferred, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: SwitchDevice with nil config must not panic when attempting
|
||||
// to restore volume after reopening the device.
|
||||
func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
err := SwitchDevice("Some Device", nil)
|
||||
if err == nil {
|
||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: SwitchDevice with empty device name (system default) should
|
||||
// not be converted to "system default" before passing to openDeviceLocked.
|
||||
func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
cfg := &db.AppConfig{SavedVolume: 0.75}
|
||||
err := SwitchDevice("", cfg)
|
||||
// Returns error because mixer is not initialized, but should not panic
|
||||
// and should handle the empty name gracefully.
|
||||
if err == nil {
|
||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error
|
||||
// (not a deadlock) when the mixer has not been initialized.
|
||||
// The old code would deadlock because Play() held mu then called SwitchDevice()
|
||||
// which also tried to acquire mu.
|
||||
func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) {
|
||||
// Ensure mixer is in uninitialized state
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
cfg := &db.AppConfig{AudioEnabled: true}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Play("nonexistent.wav", false, cfg)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("Play() returned nil error on uninitialized mixer, want error")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
const systemDefaultDeviceName = "System Default"
|
||||
|
||||
// AudioDevices lists the available audio output sinks and lets the operator
|
||||
// pick which one RadiantWave plays through. The selection is persisted to the
|
||||
// config (via mixer.SwitchDevice) and restored on the next launch.
|
||||
type AudioDevices struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
|
||||
verticalMargin int32
|
||||
ItemScale float32
|
||||
|
||||
sectionHeader StringItem
|
||||
|
||||
devices []mixer.Device
|
||||
deviceItems []StringItem
|
||||
selectedDeviceIndex int
|
||||
|
||||
focusState int
|
||||
|
||||
triVAO uint32
|
||||
triVBO uint32
|
||||
triX float32
|
||||
triCenterY float32
|
||||
spinElapsed float32
|
||||
|
||||
scrollOffset int
|
||||
maxVisible int
|
||||
listItemMargin int32
|
||||
listAreaTop int32
|
||||
listAreaBottom int32
|
||||
listItemHeight int32
|
||||
scrollTrackWidth int32
|
||||
scrollTrackX int32
|
||||
scrollTrackY int32
|
||||
scrollTrackH int32
|
||||
|
||||
dotSize float32
|
||||
|
||||
listLeftX int32
|
||||
}
|
||||
|
||||
// currentDeviceIndex maps the currently opened SDL device name onto the
|
||||
// selectable list. Index 0 is always "System Default" ("" name). A matching
|
||||
// device returns its 1-based list index; no match (or an empty list) returns
|
||||
// -1 so the caller can leave the highlight unset.
|
||||
func currentDeviceIndex(devices []mixer.Device, currentName string) int {
|
||||
if currentName == "" {
|
||||
return 0
|
||||
}
|
||||
for i, dev := range devices {
|
||||
if dev.Name == currentName {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Init(ctx *PageCtx) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
p.listItemMargin = 6
|
||||
p.focusState = 0
|
||||
p.scrollOffset = 0
|
||||
p.dotSize = 8.0
|
||||
p.scrollTrackWidth = 30
|
||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
||||
}
|
||||
p.font = font
|
||||
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
||||
}
|
||||
p.solidShader, ok = sm.Get("solid_color")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
||||
}
|
||||
|
||||
if err = p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("base init failed: %w", err)
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
baseFontSize := config.StandardFontSize
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
p.ItemScale = float32(32) / float32(baseFontSize)
|
||||
|
||||
p.title, err = NewStringItem("Audio Output Device", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
p.prompt, err = NewStringItem(
|
||||
"Enter to select -- Up/Down to navigate -- Backspace to go back",
|
||||
p.font, colors.White,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(p.ItemScale)
|
||||
|
||||
p.sectionHeader, err = NewStringItem("Output Device", p.font, colors.Silver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating section header: %w", err)
|
||||
}
|
||||
p.sectionHeader.Scale(p.ItemScale * 1.2)
|
||||
|
||||
p.buildDeviceList()
|
||||
|
||||
triVerts := []float32{
|
||||
0.5, 0.0,
|
||||
-0.5, 0.5,
|
||||
-0.5, -0.5,
|
||||
}
|
||||
gl.GenVertexArrays(1, &p.triVAO)
|
||||
gl.GenBuffers(1, &p.triVBO)
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) buildDeviceList() {
|
||||
for i := range p.deviceItems {
|
||||
gl.DeleteTextures(1, &p.deviceItems[i].ID)
|
||||
}
|
||||
p.deviceItems = nil
|
||||
|
||||
p.devices = mixer.ListDevices()
|
||||
|
||||
systemDefaultItem, err := NewStringItem(systemDefaultDeviceName, p.font, colors.White)
|
||||
if err != nil {
|
||||
logger.Error("failed to create System Default string item: %v", err)
|
||||
} else {
|
||||
systemDefaultItem.Scale(p.ItemScale)
|
||||
p.deviceItems = append(p.deviceItems, systemDefaultItem)
|
||||
}
|
||||
|
||||
for _, dev := range p.devices {
|
||||
label := dev.Label
|
||||
if label == "" {
|
||||
label = dev.Name
|
||||
}
|
||||
item, err := NewStringItem(label, p.font, colors.White)
|
||||
if err != nil {
|
||||
logger.Error("failed to create audio device string item for %q: %v", dev.Name, err)
|
||||
continue
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.deviceItems = append(p.deviceItems, item)
|
||||
}
|
||||
|
||||
p.updateSelectedIndex()
|
||||
}
|
||||
|
||||
func (p *AudioDevices) updateSelectedIndex() {
|
||||
p.selectedDeviceIndex = currentDeviceIndex(p.devices, mixer.CurrentDevice())
|
||||
if p.focusState < 0 || p.focusState >= len(p.deviceItems) {
|
||||
p.focusState = 0
|
||||
}
|
||||
}
|
||||
|
||||
// selectDevice makes the item at index the active output sink and persists the
|
||||
// choice. Index 0 is the system default ("") — the SDL default device with no
|
||||
// PipeWire/WirePlumber involvement. Errors are logged but do not leave the page.
|
||||
func (p *AudioDevices) selectDevice(index int) {
|
||||
if index < 0 || index >= len(p.deviceItems) {
|
||||
return
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
logger.Error("selectDevice: failed to load config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
name := ""
|
||||
if index > 0 {
|
||||
devIdx := index - 1
|
||||
if devIdx >= 0 && devIdx < len(p.devices) {
|
||||
name = p.devices[devIdx].Name
|
||||
}
|
||||
}
|
||||
|
||||
if err := mixer.SwitchDevice(name, config); err != nil {
|
||||
logger.Error("Failed to switch audio device: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
p.updateSelectedIndex()
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
func (p *AudioDevices) layout() {
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||
|
||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
|
||||
|
||||
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
|
||||
p.sectionHeader.Y = p.prompt.Y - p.verticalMargin - p.sectionHeader.H
|
||||
|
||||
p.listItemHeight = p.deviceItems[0].H
|
||||
listTop := p.sectionHeader.Y - p.listItemMargin
|
||||
listBottomMargin := int32(12)
|
||||
|
||||
listH := listTop - listBottomMargin
|
||||
if listH < p.listItemHeight {
|
||||
listH = p.listItemHeight
|
||||
}
|
||||
|
||||
p.listAreaTop = listTop
|
||||
p.listAreaBottom = listBottomMargin
|
||||
|
||||
itemStep := p.listItemHeight + p.listItemMargin
|
||||
n := len(p.deviceItems)
|
||||
p.maxVisible = int(listH / itemStep)
|
||||
if p.maxVisible < 1 {
|
||||
p.maxVisible = 1
|
||||
}
|
||||
if p.maxVisible > n {
|
||||
p.maxVisible = n
|
||||
}
|
||||
|
||||
var maxItemW int32
|
||||
for i := range p.deviceItems {
|
||||
w := p.deviceItems[i].W
|
||||
if w > maxItemW {
|
||||
maxItemW = w
|
||||
}
|
||||
}
|
||||
p.listLeftX = p.ScreenCenterX - maxItemW/2
|
||||
|
||||
firstVisibleY := p.listAreaTop - p.listItemHeight
|
||||
for i := range p.deviceItems {
|
||||
item := &p.deviceItems[i]
|
||||
item.X = p.listLeftX
|
||||
item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep
|
||||
}
|
||||
|
||||
maxScroll := n - p.maxVisible
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if p.scrollOffset > maxScroll {
|
||||
p.scrollOffset = maxScroll
|
||||
}
|
||||
|
||||
p.scrollTrackY = p.listAreaBottom
|
||||
p.scrollTrackH = p.listAreaTop - p.listAreaBottom
|
||||
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
func (p *AudioDevices) updateCursorPosition() {
|
||||
cursorGap := int32(12)
|
||||
|
||||
if p.focusState < 0 || p.focusState >= len(p.deviceItems) {
|
||||
return
|
||||
}
|
||||
item := &p.deviceItems[p.focusState]
|
||||
|
||||
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
||||
}
|
||||
|
||||
func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
||||
e, ok := (*event).(*sdl.KeyboardEvent)
|
||||
if !ok || e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
|
||||
n := len(p.deviceItems)
|
||||
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_RETURN:
|
||||
p.selectDevice(p.focusState)
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.focusState < n-1 {
|
||||
p.focusState++
|
||||
if p.focusState >= p.scrollOffset+p.maxVisible {
|
||||
p.scrollOffset++
|
||||
p.layout()
|
||||
} else {
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
}
|
||||
|
||||
case sdl.K_UP:
|
||||
if p.focusState > 0 {
|
||||
p.focusState--
|
||||
if p.focusState < p.scrollOffset {
|
||||
p.scrollOffset--
|
||||
p.layout()
|
||||
} else {
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
}
|
||||
|
||||
case sdl.K_PAGEUP:
|
||||
if p.focusState > 0 {
|
||||
p.focusState -= p.maxVisible - 1
|
||||
if p.focusState < 0 {
|
||||
p.focusState = 0
|
||||
}
|
||||
p.scrollOffset = p.focusState
|
||||
p.layout()
|
||||
}
|
||||
|
||||
case sdl.K_PAGEDOWN:
|
||||
if p.focusState < n-1 {
|
||||
p.focusState += p.maxVisible - 1
|
||||
if p.focusState >= n {
|
||||
p.focusState = n - 1
|
||||
}
|
||||
p.scrollOffset = p.focusState - p.maxVisible + 1
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
p.layout()
|
||||
}
|
||||
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(AUDIO)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Update(dt float32) error {
|
||||
p.spinElapsed += dt
|
||||
if p.spinElapsed >= 1.0 {
|
||||
p.spinElapsed -= 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) renderCursor() error {
|
||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
gl.UseProgram(p.solidShader.ProgramID)
|
||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
gl.Uniform4f(
|
||||
p.solidShader.LocColor,
|
||||
float32(colors.White.R)/255.0,
|
||||
float32(colors.White.G)/255.0,
|
||||
float32(colors.White.B)/255.0,
|
||||
float32(colors.White.A)/255.0,
|
||||
)
|
||||
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
||||
gl.BindVertexArray(0)
|
||||
gl.UseProgram(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) renderScrollbar() {
|
||||
n := len(p.deviceItems)
|
||||
if n <= p.maxVisible {
|
||||
return
|
||||
}
|
||||
|
||||
trackColor := sdl.Color{R: 255, G: 255, B: 255, A: 30}
|
||||
thumbColor := sdl.Color{R: 255, G: 255, B: 255, A: 100}
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackH)},
|
||||
trackColor)
|
||||
|
||||
visibleRatio := float32(p.maxVisible) / float32(n)
|
||||
scrollRatio := float32(p.scrollOffset) / float32(n)
|
||||
thumbHeight := float32(p.scrollTrackH) * visibleRatio
|
||||
if thumbHeight < 20 {
|
||||
thumbHeight = 20
|
||||
}
|
||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackH)*scrollRatio
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
||||
thumbColor)
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Render() error {
|
||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||
return fmt.Errorf("render title: %w", err)
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
|
||||
return fmt.Errorf("render prompt: %w", err)
|
||||
}
|
||||
|
||||
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
|
||||
|
||||
// List background — centered width matching widest item + padding
|
||||
var maxItemW int32
|
||||
for i := range p.deviceItems {
|
||||
w := p.deviceItems[i].W
|
||||
if w > maxItemW {
|
||||
maxItemW = w
|
||||
}
|
||||
}
|
||||
bgPad := int32(16)
|
||||
bgW := maxItemW + 2*bgPad
|
||||
bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2
|
||||
bgTop := float32(p.listAreaBottom)
|
||||
bgBottom := float32(p.listAreaTop)
|
||||
listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12}
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{bgLeft, bgTop},
|
||||
mgl32.Vec2{float32(bgW), bgBottom - bgTop},
|
||||
listBgColor)
|
||||
|
||||
// Device list (visible window only)
|
||||
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.deviceItems); i++ {
|
||||
item := &p.deviceItems[i]
|
||||
clr := colors.White
|
||||
if i == p.selectedDeviceIndex {
|
||||
clr = colors.Green
|
||||
}
|
||||
|
||||
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil {
|
||||
return fmt.Errorf("render device %d: %w", i, err)
|
||||
}
|
||||
|
||||
if i == p.selectedDeviceIndex {
|
||||
dotX := float32(item.X) - p.dotSize - 6
|
||||
dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{dotX, dotY},
|
||||
mgl32.Vec2{p.dotSize, p.dotSize},
|
||||
colors.Green)
|
||||
}
|
||||
}
|
||||
|
||||
p.renderScrollbar()
|
||||
|
||||
if err := p.renderCursor(); err != nil {
|
||||
return fmt.Errorf("render cursor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
gl.DeleteTextures(1, &p.sectionHeader.ID)
|
||||
for i := range p.deviceItems {
|
||||
gl.DeleteTextures(1, &p.deviceItems[i].ID)
|
||||
}
|
||||
gl.DeleteBuffers(1, &p.triVBO)
|
||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
)
|
||||
|
||||
func TestCurrentDeviceIndex(t *testing.T) {
|
||||
devices := []mixer.Device{
|
||||
{Name: "Built-in Audio Analog Stereo", Label: "Internal Speakers"},
|
||||
{Name: "Built-in Audio Digital Stereo (HDMI)", Label: "HDMI | Monitor | TV"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
devices []mixer.Device
|
||||
currentName string
|
||||
want int
|
||||
}{
|
||||
{"empty list, default device", nil, "", 0},
|
||||
{"empty list, named device", nil, "USB Audio", -1},
|
||||
{"default device", devices, "", 0},
|
||||
{"exact first device match", devices, "Built-in Audio Analog Stereo", 1},
|
||||
{"exact second device match", devices, "Built-in Audio Digital Stereo (HDMI)", 2},
|
||||
{"no match", devices, "USB Audio", -1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := currentDeviceIndex(tt.devices, tt.currentName); got != tt.want {
|
||||
t.Errorf("currentDeviceIndex(%v, %q) = %d, want %d", tt.devices, tt.currentName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+58
-298
@@ -19,23 +19,9 @@ const focusToggle = -1
|
||||
|
||||
const triangleSize = float32(16)
|
||||
|
||||
var audioTrackSeed = []db.AudioTrack{
|
||||
{Filename: "OriginalAudio.wav", Name: "Original Audio", AssignedFrequency: 0, MD5: "d39c2676c315438752ab0c55e9427d74", Order: 1},
|
||||
{Filename: "174Hz.wav", Name: "174 Hz", AssignedFrequency: 174, MD5: "42abaf332e628b646ac2cc9d1b5385e7", Order: 2},
|
||||
{Filename: "396Hz.wav", Name: "396 Hz", AssignedFrequency: 396, MD5: "d53d45849b042122a3c177fad95ff499", Order: 3},
|
||||
{Filename: "417Hz.wav", Name: "417 Hz", AssignedFrequency: 417, MD5: "056a7aae0b53111f5c568ea7ecd1fadb", Order: 4},
|
||||
{Filename: "432Hz.wav", Name: "432 Hz", AssignedFrequency: 432, MD5: "276c57179372404ff59c35024911468e", Order: 5},
|
||||
{Filename: "639Hz.wav", Name: "639 Hz", AssignedFrequency: 639, MD5: "4e460148230f4417fef39e9817f75e06", Order: 6},
|
||||
{Filename: "741Hz.wav", Name: "741 Hz", AssignedFrequency: 741, MD5: "cfbd9b03b539db69bcefdd91cedc9ad1", Order: 7},
|
||||
{Filename: "852Hz.wav", Name: "852 Hz", AssignedFrequency: 852, MD5: "485700296d017f8a1b0a7583a55a7783", Order: 8},
|
||||
{Filename: "963Hz.wav", Name: "963 Hz", AssignedFrequency: 963, MD5: "2670e13294daef8490a862c078aa8af4", Order: 9},
|
||||
{Filename: "1111Hz.wav", Name: "1111 Hz", AssignedFrequency: 1111, MD5: "67bbc7619dc21d0750519113472ca335", Order: 10},
|
||||
{Filename: "14Hz_Binaural.wav", Name: "14 Hz Binaural Beat", AssignedFrequency: 14, MD5: "807c14866640d2ab27a6f5d7fdd24e77", Order: 11},
|
||||
{Filename: "40Hz_Binaural.wav", Name: "40 Hz Binaural Beat", AssignedFrequency: 40, MD5: "42d60d55a16db4e9b29da47a5a102f2c", Order: 12},
|
||||
{Filename: "AlphaWaves.wav", Name: "Alpha Waves", AssignedFrequency: 0, MD5: "07a93fa0e633a00d97b8a5e5a259d6ff", Order: 13},
|
||||
{Filename: "PinealGland.wav", Name: "Pineal Gland", AssignedFrequency: 0, MD5: "998d74e3826b30086d64ff0df7b9f133", Order: 14},
|
||||
}
|
||||
|
||||
// AudioOptions is the hub for audio settings. It hosts the master volume and
|
||||
// the audio on/off toggle, and routes to the TrackSelection and AudioDevices
|
||||
// pages for picking the track and the output device.
|
||||
type AudioOptions struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
@@ -50,9 +36,8 @@ type AudioOptions struct {
|
||||
|
||||
AudioStatusToggle StringItem
|
||||
|
||||
audioFileTextures []StringItem
|
||||
audioTracks []db.AudioTrack
|
||||
selectedFileIndex int
|
||||
audioTrackItem StringItem
|
||||
outputDeviceItem StringItem
|
||||
|
||||
VolumeLabel StringItem
|
||||
volumePercent StringItem
|
||||
@@ -68,34 +53,12 @@ type AudioOptions struct {
|
||||
spinElapsed float32
|
||||
|
||||
focusState int
|
||||
|
||||
sectionHeader StringItem
|
||||
|
||||
scrollOffset int
|
||||
maxVisible int
|
||||
listItemMargin int32
|
||||
listAreaTop int32
|
||||
listAreaBottom int32
|
||||
listItemHeight int32
|
||||
scrollTrackWidth int32
|
||||
scrollTrackX int32
|
||||
scrollTrackY int32
|
||||
scrollTrackH int32
|
||||
|
||||
dotSize float32
|
||||
|
||||
listLeftX int32
|
||||
}
|
||||
|
||||
func (p *AudioOptions) Init(ctx *PageCtx) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
p.listItemMargin = 6
|
||||
p.focusState = focusToggle
|
||||
p.scrollOffset = 0
|
||||
p.dotSize = 8.0
|
||||
p.scrollTrackWidth = 30
|
||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
@@ -155,12 +118,10 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
||||
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
||||
}
|
||||
|
||||
if err = p.seedAudioTracks(); err != nil {
|
||||
logger.Error("failed to seed audio tracks: %v", err)
|
||||
if err = p.buildNavigationItems(); err != nil {
|
||||
return fmt.Errorf("creating navigation items: %w", err)
|
||||
}
|
||||
|
||||
p.buildAudioFileList()
|
||||
|
||||
triVerts := []float32{
|
||||
0.5, 0.0,
|
||||
-0.5, 0.5,
|
||||
@@ -176,84 +137,56 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
p.sectionHeader, err = NewStringItem("Audio Track", p.font, colors.Silver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating section header: %w", err)
|
||||
}
|
||||
p.sectionHeader.Scale(p.ItemScale * 1.2)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioOptions) buildAudioFileList() {
|
||||
for i := range p.audioFileTextures {
|
||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
||||
}
|
||||
p.audioFileTextures = nil
|
||||
// buildNavigationItems creates the "Audio Track" and "Output Device" entries
|
||||
// that navigate to the TrackSelection and AudioDevices pages. Labels include
|
||||
// the currently selected value so returning to this page shows the latest
|
||||
// choice.
|
||||
func (p *AudioOptions) buildNavigationItems() error {
|
||||
var err error
|
||||
|
||||
var tracks []db.AudioTrack
|
||||
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
|
||||
p.audioTracks = tracks
|
||||
|
||||
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
|
||||
p.audioTrackItem, err = NewStringItem(p.currentTrackLabel(), p.font, colors.White)
|
||||
if err != nil {
|
||||
logger.Error("failed to create Dynamic Audio string item: %v", err)
|
||||
} else {
|
||||
dynamicItem.Scale(p.ItemScale)
|
||||
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
|
||||
return fmt.Errorf("creating audio track item: %w", err)
|
||||
}
|
||||
p.audioTrackItem.Scale(p.ItemScale)
|
||||
|
||||
for _, track := range tracks {
|
||||
item, err := NewStringItem(track.Name, p.font, colors.White)
|
||||
if err != nil {
|
||||
logger.Error("failed to create audio track string item for %s: %v", track.Name, err)
|
||||
continue
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.audioFileTextures = append(p.audioFileTextures, item)
|
||||
p.outputDeviceItem, err = NewStringItem(p.currentDeviceLabel(), p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating output device item: %w", err)
|
||||
}
|
||||
p.outputDeviceItem.Scale(p.ItemScale)
|
||||
|
||||
p.updateSelectedIndex()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioOptions) updateSelectedIndex() {
|
||||
func (p *AudioOptions) currentTrackLabel() string {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
p.selectedFileIndex = 0
|
||||
return
|
||||
return "Audio Track"
|
||||
}
|
||||
|
||||
if config.DynamicAudio {
|
||||
p.selectedFileIndex = 0
|
||||
return
|
||||
return "Audio Track: Dynamic Audio"
|
||||
}
|
||||
|
||||
var selected db.AudioTrack
|
||||
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
|
||||
p.selectedFileIndex = 0
|
||||
return
|
||||
return "Audio Track"
|
||||
}
|
||||
|
||||
for i, track := range p.audioTracks {
|
||||
if track.ID == selected.ID {
|
||||
p.selectedFileIndex = i + 1
|
||||
return
|
||||
}
|
||||
if selected.Name != "" {
|
||||
return "Audio Track: " + selected.Name
|
||||
}
|
||||
|
||||
p.selectedFileIndex = 0
|
||||
return "Audio Track"
|
||||
}
|
||||
|
||||
func (p *AudioOptions) seedAudioTracks() error {
|
||||
var existing []db.AudioTrack
|
||||
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
||||
return err
|
||||
func (p *AudioOptions) currentDeviceLabel() string {
|
||||
current := mixer.CurrentDevice()
|
||||
if current == "" {
|
||||
return "Output Device: System Default"
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return p.Context.DbPtr.Save(&audioTrackSeed).Error
|
||||
}
|
||||
return nil
|
||||
return "Output Device: " + mixer.HumanizeDeviceName(current)
|
||||
}
|
||||
|
||||
func (p *AudioOptions) CreateAudioStatusItem() error {
|
||||
@@ -277,31 +210,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioOptions) selectAudioFile(index int) {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
config.DynamicAudio = true
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||
} else {
|
||||
config.DynamicAudio = false
|
||||
trackIndex := index - 1
|
||||
if trackIndex >= 0 && trackIndex < len(p.audioTracks) {
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].ID).Update("selected", true)
|
||||
}
|
||||
}
|
||||
|
||||
if err := p.Context.DbPtr.Save(config).Error; err != nil {
|
||||
logger.Error("failed to save audio config: %v", err)
|
||||
}
|
||||
|
||||
p.selectedFileIndex = index
|
||||
}
|
||||
|
||||
func (p *AudioOptions) CreateVolumePercentItem() error {
|
||||
if p.volumePercent.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||
@@ -377,57 +285,11 @@ func (p *AudioOptions) layout() {
|
||||
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
|
||||
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
||||
|
||||
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
|
||||
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
|
||||
p.outputDeviceItem.X = p.ScreenCenterX - p.outputDeviceItem.W/2
|
||||
p.outputDeviceItem.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.outputDeviceItem.H
|
||||
|
||||
p.listItemHeight = p.audioFileTextures[0].H
|
||||
listTop := p.sectionHeader.Y - p.listItemMargin
|
||||
listBottomMargin := int32(12)
|
||||
|
||||
listH := listTop - listBottomMargin
|
||||
if listH < p.listItemHeight {
|
||||
listH = p.listItemHeight
|
||||
}
|
||||
|
||||
p.listAreaTop = listTop
|
||||
p.listAreaBottom = listBottomMargin
|
||||
|
||||
itemStep := p.listItemHeight + p.listItemMargin
|
||||
n := len(p.audioFileTextures)
|
||||
p.maxVisible = int(listH / itemStep)
|
||||
if p.maxVisible < 1 {
|
||||
p.maxVisible = 1
|
||||
}
|
||||
if p.maxVisible > n {
|
||||
p.maxVisible = n
|
||||
}
|
||||
|
||||
var maxItemW int32
|
||||
for i := range p.audioFileTextures {
|
||||
w := p.audioFileTextures[i].W
|
||||
if w > maxItemW {
|
||||
maxItemW = w
|
||||
}
|
||||
}
|
||||
p.listLeftX = p.ScreenCenterX - maxItemW/2
|
||||
|
||||
firstVisibleY := p.listAreaTop - p.listItemHeight
|
||||
for i := range p.audioFileTextures {
|
||||
item := &p.audioFileTextures[i]
|
||||
item.X = p.listLeftX
|
||||
item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep
|
||||
}
|
||||
|
||||
maxScroll := n - p.maxVisible
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if p.scrollOffset > maxScroll {
|
||||
p.scrollOffset = maxScroll
|
||||
}
|
||||
|
||||
p.scrollTrackY = p.listAreaBottom
|
||||
p.scrollTrackH = p.listAreaTop - p.listAreaBottom
|
||||
p.audioTrackItem.X = p.ScreenCenterX - p.audioTrackItem.W/2
|
||||
p.audioTrackItem.Y = p.outputDeviceItem.Y - p.verticalMargin - p.audioTrackItem.H
|
||||
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
@@ -439,8 +301,10 @@ func (p *AudioOptions) updateCursorPosition() {
|
||||
switch {
|
||||
case p.focusState == focusToggle:
|
||||
item = &p.AudioStatusToggle
|
||||
case p.focusState >= 0 && p.focusState < len(p.audioFileTextures):
|
||||
item = &p.audioFileTextures[p.focusState]
|
||||
case p.focusState == 0:
|
||||
item = &p.audioTrackItem
|
||||
case p.focusState == 1:
|
||||
item = &p.outputDeviceItem
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -467,32 +331,24 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
n := len(p.audioFileTextures)
|
||||
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_RETURN:
|
||||
if p.focusState == focusToggle {
|
||||
switch p.focusState {
|
||||
case focusToggle:
|
||||
p.ToggleAudio()
|
||||
} else if p.focusState >= 0 && p.focusState < n {
|
||||
p.selectAudioFile(p.focusState)
|
||||
p.updateCursorPosition()
|
||||
case 0:
|
||||
p.Context.NavigateTo(TRACKS)
|
||||
case 1:
|
||||
p.Context.NavigateTo(AUDIO_DEVICES)
|
||||
}
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.focusState == focusToggle {
|
||||
if n > 0 {
|
||||
p.focusState = 0
|
||||
p.scrollOffset = 0
|
||||
p.layout()
|
||||
}
|
||||
} else if p.focusState < n-1 {
|
||||
p.focusState = 0
|
||||
p.updateCursorPosition()
|
||||
} else if p.focusState < 1 {
|
||||
p.focusState++
|
||||
if p.focusState >= p.scrollOffset+p.maxVisible {
|
||||
p.scrollOffset++
|
||||
p.layout()
|
||||
} else {
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
case sdl.K_UP:
|
||||
@@ -501,35 +357,7 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
||||
p.updateCursorPosition()
|
||||
} else if p.focusState > 0 {
|
||||
p.focusState--
|
||||
if p.focusState < p.scrollOffset {
|
||||
p.scrollOffset--
|
||||
p.layout()
|
||||
} else {
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
}
|
||||
|
||||
case sdl.K_PAGEUP:
|
||||
if p.focusState > 0 {
|
||||
p.focusState -= p.maxVisible - 1
|
||||
if p.focusState < 0 {
|
||||
p.focusState = 0
|
||||
}
|
||||
p.scrollOffset = p.focusState
|
||||
p.layout()
|
||||
}
|
||||
|
||||
case sdl.K_PAGEDOWN:
|
||||
if p.focusState < n-1 {
|
||||
p.focusState += p.maxVisible - 1
|
||||
if p.focusState >= n {
|
||||
p.focusState = n - 1
|
||||
}
|
||||
p.scrollOffset = p.focusState - p.maxVisible + 1
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
p.layout()
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
case sdl.K_RIGHT:
|
||||
@@ -618,34 +446,6 @@ func (p *AudioOptions) renderCursor() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioOptions) renderScrollbar() {
|
||||
n := len(p.audioFileTextures)
|
||||
if n <= p.maxVisible {
|
||||
return
|
||||
}
|
||||
|
||||
trackColor := sdl.Color{R: 255, G: 255, B: 255, A: 30}
|
||||
thumbColor := sdl.Color{R: 255, G: 255, B: 255, A: 100}
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackH)},
|
||||
trackColor)
|
||||
|
||||
visibleRatio := float32(p.maxVisible) / float32(n)
|
||||
scrollRatio := float32(p.scrollOffset) / float32(n)
|
||||
thumbHeight := float32(p.scrollTrackH) * visibleRatio
|
||||
if thumbHeight < 20 {
|
||||
thumbHeight = 20
|
||||
}
|
||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackH)*scrollRatio
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
||||
thumbColor)
|
||||
}
|
||||
|
||||
func (p *AudioOptions) Render() error {
|
||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||
return fmt.Errorf("render title: %w", err)
|
||||
@@ -668,51 +468,13 @@ func (p *AudioOptions) Render() error {
|
||||
return fmt.Errorf("render audio toggle: %w", err)
|
||||
}
|
||||
|
||||
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
|
||||
|
||||
// List background — centered width matching widest item + padding
|
||||
var maxItemW int32
|
||||
for i := range p.audioFileTextures {
|
||||
w := p.audioFileTextures[i].W
|
||||
if w > maxItemW {
|
||||
maxItemW = w
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.audioTrackItem.ID, p.audioTrackItem.Position(), p.audioTrackItem.RawDimensions(), p.audioTrackItem.Color); err != nil {
|
||||
return fmt.Errorf("render audio track item: %w", err)
|
||||
}
|
||||
bgPad := int32(16)
|
||||
bgW := maxItemW + 2*bgPad
|
||||
bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2
|
||||
bgTop := float32(p.listAreaBottom)
|
||||
bgBottom := float32(p.listAreaTop)
|
||||
listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12}
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{bgLeft, bgTop},
|
||||
mgl32.Vec2{float32(bgW), bgBottom - bgTop},
|
||||
listBgColor)
|
||||
|
||||
// Audio file list (visible window only)
|
||||
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.audioFileTextures); i++ {
|
||||
item := &p.audioFileTextures[i]
|
||||
clr := colors.White
|
||||
if i == p.selectedFileIndex {
|
||||
clr = colors.Green
|
||||
}
|
||||
|
||||
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil {
|
||||
return fmt.Errorf("render audio file %d: %w", i, err)
|
||||
}
|
||||
|
||||
if i == p.selectedFileIndex {
|
||||
dotX := float32(item.X) - p.dotSize - 6
|
||||
dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{dotX, dotY},
|
||||
mgl32.Vec2{p.dotSize, p.dotSize},
|
||||
colors.Green)
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.outputDeviceItem.ID, p.outputDeviceItem.Position(), p.outputDeviceItem.RawDimensions(), p.outputDeviceItem.Color); err != nil {
|
||||
return fmt.Errorf("render output device item: %w", err)
|
||||
}
|
||||
|
||||
p.renderScrollbar()
|
||||
|
||||
if err := p.renderCursor(); err != nil {
|
||||
return fmt.Errorf("render cursor: %w", err)
|
||||
}
|
||||
@@ -726,10 +488,8 @@ func (p *AudioOptions) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.VolumeLabel.ID)
|
||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
||||
gl.DeleteTextures(1, &p.sectionHeader.ID)
|
||||
for i := range p.audioFileTextures {
|
||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
||||
}
|
||||
gl.DeleteTextures(1, &p.audioTrackItem.ID)
|
||||
gl.DeleteTextures(1, &p.outputDeviceItem.ID)
|
||||
gl.DeleteBuffers(1, &p.triVBO)
|
||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
var audioTrackSeed = []db.AudioTrack{
|
||||
{Filename: "OriginalAudio.wav", Name: "Original Audio", AssignedFrequency: 0, MD5: "d39c2676c315438752ab0c55e9427d74", Order: 1},
|
||||
{Filename: "174Hz.wav", Name: "174 Hz", AssignedFrequency: 174, MD5: "42abaf332e628b646ac2cc9d1b5385e7", Order: 2},
|
||||
{Filename: "396Hz.wav", Name: "396 Hz", AssignedFrequency: 396, MD5: "d53d45849b042122a3c177fad95ff499", Order: 3},
|
||||
{Filename: "417Hz.wav", Name: "417 Hz", AssignedFrequency: 417, MD5: "056a7aae0b53111f5c568ea7ecd1fadb", Order: 4},
|
||||
{Filename: "432Hz.wav", Name: "432 Hz", AssignedFrequency: 432, MD5: "276c57179372404ff59c35024911468e", Order: 5},
|
||||
{Filename: "639Hz.wav", Name: "639 Hz", AssignedFrequency: 639, MD5: "4e460148230f4417fef39e9817f75e06", Order: 6},
|
||||
{Filename: "741Hz.wav", Name: "741 Hz", AssignedFrequency: 741, MD5: "cfbd9b03b539db69bcefdd91cedc9ad1", Order: 7},
|
||||
{Filename: "852Hz.wav", Name: "852 Hz", AssignedFrequency: 852, MD5: "485700296d017f8a1b0a7583a55a7783", Order: 8},
|
||||
{Filename: "963Hz.wav", Name: "963 Hz", AssignedFrequency: 963, MD5: "2670e13294daef8490a862c078aa8af4", Order: 9},
|
||||
{Filename: "1111Hz.wav", Name: "1111 Hz", AssignedFrequency: 1111, MD5: "67bbc7619dc21d0750519113472ca335", Order: 10},
|
||||
{Filename: "14Hz_Binaural.wav", Name: "14 Hz Binaural Beat", AssignedFrequency: 14, MD5: "807c14866640d2ab27a6f5d7fdd24e77", Order: 11},
|
||||
{Filename: "40Hz_Binaural.wav", Name: "40 Hz Binaural Beat", AssignedFrequency: 40, MD5: "42d60d55a16db4e9b29da47a5a102f2c", Order: 12},
|
||||
{Filename: "AlphaWaves.wav", Name: "Alpha Waves", AssignedFrequency: 0, MD5: "07a93fa0e633a00d97b8a5e5a259d6ff", Order: 13},
|
||||
{Filename: "PinealGland.wav", Name: "Pineal Gland", AssignedFrequency: 0, MD5: "998d74e3826b30086d64ff0df7b9f133", Order: 14},
|
||||
}
|
||||
|
||||
type TrackSelection struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
|
||||
verticalMargin int32
|
||||
ItemScale float32
|
||||
|
||||
sectionHeader StringItem
|
||||
|
||||
audioFileTextures []StringItem
|
||||
audioTracks []db.AudioTrack
|
||||
selectedFileIndex int
|
||||
|
||||
focusState int
|
||||
|
||||
triVAO uint32
|
||||
triVBO uint32
|
||||
triX float32
|
||||
triCenterY float32
|
||||
spinElapsed float32
|
||||
|
||||
scrollOffset int
|
||||
maxVisible int
|
||||
listItemMargin int32
|
||||
listAreaTop int32
|
||||
listAreaBottom int32
|
||||
listItemHeight int32
|
||||
scrollTrackWidth int32
|
||||
scrollTrackX int32
|
||||
scrollTrackY int32
|
||||
scrollTrackH int32
|
||||
|
||||
dotSize float32
|
||||
|
||||
listLeftX int32
|
||||
}
|
||||
|
||||
func (p *TrackSelection) Init(ctx *PageCtx) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
p.listItemMargin = 6
|
||||
p.focusState = 0
|
||||
p.scrollOffset = 0
|
||||
p.dotSize = 8.0
|
||||
p.scrollTrackWidth = 30
|
||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
||||
}
|
||||
p.font = font
|
||||
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
||||
}
|
||||
p.solidShader, ok = sm.Get("solid_color")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
||||
}
|
||||
|
||||
if err = p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("base init failed: %w", err)
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
baseFontSize := config.StandardFontSize
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
p.ItemScale = float32(32) / float32(baseFontSize)
|
||||
|
||||
p.title, err = NewStringItem("Audio Track Selection", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
p.prompt, err = NewStringItem(
|
||||
"Enter to select -- Up/Down to navigate -- Backspace to go back",
|
||||
p.font, colors.White,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(p.ItemScale)
|
||||
|
||||
p.sectionHeader, err = NewStringItem("Audio Track", p.font, colors.Silver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating section header: %w", err)
|
||||
}
|
||||
p.sectionHeader.Scale(p.ItemScale * 1.2)
|
||||
|
||||
if err = p.seedAudioTracks(); err != nil {
|
||||
logger.Error("failed to seed audio tracks: %v", err)
|
||||
}
|
||||
|
||||
p.buildAudioFileList()
|
||||
|
||||
triVerts := []float32{
|
||||
0.5, 0.0,
|
||||
-0.5, 0.5,
|
||||
-0.5, -0.5,
|
||||
}
|
||||
gl.GenVertexArrays(1, &p.triVAO)
|
||||
gl.GenBuffers(1, &p.triVBO)
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) buildAudioFileList() {
|
||||
for i := range p.audioFileTextures {
|
||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
||||
}
|
||||
p.audioFileTextures = nil
|
||||
|
||||
var tracks []db.AudioTrack
|
||||
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
|
||||
p.audioTracks = tracks
|
||||
|
||||
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
|
||||
if err != nil {
|
||||
logger.Error("failed to create Dynamic Audio string item: %v", err)
|
||||
} else {
|
||||
dynamicItem.Scale(p.ItemScale)
|
||||
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
|
||||
}
|
||||
|
||||
for _, track := range tracks {
|
||||
item, err := NewStringItem(track.Name, p.font, colors.White)
|
||||
if err != nil {
|
||||
logger.Error("failed to create audio track string item for %s: %v", track.Name, err)
|
||||
continue
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.audioFileTextures = append(p.audioFileTextures, item)
|
||||
}
|
||||
|
||||
p.updateSelectedIndex()
|
||||
}
|
||||
|
||||
func (p *TrackSelection) updateSelectedIndex() {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
p.selectedFileIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
if config.DynamicAudio {
|
||||
p.selectedFileIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
var selected db.AudioTrack
|
||||
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
|
||||
p.selectedFileIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
for i, track := range p.audioTracks {
|
||||
if track.ID == selected.ID {
|
||||
p.selectedFileIndex = i + 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
p.selectedFileIndex = 0
|
||||
}
|
||||
|
||||
func (p *TrackSelection) seedAudioTracks() error {
|
||||
var existing []db.AudioTrack
|
||||
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return p.Context.DbPtr.Save(&audioTrackSeed).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) selectAudioFile(index int) {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
config.DynamicAudio = true
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||
} else {
|
||||
config.DynamicAudio = false
|
||||
trackIndex := index - 1
|
||||
if trackIndex >= 0 && trackIndex < len(p.audioTracks) {
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].ID).Update("selected", true)
|
||||
}
|
||||
}
|
||||
|
||||
if err := p.Context.DbPtr.Save(config).Error; err != nil {
|
||||
logger.Error("failed to save audio config: %v", err)
|
||||
}
|
||||
|
||||
p.selectedFileIndex = index
|
||||
}
|
||||
|
||||
func (p *TrackSelection) layout() {
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||
|
||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
|
||||
|
||||
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
|
||||
p.sectionHeader.Y = p.prompt.Y - p.verticalMargin - p.sectionHeader.H
|
||||
|
||||
p.listItemHeight = p.audioFileTextures[0].H
|
||||
listTop := p.sectionHeader.Y - p.listItemMargin
|
||||
listBottomMargin := int32(12)
|
||||
|
||||
listH := listTop - listBottomMargin
|
||||
if listH < p.listItemHeight {
|
||||
listH = p.listItemHeight
|
||||
}
|
||||
|
||||
p.listAreaTop = listTop
|
||||
p.listAreaBottom = listBottomMargin
|
||||
|
||||
itemStep := p.listItemHeight + p.listItemMargin
|
||||
n := len(p.audioFileTextures)
|
||||
p.maxVisible = int(listH / itemStep)
|
||||
if p.maxVisible < 1 {
|
||||
p.maxVisible = 1
|
||||
}
|
||||
if p.maxVisible > n {
|
||||
p.maxVisible = n
|
||||
}
|
||||
|
||||
var maxItemW int32
|
||||
for i := range p.audioFileTextures {
|
||||
w := p.audioFileTextures[i].W
|
||||
if w > maxItemW {
|
||||
maxItemW = w
|
||||
}
|
||||
}
|
||||
p.listLeftX = p.ScreenCenterX - maxItemW/2
|
||||
|
||||
firstVisibleY := p.listAreaTop - p.listItemHeight
|
||||
for i := range p.audioFileTextures {
|
||||
item := &p.audioFileTextures[i]
|
||||
item.X = p.listLeftX
|
||||
item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep
|
||||
}
|
||||
|
||||
maxScroll := n - p.maxVisible
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if p.scrollOffset > maxScroll {
|
||||
p.scrollOffset = maxScroll
|
||||
}
|
||||
|
||||
p.scrollTrackY = p.listAreaBottom
|
||||
p.scrollTrackH = p.listAreaTop - p.listAreaBottom
|
||||
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
func (p *TrackSelection) updateCursorPosition() {
|
||||
cursorGap := int32(12)
|
||||
|
||||
if p.focusState < 0 || p.focusState >= len(p.audioFileTextures) {
|
||||
return
|
||||
}
|
||||
item := &p.audioFileTextures[p.focusState]
|
||||
|
||||
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
||||
}
|
||||
|
||||
func (p *TrackSelection) HandleEvent(event *sdl.Event) error {
|
||||
e, ok := (*event).(*sdl.KeyboardEvent)
|
||||
if !ok || e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
|
||||
n := len(p.audioFileTextures)
|
||||
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_RETURN:
|
||||
if p.focusState >= 0 && p.focusState < n {
|
||||
p.selectAudioFile(p.focusState)
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.focusState < n-1 {
|
||||
p.focusState++
|
||||
if p.focusState >= p.scrollOffset+p.maxVisible {
|
||||
p.scrollOffset++
|
||||
p.layout()
|
||||
} else {
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
}
|
||||
|
||||
case sdl.K_UP:
|
||||
if p.focusState > 0 {
|
||||
p.focusState--
|
||||
if p.focusState < p.scrollOffset {
|
||||
p.scrollOffset--
|
||||
p.layout()
|
||||
} else {
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
}
|
||||
|
||||
case sdl.K_PAGEUP:
|
||||
if p.focusState > 0 {
|
||||
p.focusState -= p.maxVisible - 1
|
||||
if p.focusState < 0 {
|
||||
p.focusState = 0
|
||||
}
|
||||
p.scrollOffset = p.focusState
|
||||
p.layout()
|
||||
}
|
||||
|
||||
case sdl.K_PAGEDOWN:
|
||||
if p.focusState < n-1 {
|
||||
p.focusState += p.maxVisible - 1
|
||||
if p.focusState >= n {
|
||||
p.focusState = n - 1
|
||||
}
|
||||
p.scrollOffset = p.focusState - p.maxVisible + 1
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
p.layout()
|
||||
}
|
||||
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(AUDIO)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) Update(dt float32) error {
|
||||
p.spinElapsed += dt
|
||||
if p.spinElapsed >= 1.0 {
|
||||
p.spinElapsed -= 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) renderCursor() error {
|
||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
gl.UseProgram(p.solidShader.ProgramID)
|
||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
gl.Uniform4f(
|
||||
p.solidShader.LocColor,
|
||||
float32(colors.White.R)/255.0,
|
||||
float32(colors.White.G)/255.0,
|
||||
float32(colors.White.B)/255.0,
|
||||
float32(colors.White.A)/255.0,
|
||||
)
|
||||
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
||||
gl.BindVertexArray(0)
|
||||
gl.UseProgram(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) renderScrollbar() {
|
||||
n := len(p.audioFileTextures)
|
||||
if n <= p.maxVisible {
|
||||
return
|
||||
}
|
||||
|
||||
trackColor := sdl.Color{R: 255, G: 255, B: 255, A: 30}
|
||||
thumbColor := sdl.Color{R: 255, G: 255, B: 255, A: 100}
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackH)},
|
||||
trackColor)
|
||||
|
||||
visibleRatio := float32(p.maxVisible) / float32(n)
|
||||
scrollRatio := float32(p.scrollOffset) / float32(n)
|
||||
thumbHeight := float32(p.scrollTrackH) * visibleRatio
|
||||
if thumbHeight < 20 {
|
||||
thumbHeight = 20
|
||||
}
|
||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackH)*scrollRatio
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
||||
thumbColor)
|
||||
}
|
||||
|
||||
func (p *TrackSelection) Render() error {
|
||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||
return fmt.Errorf("render title: %w", err)
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
|
||||
return fmt.Errorf("render prompt: %w", err)
|
||||
}
|
||||
|
||||
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
|
||||
|
||||
// List background — centered width matching widest item + padding
|
||||
var maxItemW int32
|
||||
for i := range p.audioFileTextures {
|
||||
w := p.audioFileTextures[i].W
|
||||
if w > maxItemW {
|
||||
maxItemW = w
|
||||
}
|
||||
}
|
||||
bgPad := int32(16)
|
||||
bgW := maxItemW + 2*bgPad
|
||||
bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2
|
||||
bgTop := float32(p.listAreaBottom)
|
||||
bgBottom := float32(p.listAreaTop)
|
||||
listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12}
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{bgLeft, bgTop},
|
||||
mgl32.Vec2{float32(bgW), bgBottom - bgTop},
|
||||
listBgColor)
|
||||
|
||||
// Audio file list (visible window only)
|
||||
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.audioFileTextures); i++ {
|
||||
item := &p.audioFileTextures[i]
|
||||
clr := colors.White
|
||||
if i == p.selectedFileIndex {
|
||||
clr = colors.Green
|
||||
}
|
||||
|
||||
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil {
|
||||
return fmt.Errorf("render audio file %d: %w", i, err)
|
||||
}
|
||||
|
||||
if i == p.selectedFileIndex {
|
||||
dotX := float32(item.X) - p.dotSize - 6
|
||||
dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{dotX, dotY},
|
||||
mgl32.Vec2{p.dotSize, p.dotSize},
|
||||
colors.Green)
|
||||
}
|
||||
}
|
||||
|
||||
p.renderScrollbar()
|
||||
|
||||
if err := p.renderCursor(); err != nil {
|
||||
return fmt.Errorf("render cursor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
gl.DeleteTextures(1, &p.sectionHeader.ID)
|
||||
for i := range p.audioFileTextures {
|
||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
||||
}
|
||||
gl.DeleteBuffers(1, &p.triVBO)
|
||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||
return nil
|
||||
}
|
||||
@@ -13,6 +13,8 @@ const (
|
||||
SETTINGS
|
||||
WIFI
|
||||
AUDIO
|
||||
TRACKS
|
||||
AUDIO_DEVICES
|
||||
PROCLAMATIONS
|
||||
LICENSE_KEY
|
||||
EMAIL_ADDRESS
|
||||
@@ -61,6 +63,10 @@ func (r *Router) NavigateTo(p pageType) {
|
||||
r.ActivePage = &WiFiSetupPage{}
|
||||
case AUDIO:
|
||||
r.ActivePage = &AudioOptions{}
|
||||
case TRACKS:
|
||||
r.ActivePage = &TrackSelection{}
|
||||
case AUDIO_DEVICES:
|
||||
r.ActivePage = &AudioDevices{}
|
||||
case PROCLAMATIONS:
|
||||
r.ActivePage = &ProclamationOptions{}
|
||||
case LICENSE_KEY:
|
||||
|
||||
Reference in New Issue
Block a user