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
+37 -3
View File
@@ -38,17 +38,51 @@ dev_scripts/ # developer utilities
### Runtime Data ### Runtime Data
Stored in `~/.local/share/rhemawave/`: Stored in `~/.local/share/rhemawave/`:
- `data.db` — SQLite database (config, proclamations, logs) - `data.db` — SQLite database (config, proclamations, logs)
- `log.txt` — application log - `log.txt` — application log
- `license_key` — license key file - `license_key` — license key file
### Development Rules ### Development Rules
These rules must be followed during the development of RhemaWave to retain a These rules must be followed during the development of RhemaWave to retain a
maintainable application. maintainable application.
1. The Application package is soley responsible for managing the database handler, and the 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 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()`. then signal the application to refresh the cache via a callback function `refreshCache()`.
## Deployment ## 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). // CurrentDevice returns the last requested/opened device name ("" means default).
func CurrentDevice() string { func CurrentDevice() string {
mu.Lock()
defer mu.Unlock()
return deviceName return deviceName
} }
+130 -16
View File
@@ -15,6 +15,10 @@ import (
"rhemawavetech.com/rhemawave/internal/shaderManager" "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. // 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 // Track selection UI no longer lives on this page, but other parts of the
// app still read these rows from the database. // app still read these rows from the database.
@@ -39,7 +43,8 @@ const triangleSize = float32(16)
type AudioDevice struct { type AudioDevice struct {
Label string Label string
ID int ID uint32
index int
} }
type AudioOptions struct { type AudioOptions struct {
@@ -62,6 +67,8 @@ type AudioOptions struct {
AudioStatusToggle StringItem AudioStatusToggle StringItem
// Audio Devices // Audio Devices
activeDeviceIndex int
NumAudioDevices int
AudioDevices []AudioDevice AudioDevices []AudioDevice
AudioDeviceTextures []StringItem AudioDeviceTextures []StringItem
@@ -79,6 +86,9 @@ type AudioOptions struct {
triX float32 triX float32
triCenterY float32 triCenterY float32
spinElapsed float32 spinElapsed float32
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
focusState int
} }
// ── Init ────────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────────
@@ -86,6 +96,7 @@ type AudioOptions struct {
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.focusState = focusToggle
// Font // Font
fm := fontManager.Get() fm := fontManager.Get()
@@ -122,7 +133,10 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
p.title.Scale(titleScale) p.title.Scale(titleScale)
// Prompt // 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 { if err != nil {
return fmt.Errorf("creating prompt: %w", err) 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()") logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
} }
// Make sure the audio_files table is populated. This page no longer // Audio output devices
// presents any track selection UI, but other parts of the app still if err = p.GetAudioDevices(); err != nil {
// read from this table. 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 { if err = p.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %v", err) logger.Error("failed to seed audio files: %v", err)
} }
@@ -202,25 +228,46 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
} }
// ── Audio Output Devices ────────────────────────────────────────────────────── // ── Audio Output Devices ──────────────────────────────────────────────────────
func (p *AudioOptions) GetAudioDevices() error { func (p *AudioOptions) GetAudioDevices() error {
numAudioDevices := sdl.GetNumAudioDevices(false) numAudioDevices := sdl.GetNumAudioDevices(false)
if numAudioDevices == 0 { if numAudioDevices == 0 {
return nil 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 { for i := range numAudioDevices {
audioDevice := sdl.GetAudioDeviceName(i, false) label := sdl.GetAudioDeviceName(i, false)
audioDevices = append(audioDevices, AudioDevice{Label: audioDevice, ID: i}) audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
} }
p.AudioDevices = audioDevices p.AudioDevices = audioDevices
for i := range 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 { if err != nil {
return err 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 return nil
} }
@@ -294,10 +341,36 @@ 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
// 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) 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 ──────────────────────────────────────────────────────────────────── // ── Toggle ────────────────────────────────────────────────────────────────────
@@ -320,7 +393,33 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
switch e.Keysym.Sym { switch e.Keysym.Sym {
case sdl.K_RETURN: 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: case sdl.K_RIGHT:
p.UpdateVolume(10) p.UpdateVolume(10)
@@ -372,7 +471,7 @@ func (p *AudioOptions) renderVolumeBar() error {
{{bx, by}, {bw, thickness}}, {{bx, by}, {bw, thickness}},
{{bx, by + bh - thickness}, {bw, thickness}}, {{bx, by + bh - thickness}, {bw, thickness}},
{{bx, by}, {thickness, bh}}, {{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 { for _, r := range outlineRects {
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil { 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 return nil
} }
// renderCursor draws the spinning triangle cursor. // renderCursor draws the spinning triangle cursor next to the focused row.
func (p *AudioOptions) renderCursor() error { func (p *AudioOptions) renderCursor() error {
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) 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) 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 // Spinning triangle cursor — always on top
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)
@@ -451,6 +562,9 @@ 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)
for i := range p.AudioDeviceTextures {
gl.DeleteTextures(1, &p.AudioDeviceTextures[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