package page import ( "fmt" "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" ) type AudioOptions struct { Base font *fontManager.FontEntry textShader *shaderManager.Shader solidShader *shaderManager.Shader title StringItem prompt StringItem AudioStatusToggle StringItem volumePercent StringItem verticalMargin int32 ItemScale float32 CfgPtr *db.AppConfig VolumeLabel StringItem // Volume bar geometry (computed once in layout, reused in render) volumeBarX int32 volumeBarY int32 volumeBarW int32 volumeBarH int32 } func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) // Get 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 // Get 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()) } // Initialize base if err := p.Base.Init(ctx); err != nil { return fmt.Errorf("base init failed: %w", err) } // Get font size from database baseFontSize := p.Context.CfgPtr.StandardFontSize // Calculate scales titleScale := float32(64) / float32(baseFontSize) p.ItemScale = float32(32) / float32(baseFontSize) // Create title p.title, err = NewStringItem("Toggle Audio", p.font, colors.White) if err != nil { return fmt.Errorf("creating title: %w", err) } p.title.Scale(titleScale) // Create prompt p.prompt, err = NewStringItem("Switch On/Off with Enter. Change volume with Left/Right (arrow keys). Setting saves immediately.", p.font, colors.White) if err != nil { return fmt.Errorf("creating prompt: %w", err) } p.prompt.Scale(p.ItemScale) if err = p.CreateAudioStatusItem(); err != nil { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } if err = p.CreateVolumePercentItem(); err != nil { logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") } 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) p.layout() return nil } func (p *AudioOptions) CreateAudioStatusItem() error { on := p.Context.CfgPtr.AudioEnabled statusString := "Turn Audio Off" // The audio is ON color := colors.Green if !on { statusString = "Turn Audio on" color = colors.Red } audioStatusStringItem, err := NewStringItem(statusString, p.font, color) if err != nil { return fmt.Errorf("failed to create '%s' string item: %v", statusString, err) } fmt.Printf("AudioStatusToggle (%d)", audioStatusStringItem.ID) audioStatusStringItem.Scale(p.ItemScale) p.AudioStatusToggle = audioStatusStringItem return nil } // CreateVolumePercentItem creates (or recreates) the percentage label beside the volume bar. func (p *AudioOptions) CreateVolumePercentItem() error { // Free the old texture if one exists if p.volumePercent.ID != 0 { gl.DeleteTextures(1, &p.volumePercent.ID) } pct := int(p.Context.CfgPtr.SavedVolume * 100) label := fmt.Sprintf("%d%%", pct) item, err := NewStringItem(label, 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) layout() { // Position title at the top p.title.X = p.ScreenCenterX - p.title.W/2 p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 // Position prompt below the title p.prompt.X = p.ScreenCenterX - p.prompt.W/2 p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H // Position the audio toggle below the prompt, centred p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 p.AudioStatusToggle.Y = p.prompt.Y - p.verticalMargin*2 - p.AudioStatusToggle.H // Volume bar: narrower (30% of screen width) so the right edge stays visible barH := p.AudioStatusToggle.H barW := int32(float32(p.ScreenWidth) * 0.30) barX := p.ScreenCenterX - barW/2 barY := p.AudioStatusToggle.Y - p.verticalMargin*2 - barH p.volumeBarX = barX p.volumeBarY = barY p.volumeBarW = barW p.volumeBarH = barH // "Volume" label: to the LEFT of the bar, vertically centred on it p.VolumeLabel.X = barX - p.VolumeLabel.W - 20 p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2 // Percentage label: vertically centred on the bar, small gap to the right gap := int32(12) p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 } func (p *AudioOptions) ToggleAudio() { // Toggle current setting p.Context.CfgPtr.AudioEnabled = !p.Context.CfgPtr.AudioEnabled // Update the database p.Context.DbPtr.Save(p.Context.CfgPtr) // Update the configuration p.Context.RefreshConfig() // Update AudioEnabled UI element p.CreateAudioStatusItem() p.layout() } // UpdateVolume increments or decrements system volume by level (percent change) func (p *AudioOptions) UpdateVolume(level int) { // Calculate the new volume newVol := mixer.ConvertVolume(level) + p.Context.CfgPtr.SavedVolume // Check that it's within bounds if newVol < 0.0 || newVol > 1.0 { return } // Force 1.0 rather than 0.99999999999999989 if newVol > .99 { newVol = 1.0 } // Update the config p.Context.CfgPtr.SavedVolume = newVol fmt.Printf("New Volume: %f", newVol) // Update the database p.Context.DbPtr.Save(&p.Context.CfgPtr) // Set the new volume err := mixer.SetAllSinkVolumes(newVol) if err != nil { fmt.Printf("Error setting allSinkVolumes: %v", err) } // Refresh volume label if err := p.CreateVolumePercentItem(); err != nil { logger.Error("failed to refresh volume percent item: %v", err) } // Re-position the label (bar geometry is unchanged, but label width may differ) 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) 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_RETURN: p.ToggleAudio() case sdl.K_RIGHT: // Right increase p.UpdateVolume(10) case sdl.K_LEFT: // Left decrease p.UpdateVolume(-10) case sdl.K_BACKSPACE: p.Context.NavigateTo(SETTINGS) } return nil } // renderVolumeBar draws the green outline and the white filled portion. func (p *AudioOptions) renderVolumeBar() error { vol := p.Context.CfgPtr.SavedVolume // --- White fill (clipped to current volume fraction) --- fillW := float32(p.volumeBarW) * float32(vol) if fillW > 0 { fillPos := mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)} fillDim := mgl32.Vec2{fillW, float32(p.volumeBarH)} if err := p.RenderSolidColorQuad(p.solidShader, fillPos, fillDim, colors.White); err != nil { return fmt.Errorf("rendering volume fill: %w", err) } } // --- Green outline (4 x thin quads: top, bottom, left, right) --- thickness := float32(2) bx := float32(p.volumeBarX) by := float32(p.volumeBarY) bw := float32(p.volumeBarW) bh := float32(p.volumeBarH) outlineRects := [4][2]mgl32.Vec2{ // bottom edge {{bx, by}, {bw, thickness}}, // top edge {{bx, by + bh - thickness}, {bw, thickness}}, // left edge {{bx, by}, {thickness, bh}}, // right edge {{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 edge: %w", err) } } return nil } 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 { logger.Error("unable to render title texture: %v", err) return fmt.Errorf("unable to render title texture: %w", err) } if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil { logger.Error("unable to render prompt texture: %v", err) return fmt.Errorf("unable to render prompt texture: %w", err) } if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil { logger.Error("unable to render audioStatusToggle texture: %v", err) return fmt.Errorf("unable to render audioStatusToggle texture: %w", err) } if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil { logger.Error("unable to render volume label texture: %v", err) return fmt.Errorf("unable to render volume label texture: %w", err) } if err := p.renderVolumeBar(); err != nil { logger.Error("unable to render volume bar: %v", err) return fmt.Errorf("unable to 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 { logger.Error("unable to render volume percent texture: %v", err) return fmt.Errorf("unable to render volume percent texture: %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.volumePercent.ID) gl.DeleteTextures(1, &p.VolumeLabel.ID) return nil }