diff --git a/README.md b/README.md index e5d0bed..2a7fb76 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 995dc0c..8977c00 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -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 } diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index 020ad55..a59e7a2 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -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