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 {
|
if err != nil {
|
||||||
panic(err)
|
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("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err)
|
||||||
logger.Error("Audio will be unavailable until a device is configured")
|
logger.Error("Audio will be unavailable until a device is configured")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ type AppConfig struct {
|
|||||||
EmailAddress string
|
EmailAddress string
|
||||||
LicenseKey string
|
LicenseKey string
|
||||||
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
|
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
|
InitialFontSize int // How large to create the font atlas
|
||||||
StandardFontSize int // The font size used for most text rendering
|
StandardFontSize int // The font size used for most text rendering
|
||||||
LinePattern string // "fibonacci"
|
LinePattern string // "fibonacci"
|
||||||
AudioEnabled bool
|
AudioEnabled bool
|
||||||
DynamicAudio bool // When true, scroller switches audio per-proclamation frequency
|
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?
|
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
|
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
|
||||||
|
|
||||||
refreshCacheCallback func()
|
refreshCacheCallback func()
|
||||||
|
|
||||||
|
persistConfigCallback func(*db.AppConfig) error
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
// 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()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
if inited {
|
if inited {
|
||||||
@@ -42,6 +47,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshCacheCallback = onUpdate
|
refreshCacheCallback = onUpdate
|
||||||
|
persistConfigCallback = persistConfig
|
||||||
|
|
||||||
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
||||||
return fmt.Errorf("SDL audio init failed: %w", err)
|
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)
|
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stored "default" (the first-run seed) is the system default.
|
||||||
deviceName := cfg.SelectedAudioDevice
|
deviceName := cfg.SelectedAudioDevice
|
||||||
if deviceName == "default" {
|
if deviceName == "" || deviceName == "default" {
|
||||||
deviceName = ""
|
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 {
|
if err := openDeviceLocked(deviceName); err != nil {
|
||||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||||
@@ -178,7 +195,10 @@ func IsPlaying() bool {
|
|||||||
return mix.PlayingMusic()
|
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 {
|
func SetVolume(level float64) error {
|
||||||
if level < 0 || level > 1 {
|
if level < 0 || level > 1 {
|
||||||
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
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
|
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).
|
// CurrentDevice returns the last requested/opened device name ("" means default).
|
||||||
func CurrentDevice() string {
|
func CurrentDevice() string {
|
||||||
return deviceName
|
return deviceName
|
||||||
@@ -265,6 +268,14 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg != nil {
|
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
|
currentVolume = cfg.SavedVolume
|
||||||
v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME)))
|
v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME)))
|
||||||
mix.VolumeMusic(v)
|
mix.VolumeMusic(v)
|
||||||
|
|||||||
@@ -1,145 +1,71 @@
|
|||||||
package mixer
|
package mixer
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
func TestHumanizeDeviceName(t *testing.T) {
|
||||||
)
|
tests := []struct {
|
||||||
|
name string
|
||||||
// Regression tests for critical audio bugs (v0.5)
|
raw string
|
||||||
|
want string
|
||||||
// Regression: Stop() used to return early when AudioEnabled was false,
|
}{
|
||||||
// leaving music playing after the user toggled audio off mid-playback.
|
{"hdmi description", "Built-in Audio Digital Stereo (HDMI)", "HDMI | Monitor | TV"},
|
||||||
func TestStopCleansUpWhenAudioDisabled(t *testing.T) {
|
{"hdmi alsa name", "alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1", "HDMI | Monitor | TV"},
|
||||||
mu.Lock()
|
{"monitor", "Monitor", "HDMI | Monitor | TV"},
|
||||||
wasInited := inited
|
{"tv", "TV", "HDMI | Monitor | TV"},
|
||||||
inited = true
|
{"uppercase hdmi", "HDMI", "HDMI | Monitor | TV"},
|
||||||
deviceOpen = false
|
{"headset", "Headset", "Headset"},
|
||||||
currentPath = "/some/track.wav"
|
{"headphones", "WH-1000XM4 Headphones", "Headset"},
|
||||||
currentLoops = -1
|
{"bluetooth headset name", "bluez_output.00_11_22_33_44_55.headset-head-unit", "Headset"},
|
||||||
mu.Unlock()
|
{"usb description", "USB Audio", "USB"},
|
||||||
|
{"usb alsa name", "alsa_output.usb-046d_0825-00.analog-stereo", "USB"},
|
||||||
defer func() {
|
{"internal analog description", "Built-in Audio Analog Stereo", "Internal Speakers"},
|
||||||
mu.Lock()
|
{"internal alsa name", "alsa_output.pci-0000_00_1f.3.analog-stereo", "Internal Speakers"},
|
||||||
inited = wasInited
|
{"internal speakers label", "Internal Speakers", "Internal Speakers"},
|
||||||
currentPath = ""
|
{"virtual speaker", "Virtual Speaker", "Internal Speakers"},
|
||||||
currentLoops = 0
|
{"no match fallback", "Custom Node 42", "Custom Node 42"},
|
||||||
mu.Unlock()
|
{"empty fallback", "", ""},
|
||||||
}()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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"
|
func TestMatchDeviceName(t *testing.T) {
|
||||||
// and pass that string to openDeviceLocked, which SDL does not recognize.
|
names := []string{
|
||||||
func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) {
|
"Built-in Audio Analog Stereo",
|
||||||
mu.Lock()
|
"Built-in Audio Digital Stereo (HDMI)",
|
||||||
wasInited := inited
|
"USB Audio",
|
||||||
inited = false
|
"Headset",
|
||||||
deviceOpen = false
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
mu.Lock()
|
|
||||||
inited = wasInited
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression: SwitchDevice with nil config must not panic when attempting
|
tests := []struct {
|
||||||
// to restore volume after reopening the device.
|
name string
|
||||||
func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) {
|
preferred string
|
||||||
mu.Lock()
|
want string
|
||||||
wasInited := inited
|
wantOK bool
|
||||||
inited = false
|
}{
|
||||||
deviceOpen = false
|
{"empty preferred", "", "", false},
|
||||||
mu.Unlock()
|
{"exact match", "USB Audio", "USB Audio", true},
|
||||||
|
{"substring match", "hdmi", "Built-in Audio Digital Stereo (HDMI)", true},
|
||||||
defer func() {
|
{"suffix tolerance", "Built-in Audio Digital Stereo", "Built-in Audio Digital Stereo (HDMI)", true},
|
||||||
mu.Lock()
|
{"case insensitive", "usb audio", "USB Audio", true},
|
||||||
inited = wasInited
|
{"no match", "Bluetooth Dongle", "", false},
|
||||||
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
|
for _, tt := range tests {
|
||||||
// not be converted to "system default" before passing to openDeviceLocked.
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) {
|
got, ok := matchDevice(names, tt.preferred)
|
||||||
mu.Lock()
|
if ok != tt.wantOK {
|
||||||
wasInited := inited
|
t.Fatalf("matchDevice(%q) ok = %v, want %v", tt.preferred, ok, tt.wantOK)
|
||||||
inited = false
|
}
|
||||||
deviceOpen = false
|
if ok && got != tt.want {
|
||||||
mu.Unlock()
|
t.Errorf("matchDevice(%q) = %q, want %q", tt.preferred, got, tt.want)
|
||||||
|
}
|
||||||
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)
|
const triangleSize = float32(16)
|
||||||
|
|
||||||
var audioTrackSeed = []db.AudioTrack{
|
// AudioOptions is the hub for audio settings. It hosts the master volume and
|
||||||
{Filename: "OriginalAudio.wav", Name: "Original Audio", AssignedFrequency: 0, MD5: "d39c2676c315438752ab0c55e9427d74", Order: 1},
|
// the audio on/off toggle, and routes to the TrackSelection and AudioDevices
|
||||||
{Filename: "174Hz.wav", Name: "174 Hz", AssignedFrequency: 174, MD5: "42abaf332e628b646ac2cc9d1b5385e7", Order: 2},
|
// pages for picking the track and the output device.
|
||||||
{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 AudioOptions struct {
|
type AudioOptions struct {
|
||||||
Base
|
Base
|
||||||
font *fontManager.FontEntry
|
font *fontManager.FontEntry
|
||||||
@@ -50,9 +36,8 @@ type AudioOptions struct {
|
|||||||
|
|
||||||
AudioStatusToggle StringItem
|
AudioStatusToggle StringItem
|
||||||
|
|
||||||
audioFileTextures []StringItem
|
audioTrackItem StringItem
|
||||||
audioTracks []db.AudioTrack
|
outputDeviceItem StringItem
|
||||||
selectedFileIndex int
|
|
||||||
|
|
||||||
VolumeLabel StringItem
|
VolumeLabel StringItem
|
||||||
volumePercent StringItem
|
volumePercent StringItem
|
||||||
@@ -68,34 +53,12 @@ type AudioOptions struct {
|
|||||||
spinElapsed float32
|
spinElapsed float32
|
||||||
|
|
||||||
focusState int
|
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 {
|
func (p *AudioOptions) Init(ctx *PageCtx) error {
|
||||||
var err error
|
var err error
|
||||||
p.verticalMargin = int32(20)
|
p.verticalMargin = int32(20)
|
||||||
p.listItemMargin = 6
|
|
||||||
p.focusState = focusToggle
|
p.focusState = focusToggle
|
||||||
p.scrollOffset = 0
|
|
||||||
p.dotSize = 8.0
|
|
||||||
p.scrollTrackWidth = 30
|
|
||||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
fm := fontManager.Get()
|
||||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
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()")
|
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = p.seedAudioTracks(); err != nil {
|
if err = p.buildNavigationItems(); err != nil {
|
||||||
logger.Error("failed to seed audio tracks: %v", err)
|
return fmt.Errorf("creating navigation items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.buildAudioFileList()
|
|
||||||
|
|
||||||
triVerts := []float32{
|
triVerts := []float32{
|
||||||
0.5, 0.0,
|
0.5, 0.0,
|
||||||
-0.5, 0.5,
|
-0.5, 0.5,
|
||||||
@@ -176,84 +137,56 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|||||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||||
gl.BindVertexArray(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()
|
p.layout()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioOptions) buildAudioFileList() {
|
// buildNavigationItems creates the "Audio Track" and "Output Device" entries
|
||||||
for i := range p.audioFileTextures {
|
// that navigate to the TrackSelection and AudioDevices pages. Labels include
|
||||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
// the currently selected value so returning to this page shows the latest
|
||||||
}
|
// choice.
|
||||||
p.audioFileTextures = nil
|
func (p *AudioOptions) buildNavigationItems() error {
|
||||||
|
var err error
|
||||||
|
|
||||||
var tracks []db.AudioTrack
|
p.audioTrackItem, err = NewStringItem(p.currentTrackLabel(), p.font, colors.White)
|
||||||
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
|
|
||||||
p.audioTracks = tracks
|
|
||||||
|
|
||||||
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to create Dynamic Audio string item: %v", err)
|
return fmt.Errorf("creating audio track item: %w", err)
|
||||||
} else {
|
|
||||||
dynamicItem.Scale(p.ItemScale)
|
|
||||||
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
|
|
||||||
}
|
}
|
||||||
|
p.audioTrackItem.Scale(p.ItemScale)
|
||||||
|
|
||||||
for _, track := range tracks {
|
p.outputDeviceItem, err = NewStringItem(p.currentDeviceLabel(), p.font, colors.White)
|
||||||
item, err := NewStringItem(track.Name, p.font, colors.White)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to create audio track string item for %s: %v", track.Name, err)
|
return fmt.Errorf("creating output device item: %w", err)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
item.Scale(p.ItemScale)
|
p.outputDeviceItem.Scale(p.ItemScale)
|
||||||
p.audioFileTextures = append(p.audioFileTextures, item)
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p.updateSelectedIndex()
|
func (p *AudioOptions) currentTrackLabel() string {
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AudioOptions) updateSelectedIndex() {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.selectedFileIndex = 0
|
return "Audio Track"
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.DynamicAudio {
|
if config.DynamicAudio {
|
||||||
p.selectedFileIndex = 0
|
return "Audio Track: Dynamic Audio"
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected db.AudioTrack
|
var selected db.AudioTrack
|
||||||
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
|
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
|
||||||
p.selectedFileIndex = 0
|
return "Audio Track"
|
||||||
return
|
}
|
||||||
|
if selected.Name != "" {
|
||||||
|
return "Audio Track: " + selected.Name
|
||||||
|
}
|
||||||
|
return "Audio Track"
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, track := range p.audioTracks {
|
func (p *AudioOptions) currentDeviceLabel() string {
|
||||||
if track.ID == selected.ID {
|
current := mixer.CurrentDevice()
|
||||||
p.selectedFileIndex = i + 1
|
if current == "" {
|
||||||
return
|
return "Output Device: System Default"
|
||||||
}
|
}
|
||||||
}
|
return "Output Device: " + mixer.HumanizeDeviceName(current)
|
||||||
|
|
||||||
p.selectedFileIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AudioOptions) 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 *AudioOptions) CreateAudioStatusItem() error {
|
func (p *AudioOptions) CreateAudioStatusItem() error {
|
||||||
@@ -277,31 +210,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
|
|||||||
return nil
|
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 {
|
func (p *AudioOptions) CreateVolumePercentItem() error {
|
||||||
if p.volumePercent.ID != 0 {
|
if p.volumePercent.ID != 0 {
|
||||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
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.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
|
||||||
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
||||||
|
|
||||||
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
|
p.outputDeviceItem.X = p.ScreenCenterX - p.outputDeviceItem.W/2
|
||||||
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
|
p.outputDeviceItem.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.outputDeviceItem.H
|
||||||
|
|
||||||
p.listItemHeight = p.audioFileTextures[0].H
|
p.audioTrackItem.X = p.ScreenCenterX - p.audioTrackItem.W/2
|
||||||
listTop := p.sectionHeader.Y - p.listItemMargin
|
p.audioTrackItem.Y = p.outputDeviceItem.Y - p.verticalMargin - p.audioTrackItem.H
|
||||||
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()
|
p.updateCursorPosition()
|
||||||
}
|
}
|
||||||
@@ -439,8 +301,10 @@ func (p *AudioOptions) updateCursorPosition() {
|
|||||||
switch {
|
switch {
|
||||||
case p.focusState == focusToggle:
|
case p.focusState == focusToggle:
|
||||||
item = &p.AudioStatusToggle
|
item = &p.AudioStatusToggle
|
||||||
case p.focusState >= 0 && p.focusState < len(p.audioFileTextures):
|
case p.focusState == 0:
|
||||||
item = &p.audioFileTextures[p.focusState]
|
item = &p.audioTrackItem
|
||||||
|
case p.focusState == 1:
|
||||||
|
item = &p.outputDeviceItem
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -467,32 +331,24 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
n := len(p.audioFileTextures)
|
|
||||||
|
|
||||||
switch e.Keysym.Sym {
|
switch e.Keysym.Sym {
|
||||||
case sdl.K_RETURN:
|
case sdl.K_RETURN:
|
||||||
if p.focusState == focusToggle {
|
switch p.focusState {
|
||||||
|
case focusToggle:
|
||||||
p.ToggleAudio()
|
p.ToggleAudio()
|
||||||
} else if p.focusState >= 0 && p.focusState < n {
|
case 0:
|
||||||
p.selectAudioFile(p.focusState)
|
p.Context.NavigateTo(TRACKS)
|
||||||
p.updateCursorPosition()
|
case 1:
|
||||||
|
p.Context.NavigateTo(AUDIO_DEVICES)
|
||||||
}
|
}
|
||||||
|
|
||||||
case sdl.K_DOWN:
|
case sdl.K_DOWN:
|
||||||
if p.focusState == focusToggle {
|
if p.focusState == focusToggle {
|
||||||
if n > 0 {
|
|
||||||
p.focusState = 0
|
p.focusState = 0
|
||||||
p.scrollOffset = 0
|
|
||||||
p.layout()
|
|
||||||
}
|
|
||||||
} else if p.focusState < n-1 {
|
|
||||||
p.focusState++
|
|
||||||
if p.focusState >= p.scrollOffset+p.maxVisible {
|
|
||||||
p.scrollOffset++
|
|
||||||
p.layout()
|
|
||||||
} else {
|
|
||||||
p.updateCursorPosition()
|
p.updateCursorPosition()
|
||||||
}
|
} else if p.focusState < 1 {
|
||||||
|
p.focusState++
|
||||||
|
p.updateCursorPosition()
|
||||||
}
|
}
|
||||||
|
|
||||||
case sdl.K_UP:
|
case sdl.K_UP:
|
||||||
@@ -501,36 +357,8 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|||||||
p.updateCursorPosition()
|
p.updateCursorPosition()
|
||||||
} else if p.focusState > 0 {
|
} else if p.focusState > 0 {
|
||||||
p.focusState--
|
p.focusState--
|
||||||
if p.focusState < p.scrollOffset {
|
|
||||||
p.scrollOffset--
|
|
||||||
p.layout()
|
|
||||||
} else {
|
|
||||||
p.updateCursorPosition()
|
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_RIGHT:
|
case sdl.K_RIGHT:
|
||||||
p.UpdateVolume(10)
|
p.UpdateVolume(10)
|
||||||
@@ -618,34 +446,6 @@ func (p *AudioOptions) renderCursor() error {
|
|||||||
return nil
|
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 {
|
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 {
|
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)
|
return fmt.Errorf("render title: %w", err)
|
||||||
@@ -668,50 +468,12 @@ func (p *AudioOptions) Render() error {
|
|||||||
return fmt.Errorf("render audio toggle: %w", err)
|
return fmt.Errorf("render audio toggle: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
|
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)
|
||||||
// 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.outputDeviceItem.ID, p.outputDeviceItem.Position(), p.outputDeviceItem.RawDimensions(), p.outputDeviceItem.Color); err != nil {
|
||||||
|
return fmt.Errorf("render output device 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.renderScrollbar()
|
|
||||||
|
|
||||||
if err := p.renderCursor(); err != nil {
|
if err := p.renderCursor(); err != nil {
|
||||||
return fmt.Errorf("render cursor: %w", err)
|
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.VolumeLabel.ID)
|
||||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||||
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
||||||
gl.DeleteTextures(1, &p.sectionHeader.ID)
|
gl.DeleteTextures(1, &p.audioTrackItem.ID)
|
||||||
for i := range p.audioFileTextures {
|
gl.DeleteTextures(1, &p.outputDeviceItem.ID)
|
||||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
|
||||||
}
|
|
||||||
gl.DeleteBuffers(1, &p.triVBO)
|
gl.DeleteBuffers(1, &p.triVBO)
|
||||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||||
return nil
|
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
|
SETTINGS
|
||||||
WIFI
|
WIFI
|
||||||
AUDIO
|
AUDIO
|
||||||
|
TRACKS
|
||||||
|
AUDIO_DEVICES
|
||||||
PROCLAMATIONS
|
PROCLAMATIONS
|
||||||
LICENSE_KEY
|
LICENSE_KEY
|
||||||
EMAIL_ADDRESS
|
EMAIL_ADDRESS
|
||||||
@@ -61,6 +63,10 @@ func (r *Router) NavigateTo(p pageType) {
|
|||||||
r.ActivePage = &WiFiSetupPage{}
|
r.ActivePage = &WiFiSetupPage{}
|
||||||
case AUDIO:
|
case AUDIO:
|
||||||
r.ActivePage = &AudioOptions{}
|
r.ActivePage = &AudioOptions{}
|
||||||
|
case TRACKS:
|
||||||
|
r.ActivePage = &TrackSelection{}
|
||||||
|
case AUDIO_DEVICES:
|
||||||
|
r.ActivePage = &AudioDevices{}
|
||||||
case PROCLAMATIONS:
|
case PROCLAMATIONS:
|
||||||
r.ActivePage = &ProclamationOptions{}
|
r.ActivePage = &ProclamationOptions{}
|
||||||
case LICENSE_KEY:
|
case LICENSE_KEY:
|
||||||
|
|||||||
Reference in New Issue
Block a user