Add audio features

- Downloads all audio files in the background
- Tracks overall progress of audio downloads, keeping "position" when
  returning
- Scroller dynamically switches between audio tracks based on the
  solfeggio frequency of the currently playing proclamations
- Removed audio tracks list from Audio Options page
- Honestly a whole host of other stuff I forgot to mention
This commit is contained in:
NeilClack
2026-06-19 00:13:25 -05:00
16 changed files with 1115 additions and 801 deletions
+62 -21
View File
@@ -12,6 +12,7 @@ import (
"github.com/veandco/go-sdl2/ttf" "github.com/veandco/go-sdl2/ttf"
"gorm.io/gorm" "gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/download"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/graphics" "rhemawavetech.com/rhemawave/internal/graphics"
"rhemawavetech.com/rhemawave/internal/keybinds" "rhemawavetech.com/rhemawave/internal/keybinds"
@@ -26,18 +27,20 @@ import (
const ( const (
targetFPS = 60.0 targetFPS = 60.0
volumeStepSize = 16
openGLMajorVersion = 3 openGLMajorVersion = 3
openGLMinorVersion = 3 openGLMinorVersion = 3
apiURL = "https://portal.rhemawavetech.com/api/v1" // volumeStepSize = 16
// apiURL = "https://portal.rhemawavetech.com/api/v1"
) )
type State int type State int
const ( const (
NEEDS_INTERNET State = iota FIRST_RUN State = iota
NEEDS_INTERNET
NEEDS_LICENSE_KEY NEEDS_LICENSE_KEY
NEEDS_EMAIL_ADDRESS NEEDS_EMAIL_ADDRESS
DOWNLOADING_ASSETS
READY READY
) )
@@ -107,16 +110,17 @@ func Run() {
} }
// Populate the initial Config cache OR create defaults // Populate the initial Config cache OR create defaults
firstRun := false
result = DB.First(&initialConfig) result = DB.First(&initialConfig)
if result.Error != nil { if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
logger.Info("First run detected: Creating default configuration...") logger.Info("First run detected: Creating default configuration...")
firstRun = true
initialConfig = db.AppConfig{ initialConfig = db.AppConfig{
DataDir: dataDir, DataDir: dataDir,
AssetsDir: dataDir, AssetsDir: dataDir,
SystemLevel: "BASIC", SystemLevel: "BASIC",
SelectedAudioDevice: "default", SelectedAudioDevice: "default",
SelectedAudio: "audio.wav",
InitialFontSize: 128, InitialFontSize: 128,
StandardFontSize: 32, StandardFontSize: 32,
LinePattern: "fibonacci", LinePattern: "fibonacci",
@@ -139,6 +143,10 @@ func Run() {
DB: DB, DB: DB,
} }
if firstRun {
app.state = FIRST_RUN
}
app.refreshCache() app.refreshCache()
w, h := app.initializeSDLAndOpenGL() w, h := app.initializeSDLAndOpenGL()
@@ -146,29 +154,53 @@ func Run() {
app.initializeManagers() app.initializeManagers()
app.initializeAudio() app.initializeAudio()
// Create a buffered channel so log writes don't slow down downloads.
downloadProgressChan := make(chan download.DownloadStatus, 1000)
app.PageRouter = page.NewRouter(page.PageCtx{ app.PageRouter = page.NewRouter(page.PageCtx{
CfgPtr: app.Config.Load(), CfgPtr: app.Config.Load(),
DbPtr: app.DB, DbPtr: app.DB,
W: w, W: w,
H: h, H: h,
RefreshConfig: app.refreshCache, MessageChannel: downloadProgressChan,
RefreshConfig: app.refreshCache,
}) })
// Sync the proclamations from the API to the Database // Sync the proclamations from the API to the Database
sync.SyncProclamations(DB) sync.SyncProclamations(DB)
sync.SyncCategories(DB) sync.SyncCategories(DB)
sync.SyncFrequencies(DB) sync.SyncFrequencies(DB)
audioFileNames := []string{
"OriginalAudio.wav",
"174Hz.wav",
"396Hz.wav",
"417Hz.wav",
"432Hz.wav",
"639Hz.wav",
"741Hz.wav",
"852Hz.wav",
"963Hz.wav",
"1111Hz.wav",
"14Hz_Binaural.wav",
"40Hz_Binaural.wav",
"AlphaWaves.wav",
"PinealGland.wav",
}
go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioFileNames, filepath.Join(app.PageRouter.Context.CfgPtr.AssetsDir, "audio"))
app.runEventLoop() app.runEventLoop()
} }
// checkState keeps watch of the application state and sets pages
// accordingly. This function runs once per
func (app *Application) checkState() { func (app *Application) checkState() {
// app.state is READY so do nothing.
if app.state == READY { if app.state == READY {
return return
} }
// app.state = 0 means that there is no state set // Check if there is an internet connection, and if not, force the WiFi page
// Check for an internet connection first
if !networkIsUp { if !networkIsUp {
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second { if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
networkManager, err := network.New() networkManager, err := network.New()
@@ -190,12 +222,21 @@ func (app *Application) checkState() {
} }
return return
} }
networkIsUp = true // ← internet confirmed, don't check again networkIsUp = true // internet confirmed, don't check again
} else { } else {
return // throttled, and we don't yet know if network is up — can't proceed return // throttled, and we don't yet know if network is up — can't proceed
} }
} }
// Then check for an email address
if app.Config.Load().EmailAddress == "" {
if app.state != NEEDS_EMAIL_ADDRESS {
app.PageRouter.NavigateTo(page.EMAIL_ADDRESS)
app.state = NEEDS_EMAIL_ADDRESS
}
return
}
// Then check for a license key // Then check for a license key
if app.Config.Load().LicenseKey == "" { if app.Config.Load().LicenseKey == "" {
if app.state != NEEDS_LICENSE_KEY { if app.state != NEEDS_LICENSE_KEY {
@@ -205,15 +246,6 @@ func (app *Application) checkState() {
return return
} }
// Finally, check for an email address
if app.Config.Load().EmailAddress == "" {
if app.state != NEEDS_EMAIL_ADDRESS {
app.PageRouter.NavigateTo(page.EMAIL_ADDRESS)
app.state = NEEDS_EMAIL_ADDRESS
}
return
}
app.state = READY app.state = READY
app.PageRouter.NavigateTo(page.HOME) app.PageRouter.NavigateTo(page.HOME)
} }
@@ -429,14 +461,23 @@ func (app *Application) renderFrame() {
func (app *Application) initKeybinds() { func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() { keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
if app.state != READY {
return
}
app.PageRouter.NavigateTo(page.SETTINGS) app.PageRouter.NavigateTo(page.SETTINGS)
}) })
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() { keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
if app.state != READY {
return
}
app.PageRouter.NavigateTo(page.SCROLLER) app.PageRouter.NavigateTo(page.SCROLLER)
}) })
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
if app.state != READY {
return
}
app.PageRouter.NavigateTo(page.HOME) app.PageRouter.NavigateTo(page.HOME)
}) })
+1 -1
View File
@@ -42,7 +42,7 @@ func Connect(dbpath string) (*gorm.DB, error) {
// Migrate the database schema // Migrate the database schema
func Migrate(db *gorm.DB) error { func Migrate(db *gorm.DB) error {
// Pass all models to be migrated to this function call. // 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 err
} }
return nil return nil
+9 -1
View File
@@ -10,13 +10,13 @@ type AppConfig struct {
EmailAddress string EmailAddress string
LicenseKey string LicenseKey string
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
SelectedAudio string // Audio filepath
SelectedAudioDevice string // The pipewire device name SelectedAudioDevice string // The pipewire device name
InitialFontSize int // How large to create the font atlas InitialFontSize int // How large to create the font atlas
StandardFontSize int // The font size used for most text rendering StandardFontSize int // The font size used for most text rendering
LinePattern string // "fibonacci" LinePattern string // "fibonacci"
AudioEnabled bool AudioEnabled bool
SavedVolume float64 // Last saved volume as a percentage SavedVolume float64 // Last saved volume as a percentage
FirstRun bool // Is this the first time the application has been run?
} }
type Log struct { type Log struct {
@@ -48,3 +48,11 @@ type SolfeggioFrequency struct {
Name string Name string
Value int Value int
} }
type AudioFile struct {
ID int `gorm:"primaryKey"`
Name string
Path string
Selected bool
Order int
}
+176
View File
@@ -0,0 +1,176 @@
package download
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"rhemawavetech.com/rhemawave/internal/logger"
)
// DownloadStatus holds information related to downloads and is
// the container for information returned via channels within this
// package.
type DownloadStatus struct {
Max int64 // Total bytes to download
Cur int64 // Total bytes downloaded
Msg string // Status message
Err error // Error, if any
}
// DownloadAudioFiles downloads multiple audio files and sends progress updates
// to the provided channel. The channel is closed when all downloads are complete.
func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, destDir string) {
defer close(progressChan)
urlBase := "https://files.rhemawavetech.com/audio/"
// First, calculate the total size of files that need to be downloaded
totalSize := int64(0)
filesToDownload := []string{}
totalDownloaded := int64(0)
for _, filename := range filenames {
destPath := filepath.Join(destDir, filename)
// Check if the file already exists
url := urlBase + filename
resp, err := http.Head(url)
if err != nil {
progressChan <- DownloadStatus{
Msg: fmt.Sprintf("Failed to get size for %s: %v", filename, err),
Err: err,
}
return
}
if resp.StatusCode != http.StatusOK {
progressChan <- DownloadStatus{
Msg: fmt.Sprintf("Failed to get size for %s: status %s", filename, resp.Status),
Err: fmt.Errorf("bad status: %s", resp.Status),
}
return
}
fileStat, err := os.Stat(destPath)
// If the file is incomplete, or does not exist
if os.IsNotExist(err) || fileStat.Size() != resp.ContentLength {
// Oh, and log a warning if the file is not complete
if !os.IsNotExist(err) {
logger.Warning("%s incomplete/corrupt. Adding to download queue.", filename)
} else {
logger.Info("%s not found. Adding to download queue.", filename)
}
// Append it to the list to download
filesToDownload = append(filesToDownload, filename)
} else if err != nil {
logger.Error("unknown error: %v", err)
} else {
logger.Error("File %s exists, skipping...", filename)
totalDownloaded += fileStat.Size()
}
// Calculate the total of ALL files to give the illusion of
// "picking up where you left off" to the user
totalSize += resp.ContentLength
resp.Body.Close()
}
// Send initial status
progressChan <- DownloadStatus{
Max: totalSize,
Cur: 0,
Msg: "Starting download...",
}
// Download each file that doesn't exist
for _, filename := range filesToDownload {
url := urlBase + filename
destPath := filepath.Join(destDir, filename)
// Create the destination file
out, err := os.Create(destPath)
if err != nil {
progressChan <- DownloadStatus{
Msg: fmt.Sprintf("Failed to create file %s: %v", destPath, err),
Err: err,
}
return
}
// Get the file
resp, err := http.Get(url)
if err != nil {
out.Close()
os.Remove(destPath)
progressChan <- DownloadStatus{
Msg: fmt.Sprintf("Failed to download %s: %v", filename, err),
Err: err,
}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
out.Close()
os.Remove(destPath)
progressChan <- DownloadStatus{
Msg: fmt.Sprintf("Failed to download %s: status %s", filename, resp.Status),
Err: fmt.Errorf("bad status: %s", resp.Status),
}
return
}
// Use a buffered reader for efficiency
buf := make([]byte, 8192)
for {
n, err := resp.Body.Read(buf)
if err != nil && err != io.EOF {
out.Close()
os.Remove(destPath)
progressChan <- DownloadStatus{
Msg: fmt.Sprintf("Failed to read %s: %v", filename, err),
Err: err,
}
return
}
if n == 0 {
break
}
// Write to file
if _, err := out.Write(buf[:n]); err != nil {
out.Close()
os.Remove(destPath)
progressChan <- DownloadStatus{
Msg: fmt.Sprintf("Failed to write %s: %v", filename, err),
Err: err,
}
return
}
// Update progress
totalDownloaded += int64(n)
progressChan <- DownloadStatus{
Max: totalSize,
Cur: totalDownloaded,
Msg: fmt.Sprintf("%s", filename),
}
}
out.Close()
// Send completion for this file
progressChan <- DownloadStatus{
Max: totalSize,
Cur: totalDownloaded,
Msg: "Finished Downloading",
}
}
// Send final status
progressChan <- DownloadStatus{
Max: totalSize,
Cur: totalDownloaded,
Msg: "All audio downloads completed!",
}
}
+29
View File
@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"path/filepath"
) )
// Makes a GET request to the specified URL and returns the JSON response // 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 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
}
+19 -9
View File
@@ -28,10 +28,6 @@ const (
nmDeviceTypeWifi uint32 = 2 nmDeviceTypeWifi uint32 = 2
nmDeviceStateActivated uint32 = 100 nmDeviceStateActivated uint32 = 100
APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY
APSecKeyMgmtEAP uint32 = 0x00000020
APSecKeyMgmtPSK uint32 = 0x00000100
APSecKeyMgmtOWE uint32 = 0x00000200
APSecKeyMgmtSAE uint32 = 0x00000400
) )
// ErrScanAborted is returned when a scan is cancelled via the stop channel. // ErrScanAborted is returned when a scan is cancelled via the stop channel.
@@ -88,8 +84,7 @@ type AccessPoint struct {
Strength byte Strength byte
IsProtected bool IsProtected bool
IsActive bool IsActive bool
WpaFlags uint32 KeyMgmt string
RsnFlags uint32
} }
// New creates a new network Manager and connects to the system D-Bus. // New creates a new network Manager and connects to the system D-Bus.
@@ -277,13 +272,22 @@ func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, er
isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0) isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0)
// Determine the key management type
var keyMgmt string
if wpaFlags != 0 {
keyMgmt = "wpa-psk"
} else if rsnFlags != 0 {
keyMgmt = "wpa-psk"
} else {
keyMgmt = "none"
}
currentAP := AccessPoint{ currentAP := AccessPoint{
ObjectPath: apPath, ObjectPath: apPath,
SSID: ssidStr, SSID: ssidStr,
Strength: strength, Strength: strength,
IsProtected: isProtected, IsProtected: isProtected,
WpaFlags: wpaFlags, KeyMgmt: keyMgmt,
RsnFlags: rsnFlags,
} }
if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength { if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength {
tempNetworkMap[ssidStr] = currentAP tempNetworkMap[ssidStr] = currentAP
@@ -328,7 +332,7 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string)
if ap.IsProtected { if ap.IsProtected {
settings["802-11-wireless-security"] = map[string]dbus.Variant{ settings["802-11-wireless-security"] = map[string]dbus.Variant{
"key-mgmt": dbus.MakeVariant("wpa-psk"), "key-mgmt": dbus.MakeVariant(ap.KeyMgmt),
"auth-alg": dbus.MakeVariant("open"), "auth-alg": dbus.MakeVariant("open"),
"psk": dbus.MakeVariant(passphrase), "psk": dbus.MakeVariant(passphrase),
} }
@@ -350,6 +354,12 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string)
} }
log.Printf("Connection created: %s, Active path: %s", connectionPath, activeConnectionPath) log.Printf("Connection created: %s, Active path: %s", connectionPath, activeConnectionPath)
// Wait for the connection to be fully established
if _, err := m.WaitForInternet(30*time.Second, 5*time.Second); err != nil {
return fmt.Errorf("failed to establish internet connection: %w", err)
}
return nil return nil
} }
+420
View File
@@ -0,0 +1,420 @@
package page
import (
"fmt"
"math"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// AudioFileList is kept as the canonical seed data for the audio_files table.
// Track selection UI no longer lives on this page, but other parts of the
// app still read these rows from the database.
var AudioFileList = []db.AudioFile{
{ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1},
{ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2},
{ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3},
{ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4},
{ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5},
{ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6},
{ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7},
{ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8},
{ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9},
{ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10},
{ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11},
{ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12},
{ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13},
{ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14},
}
const triangleSize = float32(16)
type AudioOptions struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
verticalMargin int32
ItemScale float32
CfgPtr *db.AppConfig
// Audio toggle
AudioStatusToggle StringItem
// Volume row
VolumeLabel StringItem
volumePercent StringItem
volumeBarX int32
volumeBarY int32
volumeBarW int32
volumeBarH int32
// 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)
// Font
fm := fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular")
if !ok {
return fmt.Errorf("failed to get font: %w", sdl.GetError())
}
p.font = font
// Shaders
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)
}
baseFontSize := p.Context.CfgPtr.StandardFontSize
titleScale := float32(64) / float32(baseFontSize)
p.ItemScale = float32(32) / float32(baseFontSize)
// 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)
// Prompt
p.prompt, err = NewStringItem("Enter to toggle audio -- 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)
// 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()")
}
// Make sure the audio_files table is populated. This page no longer
// presents any track selection UI, but other parts of the app still
// read from this table.
if err = p.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %v", 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
}
// seedAudioFiles ensures the audio_files table has its default rows.
func (p *AudioOptions) seedAudioFiles() error {
var existing []db.AudioFile
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
return err
}
if len(existing) == 0 {
return p.Context.DbPtr.Save(&AudioFileList).Error
}
return nil
}
// ── 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 {
if p.volumePercent.ID != 0 {
gl.DeleteTextures(1, &p.volumePercent.ID)
}
pct := int(p.Context.CfgPtr.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) {
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() {
// Title
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
// Prompt
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
// Volume row
barH := int32(float32(p.Context.CfgPtr.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
// Audio toggle — centred below the volume row
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
// Triangle cursor — just outside the left edge of the toggle, vertically centred on it
cursorGap := int32(12)
p.triX = float32(p.AudioStatusToggle.X) - float32(triangleSize) - float32(cursorGap)
p.triCenterY = float32(p.AudioStatusToggle.Y) + float32(p.AudioStatusToggle.H)/2
}
// ── Toggle ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) ToggleAudio() {
p.Context.CfgPtr.AudioEnabled = !p.Context.CfgPtr.AudioEnabled
p.Context.DbPtr.Save(p.Context.CfgPtr)
p.Context.RefreshConfig()
p.CreateAudioStatusItem()
p.layout()
}
// ── Events ────────────────────────────────────────────────────────────────────
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:
p.ToggleAudio()
case sdl.K_RIGHT:
p.UpdateVolume(10)
case sdl.K_LEFT:
p.UpdateVolume(-10)
case sdl.K_BACKSPACE:
p.Context.NavigateTo(SETTINGS)
}
return nil
}
// ── 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) renderVolumeBar() error {
vol := p.Context.CfgPtr.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}, {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
}
// 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 {
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)
}
// Volume row
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)
}
// 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)
}
// 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.VolumeLabel.ID)
gl.DeleteTextures(1, &p.volumePercent.ID)
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package page
import (
"github.com/veandco/go-sdl2/sdl"
)
type DownloadingAssets struct {
}
func (p *DownloadingAssets) Init(ctx *PageCtx) error {
return nil
}
func (p *DownloadingAssets) HandleEvent(event *sdl.Event) error {
return nil
}
func (p *DownloadingAssets) Update(dt float32) error {
return nil
}
func (p *DownloadingAssets) Render() error {
return nil
}
func (p *DownloadingAssets) Destroy() error {
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package page
import (
"github.com/veandco/go-sdl2/sdl"
)
type MessagePage struct {
Msg string
}
func (p *MessagePage) Init(ctx *PageCtx) error {
return nil
}
func (p *MessagePage) HandleEvent(event *sdl.Event) error {
return nil
}
func (p *MessagePage) Update(dt float32) error {
return nil
}
func (p *MessagePage) Render() error {
return nil
}
func (p *MessagePage) Destroy() error {
return nil
}
-324
View File
@@ -1,324 +0,0 @@
package page
import (
"fmt"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
type AudioDevices struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
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
}
func (p *AudioDevices) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
// Get font
fm := fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular")
if !ok {
return fmt.Errorf("failed to get font: %w", sdl.GetError())
}
p.font = font
// Get shaders
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())
}
// Initialize base
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)
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)
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 AudioDevices.Init()")
}
if err = p.CreateVolumePercentItem(); err != nil {
logger.Error("failed to create VolumePercentItem in AudioDevices.Init()")
}
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)
p.layout()
return nil
}
func (p *AudioDevices) 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
}
audioStatusStringItem, err := NewStringItem(statusString, p.font, color)
if err != nil {
return fmt.Errorf("failed to create '%s' string item: %v", statusString, err)
}
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.
func (p *AudioDevices) 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)
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 *AudioDevices) layout() {
// Position title at the top
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
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin*2 - 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
barW := int32(float32(p.ScreenWidth) * 0.30)
barX := p.ScreenCenterX - barW/2
barY := p.AudioStatusToggle.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
}
func (p *AudioDevices) 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 *AudioDevices) 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 {
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)
}
// Refresh volume label
if err := p.CreateVolumePercentItem(); err != nil {
logger.Error("failed to refresh volume percent item: %v", err)
}
// 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
}
func (p *AudioDevices) 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:
p.ToggleAudio()
case sdl.K_RIGHT: // Right increase
p.UpdateVolume(10)
case sdl.K_LEFT: // Left decrease
p.UpdateVolume(-10)
case sdl.K_BACKSPACE:
p.Context.NavigateTo(SETTINGS)
}
return nil
}
// renderVolumeBar draws the green outline and the white filled portion.
func (p *AudioDevices) 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 {
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)
bw := float32(p.volumeBarW)
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 nil
}
func (p *AudioDevices) Render() error {
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
logger.Error("unable to render title texture: %v", err)
return fmt.Errorf("unable to render title texture: %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)
}
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)
}
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)
}
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)
}
return nil
}
func (p *AudioDevices) 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)
return nil
}
+171 -47
View File
@@ -2,12 +2,12 @@ package page
import ( import (
"fmt" "fmt"
"time"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32" "github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/download"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
@@ -16,16 +16,20 @@ var GitVersion = "1.0.0-beta"
type Home struct { type Home struct {
Base Base
title StringItem fm *fontManager.FontManager
version StringItem font *fontManager.FontEntry
prompt StringItem title StringItem
note StringItem version StringItem
licenseInfoHeading StringItem prompt StringItem
emailDisplay StringItem note StringItem
licenseKeyDisplay StringItem message StringItem
hasLicenseInfo bool downloadProgressChan chan download.DownloadStatus
lastDisplayUpdate time.Time licenseInfoHeading StringItem
baseFontSize int // cached from db at init emailDisplay StringItem
licenseKeyDisplay StringItem
hasLicenseInfo bool
baseFontSize int
isDownloadComplete bool
} }
// Init performs basic page initialization and creation of static assets. // Init performs basic page initialization and creation of static assets.
@@ -40,11 +44,12 @@ func (p *Home) Init(ctx *PageCtx) error {
} }
// Get font // Get font
fm := fontManager.Get() p.fm = fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular") font, ok := p.fm.GetFont("Roboto-Regular")
if !ok { if !ok {
return fmt.Errorf("unable to fetch font") return fmt.Errorf("unable to fetch font")
} }
p.font = font
// Create title texture // Create title texture
var err error var err error
@@ -72,6 +77,15 @@ func (p *Home) Init(ctx *PageCtx) error {
return fmt.Errorf("unable to create note string texture: %w", err) return fmt.Errorf("unable to create note string texture: %w", err)
} }
// Create download channel and message
p.downloadProgressChan = make(chan download.DownloadStatus)
// Initialize message
p.message, err = NewStringItem("Status: Initializing...", font, colors.White)
if err != nil {
return fmt.Errorf("unable to create initial message texture: %w", err)
}
// Create license info textures if data exists // Create license info textures if data exists
if p.Context.CfgPtr.EmailAddress != "" || p.Context.CfgPtr.LicenseKey != "" { if p.Context.CfgPtr.EmailAddress != "" || p.Context.CfgPtr.LicenseKey != "" {
p.hasLicenseInfo = true p.hasLicenseInfo = true
@@ -103,26 +117,34 @@ func (p *Home) Init(ctx *PageCtx) error {
// Get base font size from database // Get base font size from database
baseFontSize := p.Context.CfgPtr.StandardFontSize baseFontSize := p.Context.CfgPtr.StandardFontSize
p.baseFontSize = baseFontSize p.baseFontSize = baseFontSize
// Scale the note text // Scale the note text
fontSize := int32(32) fontSize := int32(32)
scale := float32(fontSize) / float32(baseFontSize) scale := float32(fontSize) / float32(baseFontSize)
p.note.Scale(scale * 0.75) p.note.W = int32(float32(p.note.W) * scale * 0.75)
p.note.H = int32(float32(p.note.H) * scale * 0.75)
// Scale license info texts // Scale license info texts
if p.hasLicenseInfo { if p.hasLicenseInfo {
licenseInfoScale := float32(28) / float32(baseFontSize) licenseInfoScale := float32(28) / float32(baseFontSize)
p.licenseInfoHeading.Scale(licenseInfoScale) p.licenseInfoHeading.W = int32(float32(p.licenseInfoHeading.W) * licenseInfoScale)
p.licenseInfoHeading.H = int32(float32(p.licenseInfoHeading.H) * licenseInfoScale)
if p.emailDisplay.ID != 0 { if p.emailDisplay.ID != 0 {
p.emailDisplay.Scale(licenseInfoScale * 0.85) p.emailDisplay.W = int32(float32(p.emailDisplay.W) * licenseInfoScale * 0.85)
p.emailDisplay.H = int32(float32(p.emailDisplay.H) * licenseInfoScale * 0.85)
} }
if p.licenseKeyDisplay.ID != 0 { if p.licenseKeyDisplay.ID != 0 {
p.licenseKeyDisplay.Scale(licenseInfoScale * 0.85) p.licenseKeyDisplay.W = int32(float32(p.licenseKeyDisplay.W) * licenseInfoScale * 0.85)
p.licenseKeyDisplay.H = int32(float32(p.licenseKeyDisplay.H) * licenseInfoScale * 0.85)
} }
} }
// Scale message text
messageScale := float32(28) / float32(baseFontSize)
p.message.W = int32(float32(p.message.W) * messageScale)
p.message.H = int32(float32(p.message.H) * messageScale)
return nil return nil
} }
@@ -131,8 +153,81 @@ func (p *Home) HandleEvent(event *sdl.Event) error {
return nil return nil
} }
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
func (p *Home) Update(dt float32) error { func (p *Home) Update(dt float32) error {
var (
progressUpdate download.DownloadStatus
ok bool
gotUpdate bool
)
// Drain the channel, keeping only the newest update.
for {
select {
case progressUpdate, ok = <-p.Context.MessageChannel:
gotUpdate = true
if !ok {
// Channel was closed while draining.
newMsg, err := NewStringItem("Status: READY", p.font, colors.Green)
if err != nil {
return err
}
messageScale := float32(28) / float32(p.baseFontSize)
newMsg.W = int32(float32(newMsg.W) * messageScale)
newMsg.H = int32(float32(newMsg.H) * messageScale)
p.message = newMsg
p.isDownloadComplete = true
return nil
}
default:
goto drained
}
}
drained:
if !gotUpdate {
return nil
}
if progressUpdate.Err != nil {
// Handle error
newMsg, err := NewStringItem(
fmt.Sprintf("Status: %s", progressUpdate.Err.Error()),
p.font,
colors.Red,
)
if err != nil {
return err
}
messageScale := float32(28) / float32(p.baseFontSize)
newMsg.W = int32(float32(newMsg.W) * messageScale)
newMsg.H = int32(float32(newMsg.H) * messageScale)
p.message = newMsg
} else {
// Update progress message using ONLY the latest update.
percentComplete := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100
msg := fmt.Sprintf(
"Status: Downloading assets %.1f%% Complete",
percentComplete,
)
newMsg, err := NewStringItem(msg, p.font, colors.White)
if err != nil {
return err
}
messageScale := float32(28) / float32(p.baseFontSize)
newMsg.W = int32(float32(newMsg.W) * messageScale)
newMsg.H = int32(float32(newMsg.H) * messageScale)
p.message = newMsg
}
return nil return nil
} }
@@ -140,6 +235,7 @@ func (p *Home) Render() error {
baseFontSize := p.baseFontSize baseFontSize := p.baseFontSize
sm := shaderManager.Get() sm := shaderManager.Get()
verticalMargin := int32(20) verticalMargin := int32(20)
licenseInfoMargin := int32(10)
// 1) Calculate scales // 1) Calculate scales
titleFontSize := int32(64) titleFontSize := int32(64)
@@ -161,9 +257,26 @@ func (p *Home) Render() error {
promptH := float32(p.prompt.H) * bodyScale promptH := float32(p.prompt.H) * bodyScale
promptDim := mgl32.Vec2{promptW, promptH} promptDim := mgl32.Vec2{promptW, promptH}
// p.note already scaled in Init via p.note.Scale(scale*0.75) // Note dimensions (already scaled in Init)
noteDim := p.note.RawDimensions() noteW := float32(p.note.W)
noteW, noteH := noteDim[0], noteDim[1] noteH := float32(p.note.H)
// Message dimensions (already scaled in Init/Update)
messageW := float32(p.message.W)
messageH := float32(p.message.H)
// License info dimensions (already scaled in Init)
licenseHeadingW := float32(p.licenseInfoHeading.W)
licenseHeadingH := float32(p.licenseInfoHeading.H)
var emailDisplayW, emailDisplayH, licenseKeyDisplayW, licenseKeyDisplayH float32
if p.emailDisplay.ID != 0 {
emailDisplayW = float32(p.emailDisplay.W)
emailDisplayH = float32(p.emailDisplay.H)
}
if p.licenseKeyDisplay.ID != 0 {
licenseKeyDisplayW = float32(p.licenseKeyDisplay.W)
licenseKeyDisplayH = float32(p.licenseKeyDisplay.H)
}
// 3) Calculate total height of the vertical stack // 3) Calculate total height of the vertical stack
totalH := int32(titleH) + verticalMargin + totalH := int32(titleH) + verticalMargin +
@@ -174,7 +287,7 @@ func (p *Home) Render() error {
// 4) Y-up layout: start from the TOP of the centered stack and step DOWN // 4) Y-up layout: start from the TOP of the centered stack and step DOWN
top := p.Base.ScreenCenterY + totalH/2 top := p.Base.ScreenCenterY + totalH/2
titleY := top - int32(titleH) // Title sits at the top titleY := top - int32(titleH)
versionY := titleY - verticalMargin - int32(versionH) versionY := titleY - verticalMargin - int32(versionH)
p.prompt.Y = versionY - verticalMargin - int32(promptH) p.prompt.Y = versionY - verticalMargin - int32(promptH)
p.note.Y = p.prompt.Y - verticalMargin - int32(noteH) p.note.Y = p.prompt.Y - verticalMargin - int32(noteH)
@@ -190,23 +303,8 @@ func (p *Home) Render() error {
// 6) Position license info at bottom center // 6) Position license info at bottom center
if p.hasLicenseInfo { if p.hasLicenseInfo {
licenseInfoMargin := int32(10)
bottomMargin := int32(20) bottomMargin := int32(20)
// License info items are already scaled in Init
licenseHeadingDim := p.licenseInfoHeading.RawDimensions()
licenseHeadingW, licenseHeadingH := licenseHeadingDim[0], licenseHeadingDim[1]
var emailDisplayH, licenseKeyDisplayH float32
if p.emailDisplay.ID != 0 {
emailDisplayDim := p.emailDisplay.RawDimensions()
emailDisplayH = emailDisplayDim[1]
}
if p.licenseKeyDisplay.ID != 0 {
licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions()
licenseKeyDisplayH = licenseKeyDisplayDim[1]
}
// Calculate total height of license info block // Calculate total height of license info block
licenseInfoTotalH := int32(licenseHeadingH) licenseInfoTotalH := int32(licenseHeadingH)
if p.emailDisplay.ID != 0 { if p.emailDisplay.ID != 0 {
@@ -221,8 +319,6 @@ func (p *Home) Render() error {
// License key (bottom-most if it exists) // License key (bottom-most if it exists)
if p.licenseKeyDisplay.ID != 0 { if p.licenseKeyDisplay.ID != 0 {
licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions()
licenseKeyDisplayW := licenseKeyDisplayDim[0]
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2) p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
p.licenseKeyDisplay.Y = currentY p.licenseKeyDisplay.Y = currentY
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
@@ -230,8 +326,6 @@ func (p *Home) Render() error {
// Email // Email
if p.emailDisplay.ID != 0 { if p.emailDisplay.ID != 0 {
emailDisplayDim := p.emailDisplay.RawDimensions()
emailDisplayW := emailDisplayDim[0]
p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2) p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2)
p.emailDisplay.Y = currentY p.emailDisplay.Y = currentY
currentY += int32(emailDisplayH) + licenseInfoMargin currentY += int32(emailDisplayH) + licenseInfoMargin
@@ -242,6 +336,26 @@ func (p *Home) Render() error {
p.licenseInfoHeading.Y = currentY p.licenseInfoHeading.Y = currentY
} }
// 7) Position message between license info and note/prompt
if p.hasLicenseInfo {
// Calculate the top of the license info block
licenseInfoTopY := p.licenseInfoHeading.Y + int32(licenseHeadingH)
if p.emailDisplay.ID != 0 {
licenseInfoTopY += int32(emailDisplayH) + licenseInfoMargin
}
if p.licenseKeyDisplay.ID != 0 {
licenseInfoTopY += int32(licenseKeyDisplayH) + licenseInfoMargin
}
// Center the message vertically between license info and note
availableSpace := p.note.Y - licenseInfoTopY - verticalMargin
p.message.Y = licenseInfoTopY + availableSpace/2 - int32(messageH/2)
} else {
// If no license info, place message below note
p.message.Y = p.note.Y - verticalMargin - int32(messageH)
}
p.message.X = p.Base.ScreenCenterX - int32(messageW/2)
// 8) Get shader and render // 8) Get shader and render
textShader, ok := sm.Get("text") textShader, ok := sm.Get("text")
if !ok { if !ok {
@@ -251,17 +365,26 @@ func (p *Home) Render() error {
p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color) p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color)
p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color) p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color)
p.Base.RenderTexture(textShader, p.prompt.ID, p.prompt.Position(), promptDim, color) p.Base.RenderTexture(textShader, p.prompt.ID, mgl32.Vec2{float32(p.prompt.X), float32(p.prompt.Y)}, promptDim, color)
p.Base.RenderTexture(textShader, p.note.ID, p.note.Position(), p.note.RawDimensions(), color) p.Base.RenderTexture(textShader, p.note.ID, mgl32.Vec2{float32(p.note.X), float32(p.note.Y)}, mgl32.Vec2{noteW, noteH}, color)
// Render message
if p.message.ID != 0 {
msgColor := colors.White
if p.isDownloadComplete {
msgColor = colors.Green
}
p.Base.RenderTexture(textShader, p.message.ID, mgl32.Vec2{float32(p.message.X), float32(p.message.Y)}, mgl32.Vec2{messageW, messageH}, msgColor)
}
// Render license info if it exists // Render license info if it exists
if p.hasLicenseInfo { if p.hasLicenseInfo {
p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, p.licenseInfoHeading.Position(), p.licenseInfoHeading.RawDimensions(), color) p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, mgl32.Vec2{float32(p.licenseInfoHeading.X), float32(p.licenseInfoHeading.Y)}, mgl32.Vec2{licenseHeadingW, licenseHeadingH}, color)
if p.emailDisplay.ID != 0 { if p.emailDisplay.ID != 0 {
p.Base.RenderTexture(textShader, p.emailDisplay.ID, p.emailDisplay.Position(), p.emailDisplay.RawDimensions(), color) p.Base.RenderTexture(textShader, p.emailDisplay.ID, mgl32.Vec2{float32(p.emailDisplay.X), float32(p.emailDisplay.Y)}, mgl32.Vec2{emailDisplayW, emailDisplayH}, color)
} }
if p.licenseKeyDisplay.ID != 0 { if p.licenseKeyDisplay.ID != 0 {
p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, p.licenseKeyDisplay.Position(), p.licenseKeyDisplay.RawDimensions(), color) p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, mgl32.Vec2{float32(p.licenseKeyDisplay.X), float32(p.licenseKeyDisplay.Y)}, mgl32.Vec2{licenseKeyDisplayW, licenseKeyDisplayH}, color)
} }
} }
@@ -274,6 +397,7 @@ func (p *Home) Destroy() error {
gl.DeleteTextures(1, &p.version.ID) gl.DeleteTextures(1, &p.version.ID)
gl.DeleteTextures(1, &p.prompt.ID) gl.DeleteTextures(1, &p.prompt.ID)
gl.DeleteTextures(1, &p.note.ID) gl.DeleteTextures(1, &p.note.ID)
gl.DeleteTextures(1, &p.message.ID)
if p.hasLicenseInfo { if p.hasLicenseInfo {
gl.DeleteTextures(1, &p.licenseInfoHeading.ID) gl.DeleteTextures(1, &p.licenseInfoHeading.ID)
-111
View File
@@ -1,111 +0,0 @@
package page
import (
"fmt"
"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/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
type MessagePage struct {
Base
message StringItem
instructions StringItem
msgDim mgl32.Vec2
instDim mgl32.Vec2
textShader *shaderManager.Shader
msgPos mgl32.Vec2
instPos mgl32.Vec2
}
func (p *MessagePage) Init(ctx *PageCtx) error {
var err error
var ok bool
if ctx.Msg == "" {
ctx.Msg = "This is an example message because no message was passed"
}
if ctx.Inst == "" {
ctx.Inst = "Press F1 to exit"
}
if err = p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed to init Base page: %w", err)
}
// Fetch and cache shader
sm := shaderManager.Get()
p.textShader, ok = sm.Get("text")
if !ok {
return fmt.Errorf("failed to fetch text shader")
}
fm := fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular")
if !ok {
return fmt.Errorf("unable to fetch font")
}
p.message, err = NewStringItem(ctx.Msg, font, colors.Orange)
if err != nil {
return fmt.Errorf("unable to create message string texture: %w", err)
}
p.instructions, err = NewStringItem(ctx.Inst, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create instructions string texture: %w", err)
}
baseFontSize := float32(p.Context.CfgPtr.StandardFontSize)
p.message.Scale(float32(48) / baseFontSize)
p.instructions.Scale(float32(28) / baseFontSize)
// Cache scaled dimensions — never recomputed unless textures are replaced
p.msgDim = p.message.RawDimensions()
p.instDim = p.instructions.RawDimensions()
p.msgPos = mgl32.Vec2{
float32(p.Base.ScreenCenterX) - p.msgDim[0]/2,
float32(p.Base.ScreenCenterY) - p.msgDim[1]/2,
}
p.instPos = mgl32.Vec2{
float32(p.Base.ScreenCenterX) - p.instDim[0]/2,
float32(20),
}
return nil
}
func (p *MessagePage) HandleEvent(event *sdl.Event) error {
return nil
}
func (p *MessagePage) Update(dt float32) error {
return nil
}
func (p *MessagePage) Render() error {
p.Base.RenderTexture(p.textShader, p.message.ID, p.msgPos, p.msgDim, colors.Orange)
p.Base.RenderTexture(p.textShader, p.instructions.ID, p.instPos, p.instDim, colors.White)
return nil
}
func (p *MessagePage) Destroy() error {
gl.DeleteTextures(1, &p.message.ID)
gl.DeleteTextures(1, &p.instructions.ID)
return p.Base.Destroy()
}
func (p *MessagePage) SetMessage(msg string) error {
return nil
}
func (p *MessagePage) SetInstructions(inst string) error {
return nil
}
+13 -11
View File
@@ -3,6 +3,7 @@ package page
import ( import (
"gorm.io/gorm" "gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/download"
) )
type pageType int type pageType int
@@ -17,18 +18,19 @@ const (
LICENSE_KEY LICENSE_KEY
EMAIL_ADDRESS EMAIL_ADDRESS
LOGS LOGS
CHECK_UPDATES
MESSAGE MESSAGE
DOWNLOADING_ASSETS
) )
type PageCtx struct { type PageCtx struct {
CfgPtr *db.AppConfig CfgPtr *db.AppConfig
DbPtr *gorm.DB DbPtr *gorm.DB
W, H int32 W, H int32
RefreshConfig func() MessageChannel chan download.DownloadStatus
NavigateTo func(pageType) RefreshConfig func()
Msg string // Message to be displayed by MessagePage NavigateTo func(pageType)
Inst string // Instructions to be displayed on MessagePage Msg string // Message to be displayed by MessagePage
Inst string // Instructions to be displayed on MessagePage
} }
type Router struct { type Router struct {
@@ -60,7 +62,7 @@ func (r *Router) NavigateTo(p pageType) {
case WIFI: case WIFI:
r.ActivePage = &WiFiSetupPage{} r.ActivePage = &WiFiSetupPage{}
case AUDIO: case AUDIO:
r.ActivePage = &AudioDevices{} r.ActivePage = &AudioOptions{}
case PROCLAMATIONS: case PROCLAMATIONS:
r.ActivePage = &ProclamationOptions{} r.ActivePage = &ProclamationOptions{}
case LICENSE_KEY: case LICENSE_KEY:
@@ -69,10 +71,10 @@ func (r *Router) NavigateTo(p pageType) {
r.ActivePage = &EmailAddressPage{} r.ActivePage = &EmailAddressPage{}
case LOGS: case LOGS:
r.ActivePage = &LogViewerPage{} r.ActivePage = &LogViewerPage{}
case CHECK_UPDATES:
r.ActivePage = &UpdateCheckPage{}
case MESSAGE: case MESSAGE:
r.ActivePage = &MessagePage{} r.ActivePage = &MessagePage{}
case DOWNLOADING_ASSETS:
r.ActivePage = &DownloadingAssets{}
} }
r.ActivePage.Init(&r.Context) r.ActivePage.Init(&r.Context)
+154 -82
View File
@@ -3,6 +3,7 @@ package page
import ( import (
"fmt" "fmt"
"math" "math"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -19,11 +20,57 @@ import (
) )
const ( const (
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d" defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
proclamationPlayCount int = 10 proclamationPlayCount int = 20
defaultProclamationPlayCount int = 1 // defaultProclamationPlayCount int = 1
) )
type ScrollerPage struct {
Base
streams []proclamationStream
streamIndex int
// currentAudioFreq is the solfeggio Hz value of whatever track is
// currently playing. 0 means "no specific frequency" (either the
// legacy Selected-AudioFile track, or nothing yet).
currentAudioFreq int
IsDownloadingAudio bool
lines *LineDeque
lineHeight int32
shader *shaderManager.Shader
activePattern linePattern.LinePattern
font *fontManager.FontEntry
spaceWidth int32
lineCtx *linePattern.LineRenderContext
colorPattern *colorPattern.ColorPattern
scrollOffset float32
scrollY float32
fontScale float32
// Reuse one GL texture per unique word.
wordTextureCache map[string]StringItem
increment float32
velocity float32
// Time-based velocity control
velocityFloor float32
velocityCeiling float32
velocityStep float32
velocityDelay float32
velocityDirection int
velocityTimer float32
velocityState int
autoVelocity bool
hold float32
// Color palette settings
useColorPalettes bool
}
type ScrollerProclamation struct { type ScrollerProclamation struct {
db.Proclamation db.Proclamation
colorPattern.ColorPattern colorPattern.ColorPattern
@@ -45,6 +92,11 @@ type proclamationStream struct {
wordIndex int wordIndex int
palette []sdl.Color // Color palette for this proclamation palette []sdl.Color // Color palette for this proclamation
colorIndex int // Track which color to use next colorIndex int // Track which color to use next
// frequencyValue is the solfeggio Hz value that should be audible while
// this stream plays. 0 means "no specific frequency" (the default
// proclamation) — entering this stream never changes the audio.
frequencyValue int
} }
// LineDeque is a double-ended queue for Line values backed by a circular buffer. // LineDeque is a double-ended queue for Line values backed by a circular buffer.
@@ -130,45 +182,6 @@ func (d *LineDeque) Clear() {
d.head, d.tail, d.count = 0, 0, 0 d.head, d.tail, d.count = 0, 0, 0
} }
type ScrollerPage struct {
Base
streams []proclamationStream
streamIndex int
lines *LineDeque
lineHeight int32
shader *shaderManager.Shader
activePattern linePattern.LinePattern
font *fontManager.FontEntry
spaceWidth int32
lineCtx *linePattern.LineRenderContext
colorPattern *colorPattern.ColorPattern
scrollOffset float32
scrollY float32
fontScale float32
// Reuse one GL texture per unique word.
wordTextureCache map[string]StringItem
increment float32
velocity float32
// Time-based velocity control
velocityFloor float32
velocityCeiling float32
velocityStep float32
velocityDelay float32
velocityDirection int
velocityTimer float32
velocityState int
autoVelocity bool
hold float32
// Color palette settings
useColorPalettes bool
}
func (p *ScrollerPage) Init(ctx *PageCtx) error { func (p *ScrollerPage) Init(ctx *PageCtx) error {
p.increment = 100.0 p.increment = 100.0
p.velocity = 800.0 p.velocity = 800.0
@@ -261,11 +274,25 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
} }
} }
assetsDir := p.Context.CfgPtr.AssetsDir // Start audio for the first proclamation in the stream. Falls back to
audioFile := filepath.Join(assetsDir, "audio/audio.wav") // whatever track is marked Selected in the audio_files table if the
logger.Info("Attempting to play %s", audioFile) // first stream has no frequency (e.g. only the default proclamation is
if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil { // queued) or its matching file doesn't exist on disk yet.
logger.Error("Mixer returned an error: %v", err) started := false
if len(p.streams) > 0 {
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
}
if !started {
var selectedAudio db.AudioFile
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
logger.Error("error querying database for selected audio: %v", err)
}
logger.Info("Playing %s", selectedAudio.Path)
if err := mixer.Play(selectedAudio.Path, true, p.Context.CfgPtr); err != nil {
logger.Error("Mixer returned an error: %v", err)
}
} }
currentDevice := mixer.CurrentDevice() currentDevice := mixer.CurrentDevice()
@@ -345,20 +372,22 @@ func (p *ScrollerPage) loadProclamations() error {
} }
p.streams = append(p.streams, proclamationStream{ p.streams = append(p.streams, proclamationStream{
name: proclamation.Name, name: proclamation.Name,
words: words, words: words,
repeat: proclamationPlayCount, repeat: proclamationPlayCount,
palette: palette, palette: palette,
colorIndex: 0, colorIndex: 0,
frequencyValue: freqValue,
}) })
if len(defaultWords) > 0 { if len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{ p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name, name: defaultProclamation.Name,
words: defaultWords, words: defaultWords,
repeat: 1, repeat: 1,
palette: colors.Freq_528_Colors[:], // Default palette for default proclamation palette: colors.Freq_528_Colors[:], // Default palette for default proclamation
colorIndex: 0, colorIndex: 0,
frequencyValue: 0, // default proclamation never changes audio
}) })
} }
} }
@@ -366,11 +395,12 @@ func (p *ScrollerPage) loadProclamations() error {
// Fallback: if no non-default selected proclamations, at least play default. // Fallback: if no non-default selected proclamations, at least play default.
if len(p.streams) == 0 && len(defaultWords) > 0 { if len(p.streams) == 0 && len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{ p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name, name: defaultProclamation.Name,
words: defaultWords, words: defaultWords,
repeat: 1, repeat: 1,
palette: colors.Freq_528_Colors[:], palette: colors.Freq_528_Colors[:],
colorIndex: 0, colorIndex: 0,
frequencyValue: 0,
}) })
} }
@@ -382,6 +412,48 @@ func (p *ScrollerPage) loadProclamations() error {
return nil return nil
} }
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's
// audio file, e.g. 396 -> "<AssetsDir>/audio/396Hz.wav".
func (p *ScrollerPage) audioPathForFrequency(freq int) string {
return filepath.Join(p.Context.CfgPtr.AssetsDir, "audio", fmt.Sprintf("%dHz.wav", freq))
}
// switchAudioForFrequency swaps the currently playing track to match freq.
//
// - freq == 0 ("no specific frequency", i.e. the default proclamation) is
// always a no-op — whatever is playing keeps playing.
// - If freq already matches what's playing, nothing happens.
// - If the matching file doesn't exist on disk yet, the current track
// keeps playing untouched (so missing solfeggio audio never causes
// silence — and starts working automatically once the file is added).
// - Otherwise the current track is stopped immediately and freq's track
// starts right away (hard cut, no fade).
//
// Returns true if freq's track is now playing (either just switched, or
// already was).
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
if freq == 0 {
return false
}
if freq == p.currentAudioFreq {
return true
}
path := p.audioPathForFrequency(freq)
if _, err := os.Stat(path); err != nil {
logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path)
return false
}
mixer.Stop(3000, p.Context.CfgPtr)
if err := mixer.Play(path, true, p.Context.CfgPtr); err != nil {
logger.Error("Mixer returned an error: %v", err)
return false
}
p.currentAudioFreq = freq
return true
}
// nextLineWords gets words for the current line and advances proclamation stream when needed. // nextLineWords gets words for the current line and advances proclamation stream when needed.
func (p *ScrollerPage) nextLineWords() []string { func (p *ScrollerPage) nextLineWords() []string {
if len(p.streams) == 0 { if len(p.streams) == 0 {
@@ -404,13 +476,13 @@ func (p *ScrollerPage) nextLineWords() []string {
if targetRepeats <= 0 { if targetRepeats <= 0 {
targetRepeats = 1 targetRepeats = 1
} }
logger.Info("Now playing proclamation: %s", s.name)
if s.completed >= targetRepeats { if s.completed >= targetRepeats {
s.completed = 0 s.completed = 0
s.wordIndex = 0 s.wordIndex = 0
s.colorIndex = 0 // Reset color index when proclamation restarts s.colorIndex = 0 // Reset color index when proclamation restarts
p.streamIndex = (p.streamIndex + 1) % len(p.streams) p.streamIndex = (p.streamIndex + 1) % len(p.streams)
fmt.Println("Now playing proclamation:", p.streams[p.streamIndex].name) next := &p.streams[p.streamIndex]
p.switchAudioForFrequency(next.frequencyValue)
} }
} }
@@ -518,23 +590,23 @@ func (p *ScrollerPage) createLine(index int, isInitial bool) error {
} }
func (p *ScrollerPage) HandleEvent(event *sdl.Event) error { func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) { // switch e := (*event).(type) {
case *sdl.KeyboardEvent: // case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN { // if e.Type != sdl.KEYDOWN {
return nil // return nil
} // }
switch e.Keysym.Sym { // switch e.Keysym.Sym {
case sdl.K_UP: // case sdl.K_UP:
if !p.autoVelocity { // if !p.autoVelocity {
p.velocity += p.increment // p.velocity += p.increment
} // }
case sdl.K_DOWN: // case sdl.K_DOWN:
if !p.autoVelocity { // if !p.autoVelocity {
p.velocity -= p.increment // p.velocity -= p.increment
} // }
} // }
return nil // return nil
} // }
return nil return nil
} }
@@ -645,7 +717,7 @@ func (p *ScrollerPage) Destroy() error {
return nil return nil
} }
mixer.Stop(1000, p.Context.CfgPtr) mixer.Stop(3000, p.Context.CfgPtr)
// Delete each cached word texture exactly once. // Delete each cached word texture exactly once.
for k, item := range p.wordTextureCache { for k, item := range p.wordTextureCache {
-188
View File
@@ -1,188 +0,0 @@
package page
import (
"fmt"
"os/exec"
"strings"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
type updateCheckState int
const (
updateCheckStateChecking updateCheckState = iota
updateCheckStateDone
)
type UpdateCheckPage struct {
Base
textShader *shaderManager.Shader
state updateCheckState
// "Checking for Updates." / ".." / "..."
checkingTextures [3]StringItem
// "No updates available" and "Press F3 to return"
doneMessage StringItem
doneHint StringItem
// Error message if service failed to start
errorMessage StringItem
hasError bool
pollTimer float32
dotIndex int
dotTimer float32
}
func (p *UpdateCheckPage) Init(ctx *PageCtx) error {
var err error
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
fm := fontManager.Get()
sm := shaderManager.Get()
font, ok := fm.GetFont("Roboto-Regular")
if !ok {
return fmt.Errorf("failed to get font")
}
p.textShader, ok = sm.Get("text")
if !ok {
return fmt.Errorf("failed to get text shader")
}
// Get scale
baseFontSize := ctx.CfgPtr.StandardFontSize
scale := float32(48) / float32(baseFontSize)
// Create checking textures with animated dots
dots := []string{"Checking for Updates.", "Checking for Updates..", "Checking for Updates..."}
for i, text := range dots {
item, err := NewStringItem(text, font, colors.White)
if err != nil {
return fmt.Errorf("creating checking texture %d: %w", i, err)
}
item.Scale(scale)
p.checkingTextures[i] = item
}
// Create done textures
hintScale := float32(32) / float32(baseFontSize)
p.doneMessage, err = NewStringItem("No updates available", font, colors.White)
if err != nil {
return fmt.Errorf("creating done message: %w", err)
}
p.doneMessage.Scale(scale)
p.doneHint, err = NewStringItem("Press F3 to return", font, colors.White)
if err != nil {
return fmt.Errorf("creating done hint: %w", err)
}
p.doneHint.Scale(hintScale)
// Start the update service
p.state = updateCheckStateChecking
if err := exec.Command("systemctl", "start", "nixos-upgrade.service").Run(); err != nil {
p.errorMessage, _ = NewStringItem("Update service unavailable", font, colors.White)
p.errorMessage.Scale(scale)
p.hasError = true
p.state = updateCheckStateDone
}
return nil
}
func (p *UpdateCheckPage) Update(dt float32) error {
if p.state != updateCheckStateChecking {
return nil
}
// Animate dots
p.dotTimer += dt
if p.dotTimer >= 0.5 {
p.dotTimer -= 0.5
p.dotIndex = (p.dotIndex + 1) % 3
}
// Poll service status every 2 seconds
p.pollTimer += dt
if p.pollTimer >= 2.0 {
p.pollTimer -= 2.0
output, _ := exec.Command("systemctl", "is-active", "rhemawave-update.service").Output()
status := strings.TrimSpace(string(output))
if status != "active" && status != "activating" {
p.state = updateCheckStateDone
}
}
return nil
}
func (p *UpdateCheckPage) HandleEvent(event *sdl.Event) error {
if p.state != updateCheckStateDone {
return nil
}
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
switch e.Keysym.Sym {
case sdl.K_F3, sdl.K_BACKSPACE:
p.Context.NavigateTo(SETTINGS)
}
}
return nil
}
func (p *UpdateCheckPage) Render() error {
switch p.state {
case updateCheckStateChecking:
item := p.checkingTextures[p.dotIndex]
item.X = p.ScreenCenterX - item.W/2
item.Y = p.ScreenCenterY - item.H/2
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
case updateCheckStateDone:
var msg *StringItem
if p.hasError {
msg = &p.errorMessage
} else {
msg = &p.doneMessage
}
msg.X = p.ScreenCenterX - msg.W/2
msg.Y = p.ScreenCenterY + msg.H/2
p.RenderTexture(p.textShader, msg.ID, msg.Position(), msg.RawDimensions(), msg.Color)
p.doneHint.X = p.ScreenCenterX - p.doneHint.W/2
p.doneHint.Y = msg.Y - p.doneHint.H - 20
p.RenderTexture(p.textShader, p.doneHint.ID, p.doneHint.Position(), p.doneHint.RawDimensions(), p.doneHint.Color)
}
return nil
}
func (p *UpdateCheckPage) Destroy() error {
for i := range p.checkingTextures {
gl.DeleteTextures(1, &p.checkingTextures[i].ID)
}
gl.DeleteTextures(1, &p.doneMessage.ID)
gl.DeleteTextures(1, &p.doneHint.ID)
if p.hasError {
gl.DeleteTextures(1, &p.errorMessage.ID)
}
return nil
}
+4 -6
View File
@@ -2,7 +2,6 @@ package page
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
@@ -95,7 +94,7 @@ func (p *WiFiSetupPage) Init(ctx *PageCtx) error {
} }
for _, dev := range p.networkManager.Devices { for _, dev := range p.networkManager.Devices {
if dev.Type == network.TypeWifi { if dev.Type == network.TypeWifi {
d := dev d := dev
p.wifiDevice = &d p.wifiDevice = &d
break break
} }
@@ -198,14 +197,13 @@ func (p *WiFiSetupPage) handlePasswordInput(sym sdl.Keycode, mod uint16) {
switch sym { switch sym {
case sdl.K_RETURN: case sdl.K_RETURN:
p.exitPasswordMode() p.exitPasswordMode()
p.pendingSSID = p.selectedAP.SSID p.pendingSSID = p.selectedAP.SSID
p.isConnecting = true p.isConnecting = true
p.connectionError = "" p.connectionError = ""
p.connectionAttemptTime = time.Now() p.connectionAttemptTime = time.Now()
err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, p.passwordInput) err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, p.passwordInput)
if err != nil { if err != nil {
p.isConnecting = false p.isConnecting = false
@@ -589,7 +587,7 @@ func (p *WiFiSetupPage) updatePasswordTexture() {
if p.passwordInputItem.ID != 0 { if p.passwordInputItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordInputItem.ID) gl.DeleteTextures(1, &p.passwordInputItem.ID)
} }
maskedPassword := strings.Repeat("*", len(p.passwordInput)) maskedPassword := p.passwordInput
if maskedPassword == "" { if maskedPassword == "" {
maskedPassword = " " maskedPassword = " "
} }