AudioTrack table replaces AudioFile (Filename, Name, AssignedFrequency,
MD5, Selected, Order) — fixes relative-path bug in scrollerPage.go that
caused "file not found" for single-track selection.
Download goroutine now:
- Accepts *gorm.DB and updates AudioTrack.MD5 after each download.
- Treats checksums.md5 as source of truth: removes local files and DB
records not in the manifest (orphans).
- Strips "./" prefix from manifest filenames (GNU md5sum format).
AudioOptions.go seeds 14 AudioTrack records matching server checksums.md5.
List background quad width matches the widest item (+ padding), centered.
scrollerPage.go switchAudioForFrequency falls back to original/selected
audio when a frequency has no matching track or file. playOriginalAudio
is a no-op on repeat calls to avoid 5-second audio restart loop.
Fixes progress display: passes Max=0 during downloads so Home page shows
"x / X Files Downloaded" text instead of bogus "100.0%" percentage.
Downstream audio switching (DynamicAudio) verifies an AudioTrack exists
for the frequency before assigning it to a stream — frequencies without
a track (e.g. 528 Hz) gracefully use original audio instead.
890 lines
23 KiB
Go
890 lines
23 KiB
Go
package page
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"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/db"
|
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
"rhemawavetech.com/rhemawave/internal/logger"
|
|
"rhemawavetech.com/rhemawave/internal/mixer"
|
|
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
|
|
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
|
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
)
|
|
|
|
const (
|
|
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
|
|
|
|
audioRetryTimer float32
|
|
audioStarted bool
|
|
originalAudioStarted bool
|
|
dynamicAudio 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
|
|
ColorPalette []sdl.Color
|
|
}
|
|
|
|
type Line struct {
|
|
words []StringItem
|
|
width int32
|
|
color sdl.Color // Color for the entire line
|
|
}
|
|
|
|
// proclamationStream tracks one proclamation and how many full loops it should play.
|
|
type proclamationStream struct {
|
|
name string
|
|
words []string
|
|
repeat int
|
|
completed int
|
|
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.
|
|
// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal.
|
|
type LineDeque struct {
|
|
buf []Line
|
|
head, tail int
|
|
count int
|
|
}
|
|
|
|
func NewLineDeque(cap int) *LineDeque {
|
|
if cap < 4 {
|
|
cap = 4
|
|
}
|
|
return &LineDeque{buf: make([]Line, cap)}
|
|
}
|
|
|
|
func (d *LineDeque) Len() int { return d.count }
|
|
|
|
func (d *LineDeque) grow() {
|
|
newCap := len(d.buf) * 2
|
|
newBuf := make([]Line, newCap)
|
|
for i := 0; i < d.count; i++ {
|
|
newBuf[i] = d.buf[(d.head+i)%len(d.buf)]
|
|
}
|
|
d.buf = newBuf
|
|
d.head = 0
|
|
d.tail = d.count
|
|
}
|
|
|
|
func (d *LineDeque) PushBack(l Line) {
|
|
if d.count == len(d.buf) {
|
|
d.grow()
|
|
}
|
|
d.buf[d.tail] = l
|
|
d.tail = (d.tail + 1) % len(d.buf)
|
|
d.count++
|
|
}
|
|
|
|
func (d *LineDeque) PushFront(l Line) {
|
|
if d.count == len(d.buf) {
|
|
d.grow()
|
|
}
|
|
d.head = (d.head - 1 + len(d.buf)) % len(d.buf)
|
|
d.buf[d.head] = l
|
|
d.count++
|
|
}
|
|
|
|
func (d *LineDeque) PopFront() Line {
|
|
l := d.buf[d.head]
|
|
d.buf[d.head] = Line{} // clear for GC
|
|
d.head = (d.head + 1) % len(d.buf)
|
|
d.count--
|
|
return l
|
|
}
|
|
|
|
func (d *LineDeque) PopBack() Line {
|
|
d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf)
|
|
l := d.buf[d.tail]
|
|
d.buf[d.tail] = Line{} // clear for GC
|
|
d.count--
|
|
return l
|
|
}
|
|
|
|
func (d *LineDeque) Front() Line { return d.buf[d.head] }
|
|
|
|
func (d *LineDeque) Back() Line {
|
|
return d.buf[(d.tail-1+len(d.buf))%len(d.buf)]
|
|
}
|
|
|
|
func (d *LineDeque) Get(i int) *Line {
|
|
return &d.buf[(d.head+i)%len(d.buf)]
|
|
}
|
|
|
|
func (d *LineDeque) Set(i int, l Line) {
|
|
d.buf[(d.head+i)%len(d.buf)] = l
|
|
}
|
|
|
|
func (d *LineDeque) Clear() {
|
|
for i := 0; i < d.count; i++ {
|
|
d.buf[(d.head+i)%len(d.buf)] = Line{}
|
|
}
|
|
d.head, d.tail, d.count = 0, 0, 0
|
|
}
|
|
|
|
func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|
p.increment = 100.0
|
|
p.velocity = 800.0
|
|
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
|
|
p.useColorPalettes = true // Default to enabled
|
|
|
|
sm := shaderManager.Get()
|
|
fm := fontManager.Get()
|
|
|
|
// Get font
|
|
font, ok := fm.GetFont("Scrambled")
|
|
if !ok {
|
|
return fmt.Errorf("unable to fetch font 'Scrambled'")
|
|
}
|
|
p.font = font
|
|
logger.Debug("Font name: %s", font.Name)
|
|
|
|
p.Base.Init(ctx)
|
|
|
|
// Get text shader
|
|
textShader, ok := sm.Get("text")
|
|
if !ok {
|
|
return fmt.Errorf("text shader not found")
|
|
}
|
|
p.shader = textShader
|
|
logger.Debug("Shader Program ID: %d", textShader.ProgramID)
|
|
logger.Debug("p.shader Program ID: %d", p.shader.ProgramID)
|
|
|
|
p.wordTextureCache = make(map[string]StringItem, 256)
|
|
|
|
// Load proclamation streams from database
|
|
if err := p.loadProclamations(); err != nil {
|
|
logger.Error("Error loading proclamations: %v", err)
|
|
p.Context.NavigateTo(PROCLAMATIONS)
|
|
return nil
|
|
}
|
|
|
|
// Get line pattern from database
|
|
config, err := db.GetConfig(p.Context.DbPtr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
linePatternName := config.LinePattern
|
|
|
|
p.activePattern, err = linePattern.Get(linePatternName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
|
|
}
|
|
|
|
// Initialize color pattern
|
|
baseColors := []sdl.Color{
|
|
colors.Tan,
|
|
colors.PeachPuff,
|
|
colors.LightCyan,
|
|
colors.LightSeaGreen,
|
|
colors.MediumTurquoise,
|
|
colors.DarkCyan,
|
|
colors.MediumPurple,
|
|
}
|
|
p.colorPattern = colorPattern.NewColorPattern(baseColors)
|
|
|
|
// Font setup
|
|
p.fontScale = float32(32) / float32(config.StandardFontSize)
|
|
nLines := int(float32(p.ScreenHeight) / 32.0)
|
|
p.lines = NewLineDeque(nLines * 2)
|
|
|
|
// Calculate space width using the scale ratio.
|
|
spaceGlyph, ok := fm.GetGlyph(font.Name, ' ')
|
|
if !ok {
|
|
return fmt.Errorf("failed to get space glyph")
|
|
}
|
|
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
|
|
|
|
// Get line height using the same scale ratio.
|
|
tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create sample texture: %w", err)
|
|
}
|
|
p.lineHeight = int32(float32(H) * p.fontScale)
|
|
gl.DeleteTextures(1, &tmpStrID)
|
|
|
|
p.lineCtx = &linePattern.LineRenderContext{
|
|
ScreenWidth: p.ScreenWidth,
|
|
Font: font,
|
|
FontScale: p.fontScale,
|
|
SpaceWidth: p.spaceWidth,
|
|
}
|
|
|
|
// Create initial lines
|
|
for i := range nLines {
|
|
if err := p.createLine(i, true); err != nil {
|
|
return fmt.Errorf("failed to create initial line %d: %w", i, err)
|
|
}
|
|
}
|
|
|
|
// Read DynamicAudio setting from config.
|
|
p.dynamicAudio = config.DynamicAudio
|
|
logger.Info("DynamicAudio mode: %v", p.dynamicAudio)
|
|
|
|
// Start audio for the first proclamation in the stream.
|
|
if len(p.streams) > 0 && p.dynamicAudio {
|
|
p.switchAudioForFrequency(p.streams[0].frequencyValue)
|
|
p.audioStarted = true
|
|
}
|
|
|
|
if !p.audioStarted && !p.dynamicAudio {
|
|
// Static track selected in Audio Options. Play whatever track is
|
|
// marked Selected in the audio_tracks table.
|
|
var selectedTrack db.AudioTrack
|
|
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selectedTrack).Error; err != nil {
|
|
logger.Warning("No selected audio track found, audio will start when a track is chosen")
|
|
} else {
|
|
path := filepath.Join(audioDir(), selectedTrack.Filename)
|
|
if _, err := os.Stat(path); err == nil {
|
|
logger.Info("Playing %s", path)
|
|
if err := mixer.Play(path, true, config); err != nil {
|
|
logger.Error("Mixer returned an error: %v", err)
|
|
} else {
|
|
p.audioStarted = true
|
|
}
|
|
} else {
|
|
logger.Warning("Selected audio file %s not found yet (download may still be in progress)", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
currentDevice := mixer.CurrentDevice()
|
|
if currentDevice == "" {
|
|
currentDevice = "default"
|
|
}
|
|
_ = currentDevice
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadProclamations loads selected proclamations as stream units (not one giant word list).
|
|
func (p *ScrollerPage) loadProclamations() error {
|
|
p.streams = p.streams[:0]
|
|
|
|
// Load all solfeggio frequencies
|
|
var frequencies []db.SolfeggioFrequency
|
|
if err := p.Context.DbPtr.Find(&frequencies).Error; err != nil {
|
|
return fmt.Errorf("failed to load solfeggio frequencies: %v", err)
|
|
}
|
|
|
|
// Create a map of frequency ID to its value
|
|
frequencyMap := make(map[int]int)
|
|
for _, freq := range frequencies {
|
|
frequencyMap[freq.ID] = freq.Value
|
|
}
|
|
|
|
// Map frequency values to their color palettes
|
|
frequencyPalettes := map[int][]sdl.Color{
|
|
396: colors.Freq_396_Colors[:],
|
|
417: colors.Freq_417_Colors[:],
|
|
528: colors.Freq_528_Colors[:], // Default proclamation
|
|
639: colors.Freq_639_Colors[:],
|
|
741: colors.Freq_741_Colors[:],
|
|
852: colors.Freq_852_Colors[:],
|
|
963: colors.Freq_963_Colors[:],
|
|
}
|
|
|
|
// Load selected proclamations
|
|
var selected []db.Proclamation
|
|
if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Load default proclamation
|
|
var defaultProclamation db.Proclamation
|
|
if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil {
|
|
return err
|
|
}
|
|
defaultWords := strings.Fields(defaultProclamation.Content)
|
|
|
|
// Build interleaved stream:
|
|
// each selected proclamation repeats N times, then default repeats once.
|
|
for _, proclamation := range selected {
|
|
// Skip default proclamation if it's in the selected list
|
|
if proclamation.ID == defaultProclamationID {
|
|
continue
|
|
}
|
|
|
|
words := strings.Fields(proclamation.Content)
|
|
if len(words) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Get the frequency value for this proclamation
|
|
freqValue, ok := frequencyMap[proclamation.SolfeggioFrequencyID]
|
|
if !ok {
|
|
logger.Warning("No frequency found for proclamation %s, using default palette", proclamation.Name)
|
|
freqValue = 528 // Default to 528 if frequency not found
|
|
}
|
|
|
|
// Get the palette for this frequency
|
|
palette, ok := frequencyPalettes[freqValue]
|
|
if !ok {
|
|
logger.Warning("No palette found for frequency %d, using default palette", freqValue)
|
|
palette = colors.Freq_528_Colors[:] // Fallback to default palette
|
|
}
|
|
|
|
p.streams = append(p.streams, proclamationStream{
|
|
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,
|
|
frequencyValue: 0, // default proclamation never changes audio
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
frequencyValue: 0,
|
|
})
|
|
}
|
|
|
|
if len(p.streams) == 0 {
|
|
return fmt.Errorf("failed to load proclamations: no proclamation words available")
|
|
}
|
|
|
|
p.streamIndex = 0
|
|
return nil
|
|
}
|
|
|
|
// audioDir returns the path to the audio files directory (~/.radiantwave/audio).
|
|
func audioDir() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return filepath.Join(home, ".radiantwave", "audio")
|
|
}
|
|
|
|
// switchAudioForFrequency swaps the currently playing track to match freq.
|
|
// If freq is 0 or no audio track exists for it, falls back to the original
|
|
// / selected audio track (so audio never goes silent in Ready state).
|
|
//
|
|
// Returns true if audio is now playing (either frequency-specific or fallback).
|
|
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
|
|
if freq != 0 && freq == p.currentAudioFreq {
|
|
return true
|
|
}
|
|
|
|
if freq != 0 {
|
|
if track, err := p.audioTrackForFrequency(freq); err == nil {
|
|
path := filepath.Join(audioDir(), track.Filename)
|
|
if _, err := os.Stat(path); err == nil {
|
|
config, err := db.GetConfig(p.Context.DbPtr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
mixer.Stop(3000, config)
|
|
if err := mixer.Play(path, true, config); err != nil {
|
|
logger.Error("Mixer returned an error: %v", err)
|
|
return false
|
|
}
|
|
p.currentAudioFreq = freq
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return p.playOriginalAudio()
|
|
}
|
|
|
|
// playOriginalAudio plays the selected AudioTrack, or the OriginalAudio track
|
|
// if nothing is selected. Returns true if audio started successfully.
|
|
func (p *ScrollerPage) playOriginalAudio() bool {
|
|
if p.originalAudioStarted {
|
|
return true
|
|
}
|
|
|
|
var track db.AudioTrack
|
|
if err := p.Context.DbPtr.Where("selected = ?", true).First(&track).Error; err != nil {
|
|
if err := p.Context.DbPtr.Where("assigned_frequency = ?", 0).First(&track).Error; err != nil {
|
|
logger.Warning("No audio tracks available")
|
|
return false
|
|
}
|
|
}
|
|
path := filepath.Join(audioDir(), track.Filename)
|
|
if _, err := os.Stat(path); err != nil {
|
|
logger.Warning("Original audio file not found: %s", path)
|
|
return false
|
|
}
|
|
config, err := db.GetConfig(p.Context.DbPtr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
mixer.Stop(3000, config)
|
|
if err := mixer.Play(path, true, config); err != nil {
|
|
logger.Error("Mixer returned an error: %v", err)
|
|
return false
|
|
}
|
|
p.originalAudioStarted = true
|
|
p.currentAudioFreq = 0
|
|
return true
|
|
}
|
|
|
|
func (p *ScrollerPage) audioTrackForFrequency(freq int) (db.AudioTrack, error) {
|
|
var track db.AudioTrack
|
|
err := p.Context.DbPtr.Where("assigned_frequency = ?", freq).First(&track).Error
|
|
return track, err
|
|
}
|
|
|
|
// nextLineWords gets words for the current line and advances proclamation stream when needed.
|
|
func (p *ScrollerPage) nextLineWords() []string {
|
|
if len(p.streams) == 0 {
|
|
return nil
|
|
}
|
|
|
|
s := &p.streams[p.streamIndex]
|
|
if len(s.words) == 0 {
|
|
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
|
|
return p.nextLineWords()
|
|
}
|
|
|
|
prevIdx := s.wordIndex
|
|
words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx)
|
|
|
|
// If word index wraps, one full proclamation pass completed.
|
|
if s.wordIndex < prevIdx {
|
|
s.completed++
|
|
targetRepeats := s.repeat
|
|
if targetRepeats <= 0 {
|
|
targetRepeats = 1
|
|
}
|
|
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)
|
|
if p.dynamicAudio {
|
|
next := &p.streams[p.streamIndex]
|
|
result := p.switchAudioForFrequency(next.frequencyValue)
|
|
p.audioStarted = result || next.frequencyValue == 0
|
|
}
|
|
}
|
|
}
|
|
|
|
return words
|
|
}
|
|
|
|
// getCachedWord returns a reusable textured StringItem for a word.
|
|
func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) {
|
|
if cached, ok := p.wordTextureCache[word]; ok {
|
|
return cached, nil
|
|
}
|
|
|
|
base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
|
if err != nil {
|
|
return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err)
|
|
}
|
|
base.Scale(p.fontScale)
|
|
|
|
p.wordTextureCache[word] = base
|
|
return base, nil
|
|
}
|
|
|
|
// buildLineFromWords builds a line using cached texture IDs and per-instance color/position.
|
|
func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) {
|
|
line := Line{words: []StringItem{}}
|
|
|
|
// Get the current proclamation's color palette
|
|
var lineColor sdl.Color
|
|
if p.useColorPalettes && len(p.streams) > 0 {
|
|
currentStream := &p.streams[p.streamIndex]
|
|
if len(currentStream.palette) > 0 {
|
|
// Use the current color from the palette
|
|
lineColor = currentStream.palette[currentStream.colorIndex]
|
|
// Increment color index for the next line
|
|
currentStream.colorIndex = (currentStream.colorIndex + 1) % len(currentStream.palette)
|
|
} else {
|
|
// Fallback to default color if no palette is found
|
|
lineColor = p.colorPattern.Color()
|
|
}
|
|
} else {
|
|
// Use the default color pattern if useColorPalettes is false
|
|
lineColor = p.colorPattern.Color()
|
|
}
|
|
|
|
for _, word := range words {
|
|
if word == "" {
|
|
continue
|
|
}
|
|
|
|
cached, err := p.getCachedWord(word)
|
|
if err != nil {
|
|
return Line{}, err
|
|
}
|
|
|
|
item := cached
|
|
item.Color = lineColor // Assign the line color to all words in the line
|
|
|
|
if len(line.words) > 0 {
|
|
line.width += p.spaceWidth
|
|
}
|
|
line.words = append(line.words, item)
|
|
line.width += item.W
|
|
}
|
|
|
|
line.color = lineColor // Store the line color
|
|
return line, nil
|
|
}
|
|
|
|
// positionLine sets X/Y for all words in a line.
|
|
func (p *ScrollerPage) positionLine(line *Line, baseY int32) {
|
|
startX := p.ScreenCenterX - (line.width / 2)
|
|
for j := range line.words {
|
|
if j == 0 {
|
|
line.words[j].X = startX
|
|
} else {
|
|
line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth
|
|
}
|
|
line.words[j].Y = baseY
|
|
}
|
|
}
|
|
|
|
// createLine is a helper to generate a new line at a specific index.
|
|
// isInitial determines if this is during initialization (positions from top)
|
|
// or dynamic creation (positions relative to existing lines).
|
|
func (p *ScrollerPage) createLine(index int, isInitial bool) error {
|
|
words := p.nextLineWords()
|
|
line, err := p.buildLineFromWords(words)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var baseY int32
|
|
if isInitial {
|
|
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
|
|
} else {
|
|
baseY = 0
|
|
}
|
|
p.positionLine(&line, baseY)
|
|
|
|
if isInitial {
|
|
p.lines.PushBack(line)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
// }
|
|
return nil
|
|
}
|
|
|
|
func (p *ScrollerPage) Update(dt float32) error {
|
|
p.updateAutoVelocity(dt)
|
|
|
|
// Periodically try to switch to the frequency-specific file in case
|
|
// it was just downloaded (only when the stream's frequency differs from
|
|
// whatever is currently playing).
|
|
if p.dynamicAudio && len(p.streams) > 0 {
|
|
p.audioRetryTimer += dt
|
|
if p.audioRetryTimer >= 5.0 {
|
|
p.audioRetryTimer = 0
|
|
freq := p.streams[p.streamIndex].frequencyValue
|
|
if freq != 0 && freq != p.currentAudioFreq {
|
|
p.switchAudioForFrequency(freq)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-dynamic mode: retry selected track in case download just finished.
|
|
if !p.dynamicAudio && !p.audioStarted {
|
|
p.audioRetryTimer += dt
|
|
if p.audioRetryTimer >= 5.0 {
|
|
p.audioRetryTimer = 0
|
|
p.audioStarted = p.playOriginalAudio()
|
|
}
|
|
}
|
|
|
|
p.scrollOffset += dt * p.velocity
|
|
move := int32(p.scrollOffset)
|
|
if move == 0 {
|
|
return nil
|
|
}
|
|
p.scrollOffset -= float32(move)
|
|
|
|
// Move all lines vertically
|
|
for i := 0; i < p.lines.Len(); i++ {
|
|
line := p.lines.Get(i)
|
|
for j := range line.words {
|
|
line.words[j].Y += move
|
|
}
|
|
}
|
|
|
|
// Handle line replacement based on scroll direction
|
|
if p.velocity > 0 {
|
|
// Moving up: replace lines that go off top
|
|
for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight {
|
|
_ = p.lines.PopFront() // no texture delete; textures are shared via cache
|
|
|
|
if err := p.addLineAtBottom(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if p.velocity < 0 {
|
|
// Moving down: replace lines that go off bottom
|
|
for p.lines.Len() > 0 {
|
|
back := p.lines.Back()
|
|
if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 {
|
|
break
|
|
}
|
|
|
|
_ = p.lines.PopBack() // no texture delete; textures are shared via cache
|
|
|
|
if err := p.addLineAtTop(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addLineAtBottom creates and adds a new line at the bottom of the display.
|
|
func (p *ScrollerPage) addLineAtBottom() error {
|
|
newWords := p.nextLineWords()
|
|
line, err := p.buildLineFromWords(newWords)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var baseY int32
|
|
if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 {
|
|
baseY = p.lines.Back().words[0].Y - int32(p.lineHeight)
|
|
} else {
|
|
baseY = -int32(p.lineHeight)
|
|
}
|
|
|
|
p.positionLine(&line, baseY)
|
|
p.lines.PushBack(line)
|
|
return nil
|
|
}
|
|
|
|
// addLineAtTop creates and adds a new line at the top of the display.
|
|
func (p *ScrollerPage) addLineAtTop() error {
|
|
newWords := p.nextLineWords()
|
|
line, err := p.buildLineFromWords(newWords)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var baseY int32
|
|
if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 {
|
|
baseY = p.lines.Front().words[0].Y + int32(p.lineHeight)
|
|
} else {
|
|
baseY = p.ScreenHeight
|
|
}
|
|
|
|
p.positionLine(&line, baseY)
|
|
p.lines.PushFront(line)
|
|
return nil
|
|
}
|
|
|
|
func (p *ScrollerPage) Render() error {
|
|
p.BeginTextureRendering(p.shader)
|
|
for i := 0; i < p.lines.Len(); i++ {
|
|
line := p.lines.Get(i)
|
|
for _, word := range line.words {
|
|
if word.ID == 0 {
|
|
continue
|
|
}
|
|
p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color)
|
|
}
|
|
}
|
|
p.EndTextureRendering()
|
|
return nil
|
|
}
|
|
|
|
func (p *ScrollerPage) Destroy() error {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
|
|
config, err := db.GetConfig(p.Context.DbPtr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
mixer.Stop(3000, config)
|
|
|
|
// Delete each cached word texture exactly once.
|
|
for k, item := range p.wordTextureCache {
|
|
if item.ID != 0 {
|
|
id := item.ID
|
|
gl.DeleteTextures(1, &id)
|
|
}
|
|
delete(p.wordTextureCache, k)
|
|
}
|
|
|
|
gl.Flush()
|
|
|
|
// Drop references for GC safely
|
|
p.lines = nil
|
|
p.streams = nil
|
|
p.lineCtx = nil
|
|
p.font = nil
|
|
p.shader = nil
|
|
p.colorPattern = nil
|
|
p.wordTextureCache = nil
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetAutoVelocity configures the automatic velocity system for the ScrollerPage.
|
|
func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) {
|
|
p.autoVelocity = enabled
|
|
p.velocityFloor = -float32(math.Abs(float64(floor)))
|
|
p.velocityCeiling = float32(math.Abs(float64(ceiling)))
|
|
p.velocityStep = float32(math.Abs(float64(step)))
|
|
p.velocityDelay = delay
|
|
p.hold = hold
|
|
|
|
// Initialize state
|
|
p.velocityDirection = 1
|
|
p.velocityState = 0
|
|
p.velocityTimer = 0.0
|
|
p.velocity = p.velocityFloor
|
|
}
|
|
|
|
// SetUseColorPalettes enables or disables the use of color palettes for lines.
|
|
func (p *ScrollerPage) SetUseColorPalettes(enabled bool) {
|
|
p.useColorPalettes = enabled
|
|
}
|
|
|
|
// updateAutoVelocity handles automatic velocity changes.
|
|
func (p *ScrollerPage) updateAutoVelocity(dt float32) {
|
|
if !p.autoVelocity {
|
|
return
|
|
}
|
|
|
|
switch p.velocityState {
|
|
case 0: // Changing velocity
|
|
if p.velocityDirection == 1 {
|
|
p.velocity += p.velocityStep * dt
|
|
if p.velocity >= p.velocityCeiling {
|
|
p.velocity = p.velocityCeiling
|
|
p.velocityState = 1
|
|
p.velocityTimer = 0.0
|
|
}
|
|
} else {
|
|
p.velocity -= p.velocityStep * dt
|
|
if p.velocity <= p.velocityFloor {
|
|
p.velocity = p.velocityFloor
|
|
p.velocityState = 2
|
|
p.velocityTimer = 0.0
|
|
}
|
|
}
|
|
|
|
case 1: // Waiting at ceiling
|
|
p.velocityTimer += dt
|
|
if p.velocityTimer >= p.velocityDelay {
|
|
p.velocityDirection = -1
|
|
p.velocityState = 0
|
|
}
|
|
|
|
case 2: // Waiting at floor
|
|
p.velocityTimer += dt
|
|
if p.velocityTimer >= p.velocityDelay {
|
|
p.velocityDirection = 1
|
|
p.velocityState = 0
|
|
}
|
|
}
|
|
}
|