From 2212cc9b7805d2bfc01cd0713879a8608988dc96 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sat, 8 Aug 2026 00:12:57 -0500 Subject: [PATCH] Add audio device switching UI --- audio-device-picker.md | 190 +++++++++ internal/application/application.go | 4 +- internal/db/Schema.go | 4 +- internal/mixer/device.go | 108 ++++++ internal/mixer/mixer.go | 51 ++- internal/mixer/mixer_internal_test.go | 194 +++------- internal/page/AudioDevices.go | 504 ++++++++++++++++++++++++ internal/page/AudioDevices_test.go | 36 ++ internal/page/AudioOptions.go | 356 +++-------------- internal/page/TrackSelection.go | 530 ++++++++++++++++++++++++++ internal/page/router.go | 6 + 11 files changed, 1528 insertions(+), 455 deletions(-) create mode 100644 audio-device-picker.md create mode 100644 internal/mixer/device.go create mode 100644 internal/page/AudioDevices.go create mode 100644 internal/page/AudioDevices_test.go create mode 100644 internal/page/TrackSelection.go diff --git a/audio-device-picker.md b/audio-device-picker.md new file mode 100644 index 0000000..b17a4ae --- /dev/null +++ b/audio-device-picker.md @@ -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 ` (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 ` (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. diff --git a/internal/application/application.go b/internal/application/application.go index 1bfed5e..d9b6108 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -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 { diff --git a/internal/db/Schema.go b/internal/db/Schema.go index 241dac8..bbc2d66 100644 --- a/internal/db/Schema.go +++ b/internal/db/Schema.go @@ -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? } diff --git a/internal/mixer/device.go b/internal/mixer/device.go new file mode 100644 index 0000000..7cf7c47 --- /dev/null +++ b/internal/mixer/device.go @@ -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) +} diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 00b225f..a312473 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -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) diff --git a/internal/mixer/mixer_internal_test.go b/internal/mixer/mixer_internal_test.go index 8fc8469..b74e415 100644 --- a/internal/mixer/mixer_internal_test.go +++ b/internal/mixer/mixer_internal_test.go @@ -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)") - } -} - - diff --git a/internal/page/AudioDevices.go b/internal/page/AudioDevices.go new file mode 100644 index 0000000..58bb182 --- /dev/null +++ b/internal/page/AudioDevices.go @@ -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 +} diff --git a/internal/page/AudioDevices_test.go b/internal/page/AudioDevices_test.go new file mode 100644 index 0000000..8ec4695 --- /dev/null +++ b/internal/page/AudioDevices_test.go @@ -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) + } + }) + } +} diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index ff7005d..de62ba2 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -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 diff --git a/internal/page/TrackSelection.go b/internal/page/TrackSelection.go new file mode 100644 index 0000000..f083ab4 --- /dev/null +++ b/internal/page/TrackSelection.go @@ -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 +} diff --git a/internal/page/router.go b/internal/page/router.go index 340b667..41b8f17 100644 --- a/internal/page/router.go +++ b/internal/page/router.go @@ -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: