Add audio device switching UI
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
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/shaderManager"
|
||||
)
|
||||
|
||||
var audioTrackSeed = []db.AudioTrack{
|
||||
{Filename: "OriginalAudio.wav", Name: "Original Audio", AssignedFrequency: 0, MD5: "d39c2676c315438752ab0c55e9427d74", Order: 1},
|
||||
{Filename: "174Hz.wav", Name: "174 Hz", AssignedFrequency: 174, MD5: "42abaf332e628b646ac2cc9d1b5385e7", Order: 2},
|
||||
{Filename: "396Hz.wav", Name: "396 Hz", AssignedFrequency: 396, MD5: "d53d45849b042122a3c177fad95ff499", Order: 3},
|
||||
{Filename: "417Hz.wav", Name: "417 Hz", AssignedFrequency: 417, MD5: "056a7aae0b53111f5c568ea7ecd1fadb", Order: 4},
|
||||
{Filename: "432Hz.wav", Name: "432 Hz", AssignedFrequency: 432, MD5: "276c57179372404ff59c35024911468e", Order: 5},
|
||||
{Filename: "639Hz.wav", Name: "639 Hz", AssignedFrequency: 639, MD5: "4e460148230f4417fef39e9817f75e06", Order: 6},
|
||||
{Filename: "741Hz.wav", Name: "741 Hz", AssignedFrequency: 741, MD5: "cfbd9b03b539db69bcefdd91cedc9ad1", Order: 7},
|
||||
{Filename: "852Hz.wav", Name: "852 Hz", AssignedFrequency: 852, MD5: "485700296d017f8a1b0a7583a55a7783", Order: 8},
|
||||
{Filename: "963Hz.wav", Name: "963 Hz", AssignedFrequency: 963, MD5: "2670e13294daef8490a862c078aa8af4", Order: 9},
|
||||
{Filename: "1111Hz.wav", Name: "1111 Hz", AssignedFrequency: 1111, MD5: "67bbc7619dc21d0750519113472ca335", Order: 10},
|
||||
{Filename: "14Hz_Binaural.wav", Name: "14 Hz Binaural Beat", AssignedFrequency: 14, MD5: "807c14866640d2ab27a6f5d7fdd24e77", Order: 11},
|
||||
{Filename: "40Hz_Binaural.wav", Name: "40 Hz Binaural Beat", AssignedFrequency: 40, MD5: "42d60d55a16db4e9b29da47a5a102f2c", Order: 12},
|
||||
{Filename: "AlphaWaves.wav", Name: "Alpha Waves", AssignedFrequency: 0, MD5: "07a93fa0e633a00d97b8a5e5a259d6ff", Order: 13},
|
||||
{Filename: "PinealGland.wav", Name: "Pineal Gland", AssignedFrequency: 0, MD5: "998d74e3826b30086d64ff0df7b9f133", Order: 14},
|
||||
}
|
||||
|
||||
type TrackSelection struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
|
||||
verticalMargin int32
|
||||
ItemScale float32
|
||||
|
||||
sectionHeader StringItem
|
||||
|
||||
audioFileTextures []StringItem
|
||||
audioTracks []db.AudioTrack
|
||||
selectedFileIndex 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
|
||||
}
|
||||
|
||||
func (p *TrackSelection) 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 Track Selection", 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("Audio Track", p.font, colors.Silver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating section header: %w", err)
|
||||
}
|
||||
p.sectionHeader.Scale(p.ItemScale * 1.2)
|
||||
|
||||
if err = p.seedAudioTracks(); err != nil {
|
||||
logger.Error("failed to seed audio tracks: %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.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) buildAudioFileList() {
|
||||
for i := range p.audioFileTextures {
|
||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
||||
}
|
||||
p.audioFileTextures = nil
|
||||
|
||||
var tracks []db.AudioTrack
|
||||
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
|
||||
p.audioTracks = tracks
|
||||
|
||||
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 _, track := range tracks {
|
||||
item, err := NewStringItem(track.Name, p.font, colors.White)
|
||||
if err != nil {
|
||||
logger.Error("failed to create audio track string item for %s: %v", track.Name, err)
|
||||
continue
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.audioFileTextures = append(p.audioFileTextures, item)
|
||||
}
|
||||
|
||||
p.updateSelectedIndex()
|
||||
}
|
||||
|
||||
func (p *TrackSelection) 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.AudioTrack
|
||||
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
|
||||
p.selectedFileIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
for i, track := range p.audioTracks {
|
||||
if track.ID == selected.ID {
|
||||
p.selectedFileIndex = i + 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
p.selectedFileIndex = 0
|
||||
}
|
||||
|
||||
func (p *TrackSelection) seedAudioTracks() error {
|
||||
var existing []db.AudioTrack
|
||||
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return p.Context.DbPtr.Save(&audioTrackSeed).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) 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.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||
} else {
|
||||
config.DynamicAudio = false
|
||||
trackIndex := index - 1
|
||||
if trackIndex >= 0 && trackIndex < len(p.audioTracks) {
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].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 *TrackSelection) 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.audioFileTextures[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.audioFileTextures)
|
||||
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.audioFileTextures {
|
||||
w := p.audioFileTextures[i].W
|
||||
if w > maxItemW {
|
||||
maxItemW = w
|
||||
}
|
||||
}
|
||||
p.listLeftX = p.ScreenCenterX - maxItemW/2
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 *TrackSelection) updateCursorPosition() {
|
||||
cursorGap := int32(12)
|
||||
|
||||
if p.focusState < 0 || p.focusState >= len(p.audioFileTextures) {
|
||||
return
|
||||
}
|
||||
item := &p.audioFileTextures[p.focusState]
|
||||
|
||||
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
||||
}
|
||||
|
||||
func (p *TrackSelection) 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 >= 0 && p.focusState < n {
|
||||
p.selectAudioFile(p.focusState)
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
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 *TrackSelection) Update(dt float32) error {
|
||||
p.spinElapsed += dt
|
||||
if p.spinElapsed >= 1.0 {
|
||||
p.spinElapsed -= 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) 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 *TrackSelection) 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 *TrackSelection) 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.audioFileTextures {
|
||||
w := p.audioFileTextures[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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
p.renderScrollbar()
|
||||
|
||||
if err := p.renderCursor(); err != nil {
|
||||
return fmt.Errorf("render cursor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TrackSelection) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.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
|
||||
}
|
||||
Reference in New Issue
Block a user