diff --git a/internal/application/application.go b/internal/application/application.go index e77e332..b00ec7a 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -121,6 +121,7 @@ func Run() { StandardFontSize: 32, LinePattern: "fibonacci", AudioEnabled: true, + SavedVolume: 1.0, } if err := DB.Create(&initialConfig).Error; err != nil { logger.Error("failed to insert default config into database: %v", err) diff --git a/internal/db/schema.go b/internal/db/schema.go index 235bea9..89775d1 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -16,6 +16,7 @@ type AppConfig struct { StandardFontSize int // The font size used for most text rendering LinePattern string // "fibonacci" AudioEnabled bool + SavedVolume float64 // Last saved volume as a percentage } type Log struct { diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index f143680..995dc0c 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -247,6 +247,16 @@ func SetAllSinkVolumes(level float64) error { 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. func parseSinks(data []byte) ([]Sink, error) { var sinks []Sink diff --git a/internal/mixer/mixer_test.go b/internal/mixer/mixer_test.go new file mode 100644 index 0000000..f45d591 --- /dev/null +++ b/internal/mixer/mixer_test.go @@ -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) + } + }) + } +} diff --git a/internal/page/audioDevices.go b/internal/page/audioDevices.go index 1fa117e..e1ef054 100644 --- a/internal/page/audioDevices.go +++ b/internal/page/audioDevices.go @@ -4,11 +4,13 @@ import ( "fmt" "github.com/go-gl/gl/v3.3-core/gl" + "github.com/go-gl/mathgl/mgl32" "github.com/veandco/go-sdl2/sdl" "rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/logger" + "rhemawavetech.com/rhemawave/internal/mixer" "rhemawavetech.com/rhemawave/internal/shaderManager" ) @@ -20,9 +22,16 @@ type AudioDevices struct { title StringItem prompt StringItem AudioStatusToggle StringItem + volumePercent StringItem verticalMargin int32 ItemScale float32 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 { @@ -68,7 +77,7 @@ func (p *AudioDevices) Init(ctx *PageCtx) error { p.title.Scale(titleScale) // 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 { 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()") } + 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() return nil } @@ -97,10 +116,25 @@ func (p *AudioDevices) CreateAudioStatusItem() error { fmt.Printf("AudioStatusToggle (%d)", audioStatusStringItem.ID) audioStatusStringItem.Scale(p.ItemScale) p.AudioStatusToggle = audioStatusStringItem + return nil +} - // Position the audio toggle - p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 // Horizontal Position - p.AudioStatusToggle.Y = p.ScreenCenterY + (p.AudioStatusToggle.H / 2) // Vertical Position +// CreateVolumePercentItem creates (or recreates) the percentage label beside the volume bar. +func (p *AudioDevices) CreateVolumePercentItem() error { + // 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 } @@ -112,6 +146,30 @@ func (p *AudioDevices) layout() { // Position prompt below the title p.prompt.X = p.ScreenCenterX - p.prompt.W/2 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() { @@ -126,6 +184,40 @@ func (p *AudioDevices) ToggleAudio() { // Update AudioEnabled UI element 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 { @@ -135,9 +227,12 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error { } switch e.Keysym.Sym { - case sdl.K_RETURN: p.ToggleAudio() + case sdl.K_RIGHT: // Right increase + p.UpdateVolume(10) + case sdl.K_LEFT: // Left decrease + p.UpdateVolume(-10) case sdl.K_BACKSPACE: p.Context.NavigateTo(SETTINGS) } @@ -145,6 +240,47 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error { 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 { 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) @@ -161,11 +297,28 @@ func (p *AudioDevices) Render() error { 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 } func (p *AudioDevices) Destroy() error { gl.DeleteTextures(1, &p.title.ID) gl.DeleteTextures(1, &p.prompt.ID) + gl.DeleteTextures(1, &p.volumePercent.ID) + gl.DeleteTextures(1, &p.VolumeLabel.ID) return nil } diff --git a/internal/page/audioDevices_test.go b/internal/page/audioDevices_test.go new file mode 100644 index 0000000..7c7c2cd --- /dev/null +++ b/internal/page/audioDevices_test.go @@ -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) + }) + } +}