diff --git a/internal/application/application.go b/internal/application/application.go index 424bd4f..d4447f4 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -116,7 +116,6 @@ func Run() { AssetsDir: dataDir, SystemLevel: "BASIC", SelectedAudioDevice: "default", - SelectedAudio: "audio.wav", InitialFontSize: 128, StandardFontSize: 32, LinePattern: "fibonacci", diff --git a/internal/db/newDb.go b/internal/db/newDb.go index ddfc273..fd77866 100644 --- a/internal/db/newDb.go +++ b/internal/db/newDb.go @@ -42,7 +42,7 @@ func Connect(dbpath string) (*gorm.DB, error) { // Migrate the database schema func Migrate(db *gorm.DB) error { // Pass all models to be migrated to this function call. - if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}); err != nil { + if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}, &AudioFile{}); err != nil { return err } return nil diff --git a/internal/db/schema.go b/internal/db/schema.go index 1b5437a..1a1147b 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -10,7 +10,6 @@ type AppConfig struct { EmailAddress string LicenseKey string SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL - SelectedAudio string // Audio filepath SelectedAudioDevice string // The pipewire device name InitialFontSize int // How large to create the font atlas StandardFontSize int // The font size used for most text rendering @@ -48,3 +47,11 @@ type SolfeggioFrequency struct { Name string Value int } + +type AudioFile struct { + ID int `gorm:"primaryKey"` + Name string + Path string + Selected bool + Order int +} diff --git a/internal/netreq/netReq.go b/internal/netreq/netReq.go index 17e0331..f9c4b4c 100644 --- a/internal/netreq/netReq.go +++ b/internal/netreq/netReq.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" ) // Makes a GET request to the specified URL and returns the JSON response @@ -52,3 +54,30 @@ func GetJSON[T any](url string) ([]T, error) { return result, nil } + +// Download downloads a specified object at a given url and places it in +// the provided dest directory. +func Download(name string, path string, url string) error { + fullFilePath := filepath.Join(path, name) + if err := os.MkdirAll(path, os.ModeDir); err != nil { + return err + } + file, err := os.Create(fullFilePath) + if err != nil { + return err + } + defer file.Close() + + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index f649653..018a793 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -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 } diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 80dfb74..5f19b3d 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -3,7 +3,6 @@ package page import ( "fmt" "math" - "path/filepath" "strings" "github.com/go-gl/gl/v3.3-core/gl" @@ -261,12 +260,31 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { } } - assetsDir := p.Context.CfgPtr.AssetsDir - audioFile := filepath.Join(assetsDir, "audio/audio.wav") - logger.Info("Attempting to play %s", audioFile) - if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil { - logger.Error("Mixer returned an error: %v", err) - } + audioFilePath := p.Context.DbPtr.Find + + // Download if it doesn't exist + fmt.Printf("%s", audioFilePath) + + // // Check if the track has previously been downloaded + // // TODO: Download audio via a goroutine + // // this is a path + // // figure out how to get the filename, not the path, from the path. + // _, err = os.Stat(audioFilePath) + // if err != nil { + // filename := filepath.Base(audioFilePath) + // url := "https://files.radiantwavetech.com/audio/" + filename + // logger.Info("%s not present. Downloading...", entry.DisplayName) + // // Download the track if not, display loading bar + // if err = netreq.Download(filename, filepath.Join(p.Base.Context.CfgPtr.AssetsDir, "audio"), url); err != nil { + // logger.Error("Failed to download file: %v", err) + // return + // } + // logger.Info("Downloaded %s", entry.DisplayName) + // } + + // if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil { + // logger.Error("Mixer returned an error: %v", err) + // } currentDevice := mixer.CurrentDevice() if currentDevice == "" {