hdmistat/internal/fbdraw/carousel.go
2025-07-24 16:09:00 +02:00

285 lines
6.1 KiB
Go

package fbdraw
import (
"context"
"fmt"
"sync"
"time"
"git.eeqj.de/sneak/hdmistat/internal/font"
)
// Screen represents a single screen in the carousel
type Screen struct {
Name string
Generator FrameGenerator
ticker *time.Ticker
stop chan struct{}
}
// Carousel manages rotating between multiple screens
type Carousel struct {
display FramebufferDisplay
screens []*Screen
currentIndex int
rotationInterval time.Duration
fontSize float64
gridWidth int
gridHeight int
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
const DefaultFontSize float64 = 24
// NewCarousel creates a new carousel
func NewCarousel(
display FramebufferDisplay,
rotationInterval time.Duration,
) *Carousel {
ctx, cancel := context.WithCancel(context.Background())
c := &Carousel{
display: display,
screens: make([]*Screen, 0),
currentIndex: 0,
rotationInterval: rotationInterval,
fontSize: DefaultFontSize, // Default font size
ctx: ctx,
cancel: cancel,
}
// Calculate grid dimensions based on font size
if err := c.calculateGridDimensions(); err != nil {
// Log error but continue with default dimensions
log.Printf("Warning: failed to calculate grid dimensions: %v", err)
}
return c
}
// SetFontSize sets the font size and recalculates grid dimensions
func (c *Carousel) SetFontSize(size float64) error {
c.mu.Lock()
defer c.mu.Unlock()
c.fontSize = size
return c.calculateGridDimensions()
}
// calculateGridDimensions calculates grid size based on display and font
func (c *Carousel) calculateGridDimensions() error {
// Get pixel dimensions
pixelWidth, pixelHeight := c.display.PixelSize()
// Calculate character dimensions first
charWidth, charHeight, err := CalculateCharDimensions(
font.FamilyIBMPlexMono,
c.fontSize,
)
if err != nil {
return fmt.Errorf("calculating char dimensions: %w", err)
}
// Log the calculated dimensions for debugging
fmt.Printf(
"Font size: %.0f, Char dimensions: %dx%d pixels\n",
c.fontSize,
charWidth,
charHeight,
)
// Calculate grid dimensions
gridWidth, gridHeight, err := CalculateGridSize(
pixelWidth, pixelHeight,
font.FamilyIBMPlexMono, c.fontSize,
)
if err != nil {
return err
}
fmt.Printf(
"Display: %dx%d pixels, Grid: %dx%d chars\n",
pixelWidth,
pixelHeight,
gridWidth,
gridHeight,
)
c.gridWidth = gridWidth
c.gridHeight = gridHeight
// Update display if it's an FBDisplay
if fbDisplay, ok := c.display.(*FBDisplay); ok {
fbDisplay.charWidth = charWidth
fbDisplay.charHeight = charHeight
}
return nil
}
// AddScreen adds a new screen to the carousel
func (c *Carousel) AddScreen(name string, generator FrameGenerator) error {
c.mu.Lock()
defer c.mu.Unlock()
// Initialize the generator with the calculated grid dimensions
if err := generator.Init(c.gridWidth, c.gridHeight); err != nil {
return fmt.Errorf("failed to initialize %s: %w", name, err)
}
screen := &Screen{
Name: name,
Generator: generator,
stop: make(chan struct{}),
}
c.screens = append(c.screens, screen)
return nil
}
// Run starts the carousel
func (c *Carousel) Run() error {
if len(c.screens) == 0 {
return fmt.Errorf("no screens added to carousel")
}
// Start with first screen
if err := c.activateScreen(0); err != nil {
return err
}
// If no rotation, just wait for context cancellation
if c.rotationInterval <= 0 {
<-c.ctx.Done()
return c.ctx.Err()
}
// Start rotation timer
rotationTicker := time.NewTicker(c.rotationInterval)
defer rotationTicker.Stop()
// Main loop
for {
select {
case <-c.ctx.Done():
return c.ctx.Err()
case <-rotationTicker.C:
// Move to next screen
c.mu.Lock()
nextIndex := (c.currentIndex + 1) % len(c.screens)
c.mu.Unlock()
if err := c.activateScreen(nextIndex); err != nil {
return err
}
}
}
}
// Stop stops the carousel
func (c *Carousel) Stop() {
// Cancel the context
c.cancel()
// Stop the current screen
c.mu.Lock()
if c.currentIndex >= 0 && c.currentIndex < len(c.screens) {
screen := c.screens[c.currentIndex]
if screen.ticker != nil {
screen.ticker.Stop()
}
// Close stop channel if it's still open
select {
case <-screen.stop:
// Already closed
default:
close(screen.stop)
}
}
c.mu.Unlock()
// Wait for all goroutines
c.wg.Wait()
// Close the display
_ = c.display.Close()
}
// activateScreen switches to the specified screen
func (c *Carousel) activateScreen(index int) error {
c.mu.Lock()
defer c.mu.Unlock()
// Stop current screen if any
if c.currentIndex >= 0 && c.currentIndex < len(c.screens) {
close(c.screens[c.currentIndex].stop)
if c.screens[c.currentIndex].ticker != nil {
c.screens[c.currentIndex].ticker.Stop()
}
}
// Wait for current screen to stop
c.wg.Wait()
// Start new screen
c.currentIndex = index
screen := c.screens[index]
// Calculate frame interval
fps := screen.Generator.FramesPerSecond()
if fps <= 0 {
fps = 1 // Default to 1 FPS if invalid
}
frameInterval := time.Duration(float64(time.Second) / fps)
// Create new stop channel and ticker
screen.stop = make(chan struct{})
screen.ticker = time.NewTicker(frameInterval)
// Start frame generation goroutine
c.wg.Add(1)
go c.runScreen(screen)
return nil
}
// runScreen runs a single screen's frame generation loop
func (c *Carousel) runScreen(screen *Screen) {
defer c.wg.Done()
// Create grid with calculated dimensions
grid := NewCharGrid(c.gridWidth, c.gridHeight)
grid.FontSize = c.fontSize
// Set the character dimensions for proper rendering
charWidth, charHeight, _ := CalculateCharDimensions(
font.FamilyIBMPlexMono,
c.fontSize,
)
grid.CharWidth = charWidth
grid.CharHeight = charHeight
// Generate first frame immediately
if err := screen.Generator.GenerateFrame(grid); err == nil {
_ = c.display.Write(grid)
}
// Frame generation loop
for {
select {
case <-screen.stop:
return
case <-screen.ticker.C:
if err := screen.Generator.GenerateFrame(grid); err == nil {
_ = c.display.Write(grid)
}
}
}
}