diff --git a/internal/application/application.go b/internal/application/application.go index 424bd4f..cee6e72 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -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) }) diff --git a/internal/db/newDb.go b/internal/db/newDb.go index ddfc273..fd77866 100644 --- a/internal/db/newDb.go +++ b/internal/db/newDb.go @@ -42,7 +42,7 @@ func Connect(dbpath string) (*gorm.DB, error) { // Migrate the database schema func Migrate(db *gorm.DB) error { // Pass all models to be migrated to this function call. - if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}); err != nil { + if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}, &AudioFile{}); err != nil { return err } return nil diff --git a/internal/db/schema.go b/internal/db/schema.go index 1b5437a..0d7f6bb 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -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 +} diff --git a/internal/download/Download.go b/internal/download/Download.go new file mode 100644 index 0000000..5e704c6 --- /dev/null +++ b/internal/download/Download.go @@ -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!", + } +} diff --git a/internal/netreq/netReq.go b/internal/netreq/netReq.go index 17e0331..f9c4b4c 100644 --- a/internal/netreq/netReq.go +++ b/internal/netreq/netReq.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" ) // Makes a GET request to the specified URL and returns the JSON response @@ -52,3 +54,30 @@ func GetJSON[T any](url string) ([]T, error) { return result, nil } + +// Download downloads a specified object at a given url and places it in +// the provided dest directory. +func Download(name string, path string, url string) error { + fullFilePath := filepath.Join(path, name) + if err := os.MkdirAll(path, os.ModeDir); err != nil { + return err + } + file, err := os.Create(fullFilePath) + if err != nil { + return err + } + defer file.Close() + + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/internal/network/network.go b/internal/network/network.go index 7227102..08a1c99 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -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 } diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go new file mode 100644 index 0000000..82acc6e --- /dev/null +++ b/internal/page/AudioOptions.go @@ -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 +} diff --git a/internal/page/DownloadingAssets.go b/internal/page/DownloadingAssets.go new file mode 100644 index 0000000..912a6a8 --- /dev/null +++ b/internal/page/DownloadingAssets.go @@ -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 +} diff --git a/internal/page/Message.go b/internal/page/Message.go new file mode 100644 index 0000000..c010f22 --- /dev/null +++ b/internal/page/Message.go @@ -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 +} diff --git a/internal/page/audioDevices.go b/internal/page/audioDevices.go deleted file mode 100644 index e1ef054..0000000 --- a/internal/page/audioDevices.go +++ /dev/null @@ -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 -} diff --git a/internal/page/home.go b/internal/page/home.go index 1368c6c..08b6cb6 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -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) diff --git a/internal/page/message.go b/internal/page/message.go deleted file mode 100644 index 43a81d0..0000000 --- a/internal/page/message.go +++ /dev/null @@ -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 -} diff --git a/internal/page/router.go b/internal/page/router.go index fb5d4bf..eb8dda8 100644 --- a/internal/page/router.go +++ b/internal/page/router.go @@ -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) diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 80dfb74..6f128e5 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -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 -> "/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 { diff --git a/internal/page/updateCheckPage.go b/internal/page/updateCheckPage.go deleted file mode 100644 index 53065d7..0000000 --- a/internal/page/updateCheckPage.go +++ /dev/null @@ -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 -} diff --git a/internal/page/wifiSetupPage.go b/internal/page/wifiSetupPage.go index abd7ce3..92747c1 100644 --- a/internal/page/wifiSetupPage.go +++ b/internal/page/wifiSetupPage.go @@ -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 = " " }