Update audio options page to display outputs

This commit is contained in:
NeilClack
2026-06-23 13:52:44 -05:00
parent 30dd5ffdcb
commit 732bd7c9a0
3 changed files with 171 additions and 25 deletions
+41 -7
View File
@@ -38,17 +38,51 @@ dev_scripts/ # developer utilities
### Runtime Data
Stored in `~/.local/share/rhemawave/`:
- `data.db` — SQLite database (config, proclamations, logs)
- `log.txt` — application log
- `license_key` — license key file
### Development Rules
These rules must be followed during the development of RhemaWave to retain a
maintainable application.
1. The Application package is soley responsible for managing the database handler, and the
configuration cache. Internal packages may update the configuration within the database, and
then signal the application to refresh the cache via a callback function `refreshCache()`.
### Development Rules
These rules must be followed during the development of RhemaWave to retain a
maintainable application.
1. The Application package is soley responsible for managing the database handler, and the
configuration cache. Internal packages may update the configuration within the database, and
then signal the application to refresh the cache via a callback function `refreshCache()`.
## Deployment
Deployed to kiosk hardware via NixOS-Anywhere. See `packaging/` for kiosk system configs.
Deployed to kiosk hardware via NixOS.
## Notes
### Deployment Pipeline
A manual walkthough of deploying Radiant Wave updates.
For reference later when writing automated actions.
1. Create a new branch for issue being worked on
2. Perform work
3. Merge to dev when done
4. Build dev with `nix build` to verify no errors
5. Checkout KioskConfig dev
6. Update rhemawave-dev with `nix flake update rhemawave-dev`
7. Build rhemawavekiosk-dev virtual machine
8. Test virtual machine for problems
9. Build dev-iso
10. Install dev-iso to a machine and check for problems
11. Checkout `main`
12. Merge `dev`
13. `nix flake update rhemawave`
14. Build virtual machine
15. Build iso image
16. Build commercial virtual machine
17. Build commercial iso image
18. Build Raspberry Pi img
19. Approve all virtual machines and iso images
20. Approve all machines update on their own
21. Rename all iso image to "radiantwavekiosk-iso-{system}-{MM-DD-YYYY}-{radiantwave-git-hash}.iso"
22. Upload iso images to file server
23. Move old iso images to `archives/` on the fileserver
-2
View File
@@ -385,8 +385,6 @@ func ListDevices() []string {
// CurrentDevice returns the last requested/opened device name ("" means default).
func CurrentDevice() string {
mu.Lock()
defer mu.Unlock()
return deviceName
}
+130 -16
View File
@@ -15,6 +15,10 @@ import (
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
// row has keyboard focus. Device indices (0..n-1) are used for everything below.
const focusToggle = -1
// AudioFileList is kept as the canonical seed data for the audio_files table.
// Track selection UI no longer lives on this page, but other parts of the
// app still read these rows from the database.
@@ -39,7 +43,8 @@ const triangleSize = float32(16)
type AudioDevice struct {
Label string
ID int
ID uint32
index int
}
type AudioOptions struct {
@@ -62,6 +67,8 @@ type AudioOptions struct {
AudioStatusToggle StringItem
// Audio Devices
activeDeviceIndex int
NumAudioDevices int
AudioDevices []AudioDevice
AudioDeviceTextures []StringItem
@@ -79,6 +86,9 @@ type AudioOptions struct {
triX float32
triCenterY float32
spinElapsed float32
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
focusState int
}
// ── Init ──────────────────────────────────────────────────────────────────────
@@ -86,6 +96,7 @@ type AudioOptions struct {
func (p *AudioOptions) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
p.focusState = focusToggle
// Font
fm := fontManager.Get()
@@ -122,7 +133,10 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
p.title.Scale(titleScale)
// Prompt
p.prompt, err = NewStringItem("Enter to toggle audio -- Left/Right to change volume -- Backspace to go back", p.font, colors.White)
p.prompt, err = NewStringItem(
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
p.font, colors.White,
)
if err != nil {
return fmt.Errorf("creating prompt: %w", err)
}
@@ -143,9 +157,21 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
}
// Make sure the audio_files table is populated. This page no longer
// presents any track selection UI, but other parts of the app still
// read from this table.
// Audio output devices
if err = p.GetAudioDevices(); err != nil {
logger.Error("failed to enumerate audio devices: %v", err)
}
// Set active device index based on mixer's current device
currentName := mixer.CurrentDevice()
for i, dev := range p.AudioDevices {
if dev.Label == currentName {
p.activeDeviceIndex = i
break
}
}
// Make sure the audio_files table is populated.
if err = p.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %v", err)
}
@@ -202,25 +228,46 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
}
// ── Audio Output Devices ──────────────────────────────────────────────────────
func (p *AudioOptions) GetAudioDevices() error {
numAudioDevices := sdl.GetNumAudioDevices(false)
if numAudioDevices == 0 {
return nil
}
var audioDevices = make([]AudioDevice, numAudioDevices)
// Pre-allocate with zero length so append doesn't produce leading zero-value
// elements (the old code used make([]AudioDevice, n) then appended, which
// doubled the slice length with n zero-value entries at the front).
audioDevices := make([]AudioDevice, 0, numAudioDevices)
for i := range numAudioDevices {
audioDevice := sdl.GetAudioDeviceName(i, false)
audioDevices = append(audioDevices, AudioDevice{Label: audioDevice, ID: i})
label := sdl.GetAudioDeviceName(i, false)
audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
}
p.AudioDevices = audioDevices
for i := range audioDevices {
newAudioDeviceStringItem, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
if err != nil {
return err
}
p.AudioDeviceTextures = append(p.AudioDeviceTextures, newAudioDeviceStringItem)
item.Scale(p.ItemScale)
p.AudioDeviceTextures = append(p.AudioDeviceTextures, item)
}
p.NumAudioDevices = numAudioDevices
return nil
}
// SelectAudioDevice is called when the user confirms a device from the list.
// The device-switching implementation goes here; for now we just log the choice.
func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) {
return fmt.Errorf("invalid device index: %d", deviceIndex)
}
device := p.AudioDevices[deviceIndex]
if err := mixer.SwitchDevice(device.Label); err != nil {
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
}
p.activeDeviceIndex = deviceIndex
return nil
}
@@ -294,10 +341,36 @@ func (p *AudioOptions) layout() {
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
// Triangle cursor — just outside the left edge of the toggle, vertically centred on it
// Device list — each item centred and stacked below the toggle
nextY := p.AudioStatusToggle.Y - p.verticalMargin
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
item.X = p.ScreenCenterX - item.W/2
item.Y = nextY - item.H
nextY = item.Y - p.verticalMargin
}
// Reposition the triangle cursor for whatever row currently has focus
p.updateCursorPosition()
}
// updateCursorPosition moves triX / triCenterY to sit just left of the
// focused row. Call this any time focusState changes or layout() reruns.
func (p *AudioOptions) updateCursorPosition() {
cursorGap := int32(12)
p.triX = float32(p.AudioStatusToggle.X) - float32(triangleSize) - float32(cursorGap)
p.triCenterY = float32(p.AudioStatusToggle.Y) + float32(p.AudioStatusToggle.H)/2
var item *StringItem
switch {
case p.focusState == focusToggle:
item = &p.AudioStatusToggle
case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures):
item = &p.AudioDeviceTextures[p.focusState]
default:
return
}
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(item.Y) + float32(item.H)/2
}
// ── Toggle ────────────────────────────────────────────────────────────────────
@@ -320,7 +393,33 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
switch e.Keysym.Sym {
case sdl.K_RETURN:
p.ToggleAudio()
if p.focusState == focusToggle {
p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) {
if err := p.SelectAudioDevice(p.focusState); err != nil {
logger.Error("SelectAudioDevice: %v", err)
}
}
case sdl.K_DOWN:
if p.focusState == focusToggle {
if len(p.AudioDeviceTextures) > 0 {
p.focusState = 0
p.updateCursorPosition()
}
} else if p.focusState < len(p.AudioDeviceTextures)-1 {
p.focusState++
p.updateCursorPosition()
}
case sdl.K_UP:
if p.focusState == 0 {
p.focusState = focusToggle
p.updateCursorPosition()
} else if p.focusState > 0 {
p.focusState--
p.updateCursorPosition()
}
case sdl.K_RIGHT:
p.UpdateVolume(10)
@@ -372,7 +471,7 @@ func (p *AudioOptions) renderVolumeBar() error {
{{bx, by}, {bw, thickness}},
{{bx, by + bh - thickness}, {bw, thickness}},
{{bx, by}, {thickness, bh}},
{{bx + bw - thickness, by}, {thickness, bh}},
{{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component
}
for _, r := range outlineRects {
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
@@ -382,7 +481,7 @@ func (p *AudioOptions) renderVolumeBar() error {
return nil
}
// renderCursor draws the spinning triangle cursor.
// renderCursor draws the spinning triangle cursor next to the focused row.
func (p *AudioOptions) renderCursor() error {
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
@@ -435,6 +534,18 @@ func (p *AudioOptions) Render() error {
return fmt.Errorf("render audio toggle: %w", err)
}
// Device list
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
color := colors.White
if i == p.activeDeviceIndex {
color = colors.Green
}
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
return fmt.Errorf("render audio device %d: %w", i, err)
}
}
// Spinning triangle cursor — always on top
if err := p.renderCursor(); err != nil {
return fmt.Errorf("render cursor: %w", err)
@@ -451,6 +562,9 @@ func (p *AudioOptions) Destroy() error {
gl.DeleteTextures(1, &p.VolumeLabel.ID)
gl.DeleteTextures(1, &p.volumePercent.ID)
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
for i := range p.AudioDeviceTextures {
gl.DeleteTextures(1, &p.AudioDeviceTextures[i].ID)
}
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil