package page import ( "fmt" "math" "strings" "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" ) var AudioFileList = []db.AudioFile{ {ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false}, {ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false}, {ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false}, {ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false}, {ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false}, {ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false}, {ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false}, {ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false}, {ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false}, {ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false}, {ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false}, {ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false}, {ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false}, {ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false}, } const ( triangleSize = float32(16) visibleCount = 9 listShiftDown = int32(50) scrollbarWidth = int32(4) scrollbarGap = int32(16) // gap between right edge of list and scrollbar ) type AudioOptions struct { Base font *fontManager.FontEntry textShader *shaderManager.Shader solidShader *shaderManager.Shader title StringItem prompt StringItem verticalMargin int32 ItemScale float32 CfgPtr *db.AppConfig // Audio toggle AudioStatusToggle StringItem // Volume row (excluded from focus) VolumeLabel StringItem volumePercent StringItem volumeBarX int32 volumeBarY int32 volumeBarW int32 volumeBarH int32 // Separator + track section separatorY int32 tracksHeading StringItem trackItems []StringItem // Selected track (-1 = none) selectedTrack int // Navigation // focusedIndex: 0 = AudioStatusToggle, 1..N = trackItems[focusedIndex-1] // Volume is intentionally excluded from focus. focusedIndex int // Scrolling window windowStart int listTopY int32 listLeftX int32 itemHeight int32 trackSpacing int32 // Scrollbar geometry (computed once in layout, thumb Y updated on scroll) scrollbarX int32 scrollbarTrackY int32 // bottom of the scrollbar track scrollbarTrackH int32 // total height of the track scrollbarThumbH int32 // height of the thumb scrollbarThumbY int32 // current bottom Y of the thumb (updated on scroll) // Triangle cursor triVAO uint32 triVBO uint32 triX float32 triCenterY float32 spinElapsed float32 } // ── Init ────────────────────────────────────────────────────────────────────── func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) p.focusedIndex = 0 p.selectedTrack = -1 p.windowStart = 0 p.trackSpacing = p.verticalMargin / 2 // Font fm := fontManager.Get() font, ok := fm.GetFont("Roboto-Regular") if !ok { return fmt.Errorf("failed to get font: %w", sdl.GetError()) } p.font = font // Shaders 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) } baseFontSize := p.Context.CfgPtr.StandardFontSize titleScale := float32(64) / float32(baseFontSize) p.ItemScale = float32(32) / float32(baseFontSize) // Title p.title, err = NewStringItem("Audio Options", p.font, colors.White) if err != nil { return fmt.Errorf("creating title: %w", err) } p.title.Scale(titleScale) // Prompt p.prompt, err = NewStringItem("Up/Down to navigate -- Enter to select -- 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) // Volume row 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()") } // Audio toggle if err = p.CreateAudioStatusItem(); err != nil { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } // "Available Tracks" heading headingScale := float32(40) / float32(baseFontSize) p.tracksHeading, err = NewStringItem("Available Tracks", p.font, colors.White) if err != nil { return fmt.Errorf("creating tracks heading: %w", err) } p.tracksHeading.Scale(headingScale) // Track items if err = p.buildTrackItems(); err != nil { return fmt.Errorf("building track items: %w", err) } // Triangle cursor VAO/VBO — stride is vec2 only, no tex coords 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 } // ── Track items ─────────────────────────────────────────────────────────────── func (p *AudioOptions) buildTrackItems() error { var audioFileList = []db.AudioFile{} if err := p.Context.DbPtr.Find(&audioFileList).Error; err != nil { return err } if len(audioFileList) == 0 { audioFileList = AudioFileList } for i := range p.trackItems { if p.trackItems[i].ID != 0 { gl.DeleteTextures(1, &p.trackItems[i].ID) } } p.trackItems = make([]StringItem, len(AudioFileList)) for i, entry := range AudioFileList { color := colors.White if i == p.selectedTrack { color = colors.Green } item, err := NewStringItem(entry.Name, p.font, color) if err != nil { return fmt.Errorf("creating track item %d: %w", i, err) } item.Scale(p.ItemScale) p.trackItems[i] = item } return nil } // positionTrackItems places the visible window of items and parks the rest off-screen. func (p *AudioOptions) positionTrackItems() { peekCount := visibleCount + 1 for i := range p.trackItems { slot := i - p.windowStart if slot < 0 || slot >= peekCount { p.trackItems[i].X = p.listLeftX p.trackItems[i].Y = -9999 continue } itemY := p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing) p.trackItems[i].X = p.listLeftX p.trackItems[i].Y = itemY } } // ── Scrollbar ───────────────────────────────────────────────────────────────── // updateScrollbarThumb recalculates the thumb Y position based on windowStart. // Called whenever windowStart changes. func (p *AudioOptions) updateScrollbarThumb() { total := len(AudioFileList) if total <= visibleCount { // Entire list fits — thumb fills the track, nothing to scroll p.scrollbarThumbY = p.scrollbarTrackY return } // How far through the scrollable range are we? // maxWindowStart is the furthest windowStart can go maxWindowStart := total - visibleCount fraction := float32(p.windowStart) / float32(maxWindowStart) // Thumb travels from the top of the track down to (trackTop - thumbH) // "top" in our Y-up projection means the larger Y value trackTop := p.scrollbarTrackY + p.scrollbarTrackH travelRange := p.scrollbarTrackH - p.scrollbarThumbH p.scrollbarThumbY = trackTop - p.scrollbarThumbH - int32(fraction*float32(travelRange)) } // ── Audio status toggle ─────────────────────────────────────────────────────── func (p *AudioOptions) CreateAudioStatusItem() error { on := p.Context.CfgPtr.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 } // ── Volume ──────────────────────────────────────────────────────────────────── func (p *AudioOptions) CreateVolumePercentItem() error { if p.volumePercent.ID != 0 { gl.DeleteTextures(1, &p.volumePercent.ID) } pct := int(p.Context.CfgPtr.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) { newVol := mixer.ConvertVolume(level) + p.Context.CfgPtr.SavedVolume if newVol < 0.0 || newVol > 1.0 { return } if newVol > .99 { newVol = 1.0 } p.Context.CfgPtr.SavedVolume = newVol p.Context.DbPtr.Save(&p.Context.CfgPtr) if err := mixer.SetAllSinkVolumes(newVol); err != nil { logger.Error("Error setting allSinkVolumes: %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 } // ── Layout ──────────────────────────────────────────────────────────────────── func (p *AudioOptions) layout() { // 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 (not focusable) barH := int32(float32(p.Context.CfgPtr.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 // "Available Tracks" heading — centred, left edge becomes the list anchor p.tracksHeading.X = p.ScreenCenterX - p.tracksHeading.W/2 // Toggle — left-aligned to the heading p.AudioStatusToggle.X = p.tracksHeading.X p.AudioStatusToggle.Y = barY - p.verticalMargin*2 - p.AudioStatusToggle.H // Separator p.separatorY = p.AudioStatusToggle.Y - p.verticalMargin*3 // Heading Y — below separator p.tracksHeading.Y = p.separatorY - p.verticalMargin - p.tracksHeading.H // Track list geometry — shifted down by listShiftDown if len(p.trackItems) > 0 { p.itemHeight = p.trackItems[0].H } p.listLeftX = p.tracksHeading.X p.listTopY = p.tracksHeading.Y - p.verticalMargin - listShiftDown // ── Scrollbar geometry ──────────────────────────────────────────────────── // The scrollbar track spans the full visible window height. // It sits to the right of the list with a small gap. windowH := int32(visibleCount)*(p.itemHeight+p.trackSpacing) - p.trackSpacing scrollbarBottom := p.listTopY - windowH p.scrollbarTrackH = windowH p.scrollbarTrackY = scrollbarBottom // bottom Y of the track (Y-up coords) // Right edge of the list: use the widest track name as an approximation. // Since items are left-aligned and widths vary, we use a fixed width based // on the heading width which is a reasonable proxy for the list column width. listRightX := p.listLeftX + p.tracksHeading.W p.scrollbarX = listRightX + scrollbarGap // Thumb height is proportional: visibleCount / total items total := len(AudioFileList) if total > 0 { thumbFraction := float32(visibleCount) / float32(total) p.scrollbarThumbH = int32(thumbFraction * float32(p.scrollbarTrackH)) if p.scrollbarThumbH < 8 { p.scrollbarThumbH = 8 // minimum thumb size so it's always clickable } } // Triangle cursor X — just outside the left edge of the list cursorGap := int32(12) p.triX = float32(p.listLeftX) - float32(triangleSize) - float32(cursorGap) p.positionTrackItems() p.updateScrollbarThumb() p.repositionCursor() } // repositionCursor vertically centres the triangle on the focused item. func (p *AudioOptions) repositionCursor() { var targetY, targetH int32 if p.focusedIndex == 0 { targetY = p.AudioStatusToggle.Y targetH = p.AudioStatusToggle.H } else { trackIdx := p.focusedIndex - 1 slot := trackIdx - p.windowStart targetY = p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing) targetH = p.itemHeight } p.triCenterY = float32(targetY) + float32(targetH)/2 } // ── Scroll ──────────────────────────────────────────────────────────────────── func (p *AudioOptions) scrollToFocus() { if p.focusedIndex == 0 { p.windowStart = 0 p.positionTrackItems() p.updateScrollbarThumb() p.repositionCursor() return } trackIdx := p.focusedIndex - 1 if trackIdx >= p.windowStart+visibleCount { p.windowStart = trackIdx - visibleCount + 1 } if trackIdx < p.windowStart { p.windowStart = trackIdx } p.positionTrackItems() p.updateScrollbarThumb() p.repositionCursor() } // ── Toggle ──────────────────────────────────────────────────────────────────── func (p *AudioOptions) ToggleAudio() { p.Context.CfgPtr.AudioEnabled = !p.Context.CfgPtr.AudioEnabled p.Context.DbPtr.Save(p.Context.CfgPtr) p.Context.RefreshConfig() p.CreateAudioStatusItem() p.layout() } // ── Track selection ─────────────────────────────────────────────────────────── func (p *AudioOptions) onTrackSelected(index int, entry db.AudioFile) { p.selectedTrack = index if err := p.buildTrackItems(); err != nil { logger.Error("onTrackSelected: failed to rebuild track items: %v", err) return } p.layout() // Create file names and paths // Set Selected entry.Selected = true // Set path if !strings.Contains(entry.Path, ".local/") { entry.Path = p.Context.CfgPtr.AssetsDir + "audio" + entry.Path } // Save it to the database if err := p.Context.DbPtr.Save(&entry).Error; err != nil { logger.Error("Error saving to database: %v", err) return } logger.Info("Updated audio selection: %s", entry.Name) } // ── Events ──────────────────────────────────────────────────────────────────── func (p *AudioOptions) HandleEvent(event *sdl.Event) error { e, ok := (*event).(*sdl.KeyboardEvent) if !ok || e.Type != sdl.KEYDOWN { return nil } switch e.Keysym.Sym { case sdl.K_UP: if p.focusedIndex > 0 { p.focusedIndex-- p.scrollToFocus() } case sdl.K_DOWN: if p.focusedIndex < len(AudioFileList) { p.focusedIndex++ p.scrollToFocus() } case sdl.K_RETURN: if p.focusedIndex == 0 { p.ToggleAudio() } else { trackIdx := p.focusedIndex - 1 p.onTrackSelected(trackIdx, AudioFileList[trackIdx]) } case sdl.K_RIGHT: p.UpdateVolume(10) case sdl.K_LEFT: p.UpdateVolume(-10) case sdl.K_BACKSPACE: p.Context.NavigateTo(SETTINGS) } return nil } // ── Update ──────────────────────────────────────────────────────────────────── func (p *AudioOptions) Update(dt float32) error { p.spinElapsed += dt if p.spinElapsed >= 1.0 { p.spinElapsed -= 1.0 } return nil } // ── Render helpers ──────────────────────────────────────────────────────────── func (p *AudioOptions) renderSeparator() error { lineW := float32(p.ScreenWidth) * 0.80 lineX := float32(p.ScreenCenterX) - lineW/2 return p.RenderSolidColorQuad( p.solidShader, mgl32.Vec2{lineX, float32(p.separatorY)}, mgl32.Vec2{lineW, 2}, colors.White, ) } func (p *AudioOptions) renderVolumeBar() error { vol := p.Context.CfgPtr.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}, {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 } // renderScrollbar draws the scrollbar track (dim) and thumb (white). // Only rendered when the list is longer than the visible window. func (p *AudioOptions) renderScrollbar() error { if len(AudioFileList) <= visibleCount { return nil } // Track — a thin dim line the full height of the window trackColor := sdl.Color{R: 80, G: 80, B: 80, A: 255} if err := p.RenderSolidColorQuad( p.solidShader, mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarTrackY)}, mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarTrackH)}, trackColor, ); err != nil { return fmt.Errorf("rendering scrollbar track: %w", err) } // Thumb — white, positioned proportionally within the track if err := p.RenderSolidColorQuad( p.solidShader, mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarThumbY)}, mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarThumbH)}, colors.White, ); err != nil { return fmt.Errorf("rendering scrollbar thumb: %w", err) } return nil } // renderCursor draws the spinning triangle cursor. 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 } // ── Render ──────────────────────────────────────────────────────────────────── 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) } // Volume row 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) } // Toggle 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) } // Separator if err := p.renderSeparator(); err != nil { return fmt.Errorf("render separator: %w", err) } // "Available Tracks" heading — fixed, never moves if err := p.RenderTexture(p.textShader, p.tracksHeading.ID, p.tracksHeading.Position(), p.tracksHeading.RawDimensions(), p.tracksHeading.Color); err != nil { return fmt.Errorf("render tracks heading: %w", err) } // Track list for i := range p.trackItems { item := &p.trackItems[i] if item.Y < 0 { continue } if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color); err != nil { return fmt.Errorf("render track item %d: %w", i, err) } } // Scrollbar if err := p.renderScrollbar(); err != nil { return fmt.Errorf("render scrollbar: %w", err) } // Spinning triangle cursor — always on top if err := p.renderCursor(); err != nil { return fmt.Errorf("render cursor: %w", err) } return nil } // ── Destroy ─────────────────────────────────────────────────────────────────── 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.tracksHeading.ID) for i := range p.trackItems { gl.DeleteTextures(1, &p.trackItems[i].ID) } gl.DeleteBuffers(1, &p.triVBO) gl.DeleteVertexArrays(1, &p.triVAO) return nil }