Update audio options page to display outputs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-15
@@ -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:
|
||||||
|
if p.focusState == focusToggle {
|
||||||
p.ToggleAudio()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user