Merge pull request 'Volume Controls' (#32) from dev into main
Reviewed-on: https://codeberg.org/RhemaWave-Technologies/RhemaWave/pulls/32
This commit is contained in:
@@ -121,6 +121,7 @@ func Run() {
|
|||||||
StandardFontSize: 32,
|
StandardFontSize: 32,
|
||||||
LinePattern: "fibonacci",
|
LinePattern: "fibonacci",
|
||||||
AudioEnabled: true,
|
AudioEnabled: true,
|
||||||
|
SavedVolume: 1.0,
|
||||||
}
|
}
|
||||||
if err := DB.Create(&initialConfig).Error; err != nil {
|
if err := DB.Create(&initialConfig).Error; err != nil {
|
||||||
logger.Error("failed to insert default config into database: %v", err)
|
logger.Error("failed to insert default config into database: %v", err)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type AppConfig struct {
|
|||||||
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"
|
||||||
AudioEnabled bool
|
AudioEnabled bool
|
||||||
|
SavedVolume float64 // Last saved volume as a percentage
|
||||||
}
|
}
|
||||||
|
|
||||||
type Log struct {
|
type Log struct {
|
||||||
|
|||||||
@@ -247,6 +247,16 @@ func SetAllSinkVolumes(level float64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to
|
||||||
|
// the float64 value needed to pass to SetAllSinkVolumes 0..1.0
|
||||||
|
// Percentages should be passed as integers. 25% -> 25
|
||||||
|
func ConvertVolume(volPerc int) float64 {
|
||||||
|
if volPerc == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(volPerc) / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
|
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
|
||||||
func parseSinks(data []byte) ([]Sink, error) {
|
func parseSinks(data []byte) ([]Sink, error) {
|
||||||
var sinks []Sink
|
var sinks []Sink
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mixer_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertVolume(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input int
|
||||||
|
expected float64
|
||||||
|
}{
|
||||||
|
{"0 percent", 0, 0.0},
|
||||||
|
{"25 percent", 25, 0.25},
|
||||||
|
{"33 percent", 33, 0.33},
|
||||||
|
{"50 percent", 50, 0.50},
|
||||||
|
{"90 percent", 90, 0.90},
|
||||||
|
{"100 percent", 100, 1.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
const epsilon = 1e-9
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := mixer.ConvertVolume(tt.input)
|
||||||
|
if math.Abs(got-tt.expected) > epsilon {
|
||||||
|
t.Errorf("ConvertVolume(%d) = %v, want %v", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
"github.com/go-gl/gl/v3.3-core/gl"
|
||||||
|
"github.com/go-gl/mathgl/mgl32"
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
"rhemawavetech.com/rhemawave/internal/colors"
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
"rhemawavetech.com/rhemawave/internal/db"
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
"rhemawavetech.com/rhemawave/internal/logger"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,9 +22,16 @@ type AudioDevices struct {
|
|||||||
title StringItem
|
title StringItem
|
||||||
prompt StringItem
|
prompt StringItem
|
||||||
AudioStatusToggle StringItem
|
AudioStatusToggle StringItem
|
||||||
|
volumePercent StringItem
|
||||||
verticalMargin int32
|
verticalMargin int32
|
||||||
ItemScale float32
|
ItemScale float32
|
||||||
CfgPtr *db.AppConfig
|
CfgPtr *db.AppConfig
|
||||||
|
VolumeLabel StringItem
|
||||||
|
// Volume bar geometry (computed once in layout, reused in render)
|
||||||
|
volumeBarX int32
|
||||||
|
volumeBarY int32
|
||||||
|
volumeBarW int32
|
||||||
|
volumeBarH int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) Init(ctx *PageCtx) error {
|
func (p *AudioDevices) Init(ctx *PageCtx) error {
|
||||||
@@ -68,7 +77,7 @@ func (p *AudioDevices) Init(ctx *PageCtx) error {
|
|||||||
p.title.Scale(titleScale)
|
p.title.Scale(titleScale)
|
||||||
|
|
||||||
// Create prompt
|
// Create prompt
|
||||||
p.prompt, err = NewStringItem("Switch On/Off with Enter. Setting saves immediately.", p.font, colors.White)
|
p.prompt, err = NewStringItem("Switch On/Off with Enter. Change volume with Left/Right (arrow keys). Setting saves immediately.", p.font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating prompt: %w", err)
|
return fmt.Errorf("creating prompt: %w", err)
|
||||||
}
|
}
|
||||||
@@ -78,6 +87,16 @@ func (p *AudioDevices) Init(ctx *PageCtx) error {
|
|||||||
logger.Error("failed to create AudioStatusItem in AudioDevices.Init()")
|
logger.Error("failed to create AudioStatusItem in AudioDevices.Init()")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = p.CreateVolumePercentItem(); err != nil {
|
||||||
|
logger.Error("failed to create VolumePercentItem in AudioDevices.Init()")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating volume label: %w", err)
|
||||||
|
}
|
||||||
|
p.VolumeLabel.Scale(p.ItemScale)
|
||||||
|
|
||||||
p.layout()
|
p.layout()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -97,10 +116,25 @@ func (p *AudioDevices) CreateAudioStatusItem() error {
|
|||||||
fmt.Printf("AudioStatusToggle (%d)", audioStatusStringItem.ID)
|
fmt.Printf("AudioStatusToggle (%d)", audioStatusStringItem.ID)
|
||||||
audioStatusStringItem.Scale(p.ItemScale)
|
audioStatusStringItem.Scale(p.ItemScale)
|
||||||
p.AudioStatusToggle = audioStatusStringItem
|
p.AudioStatusToggle = audioStatusStringItem
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Position the audio toggle
|
// CreateVolumePercentItem creates (or recreates) the percentage label beside the volume bar.
|
||||||
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 // Horizontal Position
|
func (p *AudioDevices) CreateVolumePercentItem() error {
|
||||||
p.AudioStatusToggle.Y = p.ScreenCenterY + (p.AudioStatusToggle.H / 2) // Vertical Position
|
// Free the old texture if one exists
|
||||||
|
if p.volumePercent.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pct := int(p.Context.CfgPtr.SavedVolume * 100)
|
||||||
|
label := fmt.Sprintf("%d%%", pct)
|
||||||
|
|
||||||
|
item, err := NewStringItem(label, p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create volume percent item: %v", err)
|
||||||
|
}
|
||||||
|
item.Scale(p.ItemScale)
|
||||||
|
p.volumePercent = item
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +146,30 @@ func (p *AudioDevices) layout() {
|
|||||||
// Position prompt below the title
|
// Position prompt below the title
|
||||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||||
|
|
||||||
|
// Position the audio toggle below the prompt, centred
|
||||||
|
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
|
||||||
|
p.AudioStatusToggle.Y = p.prompt.Y - p.verticalMargin*2 - p.AudioStatusToggle.H
|
||||||
|
|
||||||
|
// Volume bar: narrower (30% of screen width) so the right edge stays visible
|
||||||
|
barH := p.AudioStatusToggle.H
|
||||||
|
barW := int32(float32(p.ScreenWidth) * 0.30)
|
||||||
|
barX := p.ScreenCenterX - barW/2
|
||||||
|
barY := p.AudioStatusToggle.Y - p.verticalMargin*2 - barH
|
||||||
|
|
||||||
|
p.volumeBarX = barX
|
||||||
|
p.volumeBarY = barY
|
||||||
|
p.volumeBarW = barW
|
||||||
|
p.volumeBarH = barH
|
||||||
|
|
||||||
|
// "Volume" label: to the LEFT of the bar, vertically centred on it
|
||||||
|
p.VolumeLabel.X = barX - p.VolumeLabel.W - 20
|
||||||
|
p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2
|
||||||
|
|
||||||
|
// Percentage label: vertically centred on the bar, small gap to the right
|
||||||
|
gap := int32(12)
|
||||||
|
p.volumePercent.X = barX + barW + gap
|
||||||
|
p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) ToggleAudio() {
|
func (p *AudioDevices) ToggleAudio() {
|
||||||
@@ -126,6 +184,40 @@ func (p *AudioDevices) ToggleAudio() {
|
|||||||
|
|
||||||
// Update AudioEnabled UI element
|
// Update AudioEnabled UI element
|
||||||
p.CreateAudioStatusItem()
|
p.CreateAudioStatusItem()
|
||||||
|
p.layout()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVolume increments or decrements system volume by level (percent change)
|
||||||
|
func (p *AudioDevices) UpdateVolume(level int) {
|
||||||
|
// Calculate the new volume
|
||||||
|
newVol := mixer.ConvertVolume(level) + p.Context.CfgPtr.SavedVolume
|
||||||
|
// Check that it's within bounds
|
||||||
|
if newVol < 0.0 || newVol > 1.0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Force 1.0 rather than 0.99999999999999989
|
||||||
|
if newVol > .99 {
|
||||||
|
newVol = 1.0
|
||||||
|
}
|
||||||
|
// Update the config
|
||||||
|
p.Context.CfgPtr.SavedVolume = newVol
|
||||||
|
fmt.Printf("New Volume: %f", newVol)
|
||||||
|
// Update the database
|
||||||
|
p.Context.DbPtr.Save(&p.Context.CfgPtr)
|
||||||
|
// Set the new volume
|
||||||
|
err := mixer.SetAllSinkVolumes(newVol)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error setting allSinkVolumes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh volume label
|
||||||
|
if err := p.CreateVolumePercentItem(); err != nil {
|
||||||
|
logger.Error("failed to refresh volume percent item: %v", err)
|
||||||
|
}
|
||||||
|
// Re-position the label (bar geometry is unchanged, but label width may differ)
|
||||||
|
gap := int32(12)
|
||||||
|
p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap
|
||||||
|
p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
||||||
@@ -135,9 +227,12 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch e.Keysym.Sym {
|
switch e.Keysym.Sym {
|
||||||
|
|
||||||
case sdl.K_RETURN:
|
case sdl.K_RETURN:
|
||||||
p.ToggleAudio()
|
p.ToggleAudio()
|
||||||
|
case sdl.K_RIGHT: // Right increase
|
||||||
|
p.UpdateVolume(10)
|
||||||
|
case sdl.K_LEFT: // Left decrease
|
||||||
|
p.UpdateVolume(-10)
|
||||||
case sdl.K_BACKSPACE:
|
case sdl.K_BACKSPACE:
|
||||||
p.Context.NavigateTo(SETTINGS)
|
p.Context.NavigateTo(SETTINGS)
|
||||||
}
|
}
|
||||||
@@ -145,6 +240,47 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderVolumeBar draws the green outline and the white filled portion.
|
||||||
|
func (p *AudioDevices) renderVolumeBar() error {
|
||||||
|
vol := p.Context.CfgPtr.SavedVolume
|
||||||
|
|
||||||
|
// --- White fill (clipped to current volume fraction) ---
|
||||||
|
fillW := float32(p.volumeBarW) * float32(vol)
|
||||||
|
if fillW > 0 {
|
||||||
|
fillPos := mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)}
|
||||||
|
fillDim := mgl32.Vec2{fillW, float32(p.volumeBarH)}
|
||||||
|
if err := p.RenderSolidColorQuad(p.solidShader, fillPos, fillDim, colors.White); err != nil {
|
||||||
|
return fmt.Errorf("rendering volume fill: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Green outline (4 x thin quads: top, bottom, left, right) ---
|
||||||
|
thickness := float32(2)
|
||||||
|
bx := float32(p.volumeBarX)
|
||||||
|
by := float32(p.volumeBarY)
|
||||||
|
bw := float32(p.volumeBarW)
|
||||||
|
bh := float32(p.volumeBarH)
|
||||||
|
|
||||||
|
outlineRects := [4][2]mgl32.Vec2{
|
||||||
|
// bottom edge
|
||||||
|
{{bx, by}, {bw, thickness}},
|
||||||
|
// top edge
|
||||||
|
{{bx, by + bh - thickness}, {bw, thickness}},
|
||||||
|
// left edge
|
||||||
|
{{bx, by}, {thickness, bh}},
|
||||||
|
// right edge
|
||||||
|
{{bx + bw - thickness}, {thickness, bh}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range outlineRects {
|
||||||
|
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
|
||||||
|
return fmt.Errorf("rendering volume outline edge: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) Render() error {
|
func (p *AudioDevices) Render() error {
|
||||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||||
logger.Error("unable to render title texture: %v", err)
|
logger.Error("unable to render title texture: %v", err)
|
||||||
@@ -161,11 +297,28 @@ func (p *AudioDevices) Render() error {
|
|||||||
return fmt.Errorf("unable to render audioStatusToggle texture: %w", err)
|
return fmt.Errorf("unable to render audioStatusToggle texture: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil {
|
||||||
|
logger.Error("unable to render volume label texture: %v", err)
|
||||||
|
return fmt.Errorf("unable to render volume label texture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.renderVolumeBar(); err != nil {
|
||||||
|
logger.Error("unable to render volume bar: %v", err)
|
||||||
|
return fmt.Errorf("unable to render volume bar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil {
|
||||||
|
logger.Error("unable to render volume percent texture: %v", err)
|
||||||
|
return fmt.Errorf("unable to render volume percent texture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) Destroy() error {
|
func (p *AudioDevices) Destroy() error {
|
||||||
gl.DeleteTextures(1, &p.title.ID)
|
gl.DeleteTextures(1, &p.title.ID)
|
||||||
gl.DeleteTextures(1, &p.prompt.ID)
|
gl.DeleteTextures(1, &p.prompt.ID)
|
||||||
|
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||||
|
gl.DeleteTextures(1, &p.VolumeLabel.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package page_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"rhemawavetech.com/rhemawave/internal/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateVolume(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input int
|
||||||
|
}{
|
||||||
|
{"0 percent", 0},
|
||||||
|
{"25 percent", 25},
|
||||||
|
{"50 percent", 50},
|
||||||
|
{"100 percent", 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
devices := &page.AudioDevices{}
|
||||||
|
// Currently only verifies no panic — expand assertions when
|
||||||
|
// UpdateVolume has observable side effects or a return value.
|
||||||
|
devices.UpdateVolume(tt.input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user