Merge branch 'dev'

This commit is contained in:
NeilClack
2026-03-30 00:43:37 -05:00
4 changed files with 189 additions and 59 deletions
-9
View File
@@ -120,7 +120,6 @@ func Run() {
InitialFontSize: 128,
StandardFontSize: 32,
LinePattern: "fibonacci",
LastVolume: 128,
AudioEnabled: true,
}
if err := DB.Create(&initialConfig).Error; err != nil {
@@ -443,14 +442,6 @@ func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
app.Stop()
})
keybinds.Register(sdl.K_UP, sdl.KMOD_SHIFT, func() {
mixer.SetVolume128(volumeStepSize, app.Config.Load())
})
keybinds.Register(sdl.K_DOWN, sdl.KMOD_SHIFT, func() {
mixer.SetVolume128(-volumeStepSize, app.Config.Load())
})
}
func (app *Application) Stop() {
-1
View File
@@ -15,7 +15,6 @@ type AppConfig struct {
InitialFontSize int // How large to create the font atlas
StandardFontSize int // The font size used for most text rendering
LinePattern string // "fibonacci"
LastVolume int // 0-128
AudioEnabled bool
}
+176 -37
View File
@@ -2,8 +2,13 @@
package mixer
import (
"bufio"
"bytes"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"github.com/veandco/go-sdl2/mix"
@@ -33,6 +38,13 @@ var (
refreshCacheCallback func()
)
type Sink struct {
ID int
Name string
Volume float64
IsDefault bool
}
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
func Init(cfg *db.AppConfig, onUpdate func()) error {
mu.Lock()
@@ -58,12 +70,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
return err
}
// FIX 2: Actually set a default volume!
currentVolume = 128 // MAX_VOLUME
if cfg.LastVolume > 0 {
currentVolume = cfg.LastVolume
}
mix.VolumeMusic(currentVolume)
SetAllSinkVolumes(1.0)
inited = true
return nil
@@ -131,10 +138,9 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
currentLoops = 0
}
// DELETE the openDeviceLocked block that was here!
// Apply remembered volume
mix.VolumeMusic(currentVolume)
mix.VolumeMusic(mix.MAX_VOLUME)
volume := mix.VolumeMusic(-1)
logger.Info("Current volume is %d", volume)
if err := currentMusic.Play(currentLoops); err != nil {
currentMusic.Free()
@@ -171,33 +177,6 @@ func Stop(fadeMs int, cfg *db.AppConfig) {
currentLoops = 0
}
// SetVolume128 sets music volume using SDL_mixer’s 0..128 scale.
func SetVolume128(increment int, cfg *db.AppConfig) {
if !cfg.AudioEnabled {
return
}
mu.Lock()
defer mu.Unlock()
currentVolume += increment
if currentVolume < 0 {
currentVolume = 0
} else if currentVolume > mix.MAX_VOLUME {
currentVolume = int(mix.MAX_VOLUME)
}
// Store in config
cfg.LastVolume = currentVolume
refreshCacheCallback()
// Apply to SDL_mixer
if inited && deviceOpen {
mix.VolumeMusic(currentVolume)
}
}
// IsPlaying reports whether music is currently playing (or fading).
func IsPlaying() bool {
mu.Lock()
@@ -208,6 +187,166 @@ func IsPlaying() bool {
return mix.PlayingMusic()
}
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
func SetDefaultSinkVolume(level float64) error {
if level < 0 || level > 1 {
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
}
pct := fmt.Sprintf("%.2f", level)
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out)
}
return nil
}
// listSinkIDs returns just the numeric IDs of all audio sinks.
func listSinkIDs() ([]int, error) {
sinks, err := listSinks()
if err != nil {
return nil, err
}
ids := make([]int, len(sinks))
for i, s := range sinks {
ids[i] = s.ID
}
return ids, nil
}
// listSinks returns full Sink info for all audio sinks.
func listSinks() ([]Sink, error) {
out, err := exec.Command("wpctl", "status").Output()
if err != nil {
return nil, fmt.Errorf("wpctl status: %w", err)
}
return parseSinks(out)
}
// SetAllSinkVolumes sets volume on every audio sink found via wpctl status
func SetAllSinkVolumes(level float64) error {
ids, err := listSinkIDs()
if err != nil {
return err
}
pct := fmt.Sprintf("%.2f", level)
for _, id := range ids {
cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out)
}
}
return nil
}
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
func parseSinks(data []byte) ([]Sink, error) {
var sinks []Sink
scanner := bufio.NewScanner(bytes.NewReader(data))
inAudioSection := false
inSinksSection := false
for scanner.Scan() {
line := scanner.Text()
stripped := strings.TrimLeft(line, " │├└─")
// Detect top-level sections: "Audio", "Video", "Settings", etc.
// These are lines with no leading tree characters.
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") {
inAudioSection = strings.TrimSpace(line) == "Audio"
inSinksSection = false
continue
}
if !inAudioSection {
continue
}
// Detect subsection headers within Audio
if strings.Contains(stripped, "Sinks:") {
inSinksSection = true
continue
}
// Any other subsection header resets sink parsing
if strings.HasSuffix(strings.TrimSpace(stripped), ":") {
inSinksSection = false
continue
}
if !inSinksSection {
continue
}
sink, ok := parseSinkLine(stripped)
if ok {
sinks = append(sinks, sink)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning wpctl output: %w", err)
}
return sinks, nil
}
// parseSinkLine parses a single sink entry line like:
//
// "*51. USB Audio [vol: 1.00]"
// "33. Built-in Audio Analog Stereo [vol: 0.65]"
func parseSinkLine(s string) (Sink, bool) {
s = strings.TrimSpace(s)
if s == "" {
return Sink{}, false
}
var sink Sink
// Leading '*' marks the default sink
if strings.HasPrefix(s, "*") {
sink.IsDefault = true
s = strings.TrimPrefix(s, "*")
s = strings.TrimSpace(s)
}
// Expect "<id>. <name> ..."
dotIdx := strings.Index(s, ".")
if dotIdx < 0 {
return Sink{}, false
}
idStr := strings.TrimSpace(s[:dotIdx])
id, err := strconv.Atoi(idStr)
if err != nil {
return Sink{}, false
}
sink.ID = id
rest := strings.TrimSpace(s[dotIdx+1:])
// Extract [vol: X.XX] if present
volStart := strings.Index(rest, "[vol:")
if volStart >= 0 {
sink.Name = strings.TrimSpace(rest[:volStart])
volPart := rest[volStart:]
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
volPart = strings.TrimPrefix(volPart, "[vol:")
volPart = strings.TrimSpace(volPart)
fields := strings.Fields(volPart)
if len(fields) > 0 {
v, err := strconv.ParseFloat(fields[0], 64)
if err == nil {
sink.Volume = v
}
}
} else {
sink.Name = strings.TrimSpace(rest)
}
return sink, true
}
// ListDevices returns the list of available output device names (from core SDL).
func ListDevices() []string {
mu.Lock()
+13 -12
View File
@@ -21,11 +21,12 @@ var (
)
type FlatItem struct {
Label StringItem
ID string
IsCategory bool
Depth int
Expanded bool
Label StringItem
ID string
IsCategory bool
IsSubCategory bool
Depth int
Expanded bool
}
type ProclamationOptions struct {
@@ -79,7 +80,7 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error {
p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
p.title.Scale(float32(64) / float32(ctx.CfgPtr.StandardFontSize))
p.prompt, _ = NewStringItem("Arrows: Nav | Enter: Exp/Col | Space: Toggle | A: Accept | Back: Cancel", p.font, colors.White)
p.prompt, _ = NewStringItem("Arrows: Nav Space: Select/Expand Enter: Save Changes Back: Cancel", p.font, colors.White)
p.prompt.Scale(itemScale * 0.8)
// Calculation for visible area
@@ -226,14 +227,14 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
p.scrollOffset = bottomIdx
p.highlightIndex = bottomIdx
case sdl.K_RETURN:
p.toggleExpansion(p.highlightIndex)
p.acceptChanges()
case sdl.K_SPACE:
item := p.allItems[p.highlightIndex]
if !item.IsCategory {
p.activeMap[item.ID] = !p.activeMap[item.ID]
} else {
p.toggleExpansion(p.highlightIndex)
}
case sdl.K_a:
p.acceptChanges()
case sdl.K_BACKSPACE, sdl.K_ESCAPE:
p.Context.NavigateTo(SETTINGS)
}
@@ -256,7 +257,7 @@ func (p *ProclamationOptions) acceptChanges() {
}
return nil
})
p.Context.NavigateTo(SETTINGS)
p.Context.NavigateTo(SCROLLER)
}
func (p *ProclamationOptions) Render() error {
@@ -278,9 +279,9 @@ func (p *ProclamationOptions) Render() error {
if i == p.highlightIndex {
item.Label.Color = colors.Black
} else if item.IsCategory {
item.Label.Color = colors.Cyan
item.Label.Color = colors.DodgerBlue
} else if p.activeMap[item.ID] {
item.Label.Color = colors.Green
item.Label.Color = colors.Orange
} else {
item.Label.Color = colors.White
}