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 }