Merge branch 'dev'
This commit is contained in:
@@ -120,7 +120,6 @@ func Run() {
|
|||||||
InitialFontSize: 128,
|
InitialFontSize: 128,
|
||||||
StandardFontSize: 32,
|
StandardFontSize: 32,
|
||||||
LinePattern: "fibonacci",
|
LinePattern: "fibonacci",
|
||||||
LastVolume: 128,
|
|
||||||
AudioEnabled: true,
|
AudioEnabled: true,
|
||||||
}
|
}
|
||||||
if err := DB.Create(&initialConfig).Error; err != nil {
|
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() {
|
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
|
||||||
app.Stop()
|
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() {
|
func (app *Application) Stop() {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type AppConfig struct {
|
|||||||
InitialFontSize int // How large to create the font atlas
|
InitialFontSize int // How large to create the font atlas
|
||||||
StandardFontSize int // The font size used for most text rendering
|
StandardFontSize int // The font size used for most text rendering
|
||||||
LinePattern string // "fibonacci"
|
LinePattern string // "fibonacci"
|
||||||
LastVolume int // 0-128
|
|
||||||
AudioEnabled bool
|
AudioEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+176
-37
@@ -2,8 +2,13 @@
|
|||||||
package mixer
|
package mixer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/veandco/go-sdl2/mix"
|
"github.com/veandco/go-sdl2/mix"
|
||||||
@@ -33,6 +38,13 @@ var (
|
|||||||
refreshCacheCallback func()
|
refreshCacheCallback func()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Sink struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Volume float64
|
||||||
|
IsDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
||||||
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -58,12 +70,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIX 2: Actually set a default volume!
|
SetAllSinkVolumes(1.0)
|
||||||
currentVolume = 128 // MAX_VOLUME
|
|
||||||
if cfg.LastVolume > 0 {
|
|
||||||
currentVolume = cfg.LastVolume
|
|
||||||
}
|
|
||||||
mix.VolumeMusic(currentVolume)
|
|
||||||
|
|
||||||
inited = true
|
inited = true
|
||||||
return nil
|
return nil
|
||||||
@@ -131,10 +138,9 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
|||||||
currentLoops = 0
|
currentLoops = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE the openDeviceLocked block that was here!
|
mix.VolumeMusic(mix.MAX_VOLUME)
|
||||||
|
volume := mix.VolumeMusic(-1)
|
||||||
// Apply remembered volume
|
logger.Info("Current volume is %d", volume)
|
||||||
mix.VolumeMusic(currentVolume)
|
|
||||||
|
|
||||||
if err := currentMusic.Play(currentLoops); err != nil {
|
if err := currentMusic.Play(currentLoops); err != nil {
|
||||||
currentMusic.Free()
|
currentMusic.Free()
|
||||||
@@ -171,33 +177,6 @@ func Stop(fadeMs int, cfg *db.AppConfig) {
|
|||||||
currentLoops = 0
|
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).
|
// IsPlaying reports whether music is currently playing (or fading).
|
||||||
func IsPlaying() bool {
|
func IsPlaying() bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -208,6 +187,166 @@ func IsPlaying() bool {
|
|||||||
return mix.PlayingMusic()
|
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).
|
// ListDevices returns the list of available output device names (from core SDL).
|
||||||
func ListDevices() []string {
|
func ListDevices() []string {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FlatItem struct {
|
type FlatItem struct {
|
||||||
Label StringItem
|
Label StringItem
|
||||||
ID string
|
ID string
|
||||||
IsCategory bool
|
IsCategory bool
|
||||||
Depth int
|
IsSubCategory bool
|
||||||
Expanded bool
|
Depth int
|
||||||
|
Expanded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProclamationOptions struct {
|
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, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
|
||||||
p.title.Scale(float32(64) / float32(ctx.CfgPtr.StandardFontSize))
|
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)
|
p.prompt.Scale(itemScale * 0.8)
|
||||||
|
|
||||||
// Calculation for visible area
|
// Calculation for visible area
|
||||||
@@ -226,14 +227,14 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
|
|||||||
p.scrollOffset = bottomIdx
|
p.scrollOffset = bottomIdx
|
||||||
p.highlightIndex = bottomIdx
|
p.highlightIndex = bottomIdx
|
||||||
case sdl.K_RETURN:
|
case sdl.K_RETURN:
|
||||||
p.toggleExpansion(p.highlightIndex)
|
p.acceptChanges()
|
||||||
case sdl.K_SPACE:
|
case sdl.K_SPACE:
|
||||||
item := p.allItems[p.highlightIndex]
|
item := p.allItems[p.highlightIndex]
|
||||||
if !item.IsCategory {
|
if !item.IsCategory {
|
||||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
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:
|
case sdl.K_BACKSPACE, sdl.K_ESCAPE:
|
||||||
p.Context.NavigateTo(SETTINGS)
|
p.Context.NavigateTo(SETTINGS)
|
||||||
}
|
}
|
||||||
@@ -256,7 +257,7 @@ func (p *ProclamationOptions) acceptChanges() {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
p.Context.NavigateTo(SETTINGS)
|
p.Context.NavigateTo(SCROLLER)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProclamationOptions) Render() error {
|
func (p *ProclamationOptions) Render() error {
|
||||||
@@ -278,9 +279,9 @@ func (p *ProclamationOptions) Render() error {
|
|||||||
if i == p.highlightIndex {
|
if i == p.highlightIndex {
|
||||||
item.Label.Color = colors.Black
|
item.Label.Color = colors.Black
|
||||||
} else if item.IsCategory {
|
} else if item.IsCategory {
|
||||||
item.Label.Color = colors.Cyan
|
item.Label.Color = colors.DodgerBlue
|
||||||
} else if p.activeMap[item.ID] {
|
} else if p.activeMap[item.ID] {
|
||||||
item.Label.Color = colors.Green
|
item.Label.Color = colors.Orange
|
||||||
} else {
|
} else {
|
||||||
item.Label.Color = colors.White
|
item.Label.Color = colors.White
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user