mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -0,0 +1,173 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
type appsState struct {
|
||||
loading bool
|
||||
err error
|
||||
streams []audio.Stream
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newAppsState() appsState {
|
||||
return appsState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleAppsList(msg appsListMsg) {
|
||||
m.apps.loading = false
|
||||
m.apps.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.apps.streams = msg.streams
|
||||
if m.apps.cursor >= len(m.apps.streams) {
|
||||
m.apps.cursor = max(0, len(m.apps.streams)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleAppsAction(msg appsActionMsg) tea.Cmd {
|
||||
m.apps.pending = false
|
||||
if msg.err != nil {
|
||||
m.apps.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
return fetchAppsCmd()
|
||||
}
|
||||
|
||||
func (m Model) selectedStream() *audio.Stream {
|
||||
if m.apps.cursor < 0 || m.apps.cursor >= len(m.apps.streams) {
|
||||
return nil
|
||||
}
|
||||
return &m.apps.streams[m.apps.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateApps(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.apps.cursor > 0 {
|
||||
m.apps.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.apps.cursor < len(m.apps.streams)-1 {
|
||||
m.apps.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, g.Left):
|
||||
return m.adjustStreamVolume(-volumeStep)
|
||||
case key.Matches(keyMsg, g.Right):
|
||||
return m.adjustStreamVolume(volumeStep)
|
||||
case key.Matches(keyMsg, a.Refresh):
|
||||
m.apps.pending = true
|
||||
return fetchAppsCmd()
|
||||
case key.Matches(keyMsg, a.Mute):
|
||||
s := m.selectedStream()
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
m.apps.feedback = ""
|
||||
m.apps.pending = true
|
||||
return appsToggleMuteCmd(s.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) adjustStreamVolume(delta int) tea.Cmd {
|
||||
s := m.selectedStream()
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
pct := s.Volume + delta
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
s.Volume = pct
|
||||
m.apps.feedback = ""
|
||||
return appsSetVolumeCmd(s.ID, pct)
|
||||
}
|
||||
|
||||
func sectionTitle(k audio.StreamKind) string {
|
||||
if k == audio.StreamInput {
|
||||
return "INPUT STREAMS (recording)"
|
||||
}
|
||||
return "OUTPUT STREAMS (playback)"
|
||||
}
|
||||
|
||||
// windowAroundCursor picks a [start, end) slice of size items (or fewer, if
|
||||
// total is smaller) that keeps cursor in view.
|
||||
func windowAroundCursor(cursor, total, size int) (start, end int) {
|
||||
if size >= total {
|
||||
return 0, total
|
||||
}
|
||||
start = cursor - size/2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start+size > total {
|
||||
start = total - size
|
||||
}
|
||||
return start, start + size
|
||||
}
|
||||
|
||||
func (m Model) renderApps(width, height int) string {
|
||||
if m.apps.loading {
|
||||
return ilovetui.S.Faint.Render("Loading application streams…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("APPLICATIONS")
|
||||
if m.apps.pending {
|
||||
header += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
if m.apps.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.apps.err.Error()))
|
||||
}
|
||||
if len(m.apps.streams) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No application is currently playing or recording audio."))
|
||||
}
|
||||
|
||||
maxItems := (height - 3) / streamRowHeight
|
||||
if maxItems < 1 {
|
||||
maxItems = 1
|
||||
}
|
||||
start, end := windowAroundCursor(m.apps.cursor, len(m.apps.streams), maxItems)
|
||||
|
||||
sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle)
|
||||
var b strings.Builder
|
||||
lastKind := audio.StreamKind(-1)
|
||||
for i := start; i < end; i++ {
|
||||
s := m.apps.streams[i]
|
||||
if s.Kind != lastKind {
|
||||
b.WriteString(sectionStyle.Render(sectionTitle(s.Kind)) + "\n\n")
|
||||
lastKind = s.Kind
|
||||
}
|
||||
b.WriteString(renderStreamRow(s, i == m.apps.cursor, width))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
list := strings.TrimRight(b.String(), "\n")
|
||||
|
||||
footer := ""
|
||||
if m.apps.feedback != "" {
|
||||
footer = subtleStyle().Render(m.apps.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications"
|
||||
)
|
||||
|
||||
const tickInterval = 5 * time.Second
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
|
||||
}
|
||||
|
||||
func checkCmd() tea.Cmd {
|
||||
return func() tea.Msg { return checkMsg{err: audio.CheckAvailable()} }
|
||||
}
|
||||
|
||||
func fetchOutputCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
devices, err := audio.ListSinks()
|
||||
return outputListMsg{devices: devices, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchInputCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
devices, err := audio.ListSources()
|
||||
return inputListMsg{devices: devices, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAppsCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
streams, err := audio.ListStreams()
|
||||
return appsListMsg{streams: streams, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func outputSetVolumeCmd(id string, pct int) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.SetVolume(id, pct)} }
|
||||
}
|
||||
|
||||
func outputToggleMuteCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.ToggleMute(id)} }
|
||||
}
|
||||
|
||||
func outputSetDefaultCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.SetDefault(id)} }
|
||||
}
|
||||
|
||||
func outputTestToneCmd(sinkID string) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.PlayTestTone(sinkID)} }
|
||||
}
|
||||
|
||||
func inputSetVolumeCmd(id string, pct int) tea.Cmd {
|
||||
return func() tea.Msg { return inputActionMsg{err: audio.SetVolume(id, pct)} }
|
||||
}
|
||||
|
||||
func inputToggleMuteCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return inputActionMsg{err: audio.ToggleMute(id)} }
|
||||
}
|
||||
|
||||
func inputSetDefaultCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return inputActionMsg{err: audio.SetDefault(id)} }
|
||||
}
|
||||
|
||||
func appsSetVolumeCmd(id string, pct int) tea.Cmd {
|
||||
return func() tea.Msg { return appsActionMsg{err: audio.SetVolume(id, pct)} }
|
||||
}
|
||||
|
||||
func appsToggleMuteCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return appsActionMsg{err: audio.ToggleMute(id)} }
|
||||
}
|
||||
|
||||
func notifyCmd(title, body string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return notificationsUI.NotificationMsg{Title: title, Body: body, Kind: notificationsUI.KindInfo}
|
||||
}
|
||||
}
|
||||
|
||||
func startLevelMonitorCmd(sourceID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
mon, err := audio.StartLevelMonitor(sourceID)
|
||||
return levelStartedMsg{monitor: mon, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func waitLevelCmd(mon *audio.LevelMonitor) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
peak, err := mon.Next()
|
||||
return levelMsg{peak: peak, err: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
type inputState struct {
|
||||
loading bool
|
||||
err error
|
||||
devices []audio.Device
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
|
||||
testing bool
|
||||
monitor *audio.LevelMonitor
|
||||
level float64 // last peak reading in [0, 1], only meaningful while testing
|
||||
}
|
||||
|
||||
func newInputState() inputState {
|
||||
return inputState{loading: true}
|
||||
}
|
||||
|
||||
// stopLevelMonitor tears down a running live input-level test, if any. Safe
|
||||
// to call unconditionally.
|
||||
func (s *inputState) stopLevelMonitor() {
|
||||
if s.monitor != nil {
|
||||
s.monitor.Stop()
|
||||
s.monitor = nil
|
||||
}
|
||||
s.testing = false
|
||||
s.level = 0
|
||||
}
|
||||
|
||||
func (m *Model) handleInputList(msg inputListMsg) {
|
||||
m.input.loading = false
|
||||
m.input.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.input.devices = msg.devices
|
||||
if m.input.cursor >= len(m.input.devices) {
|
||||
m.input.cursor = max(0, len(m.input.devices)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleInputAction(msg inputActionMsg) tea.Cmd {
|
||||
m.input.pending = false
|
||||
if msg.err != nil {
|
||||
m.input.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
return fetchInputCmd()
|
||||
}
|
||||
|
||||
// handleLevelStarted reacts to a StartLevelMonitor result. If the user
|
||||
// toggled testing off again before this arrived, the freshly-started monitor
|
||||
// is stopped immediately instead of being adopted.
|
||||
func (m *Model) handleLevelStarted(msg levelStartedMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
m.input.testing = false
|
||||
m.input.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
if !m.input.testing {
|
||||
msg.monitor.Stop()
|
||||
return nil
|
||||
}
|
||||
m.input.monitor = msg.monitor
|
||||
return waitLevelCmd(msg.monitor)
|
||||
}
|
||||
|
||||
// handleLevel reacts to one peak reading, re-arming the wait for the next
|
||||
// one as long as the test is still active.
|
||||
func (m *Model) handleLevel(msg levelMsg) tea.Cmd {
|
||||
if !m.input.testing || m.input.monitor == nil {
|
||||
return nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.input.feedback = "Input test stopped: " + msg.err.Error()
|
||||
m.input.stopLevelMonitor()
|
||||
return nil
|
||||
}
|
||||
m.input.level = msg.peak
|
||||
return waitLevelCmd(m.input.monitor)
|
||||
}
|
||||
|
||||
func (m Model) selectedInput() *audio.Device {
|
||||
if m.input.cursor < 0 || m.input.cursor >= len(m.input.devices) {
|
||||
return nil
|
||||
}
|
||||
return &m.input.devices[m.input.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateInput(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.input.cursor > 0 {
|
||||
m.input.stopLevelMonitor()
|
||||
m.input.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.input.cursor < len(m.input.devices)-1 {
|
||||
m.input.stopLevelMonitor()
|
||||
m.input.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, g.Left):
|
||||
return m.adjustInputVolume(-volumeStep)
|
||||
case key.Matches(keyMsg, g.Right):
|
||||
return m.adjustInputVolume(volumeStep)
|
||||
case key.Matches(keyMsg, a.Refresh):
|
||||
m.input.pending = true
|
||||
return fetchInputCmd()
|
||||
case key.Matches(keyMsg, a.Mute):
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
m.input.feedback = ""
|
||||
m.input.pending = true
|
||||
return inputToggleMuteCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Select):
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
if dev.Default {
|
||||
m.input.feedback = "Already the default input."
|
||||
return nil
|
||||
}
|
||||
m.input.feedback = ""
|
||||
m.input.pending = true
|
||||
return inputSetDefaultCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Test):
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
if m.input.testing {
|
||||
m.input.stopLevelMonitor()
|
||||
return nil
|
||||
}
|
||||
m.input.testing = true
|
||||
m.input.feedback = ""
|
||||
return startLevelMonitorCmd(dev.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) adjustInputVolume(delta int) tea.Cmd {
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
pct := dev.Volume + delta
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
dev.Volume = pct
|
||||
m.input.feedback = ""
|
||||
return inputSetVolumeCmd(dev.ID, pct)
|
||||
}
|
||||
|
||||
func (m Model) renderInput(width, height int) string {
|
||||
if m.input.loading {
|
||||
return ilovetui.S.Faint.Render("Loading input devices…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("INPUT DEVICES")
|
||||
if m.input.pending {
|
||||
header += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
if m.input.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.input.err.Error()))
|
||||
}
|
||||
if len(m.input.devices) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No input devices found."))
|
||||
}
|
||||
|
||||
available := (height - 3) / deviceRowHeight
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.input.cursor, len(m.input.devices), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
selected := i == m.input.cursor
|
||||
row := renderDeviceRow(m.input.devices[i], selected, width)
|
||||
if selected && m.input.testing {
|
||||
row += "\n " + renderLevelMeter(m.input.level, barWidth(width))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
list := strings.Join(rows, "\n\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.input.feedback != "" {
|
||||
footer = subtleStyle().Render(m.input.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
// audioKeyMap renders the bottom help bar, adapting to the active tab.
|
||||
type audioKeyMap struct {
|
||||
m Model
|
||||
width int
|
||||
}
|
||||
|
||||
// relabel reuses an existing binding's keys under a different help
|
||||
// description. Volume adjustment reuses the global Left/Right binding (h/l,
|
||||
// arrow keys), which is normally labelled "scroll left/right" — here it
|
||||
// means something else, so the help bar needs its own wording.
|
||||
func relabel(b key.Binding, desc string) key.Binding {
|
||||
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(b.Help().Key, desc))
|
||||
}
|
||||
|
||||
func (k audioKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
volDown, volUp := relabel(g.Left, "vol down"), relabel(g.Right, "vol up")
|
||||
|
||||
switch k.m.active {
|
||||
case tabApps:
|
||||
return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, g.CycleFocus, g.Help}
|
||||
default:
|
||||
return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Test, g.Help}
|
||||
}
|
||||
}
|
||||
|
||||
func (k audioKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
volDown, volUp := relabel(g.Left, "volume down"), relabel(g.Right, "volume up")
|
||||
|
||||
var bindings []key.Binding
|
||||
switch k.m.active {
|
||||
case tabOutput:
|
||||
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, a.Test, a.Refresh, g.CycleFocus}
|
||||
case tabInput:
|
||||
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, relabel(a.Test, "test mic"), a.Refresh, g.CycleFocus}
|
||||
case tabApps:
|
||||
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Refresh, g.CycleFocus}
|
||||
}
|
||||
bindings = append(bindings, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(bindings, k.width)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package audio
|
||||
|
||||
import "github.com/anotherhadi/settuings/internal/audio"
|
||||
|
||||
// checkMsg carries the result of audio.CheckAvailable.
|
||||
type checkMsg struct{ err error }
|
||||
|
||||
// tickMsg triggers a periodic refresh of every tab while this page is active.
|
||||
type tickMsg struct{}
|
||||
|
||||
type outputListMsg struct {
|
||||
devices []audio.Device
|
||||
err error
|
||||
}
|
||||
|
||||
type inputListMsg struct {
|
||||
devices []audio.Device
|
||||
err error
|
||||
}
|
||||
|
||||
type appsListMsg struct {
|
||||
streams []audio.Stream
|
||||
err error
|
||||
}
|
||||
|
||||
// outputActionMsg carries the result of a control action (set volume, mute,
|
||||
// set default, play test tone) performed on the Output tab.
|
||||
type outputActionMsg struct{ err error }
|
||||
|
||||
// inputActionMsg is the Input tab's equivalent of outputActionMsg.
|
||||
type inputActionMsg struct{ err error }
|
||||
|
||||
// appsActionMsg is the Apps tab's equivalent of outputActionMsg.
|
||||
type appsActionMsg struct{ err error }
|
||||
|
||||
// levelStartedMsg carries the result of starting a live input-level meter.
|
||||
type levelStartedMsg struct {
|
||||
monitor *audio.LevelMonitor
|
||||
err error
|
||||
}
|
||||
|
||||
// levelMsg carries one peak reading from the live input-level meter.
|
||||
type levelMsg struct {
|
||||
peak float64
|
||||
err error
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package audio provides the "Audio" TUI page: a thin, tabbed wrapper around
|
||||
// wpctl (and the pw-play/pw-cat helper tools that ship with it) covering
|
||||
// output devices, input devices, and per-application streams, in the same
|
||||
// spirit as the network page's tabs.
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
type tab int
|
||||
|
||||
const (
|
||||
tabOutput tab = iota
|
||||
tabInput
|
||||
tabApps
|
||||
)
|
||||
|
||||
var visibleTabs = []tab{tabOutput, tabInput, tabApps}
|
||||
|
||||
func (t tab) label() string {
|
||||
switch t {
|
||||
case tabOutput:
|
||||
return "Output"
|
||||
case tabInput:
|
||||
return "Input"
|
||||
case tabApps:
|
||||
return "Apps"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (t tab) icon() string {
|
||||
switch t {
|
||||
case tabOutput:
|
||||
return icons.I.Output
|
||||
case tabInput:
|
||||
return icons.I.Input
|
||||
case tabApps:
|
||||
return icons.I.Apps
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
active tab
|
||||
width int
|
||||
height int
|
||||
help help.Model
|
||||
|
||||
checkErr error // reason wpctl is unusable; nil once confirmed OK
|
||||
checked bool
|
||||
|
||||
output outputState
|
||||
input inputState
|
||||
apps appsState
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
active: tabOutput,
|
||||
help: ilovetui.NewHelp(),
|
||||
output: newOutputState(),
|
||||
input: newInputState(),
|
||||
apps: newAppsState(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate is called every time the user navigates onto this page. It
|
||||
// re-checks wpctl availability, kicks off a fetch for every tab, and starts
|
||||
// the periodic auto-refresh loop.
|
||||
func (m *Model) Activate() tea.Cmd {
|
||||
return tea.Batch(
|
||||
checkCmd(),
|
||||
fetchOutputCmd(),
|
||||
fetchInputCmd(),
|
||||
fetchAppsCmd(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
// Deactivate is called synchronously when the user navigates away from this
|
||||
// page (or quits) so the live input-level meter, if running, never leaks a
|
||||
// background pw-cat process.
|
||||
func (m *Model) Deactivate() {
|
||||
m.input.stopLevelMonitor()
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.help.SetWidth(w - 2)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
const volumeStep = 5
|
||||
|
||||
type outputState struct {
|
||||
loading bool
|
||||
err error
|
||||
devices []audio.Device
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newOutputState() outputState {
|
||||
return outputState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleOutputList(msg outputListMsg) {
|
||||
m.output.loading = false
|
||||
m.output.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.output.devices = msg.devices
|
||||
if m.output.cursor >= len(m.output.devices) {
|
||||
m.output.cursor = max(0, len(m.output.devices)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleOutputAction(msg outputActionMsg) tea.Cmd {
|
||||
m.output.pending = false
|
||||
if msg.err != nil {
|
||||
m.output.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
return fetchOutputCmd()
|
||||
}
|
||||
|
||||
func (m Model) selectedOutput() *audio.Device {
|
||||
if m.output.cursor < 0 || m.output.cursor >= len(m.output.devices) {
|
||||
return nil
|
||||
}
|
||||
return &m.output.devices[m.output.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateOutput(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.output.cursor > 0 {
|
||||
m.output.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.output.cursor < len(m.output.devices)-1 {
|
||||
m.output.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, g.Left):
|
||||
return m.adjustOutputVolume(-volumeStep)
|
||||
case key.Matches(keyMsg, g.Right):
|
||||
return m.adjustOutputVolume(volumeStep)
|
||||
case key.Matches(keyMsg, a.Refresh):
|
||||
m.output.pending = true
|
||||
return fetchOutputCmd()
|
||||
case key.Matches(keyMsg, a.Mute):
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
m.output.feedback = ""
|
||||
m.output.pending = true
|
||||
return outputToggleMuteCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Select):
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
if dev.Default {
|
||||
m.output.feedback = "Already the default output."
|
||||
return nil
|
||||
}
|
||||
m.output.feedback = ""
|
||||
m.output.pending = true
|
||||
return outputSetDefaultCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Test):
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
return tea.Batch(outputTestToneCmd(dev.ID), notifyCmd("Audio", "Playing test sound on "+dev.Name+"…"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adjustOutputVolume nudges the selected device's volume by delta, updating
|
||||
// it locally right away so the bar responds instantly instead of waiting on
|
||||
// the round trip to wpctl and back.
|
||||
func (m *Model) adjustOutputVolume(delta int) tea.Cmd {
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
pct := dev.Volume + delta
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
dev.Volume = pct
|
||||
m.output.feedback = ""
|
||||
return outputSetVolumeCmd(dev.ID, pct)
|
||||
}
|
||||
|
||||
func (m Model) renderOutput(width, height int) string {
|
||||
if m.output.loading {
|
||||
return ilovetui.S.Faint.Render("Loading output devices…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("OUTPUT DEVICES")
|
||||
if m.output.pending {
|
||||
header += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
if m.output.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.output.err.Error()))
|
||||
}
|
||||
if len(m.output.devices) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No output devices found."))
|
||||
}
|
||||
|
||||
available := (height - 3) / deviceRowHeight
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.output.cursor, len(m.output.devices), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, renderDeviceRow(m.output.devices[i], i == m.output.cursor, width))
|
||||
}
|
||||
list := strings.Join(rows, "\n\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.output.feedback != "" {
|
||||
footer = subtleStyle().Render(m.output.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func newPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•")
|
||||
p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•")
|
||||
return p
|
||||
}
|
||||
|
||||
// paginate slices a list of length total into pages of availableRows items,
|
||||
// deriving the current page from cursor (the globally-indexed selection)
|
||||
// rather than tracking page state separately. It returns the configured
|
||||
// paginator and the [start, end) bounds of the visible slice.
|
||||
func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) {
|
||||
p = newPaginator()
|
||||
perPage := availableRows
|
||||
if perPage < 1 {
|
||||
perPage = 1
|
||||
}
|
||||
p.PerPage = perPage
|
||||
p.SetTotalPages(total)
|
||||
if p.TotalPages > 0 {
|
||||
p.Page = cursor / perPage
|
||||
if p.Page >= p.TotalPages {
|
||||
p.Page = p.TotalPages - 1
|
||||
}
|
||||
}
|
||||
start, end = p.GetSliceBounds(total)
|
||||
return p, start, end
|
||||
}
|
||||
|
||||
func renderPaginatorDots(p paginator.Model) string {
|
||||
if p.TotalPages <= 1 {
|
||||
return ""
|
||||
}
|
||||
return mutedStyle().Render(p.View())
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
// deviceRowHeight and streamRowHeight are the number of lines each list row
|
||||
// occupies (name line + bar line + one blank spacer line), used to size how
|
||||
// many rows fit in the available height.
|
||||
const (
|
||||
deviceRowHeight = 3
|
||||
streamRowHeight = 3
|
||||
)
|
||||
|
||||
// barWidth derives a volume-bar width from the row's available width,
|
||||
// leaving room for the name line's cursor/default marker on one side and the
|
||||
// percentage/MUTED label on the other.
|
||||
func barWidth(rowWidth int) int {
|
||||
w := rowWidth - 24
|
||||
switch {
|
||||
case w > 30:
|
||||
w = 30
|
||||
case w < 10:
|
||||
w = 10
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func renderDeviceRow(d audio.Device, selected bool, width int) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
name := textStyle.Render(d.Name)
|
||||
if d.Default {
|
||||
name += " " + successStyle().Render(icons.I.Check+"default")
|
||||
}
|
||||
nameLine := cursor + name
|
||||
barLine := " " + renderVolumeBar(d.Volume, barWidth(width), d.Muted)
|
||||
return nameLine + "\n" + barLine
|
||||
}
|
||||
|
||||
func renderStreamRow(s audio.Stream, selected bool, width int) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
nameLine := cursor + textStyle.Render(s.App)
|
||||
barLine := " " + renderVolumeBar(s.Volume, barWidth(width), s.Muted)
|
||||
return nameLine + "\n" + barLine
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
// renderVolumeBar draws a static block-bar slider. bubbles has no dedicated
|
||||
// slider widget, only an animated progress bar meant for async progress, not
|
||||
// an immediately-adjustable value — a static bar reflects every keypress
|
||||
// instantly without depending on progress.Model's frame-tick animation.
|
||||
func renderVolumeBar(pct, barWidth int, muted bool) string {
|
||||
if barWidth < 4 {
|
||||
barWidth = 4
|
||||
}
|
||||
filled := pct * barWidth / 100
|
||||
switch {
|
||||
case filled > barWidth:
|
||||
filled = barWidth
|
||||
case filled < 0:
|
||||
filled = 0
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
pctText := fmt.Sprintf("%3d%%", pct)
|
||||
|
||||
barColor := ilovetui.S.Primary
|
||||
if muted {
|
||||
barColor = ilovetui.S.Subtle
|
||||
}
|
||||
line := lipgloss.NewStyle().Foreground(barColor).Render("["+bar+"]") + " " + subtleStyle().Render(pctText)
|
||||
if muted {
|
||||
line += " " + errorStyle().Bold(true).Render("MUTED")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// renderLevelMeter draws a live input-level meter from a peak value in
|
||||
// [0, 1], used to test a microphone.
|
||||
func renderLevelMeter(peak float64, barWidth int) string {
|
||||
if barWidth < 4 {
|
||||
barWidth = 4
|
||||
}
|
||||
filled := int(peak*float64(barWidth) + 0.5)
|
||||
switch {
|
||||
case filled > barWidth:
|
||||
filled = barWidth
|
||||
case filled < 0:
|
||||
filled = 0
|
||||
}
|
||||
color := ilovetui.S.Success
|
||||
if peak > 0.9 {
|
||||
color = ilovetui.S.Error
|
||||
} else if peak > 0.65 {
|
||||
color = ilovetui.S.Warning
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
return "Level: " + lipgloss.NewStyle().Foreground(color).Render("["+bar+"]")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func errorStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Error)
|
||||
}
|
||||
|
||||
func successStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||
}
|
||||
|
||||
func subtleStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||
}
|
||||
|
||||
func mutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Muted)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
// cycleTab moves to the next tab, stopping any live input-level test running
|
||||
// on the tab being left.
|
||||
func (m *Model) cycleTab() {
|
||||
m.input.stopLevelMonitor()
|
||||
for i, t := range visibleTabs {
|
||||
if t == m.active {
|
||||
m.active = visibleTabs[(i+1)%len(visibleTabs)]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderTabBar(width int) string {
|
||||
active := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Primary)
|
||||
inactive := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle)
|
||||
|
||||
var tabs []string
|
||||
for _, t := range visibleTabs {
|
||||
style := inactive
|
||||
if t == m.active {
|
||||
style = active
|
||||
}
|
||||
tabs = append(tabs, style.Render(t.icon()+t.label()))
|
||||
}
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...)
|
||||
|
||||
filler := width - lipgloss.Width(row)
|
||||
if filler > 0 {
|
||||
row = lipgloss.JoinHorizontal(lipgloss.Bottom, row,
|
||||
lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Render(strings.Repeat(" ", filler)))
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case checkMsg:
|
||||
wasUnavailable := m.checkErr != nil
|
||||
m.checked = true
|
||||
m.checkErr = msg.err
|
||||
if msg.err == nil && wasUnavailable {
|
||||
// recovered: resume fetching + the auto-refresh loop
|
||||
return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.checkErr != nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd())
|
||||
|
||||
case outputListMsg:
|
||||
m.handleOutputList(msg)
|
||||
return m, nil
|
||||
case outputActionMsg:
|
||||
return m, m.handleOutputAction(msg)
|
||||
|
||||
case inputListMsg:
|
||||
m.handleInputList(msg)
|
||||
return m, nil
|
||||
case inputActionMsg:
|
||||
return m, m.handleInputAction(msg)
|
||||
case levelStartedMsg:
|
||||
return m, m.handleLevelStarted(msg)
|
||||
case levelMsg:
|
||||
return m, m.handleLevel(msg)
|
||||
|
||||
case appsListMsg:
|
||||
m.handleAppsList(msg)
|
||||
return m, nil
|
||||
case appsActionMsg:
|
||||
return m, m.handleAppsAction(msg)
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.checkErr != nil {
|
||||
if key.Matches(msg, keys.Keys.Audio.Refresh) {
|
||||
return m, checkCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
switch {
|
||||
case key.Matches(msg, g.CycleFocus):
|
||||
m.cycleTab()
|
||||
return m, nil
|
||||
case key.Matches(msg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.active {
|
||||
case tabOutput:
|
||||
cmd = m.updateOutput(msg)
|
||||
case tabInput:
|
||||
cmd = m.updateInput(msg)
|
||||
case tabApps:
|
||||
cmd = m.updateApps(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
)
|
||||
|
||||
func windowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Padding(1, 2)
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
statusBar := m.renderStatusBar()
|
||||
statusH := strings.Count(statusBar, "\n") + 1
|
||||
|
||||
frameW := windowStyle().GetHorizontalFrameSize()
|
||||
frameH := windowStyle().GetVerticalFrameSize()
|
||||
innerW := m.width - frameW
|
||||
|
||||
tabBar := m.renderTabBar(innerW)
|
||||
tabBarH := lipgloss.Height(tabBar)
|
||||
|
||||
innerH := m.height - tabBarH - frameH - statusH
|
||||
|
||||
var body string
|
||||
switch {
|
||||
case !m.checked:
|
||||
body = ilovetui.S.Faint.Render("Checking wpctl…")
|
||||
case m.checkErr != nil:
|
||||
body = m.renderUnavailable()
|
||||
default:
|
||||
body = m.renderActiveTab(innerW, innerH)
|
||||
}
|
||||
|
||||
box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body)
|
||||
content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box))
|
||||
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar))
|
||||
}
|
||||
|
||||
func (m Model) renderStatusBar() string {
|
||||
km := audioKeyMap{m: m, width: m.width}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
|
||||
}
|
||||
|
||||
func (m Model) renderActiveTab(width, height int) string {
|
||||
switch m.active {
|
||||
case tabOutput:
|
||||
return m.renderOutput(width, height)
|
||||
case tabInput:
|
||||
return m.renderInput(width, height)
|
||||
case tabApps:
|
||||
return m.renderApps(width, height)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderUnavailable() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Audio page unavailable")
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case errors.Is(m.checkErr, audio.ErrNotFound):
|
||||
hint = "wpctl was not found in PATH. Install PipeWire/WirePlumber to use this page."
|
||||
default:
|
||||
hint = m.checkErr.Error()
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
subtleStyle().Render(hint),
|
||||
"",
|
||||
mutedStyle().Render("Press r to try again once fixed."),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user