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:
@@ -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)
|
||||
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = " "
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user