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"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/download"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/graphics"
"rhemawavetech.com/rhemawave/internal/keybinds"
@@ -26,18 +27,20 @@ import (
const (
targetFPS = 60.0
volumeStepSize = 16
openGLMajorVersion = 3
openGLMinorVersion = 3
apiURL = "https://portal.rhemawavetech.com/api/v1"
// volumeStepSize = 16
// apiURL = "https://portal.rhemawavetech.com/api/v1"
)
type State int
const (
NEEDS_INTERNET State = iota
FIRST_RUN State = iota
NEEDS_INTERNET
NEEDS_LICENSE_KEY
NEEDS_EMAIL_ADDRESS
DOWNLOADING_ASSETS
READY
)
@@ -107,16 +110,17 @@ func Run() {
}
// Populate the initial Config cache OR create defaults
firstRun := false
result = DB.First(&initialConfig)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
logger.Info("First run detected: Creating default configuration...")
firstRun = true
initialConfig = db.AppConfig{
DataDir: dataDir,
AssetsDir: dataDir,
SystemLevel: "BASIC",
SelectedAudioDevice: "default",
SelectedAudio: "audio.wav",
InitialFontSize: 128,
StandardFontSize: 32,
LinePattern: "fibonacci",
@@ -139,6 +143,10 @@ func Run() {
DB: DB,
}
if firstRun {
app.state = FIRST_RUN
}
app.refreshCache()
w, h := app.initializeSDLAndOpenGL()
@@ -146,29 +154,53 @@ func Run() {
app.initializeManagers()
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{
CfgPtr: app.Config.Load(),
DbPtr: app.DB,
W: w,
H: h,
RefreshConfig: app.refreshCache,
CfgPtr: app.Config.Load(),
DbPtr: app.DB,
W: w,
H: h,
MessageChannel: downloadProgressChan,
RefreshConfig: app.refreshCache,
})
// Sync the proclamations from the API to the Database
sync.SyncProclamations(DB)
sync.SyncCategories(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()
}
// checkState keeps watch of the application state and sets pages
// accordingly. This function runs once per
func (app *Application) checkState() {
// app.state is READY so do nothing.
if app.state == READY {
return
}
// app.state = 0 means that there is no state set
// Check for an internet connection first
// Check if there is an internet connection, and if not, force the WiFi page
if !networkIsUp {
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
networkManager, err := network.New()
@@ -190,12 +222,21 @@ func (app *Application) checkState() {
}
return
}
networkIsUp = true // ← internet confirmed, don't check again
networkIsUp = true // internet confirmed, don't check again
} else {
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
if app.Config.Load().LicenseKey == "" {
if app.state != NEEDS_LICENSE_KEY {
@@ -205,15 +246,6 @@ func (app *Application) checkState() {
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.PageRouter.NavigateTo(page.HOME)
}
@@ -429,14 +461,23 @@ func (app *Application) renderFrame() {
func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
if app.state != READY {
return
}
app.PageRouter.NavigateTo(page.SETTINGS)
})
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
if app.state != READY {
return
}
app.PageRouter.NavigateTo(page.SCROLLER)
})
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
if app.state != READY {
return
}
app.PageRouter.NavigateTo(page.HOME)
})
+1 -1
View File
@@ -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
+9 -1
View File
@@ -10,13 +10,13 @@ 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
LinePattern string // "fibonacci"
AudioEnabled bool
SavedVolume float64 // Last saved volume as a percentage
FirstRun bool // Is this the first time the application has been run?
}
type Log struct {
@@ -48,3 +48,11 @@ type SolfeggioFrequency struct {
Name string
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"
"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
}
+19 -9
View File
@@ -28,10 +28,6 @@ const (
nmDeviceTypeWifi uint32 = 2
nmDeviceStateActivated uint32 = 100
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.
@@ -88,8 +84,7 @@ type AccessPoint struct {
Strength byte
IsProtected bool
IsActive bool
WpaFlags uint32
RsnFlags uint32
KeyMgmt string
}
// 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)
// 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{
ObjectPath: apPath,
SSID: ssidStr,
Strength: strength,
IsProtected: isProtected,
WpaFlags: wpaFlags,
RsnFlags: rsnFlags,
KeyMgmt: keyMgmt,
}
if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength {
tempNetworkMap[ssidStr] = currentAP
@@ -328,7 +332,7 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string)
if ap.IsProtected {
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"),
"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)
// 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
}
+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 (
"fmt"
"time"
"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/download"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
@@ -16,16 +16,20 @@ var GitVersion = "1.0.0-beta"
type Home struct {
Base
title StringItem
version StringItem
prompt StringItem
note StringItem
licenseInfoHeading StringItem
emailDisplay StringItem
licenseKeyDisplay StringItem
hasLicenseInfo bool
lastDisplayUpdate time.Time
baseFontSize int // cached from db at init
fm *fontManager.FontManager
font *fontManager.FontEntry
title StringItem
version StringItem
prompt StringItem
note StringItem
message StringItem
downloadProgressChan chan download.DownloadStatus
licenseInfoHeading StringItem
emailDisplay StringItem
licenseKeyDisplay StringItem
hasLicenseInfo bool
baseFontSize int
isDownloadComplete bool
}
// Init performs basic page initialization and creation of static assets.
@@ -40,11 +44,12 @@ func (p *Home) Init(ctx *PageCtx) error {
}
// Get font
fm := fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular")
p.fm = fontManager.Get()
font, ok := p.fm.GetFont("Roboto-Regular")
if !ok {
return fmt.Errorf("unable to fetch font")
}
p.font = font
// Create title texture
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)
}
// 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
if p.Context.CfgPtr.EmailAddress != "" || p.Context.CfgPtr.LicenseKey != "" {
p.hasLicenseInfo = true
@@ -103,26 +117,34 @@ func (p *Home) Init(ctx *PageCtx) error {
// Get base font size from database
baseFontSize := p.Context.CfgPtr.StandardFontSize
p.baseFontSize = baseFontSize
// Scale the note text
fontSize := int32(32)
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
if p.hasLicenseInfo {
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 {
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 {
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
}
@@ -131,8 +153,81 @@ func (p *Home) HandleEvent(event *sdl.Event) error {
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 {
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
}
@@ -140,6 +235,7 @@ func (p *Home) Render() error {
baseFontSize := p.baseFontSize
sm := shaderManager.Get()
verticalMargin := int32(20)
licenseInfoMargin := int32(10)
// 1) Calculate scales
titleFontSize := int32(64)
@@ -161,9 +257,26 @@ func (p *Home) Render() error {
promptH := float32(p.prompt.H) * bodyScale
promptDim := mgl32.Vec2{promptW, promptH}
// p.note already scaled in Init via p.note.Scale(scale*0.75)
noteDim := p.note.RawDimensions()
noteW, noteH := noteDim[0], noteDim[1]
// Note dimensions (already scaled in Init)
noteW := float32(p.note.W)
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
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
top := p.Base.ScreenCenterY + totalH/2
titleY := top - int32(titleH) // Title sits at the top
titleY := top - int32(titleH)
versionY := titleY - verticalMargin - int32(versionH)
p.prompt.Y = versionY - verticalMargin - int32(promptH)
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
if p.hasLicenseInfo {
licenseInfoMargin := int32(10)
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
licenseInfoTotalH := int32(licenseHeadingH)
if p.emailDisplay.ID != 0 {
@@ -221,8 +319,6 @@ func (p *Home) Render() error {
// License key (bottom-most if it exists)
if p.licenseKeyDisplay.ID != 0 {
licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions()
licenseKeyDisplayW := licenseKeyDisplayDim[0]
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
p.licenseKeyDisplay.Y = currentY
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
@@ -230,8 +326,6 @@ func (p *Home) Render() error {
// Email
if p.emailDisplay.ID != 0 {
emailDisplayDim := p.emailDisplay.RawDimensions()
emailDisplayW := emailDisplayDim[0]
p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2)
p.emailDisplay.Y = currentY
currentY += int32(emailDisplayH) + licenseInfoMargin
@@ -242,6 +336,26 @@ func (p *Home) Render() error {
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
textShader, ok := sm.Get("text")
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.version.ID, versionPos, versionDim, color)
p.Base.RenderTexture(textShader, p.prompt.ID, p.prompt.Position(), promptDim, color)
p.Base.RenderTexture(textShader, p.note.ID, p.note.Position(), p.note.RawDimensions(), 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, 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
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 {
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 {
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.prompt.ID)
gl.DeleteTextures(1, &p.note.ID)
gl.DeleteTextures(1, &p.message.ID)
if p.hasLicenseInfo {
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 (
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/download"
)
type pageType int
@@ -17,18 +18,19 @@ const (
LICENSE_KEY
EMAIL_ADDRESS
LOGS
CHECK_UPDATES
MESSAGE
DOWNLOADING_ASSETS
)
type PageCtx struct {
CfgPtr *db.AppConfig
DbPtr *gorm.DB
W, H int32
RefreshConfig func()
NavigateTo func(pageType)
Msg string // Message to be displayed by MessagePage
Inst string // Instructions to be displayed on MessagePage
CfgPtr *db.AppConfig
DbPtr *gorm.DB
W, H int32
MessageChannel chan download.DownloadStatus
RefreshConfig func()
NavigateTo func(pageType)
Msg string // Message to be displayed by MessagePage
Inst string // Instructions to be displayed on MessagePage
}
type Router struct {
@@ -60,7 +62,7 @@ func (r *Router) NavigateTo(p pageType) {
case WIFI:
r.ActivePage = &WiFiSetupPage{}
case AUDIO:
r.ActivePage = &AudioDevices{}
r.ActivePage = &AudioOptions{}
case PROCLAMATIONS:
r.ActivePage = &ProclamationOptions{}
case LICENSE_KEY:
@@ -69,10 +71,10 @@ func (r *Router) NavigateTo(p pageType) {
r.ActivePage = &EmailAddressPage{}
case LOGS:
r.ActivePage = &LogViewerPage{}
case CHECK_UPDATES:
r.ActivePage = &UpdateCheckPage{}
case MESSAGE:
r.ActivePage = &MessagePage{}
case DOWNLOADING_ASSETS:
r.ActivePage = &DownloadingAssets{}
}
r.ActivePage.Init(&r.Context)
+154 -82
View File
@@ -3,6 +3,7 @@ package page
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
@@ -19,11 +20,57 @@ import (
)
const (
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
proclamationPlayCount int = 10
defaultProclamationPlayCount int = 1
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
proclamationPlayCount int = 20
// 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 {
db.Proclamation
colorPattern.ColorPattern
@@ -45,6 +92,11 @@ type proclamationStream struct {
wordIndex int
palette []sdl.Color // Color palette for this proclamation
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.
@@ -130,45 +182,6 @@ func (d *LineDeque) Clear() {
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 {
p.increment = 100.0
p.velocity = 800.0
@@ -261,11 +274,25 @@ 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)
// Start audio for the first proclamation in the stream. Falls back to
// whatever track is marked Selected in the audio_files table if the
// first stream has no frequency (e.g. only the default proclamation is
// queued) or its matching file doesn't exist on disk yet.
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()
@@ -345,20 +372,22 @@ func (p *ScrollerPage) loadProclamations() error {
}
p.streams = append(p.streams, proclamationStream{
name: proclamation.Name,
words: words,
repeat: proclamationPlayCount,
palette: palette,
colorIndex: 0,
name: proclamation.Name,
words: words,
repeat: proclamationPlayCount,
palette: palette,
colorIndex: 0,
frequencyValue: freqValue,
})
if len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1,
palette: colors.Freq_528_Colors[:], // Default palette for default proclamation
colorIndex: 0,
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1,
palette: colors.Freq_528_Colors[:], // Default palette for default proclamation
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.
if len(p.streams) == 0 && len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1,
palette: colors.Freq_528_Colors[:],
colorIndex: 0,
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1,
palette: colors.Freq_528_Colors[:],
colorIndex: 0,
frequencyValue: 0,
})
}
@@ -382,6 +412,48 @@ func (p *ScrollerPage) loadProclamations() error {
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.
func (p *ScrollerPage) nextLineWords() []string {
if len(p.streams) == 0 {
@@ -404,13 +476,13 @@ func (p *ScrollerPage) nextLineWords() []string {
if targetRepeats <= 0 {
targetRepeats = 1
}
logger.Info("Now playing proclamation: %s", s.name)
if s.completed >= targetRepeats {
s.completed = 0
s.wordIndex = 0
s.colorIndex = 0 // Reset color index when proclamation restarts
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 {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
switch e.Keysym.Sym {
case sdl.K_UP:
if !p.autoVelocity {
p.velocity += p.increment
}
case sdl.K_DOWN:
if !p.autoVelocity {
p.velocity -= p.increment
}
}
return nil
}
// switch e := (*event).(type) {
// case *sdl.KeyboardEvent:
// if e.Type != sdl.KEYDOWN {
// return nil
// }
// switch e.Keysym.Sym {
// case sdl.K_UP:
// if !p.autoVelocity {
// p.velocity += p.increment
// }
// case sdl.K_DOWN:
// if !p.autoVelocity {
// p.velocity -= p.increment
// }
// }
// return nil
// }
return nil
}
@@ -645,7 +717,7 @@ func (p *ScrollerPage) Destroy() error {
return nil
}
mixer.Stop(1000, p.Context.CfgPtr)
mixer.Stop(3000, p.Context.CfgPtr)
// Delete each cached word texture exactly once.
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 (
"fmt"
"strings"
"time"
"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 {
if dev.Type == network.TypeWifi {
d := dev
d := dev
p.wifiDevice = &d
break
}
@@ -198,14 +197,13 @@ func (p *WiFiSetupPage) handlePasswordInput(sym sdl.Keycode, mod uint16) {
switch sym {
case sdl.K_RETURN:
p.exitPasswordMode()
p.exitPasswordMode()
p.pendingSSID = p.selectedAP.SSID
p.isConnecting = true
p.connectionError = ""
p.connectionAttemptTime = time.Now()
err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, p.passwordInput)
if err != nil {
p.isConnecting = false
@@ -589,7 +587,7 @@ func (p *WiFiSetupPage) updatePasswordTexture() {
if p.passwordInputItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordInputItem.ID)
}
maskedPassword := strings.Repeat("*", len(p.passwordInput))
maskedPassword := p.passwordInput
if maskedPassword == "" {
maskedPassword = " "
}