Merge branch 'dev'
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user