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" ) // focusToggle is the focus state of the audio on/off toggle, the bottommost // focusable item. Focus states increase from top to bottom on screen: // 0 = Audio Track entry, 1 = Output Device entry, focusToggle = toggle. const focusToggle = 2 const triangleSize = float32(16) // 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 textShader *shaderManager.Shader solidShader *shaderManager.Shader title StringItem prompt StringItem verticalMargin int32 ItemScale float32 AudioStatusToggle StringItem audioTrackItem StringItem outputDeviceItem StringItem VolumeLabel StringItem volumePercent StringItem volumeBarX int32 volumeBarY int32 volumeBarW int32 volumeBarH int32 triVAO uint32 triVBO uint32 triX float32 triCenterY float32 spinElapsed float32 focusState int } func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) p.focusState = focusToggle 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.buildNavigationItems(); err != nil { return fmt.Errorf("creating navigation items: %w", err) } 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 } // 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 p.audioTrackItem, err = NewStringItem(p.currentTrackLabel(), p.font, colors.White) if err != nil { return fmt.Errorf("creating audio track item: %w", err) } p.audioTrackItem.Scale(p.ItemScale) 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) return nil } func (p *AudioOptions) currentTrackLabel() string { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { return "Audio Track" } if config.DynamicAudio { return "Audio Track: Dynamic Audio" } var selected db.AudioTrack if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil { return "Audio Track" } if selected.Name != "" { return "Audio Track: " + selected.Name } return "Audio Track" } func (p *AudioOptions) currentDeviceLabel() string { current := mixer.CurrentDevice() if current == "" { return "Output Device: System Default" } return "Output Device: " + mixer.HumanizeDeviceName(current) } 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) 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) } 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 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 p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H p.outputDeviceItem.X = p.ScreenCenterX - p.outputDeviceItem.W/2 p.outputDeviceItem.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.outputDeviceItem.H p.audioTrackItem.X = p.ScreenCenterX - p.audioTrackItem.W/2 p.audioTrackItem.Y = p.outputDeviceItem.Y - p.verticalMargin - p.audioTrackItem.H p.updateCursorPosition() } func (p *AudioOptions) updateCursorPosition() { cursorGap := int32(12) var item *StringItem switch { case p.focusState == focusToggle: item = &p.AudioStatusToggle case p.focusState == 0: item = &p.audioTrackItem case p.focusState == 1: item = &p.outputDeviceItem 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() } // moveFocus returns the focus state after moving by delta (1 = down, -1 = up) // on screen. Focus states run 0 (Audio Track) to focusToggle (toggle), so // navigation never skips the Output Device entry. func moveFocus(current, delta int) int { next := current + delta if next < 0 { next = 0 } if next > focusToggle { next = focusToggle } return next } 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: switch p.focusState { case focusToggle: p.ToggleAudio() case 0: p.Context.NavigateTo(TRACKS) case 1: p.Context.NavigateTo(AUDIO_DEVICES) } case sdl.K_DOWN: p.focusState = moveFocus(p.focusState, 1) p.updateCursorPosition() case sdl.K_UP: p.focusState = moveFocus(p.focusState, -1) p.updateCursorPosition() 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) 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) } 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) } 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) } 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.audioTrackItem.ID) gl.DeleteTextures(1, &p.outputDeviceItem.ID) gl.DeleteBuffers(1, &p.triVBO) gl.DeleteVertexArrays(1, &p.triVAO) return nil }