506 lines
14 KiB
Go
506 lines
14 KiB
Go
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"
|
|
"radiantwavetech.com/radiantwave/internal/colors"
|
|
"radiantwavetech.com/radiantwave/internal/db"
|
|
"radiantwavetech.com/radiantwave/internal/fontManager"
|
|
"radiantwavetech.com/radiantwave/internal/logger"
|
|
"radiantwavetech.com/radiantwave/internal/mixer"
|
|
"radiantwavetech.com/radiantwave/internal/shaderManager"
|
|
)
|
|
|
|
// focusToggle is the focus state of the audio on/off toggle, the topmost
|
|
// focusable item on screen (Y increases upward). Focus states increase from
|
|
// top to bottom: focusToggle = toggle, 1 = Output Device, maxFocus = Audio Track.
|
|
const focusToggle = 0
|
|
|
|
const maxFocus = 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 == 1:
|
|
item = &p.outputDeviceItem
|
|
case p.focusState == 2:
|
|
item = &p.audioTrackItem
|
|
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 focusToggle (toggle) to maxFocus (Audio Track),
|
|
// so navigation never skips the Output Device entry.
|
|
func moveFocus(current, delta int) int {
|
|
next := current + delta
|
|
if next < focusToggle {
|
|
next = focusToggle
|
|
}
|
|
if next > maxFocus {
|
|
next = maxFocus
|
|
}
|
|
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 1:
|
|
p.Context.NavigateTo(AUDIO_DEVICES)
|
|
case 2:
|
|
p.Context.NavigateTo(TRACKS)
|
|
}
|
|
|
|
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
|
|
}
|