Add audio device switching UI

This commit is contained in:
NeilClack
2026-08-08 00:12:57 -05:00
parent b54dbca2d5
commit 2212cc9b78
11 changed files with 1528 additions and 455 deletions
+504
View File
@@ -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
}
+36
View File
@@ -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
View File
@@ -19,23 +19,9 @@ const focusToggle = -1
const triangleSize = float32(16)
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},
}
// AudioOptions is the hub for audio settings. It hosts the master volume and
// the audio on/off toggle, and routes to the TrackSelection and AudioDevices
// pages for picking the track and the output device.
type AudioOptions struct {
Base
font *fontManager.FontEntry
@@ -50,9 +36,8 @@ type AudioOptions struct {
AudioStatusToggle StringItem
audioFileTextures []StringItem
audioTracks []db.AudioTrack
selectedFileIndex int
audioTrackItem StringItem
outputDeviceItem StringItem
VolumeLabel StringItem
volumePercent StringItem
@@ -68,34 +53,12 @@ type AudioOptions struct {
spinElapsed float32
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 {
var err error
p.verticalMargin = int32(20)
p.listItemMargin = 6
p.focusState = focusToggle
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")
@@ -155,12 +118,10 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
}
if err = p.seedAudioTracks(); err != nil {
logger.Error("failed to seed audio tracks: %v", err)
if err = p.buildNavigationItems(); err != nil {
return fmt.Errorf("creating navigation items: %w", err)
}
p.buildAudioFileList()
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
@@ -176,84 +137,56 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
gl.BindBuffer(gl.ARRAY_BUFFER, 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()
return nil
}
func (p *AudioOptions) buildAudioFileList() {
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
p.audioFileTextures = nil
// buildNavigationItems creates the "Audio Track" and "Output Device" entries
// that navigate to the TrackSelection and AudioDevices pages. Labels include
// the currently selected value so returning to this page shows the latest
// choice.
func (p *AudioOptions) buildNavigationItems() error {
var err error
var tracks []db.AudioTrack
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
p.audioTracks = tracks
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
p.audioTrackItem, err = NewStringItem(p.currentTrackLabel(), 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)
return fmt.Errorf("creating audio track item: %w", err)
}
p.audioTrackItem.Scale(p.ItemScale)
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.outputDeviceItem, err = NewStringItem(p.currentDeviceLabel(), p.font, colors.White)
if err != nil {
return fmt.Errorf("creating output device item: %w", err)
}
p.outputDeviceItem.Scale(p.ItemScale)
p.updateSelectedIndex()
return nil
}
func (p *AudioOptions) updateSelectedIndex() {
func (p *AudioOptions) currentTrackLabel() string {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
p.selectedFileIndex = 0
return
return "Audio Track"
}
if config.DynamicAudio {
p.selectedFileIndex = 0
return
return "Audio Track: Dynamic Audio"
}
var selected db.AudioTrack
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
p.selectedFileIndex = 0
return
return "Audio Track"
}
for i, track := range p.audioTracks {
if track.ID == selected.ID {
p.selectedFileIndex = i + 1
return
}
if selected.Name != "" {
return "Audio Track: " + selected.Name
}
p.selectedFileIndex = 0
return "Audio Track"
}
func (p *AudioOptions) seedAudioTracks() error {
var existing []db.AudioTrack
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
return err
func (p *AudioOptions) currentDeviceLabel() string {
current := mixer.CurrentDevice()
if current == "" {
return "Output Device: System Default"
}
if len(existing) == 0 {
return p.Context.DbPtr.Save(&audioTrackSeed).Error
}
return nil
return "Output Device: " + mixer.HumanizeDeviceName(current)
}
func (p *AudioOptions) CreateAudioStatusItem() error {
@@ -277,31 +210,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
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 {
if p.volumePercent.ID != 0 {
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.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
p.outputDeviceItem.X = p.ScreenCenterX - p.outputDeviceItem.W/2
p.outputDeviceItem.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.outputDeviceItem.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.audioTrackItem.X = p.ScreenCenterX - p.audioTrackItem.W/2
p.audioTrackItem.Y = p.outputDeviceItem.Y - p.verticalMargin - p.audioTrackItem.H
p.updateCursorPosition()
}
@@ -439,8 +301,10 @@ func (p *AudioOptions) updateCursorPosition() {
switch {
case p.focusState == focusToggle:
item = &p.AudioStatusToggle
case p.focusState >= 0 && p.focusState < len(p.audioFileTextures):
item = &p.audioFileTextures[p.focusState]
case p.focusState == 0:
item = &p.audioTrackItem
case p.focusState == 1:
item = &p.outputDeviceItem
default:
return
}
@@ -467,32 +331,24 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
return nil
}
n := len(p.audioFileTextures)
switch e.Keysym.Sym {
case sdl.K_RETURN:
if p.focusState == focusToggle {
switch p.focusState {
case focusToggle:
p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < n {
p.selectAudioFile(p.focusState)
p.updateCursorPosition()
case 0:
p.Context.NavigateTo(TRACKS)
case 1:
p.Context.NavigateTo(AUDIO_DEVICES)
}
case sdl.K_DOWN:
if p.focusState == focusToggle {
if n > 0 {
p.focusState = 0
p.scrollOffset = 0
p.layout()
}
} else if p.focusState < n-1 {
p.focusState = 0
p.updateCursorPosition()
} else if p.focusState < 1 {
p.focusState++
if p.focusState >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
p.layout()
} else {
p.updateCursorPosition()
}
p.updateCursorPosition()
}
case sdl.K_UP:
@@ -501,35 +357,7 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
p.updateCursorPosition()
} else 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()
p.updateCursorPosition()
}
case sdl.K_RIGHT:
@@ -618,34 +446,6 @@ func (p *AudioOptions) renderCursor() error {
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 {
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)
@@ -668,51 +468,13 @@ func (p *AudioOptions) Render() error {
return fmt.Errorf("render audio toggle: %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
}
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)
}
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)
}
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)
}
p.renderScrollbar()
if err := p.renderCursor(); err != nil {
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.volumePercent.ID)
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
gl.DeleteTextures(1, &p.sectionHeader.ID)
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
gl.DeleteTextures(1, &p.audioTrackItem.ID)
gl.DeleteTextures(1, &p.outputDeviceItem.ID)
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil
+530
View File
@@ -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
}
+6
View File
@@ -13,6 +13,8 @@ const (
SETTINGS
WIFI
AUDIO
TRACKS
AUDIO_DEVICES
PROCLAMATIONS
LICENSE_KEY
EMAIL_ADDRESS
@@ -61,6 +63,10 @@ func (r *Router) NavigateTo(p pageType) {
r.ActivePage = &WiFiSetupPage{}
case AUDIO:
r.ActivePage = &AudioOptions{}
case TRACKS:
r.ActivePage = &TrackSelection{}
case AUDIO_DEVICES:
r.ActivePage = &AudioDevices{}
case PROCLAMATIONS:
r.ActivePage = &ProclamationOptions{}
case LICENSE_KEY: