|
|
|
@@ -2,6 +2,8 @@ package page
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"math"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/go-gl/gl/v3.3-core/gl"
|
|
|
|
|
"github.com/go-gl/mathgl/mgl32"
|
|
|
|
@@ -14,31 +16,99 @@ import (
|
|
|
|
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var AudioFileList = []db.AudioFile{
|
|
|
|
|
{ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false},
|
|
|
|
|
{ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false},
|
|
|
|
|
{ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false},
|
|
|
|
|
{ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false},
|
|
|
|
|
{ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false},
|
|
|
|
|
{ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false},
|
|
|
|
|
{ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false},
|
|
|
|
|
{ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false},
|
|
|
|
|
{ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false},
|
|
|
|
|
{ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false},
|
|
|
|
|
{ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false},
|
|
|
|
|
{ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false},
|
|
|
|
|
{ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false},
|
|
|
|
|
{ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
triangleSize = float32(16)
|
|
|
|
|
visibleCount = 9
|
|
|
|
|
listShiftDown = int32(50)
|
|
|
|
|
scrollbarWidth = int32(4)
|
|
|
|
|
scrollbarGap = int32(16) // gap between right edge of list and scrollbar
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type AudioOptions struct {
|
|
|
|
|
Base
|
|
|
|
|
font *fontManager.FontEntry
|
|
|
|
|
textShader *shaderManager.Shader
|
|
|
|
|
solidShader *shaderManager.Shader
|
|
|
|
|
title StringItem
|
|
|
|
|
prompt StringItem
|
|
|
|
|
font *fontManager.FontEntry
|
|
|
|
|
textShader *shaderManager.Shader
|
|
|
|
|
solidShader *shaderManager.Shader
|
|
|
|
|
title StringItem
|
|
|
|
|
prompt StringItem
|
|
|
|
|
verticalMargin int32
|
|
|
|
|
ItemScale float32
|
|
|
|
|
CfgPtr *db.AppConfig
|
|
|
|
|
|
|
|
|
|
// Audio toggle
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
// Volume row (excluded from focus)
|
|
|
|
|
VolumeLabel StringItem
|
|
|
|
|
volumePercent StringItem
|
|
|
|
|
volumeBarX int32
|
|
|
|
|
volumeBarY int32
|
|
|
|
|
volumeBarW int32
|
|
|
|
|
volumeBarH int32
|
|
|
|
|
|
|
|
|
|
// Separator + track section
|
|
|
|
|
separatorY int32
|
|
|
|
|
tracksHeading StringItem
|
|
|
|
|
trackItems []StringItem
|
|
|
|
|
|
|
|
|
|
// Selected track (-1 = none)
|
|
|
|
|
selectedTrack int
|
|
|
|
|
|
|
|
|
|
// Navigation
|
|
|
|
|
// focusedIndex: 0 = AudioStatusToggle, 1..N = trackItems[focusedIndex-1]
|
|
|
|
|
// Volume is intentionally excluded from focus.
|
|
|
|
|
focusedIndex int
|
|
|
|
|
|
|
|
|
|
// Scrolling window
|
|
|
|
|
windowStart int
|
|
|
|
|
listTopY int32
|
|
|
|
|
listLeftX int32
|
|
|
|
|
itemHeight int32
|
|
|
|
|
trackSpacing int32
|
|
|
|
|
|
|
|
|
|
// Scrollbar geometry (computed once in layout, thumb Y updated on scroll)
|
|
|
|
|
scrollbarX int32
|
|
|
|
|
scrollbarTrackY int32 // bottom of the scrollbar track
|
|
|
|
|
scrollbarTrackH int32 // total height of the track
|
|
|
|
|
scrollbarThumbH int32 // height of the thumb
|
|
|
|
|
scrollbarThumbY int32 // current bottom Y of the thumb (updated on scroll)
|
|
|
|
|
|
|
|
|
|
// Triangle cursor
|
|
|
|
|
triVAO uint32
|
|
|
|
|
triVBO uint32
|
|
|
|
|
triX float32
|
|
|
|
|
triCenterY float32
|
|
|
|
|
spinElapsed float32
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|
|
|
|
var err error
|
|
|
|
|
p.verticalMargin = int32(20)
|
|
|
|
|
p.focusedIndex = 0
|
|
|
|
|
p.selectedTrack = -1
|
|
|
|
|
p.windowStart = 0
|
|
|
|
|
p.trackSpacing = p.verticalMargin / 2
|
|
|
|
|
|
|
|
|
|
// Get font
|
|
|
|
|
// Font
|
|
|
|
|
fm := fontManager.Get()
|
|
|
|
|
font, ok := fm.GetFont("Roboto-Regular")
|
|
|
|
|
if !ok {
|
|
|
|
@@ -46,7 +116,7 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|
|
|
|
}
|
|
|
|
|
p.font = font
|
|
|
|
|
|
|
|
|
|
// Get shaders
|
|
|
|
|
// Shaders
|
|
|
|
|
sm := shaderManager.Get()
|
|
|
|
|
p.textShader, ok = sm.Get("text")
|
|
|
|
|
if !ok {
|
|
|
|
@@ -57,79 +127,180 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|
|
|
|
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialize base
|
|
|
|
|
if err := p.Base.Init(ctx); err != nil {
|
|
|
|
|
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)
|
|
|
|
|
// Title
|
|
|
|
|
p.title, err = NewStringItem("Audio Options", 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)
|
|
|
|
|
// Prompt
|
|
|
|
|
p.prompt, err = NewStringItem("Up/Down to navigate -- Enter to select -- 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)
|
|
|
|
|
|
|
|
|
|
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()")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Volume row
|
|
|
|
|
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()")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audio toggle
|
|
|
|
|
if err = p.CreateAudioStatusItem(); err != nil {
|
|
|
|
|
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "Available Tracks" heading
|
|
|
|
|
headingScale := float32(40) / float32(baseFontSize)
|
|
|
|
|
p.tracksHeading, err = NewStringItem("Available Tracks", p.font, colors.White)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("creating tracks heading: %w", err)
|
|
|
|
|
}
|
|
|
|
|
p.tracksHeading.Scale(headingScale)
|
|
|
|
|
|
|
|
|
|
// Track items
|
|
|
|
|
if err = p.buildTrackItems(); err != nil {
|
|
|
|
|
return fmt.Errorf("building track items: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
|
|
|
|
|
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 *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
|
|
|
|
|
// ── Track items ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) buildTrackItems() error {
|
|
|
|
|
var audioFileList = []db.AudioFile{}
|
|
|
|
|
if err := p.Context.DbPtr.Find(&audioFileList).Error; err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
audioStatusStringItem, err := NewStringItem(statusString, p.font, color)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create '%s' string item: %v", statusString, err)
|
|
|
|
|
|
|
|
|
|
if len(audioFileList) == 0 {
|
|
|
|
|
audioFileList = AudioFileList
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := range p.trackItems {
|
|
|
|
|
if p.trackItems[i].ID != 0 {
|
|
|
|
|
gl.DeleteTextures(1, &p.trackItems[i].ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
p.trackItems = make([]StringItem, len(AudioFileList))
|
|
|
|
|
|
|
|
|
|
for i, entry := range AudioFileList {
|
|
|
|
|
color := colors.White
|
|
|
|
|
if i == p.selectedTrack {
|
|
|
|
|
color = colors.Green
|
|
|
|
|
}
|
|
|
|
|
item, err := NewStringItem(entry.Name, p.font, color)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("creating track item %d: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
item.Scale(p.ItemScale)
|
|
|
|
|
p.trackItems[i] = item
|
|
|
|
|
}
|
|
|
|
|
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.
|
|
|
|
|
// positionTrackItems places the visible window of items and parks the rest off-screen.
|
|
|
|
|
func (p *AudioOptions) positionTrackItems() {
|
|
|
|
|
peekCount := visibleCount + 1
|
|
|
|
|
|
|
|
|
|
for i := range p.trackItems {
|
|
|
|
|
slot := i - p.windowStart
|
|
|
|
|
|
|
|
|
|
if slot < 0 || slot >= peekCount {
|
|
|
|
|
p.trackItems[i].X = p.listLeftX
|
|
|
|
|
p.trackItems[i].Y = -9999
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
itemY := p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing)
|
|
|
|
|
p.trackItems[i].X = p.listLeftX
|
|
|
|
|
p.trackItems[i].Y = itemY
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Scrollbar ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// updateScrollbarThumb recalculates the thumb Y position based on windowStart.
|
|
|
|
|
// Called whenever windowStart changes.
|
|
|
|
|
func (p *AudioOptions) updateScrollbarThumb() {
|
|
|
|
|
total := len(AudioFileList)
|
|
|
|
|
if total <= visibleCount {
|
|
|
|
|
// Entire list fits — thumb fills the track, nothing to scroll
|
|
|
|
|
p.scrollbarThumbY = p.scrollbarTrackY
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// How far through the scrollable range are we?
|
|
|
|
|
// maxWindowStart is the furthest windowStart can go
|
|
|
|
|
maxWindowStart := total - visibleCount
|
|
|
|
|
fraction := float32(p.windowStart) / float32(maxWindowStart)
|
|
|
|
|
|
|
|
|
|
// Thumb travels from the top of the track down to (trackTop - thumbH)
|
|
|
|
|
// "top" in our Y-up projection means the larger Y value
|
|
|
|
|
trackTop := p.scrollbarTrackY + p.scrollbarTrackH
|
|
|
|
|
travelRange := p.scrollbarTrackH - p.scrollbarThumbH
|
|
|
|
|
p.scrollbarThumbY = trackTop - p.scrollbarThumbH - int32(fraction*float32(travelRange))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Audio status toggle ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) CreateAudioStatusItem() error {
|
|
|
|
|
on := p.Context.CfgPtr.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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Volume ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
@@ -138,88 +309,189 @@ func (p *AudioOptions) CreateVolumePercentItem() error {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) UpdateVolume(level int) {
|
|
|
|
|
newVol := mixer.ConvertVolume(level) + p.Context.CfgPtr.SavedVolume
|
|
|
|
|
if newVol < 0.0 || newVol > 1.0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if newVol > .99 {
|
|
|
|
|
newVol = 1.0
|
|
|
|
|
}
|
|
|
|
|
p.Context.CfgPtr.SavedVolume = newVol
|
|
|
|
|
p.Context.DbPtr.Save(&p.Context.CfgPtr)
|
|
|
|
|
if err := mixer.SetAllSinkVolumes(newVol); err != nil {
|
|
|
|
|
logger.Error("Error setting allSinkVolumes: %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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) layout() {
|
|
|
|
|
// Position title at the top
|
|
|
|
|
// Title
|
|
|
|
|
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
|
|
|
|
|
// Prompt
|
|
|
|
|
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
|
|
|
|
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
|
|
|
|
p.prompt.Y = p.title.Y - p.verticalMargin - 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
|
|
|
|
|
// Volume row (not focusable)
|
|
|
|
|
barH := int32(float32(p.Context.CfgPtr.StandardFontSize) * p.ItemScale)
|
|
|
|
|
barW := int32(float32(p.ScreenWidth) * 0.30)
|
|
|
|
|
barX := p.ScreenCenterX - barW/2
|
|
|
|
|
barY := p.AudioStatusToggle.Y - p.verticalMargin*2 - barH
|
|
|
|
|
barY := p.prompt.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
|
|
|
|
|
|
|
|
|
|
// "Available Tracks" heading — centred, left edge becomes the list anchor
|
|
|
|
|
p.tracksHeading.X = p.ScreenCenterX - p.tracksHeading.W/2
|
|
|
|
|
|
|
|
|
|
// Toggle — left-aligned to the heading
|
|
|
|
|
p.AudioStatusToggle.X = p.tracksHeading.X
|
|
|
|
|
p.AudioStatusToggle.Y = barY - p.verticalMargin*2 - p.AudioStatusToggle.H
|
|
|
|
|
|
|
|
|
|
// Separator
|
|
|
|
|
p.separatorY = p.AudioStatusToggle.Y - p.verticalMargin*3
|
|
|
|
|
|
|
|
|
|
// Heading Y — below separator
|
|
|
|
|
p.tracksHeading.Y = p.separatorY - p.verticalMargin - p.tracksHeading.H
|
|
|
|
|
|
|
|
|
|
// Track list geometry — shifted down by listShiftDown
|
|
|
|
|
if len(p.trackItems) > 0 {
|
|
|
|
|
p.itemHeight = p.trackItems[0].H
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.listLeftX = p.tracksHeading.X
|
|
|
|
|
p.listTopY = p.tracksHeading.Y - p.verticalMargin - listShiftDown
|
|
|
|
|
|
|
|
|
|
// ── Scrollbar geometry ────────────────────────────────────────────────────
|
|
|
|
|
// The scrollbar track spans the full visible window height.
|
|
|
|
|
// It sits to the right of the list with a small gap.
|
|
|
|
|
windowH := int32(visibleCount)*(p.itemHeight+p.trackSpacing) - p.trackSpacing
|
|
|
|
|
scrollbarBottom := p.listTopY - windowH
|
|
|
|
|
p.scrollbarTrackH = windowH
|
|
|
|
|
p.scrollbarTrackY = scrollbarBottom // bottom Y of the track (Y-up coords)
|
|
|
|
|
|
|
|
|
|
// Right edge of the list: use the widest track name as an approximation.
|
|
|
|
|
// Since items are left-aligned and widths vary, we use a fixed width based
|
|
|
|
|
// on the heading width which is a reasonable proxy for the list column width.
|
|
|
|
|
listRightX := p.listLeftX + p.tracksHeading.W
|
|
|
|
|
p.scrollbarX = listRightX + scrollbarGap
|
|
|
|
|
|
|
|
|
|
// Thumb height is proportional: visibleCount / total items
|
|
|
|
|
total := len(AudioFileList)
|
|
|
|
|
if total > 0 {
|
|
|
|
|
thumbFraction := float32(visibleCount) / float32(total)
|
|
|
|
|
p.scrollbarThumbH = int32(thumbFraction * float32(p.scrollbarTrackH))
|
|
|
|
|
if p.scrollbarThumbH < 8 {
|
|
|
|
|
p.scrollbarThumbH = 8 // minimum thumb size so it's always clickable
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Triangle cursor X — just outside the left edge of the list
|
|
|
|
|
cursorGap := int32(12)
|
|
|
|
|
p.triX = float32(p.listLeftX) - float32(triangleSize) - float32(cursorGap)
|
|
|
|
|
|
|
|
|
|
p.positionTrackItems()
|
|
|
|
|
p.updateScrollbarThumb()
|
|
|
|
|
p.repositionCursor()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// repositionCursor vertically centres the triangle on the focused item.
|
|
|
|
|
func (p *AudioOptions) repositionCursor() {
|
|
|
|
|
var targetY, targetH int32
|
|
|
|
|
|
|
|
|
|
if p.focusedIndex == 0 {
|
|
|
|
|
targetY = p.AudioStatusToggle.Y
|
|
|
|
|
targetH = p.AudioStatusToggle.H
|
|
|
|
|
} else {
|
|
|
|
|
trackIdx := p.focusedIndex - 1
|
|
|
|
|
slot := trackIdx - p.windowStart
|
|
|
|
|
targetY = p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing)
|
|
|
|
|
targetH = p.itemHeight
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.triCenterY = float32(targetY) + float32(targetH)/2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Scroll ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) scrollToFocus() {
|
|
|
|
|
if p.focusedIndex == 0 {
|
|
|
|
|
p.windowStart = 0
|
|
|
|
|
p.positionTrackItems()
|
|
|
|
|
p.updateScrollbarThumb()
|
|
|
|
|
p.repositionCursor()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
trackIdx := p.focusedIndex - 1
|
|
|
|
|
|
|
|
|
|
if trackIdx >= p.windowStart+visibleCount {
|
|
|
|
|
p.windowStart = trackIdx - visibleCount + 1
|
|
|
|
|
}
|
|
|
|
|
if trackIdx < p.windowStart {
|
|
|
|
|
p.windowStart = trackIdx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.positionTrackItems()
|
|
|
|
|
p.updateScrollbarThumb()
|
|
|
|
|
p.repositionCursor()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Toggle ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
// ── Track selection ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) onTrackSelected(index int, entry db.AudioFile) {
|
|
|
|
|
p.selectedTrack = index
|
|
|
|
|
if err := p.buildTrackItems(); err != nil {
|
|
|
|
|
logger.Error("onTrackSelected: failed to rebuild track items: %v", err)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
p.layout()
|
|
|
|
|
|
|
|
|
|
// Refresh volume label
|
|
|
|
|
if err := p.CreateVolumePercentItem(); err != nil {
|
|
|
|
|
logger.Error("failed to refresh volume percent item: %v", err)
|
|
|
|
|
// Create file names and paths
|
|
|
|
|
// Set Selected
|
|
|
|
|
entry.Selected = true
|
|
|
|
|
// Set path
|
|
|
|
|
if !strings.Contains(entry.Path, ".local/") {
|
|
|
|
|
entry.Path = p.Context.CfgPtr.AssetsDir + "audio" + entry.Path
|
|
|
|
|
}
|
|
|
|
|
// 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
|
|
|
|
|
// Save it to the database
|
|
|
|
|
if err := p.Context.DbPtr.Save(&entry).Error; err != nil {
|
|
|
|
|
logger.Error("Error saving to database: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
logger.Info("Updated audio selection: %s", entry.Name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Events ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|
|
|
|
e, ok := (*event).(*sdl.KeyboardEvent)
|
|
|
|
|
if !ok || e.Type != sdl.KEYDOWN {
|
|
|
|
@@ -227,12 +499,32 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch e.Keysym.Sym {
|
|
|
|
|
case sdl.K_UP:
|
|
|
|
|
if p.focusedIndex > 0 {
|
|
|
|
|
p.focusedIndex--
|
|
|
|
|
p.scrollToFocus()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case sdl.K_DOWN:
|
|
|
|
|
if p.focusedIndex < len(AudioFileList) {
|
|
|
|
|
p.focusedIndex++
|
|
|
|
|
p.scrollToFocus()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case sdl.K_RETURN:
|
|
|
|
|
p.ToggleAudio()
|
|
|
|
|
case sdl.K_RIGHT: // Right increase
|
|
|
|
|
if p.focusedIndex == 0 {
|
|
|
|
|
p.ToggleAudio()
|
|
|
|
|
} else {
|
|
|
|
|
trackIdx := p.focusedIndex - 1
|
|
|
|
|
p.onTrackSelected(trackIdx, AudioFileList[trackIdx])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case sdl.K_RIGHT:
|
|
|
|
|
p.UpdateVolume(10)
|
|
|
|
|
case sdl.K_LEFT: // Left decrease
|
|
|
|
|
|
|
|
|
|
case sdl.K_LEFT:
|
|
|
|
|
p.UpdateVolume(-10)
|
|
|
|
|
|
|
|
|
|
case sdl.K_BACKSPACE:
|
|
|
|
|
p.Context.NavigateTo(SETTINGS)
|
|
|
|
|
}
|
|
|
|
@@ -240,21 +532,44 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderVolumeBar draws the green outline and the white filled portion.
|
|
|
|
|
// ── Update ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) Update(dt float32) error {
|
|
|
|
|
p.spinElapsed += dt
|
|
|
|
|
if p.spinElapsed >= 1.0 {
|
|
|
|
|
p.spinElapsed -= 1.0
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Render helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func (p *AudioOptions) renderSeparator() error {
|
|
|
|
|
lineW := float32(p.ScreenWidth) * 0.80
|
|
|
|
|
lineX := float32(p.ScreenCenterX) - lineW/2
|
|
|
|
|
return p.RenderSolidColorQuad(
|
|
|
|
|
p.solidShader,
|
|
|
|
|
mgl32.Vec2{lineX, float32(p.separatorY)},
|
|
|
|
|
mgl32.Vec2{lineW, 2},
|
|
|
|
|
colors.White,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Green outline (4 x thin quads: top, bottom, left, right) ---
|
|
|
|
|
thickness := float32(2)
|
|
|
|
|
bx := float32(p.volumeBarX)
|
|
|
|
|
by := float32(p.volumeBarY)
|
|
|
|
@@ -262,63 +577,150 @@ func (p *AudioOptions) renderVolumeBar() error {
|
|
|
|
|
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 fmt.Errorf("rendering volume outline: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderScrollbar draws the scrollbar track (dim) and thumb (white).
|
|
|
|
|
// Only rendered when the list is longer than the visible window.
|
|
|
|
|
func (p *AudioOptions) renderScrollbar() error {
|
|
|
|
|
if len(AudioFileList) <= visibleCount {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track — a thin dim line the full height of the window
|
|
|
|
|
trackColor := sdl.Color{R: 80, G: 80, B: 80, A: 255}
|
|
|
|
|
if err := p.RenderSolidColorQuad(
|
|
|
|
|
p.solidShader,
|
|
|
|
|
mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarTrackY)},
|
|
|
|
|
mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarTrackH)},
|
|
|
|
|
trackColor,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return fmt.Errorf("rendering scrollbar track: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Thumb — white, positioned proportionally within the track
|
|
|
|
|
if err := p.RenderSolidColorQuad(
|
|
|
|
|
p.solidShader,
|
|
|
|
|
mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarThumbY)},
|
|
|
|
|
mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarThumbH)},
|
|
|
|
|
colors.White,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return fmt.Errorf("rendering scrollbar thumb: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderCursor draws the spinning triangle cursor.
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
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 {
|
|
|
|
|
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)
|
|
|
|
|
return fmt.Errorf("render prompt: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Volume row
|
|
|
|
|
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)
|
|
|
|
|
return fmt.Errorf("render volume label: %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)
|
|
|
|
|
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.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)
|
|
|
|
|
// Toggle
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Separator
|
|
|
|
|
if err := p.renderSeparator(); err != nil {
|
|
|
|
|
return fmt.Errorf("render separator: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "Available Tracks" heading — fixed, never moves
|
|
|
|
|
if err := p.RenderTexture(p.textShader, p.tracksHeading.ID, p.tracksHeading.Position(), p.tracksHeading.RawDimensions(), p.tracksHeading.Color); err != nil {
|
|
|
|
|
return fmt.Errorf("render tracks heading: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track list
|
|
|
|
|
for i := range p.trackItems {
|
|
|
|
|
item := &p.trackItems[i]
|
|
|
|
|
if item.Y < 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color); err != nil {
|
|
|
|
|
return fmt.Errorf("render track item %d: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scrollbar
|
|
|
|
|
if err := p.renderScrollbar(); err != nil {
|
|
|
|
|
return fmt.Errorf("render scrollbar: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spinning triangle cursor — always on top
|
|
|
|
|
if err := p.renderCursor(); err != nil {
|
|
|
|
|
return fmt.Errorf("render cursor: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Destroy ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
gl.DeleteTextures(1, &p.volumePercent.ID)
|
|
|
|
|
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
|
|
|
|
gl.DeleteTextures(1, &p.tracksHeading.ID)
|
|
|
|
|
for i := range p.trackItems {
|
|
|
|
|
gl.DeleteTextures(1, &p.trackItems[i].ID)
|
|
|
|
|
}
|
|
|
|
|
gl.DeleteBuffers(1, &p.triVBO)
|
|
|
|
|
gl.DeleteVertexArrays(1, &p.triVAO)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|