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 focusToggle = -1 var AudioFileList = []db.AudioFile{ {ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1}, {ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2}, {ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3}, {ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4}, {ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5}, {ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6}, {ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7}, {ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8}, {ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9}, {ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10}, {ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11}, {ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12}, {ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13}, {ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14}, } const triangleSize = float32(16) type AudioOptions struct { Base font *fontManager.FontEntry textShader *shaderManager.Shader solidShader *shaderManager.Shader title StringItem prompt StringItem verticalMargin int32 ItemScale float32 AudioStatusToggle StringItem audioFileTextures []StringItem selectedFileIndex int VolumeLabel StringItem volumePercent StringItem volumeBarX int32 volumeBarY int32 volumeBarW int32 volumeBarH int32 triVAO uint32 triVBO uint32 triX float32 triCenterY float32 spinElapsed float32 focusState int // Section header sectionHeader StringItem // Scrolling scrollOffset int maxVisible int listItemMargin int32 listAreaTop int32 listAreaBottom int32 listItemHeight int32 scrollTrackWidth int32 scrollTrackX int32 scrollTrackY int32 scrollTrackH int32 // Selection dot dotSize float32 // List left-alignment X (same for all items) 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") 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 Options", 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 -- Left/Right to change volume -- Backspace to go back", p.font, colors.White, ) if err != nil { return fmt.Errorf("creating prompt: %w", err) } p.prompt.Scale(p.ItemScale) p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White) if err != nil { return fmt.Errorf("creating volume label: %w", err) } p.VolumeLabel.Scale(p.ItemScale) if err = p.CreateVolumePercentItem(); err != nil { logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") } if err = p.CreateAudioStatusItem(); err != nil { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } if err = p.seedAudioFiles(); err != nil { logger.Error("failed to seed audio files: %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.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 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 _, af := range AudioFileList { item, err := NewStringItem(af.Name, p.font, colors.White) if err != nil { logger.Error("failed to create audio file string item for %s: %v", af.Name, err) continue } item.Scale(p.ItemScale) p.audioFileTextures = append(p.audioFileTextures, item) } p.updateSelectedIndex() } func (p *AudioOptions) 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.AudioFile if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil { p.selectedFileIndex = 0 return } for i, af := range AudioFileList { if af.ID == selected.ID { p.selectedFileIndex = i + 1 return } } p.selectedFileIndex = 0 } func (p *AudioOptions) seedAudioFiles() error { var existing []db.AudioFile if err := p.Context.DbPtr.Find(&existing).Error; err != nil { return err } if len(existing) == 0 { return p.Context.DbPtr.Save(&AudioFileList).Error } return nil } func (p *AudioOptions) CreateAudioStatusItem() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } on := config.AudioEnabled statusString := "Turn Audio On" color := colors.Green if on { statusString = "Turn Audio Off" color = colors.Red } item, err := NewStringItem(statusString, p.font, color) if err != nil { return fmt.Errorf("failed to create '%s' string item: %v", statusString, err) } item.Scale(p.ItemScale) p.AudioStatusToggle = item 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.AudioFile{}).Where("1 = 1").Update("selected", false) } else { config.DynamicAudio = false fileIndex := index - 1 if fileIndex >= 0 && fileIndex < len(AudioFileList) { p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false) p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].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) } config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } pct := int(config.SavedVolume * 100) item, err := NewStringItem(fmt.Sprintf("%d%%", pct), p.font, colors.White) if err != nil { return fmt.Errorf("failed to create volume percent item: %v", err) } item.Scale(p.ItemScale) p.volumePercent = item return nil } func (p *AudioOptions) UpdateVolume(level int) { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } newVol := mixer.ConvertVolume(level) + config.SavedVolume if newVol < 0.0 || newVol > 1.0 { return } if newVol > .99 { newVol = 1.0 } config.SavedVolume = newVol p.Context.DbPtr.Save(&config) if err := mixer.SetVolume(newVol); err != nil { logger.Error("Error setting volume: %v", err) } if err := p.CreateVolumePercentItem(); err != nil { logger.Error("failed to refresh volume percent item: %v", err) } gap := int32(12) p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2 } func (p *AudioOptions) layout() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } // Title p.title.X = p.ScreenCenterX - p.title.W/2 p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 // Prompt p.prompt.X = p.ScreenCenterX - p.prompt.W/2 p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H // Volume row barH := int32(float32(config.StandardFontSize) * p.ItemScale) barW := int32(float32(p.ScreenWidth) * 0.30) barX := p.ScreenCenterX - barW/2 barY := p.prompt.Y - p.verticalMargin*2 - barH p.volumeBarX = barX p.volumeBarY = barY p.volumeBarW = barW p.volumeBarH = barH p.VolumeLabel.X = barX - p.VolumeLabel.W - 20 p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2 gap := int32(12) p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 // Audio toggle p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H // Section header — sits above the toggle, marks the start of the audio track list p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2 p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H // Scrollable list area boundaries p.listItemHeight = p.audioFileTextures[0].H listTop := p.sectionHeader.Y - p.listItemMargin listBottomMargin := int32(12) // Available height for the list listH := listTop - listBottomMargin if listH < p.listItemHeight { listH = p.listItemHeight } p.listAreaTop = listTop p.listAreaBottom = listBottomMargin // How many items fit 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 } // Compute left alignment: find the widest item to center the block var maxItemW int32 for i := range p.audioFileTextures { if p.audioFileTextures[i].W > maxItemW { maxItemW = p.audioFileTextures[i].W } } p.listLeftX = p.ScreenCenterX - maxItemW/2 // Position each item — scrollOffset shifts the whole list up/down 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 } // Clamp scroll offset so we never show empty space maxScroll := n - p.maxVisible if maxScroll < 0 { maxScroll = 0 } if p.scrollOffset > maxScroll { p.scrollOffset = maxScroll } // Scrollbar track position p.scrollTrackY = p.listAreaBottom p.scrollTrackH = p.listAreaTop - p.listAreaBottom p.updateCursorPosition() } func (p *AudioOptions) updateCursorPosition() { cursorGap := int32(12) var item *StringItem switch { case p.focusState == focusToggle: item = &p.AudioStatusToggle case p.focusState >= 0 && p.focusState < len(p.audioFileTextures): item = &p.audioFileTextures[p.focusState] default: return } p.triX = float32(item.X) - triangleSize - float32(cursorGap) p.triCenterY = float32(item.Y) + float32(item.H)/2 } func (p *AudioOptions) ToggleAudio() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { logger.Error("panic! Cannot query database for configuration") panic(err) } config.AudioEnabled = !config.AudioEnabled p.Context.DbPtr.Save(config) p.CreateAudioStatusItem() p.layout() } func (p *AudioOptions) 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 == focusToggle { p.ToggleAudio() } else if p.focusState >= 0 && p.focusState < n { p.selectAudioFile(p.focusState) p.updateCursorPosition() } 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++ if p.focusState >= p.scrollOffset+p.maxVisible { p.scrollOffset++ p.layout() } else { p.updateCursorPosition() } } case sdl.K_UP: if p.focusState == 0 { p.focusState = focusToggle 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() } case sdl.K_RIGHT: p.UpdateVolume(10) case sdl.K_LEFT: p.UpdateVolume(-10) case sdl.K_BACKSPACE: p.Context.NavigateTo(SETTINGS) } return nil } func (p *AudioOptions) Update(dt float32) error { p.spinElapsed += dt if p.spinElapsed >= 1.0 { p.spinElapsed -= 1.0 } return nil } func (p *AudioOptions) renderVolumeBar() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } vol := config.SavedVolume fillW := float32(p.volumeBarW) * float32(vol) if fillW > 0 { if err := p.RenderSolidColorQuad( p.solidShader, mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)}, mgl32.Vec2{fillW, float32(p.volumeBarH)}, colors.White, ); err != nil { return fmt.Errorf("rendering volume fill: %w", err) } } thickness := float32(2) bx := float32(p.volumeBarX) by := float32(p.volumeBarY) bw := float32(p.volumeBarW) bh := float32(p.volumeBarH) outlineRects := [4][2]mgl32.Vec2{ {{bx, by}, {bw, thickness}}, {{bx, by + bh - thickness}, {bw, thickness}}, {{bx, by}, {thickness, bh}}, {{bx + bw - thickness, by}, {thickness, bh}}, } for _, r := range outlineRects { if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil { return fmt.Errorf("rendering volume outline: %w", err) } } return nil } func (p *AudioOptions) 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 *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) } 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) } if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil { return fmt.Errorf("render volume label: %w", err) } if err := p.renderVolumeBar(); err != nil { return fmt.Errorf("render volume bar: %w", err) } if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil { return fmt.Errorf("render volume percent: %w", err) } if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil { return fmt.Errorf("render audio toggle: %w", err) } // Section header p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color) // List background listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12} bgPad := int32(16) bgLeft := float32(p.listLeftX - bgPad) bgTop := float32(p.listAreaBottom) bgRight := float32(p.scrollTrackX) bgBottom := float32(p.listAreaTop) p.RenderSolidColorQuad(p.solidShader, mgl32.Vec2{bgLeft, bgTop}, mgl32.Vec2{bgRight - bgLeft, 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) } // Selection dot — small filled circle left of the item name 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) } } // Scrollbar p.renderScrollbar() // Spinning triangle cursor — always on top if err := p.renderCursor(); err != nil { return fmt.Errorf("render cursor: %w", err) } return nil } func (p *AudioOptions) Destroy() error { gl.DeleteTextures(1, &p.title.ID) gl.DeleteTextures(1, &p.prompt.ID) 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.DeleteBuffers(1, &p.triVBO) gl.DeleteVertexArrays(1, &p.triVAO) return nil }