diff --git a/README.md b/README.md index 7c86da7..567fbaa 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,23 @@ corresponding icons with correct names need to be placed in `~/.local/share/deckmaster/themes/[theme]`. The default icons with their respective names can be found [here](https://github.com/muesli/deckmaster/tree/master/assets/weather). +#### Pulseaudio Control + +A widget that can controls a specific pulseaudio input sink (like Rhythmbox, Firefox etc.). + +```toml +[keys.widget] + id = "pulseAudioControl" + [keys.widget.config] + appName = "Application name" # Like "Rhythmbox" + mode = "mute" # Only mute is supported at the moment. + showTitle = true # optional +``` + +This widget is using "pactl" and "pacmd". + +You can use `pacmd list-sink-inputs` to see the current sinks. Use the value of "application.name" for appName. + ### Actions You can hook up any key with several actions. A regular keypress will trigger diff --git a/decks/assets/muted.png b/decks/assets/muted.png new file mode 100644 index 0000000..a1f6b79 Binary files /dev/null and b/decks/assets/muted.png differ diff --git a/decks/assets/not_muted.png b/decks/assets/not_muted.png new file mode 100644 index 0000000..61535f5 Binary files /dev/null and b/decks/assets/not_muted.png differ diff --git a/decks/assets/not_playing.png b/decks/assets/not_playing.png new file mode 100644 index 0000000..3e8d143 Binary files /dev/null and b/decks/assets/not_playing.png differ diff --git a/decks/pulseaudio_control.deck b/decks/pulseaudio_control.deck new file mode 100644 index 0000000..c680cb0 --- /dev/null +++ b/decks/pulseaudio_control.deck @@ -0,0 +1,20 @@ +[[keys]] + index = 0 + [keys.widget] + id = "pulseAudioControl" + interval = 1000 + [keys.widget.config] + appName = "Firefox" + mode = "mute" + showTitle = false + +[[keys]] + index = 1 + [keys.widget] + id = "pulseAudioControl" + interval = 1000 + [keys.widget.config] + appName = "Rhythmbox" + mode = "mute" + showTitle = true + diff --git a/widget.go b/widget.go index 60764b1..bcfe309 100644 --- a/widget.go +++ b/widget.go @@ -124,6 +124,9 @@ func NewWidget(dev *streamdeck.Device, base string, kc KeyConfig, bg image.Image case "weather": return NewWeatherWidget(bw, kc.Widget) + + case "pulseAudioControl": + return NewPulseAudioControlWidget(bw, kc.Widget) } // unknown widget ID diff --git a/widget_pulseaudio_control.go b/widget_pulseaudio_control.go new file mode 100644 index 0000000..3dd8ac1 --- /dev/null +++ b/widget_pulseaudio_control.go @@ -0,0 +1,169 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "regexp" +) + +const ( + regexExpression = `index: ([0-9]+)[\s\S]*?muted: (no|yes)[\s\S]*?media.name = \"(.*?)\"[\s\S]*?application.name = \"(.*?)\"` + regexGroupClientId = 1 + regexGroupMuted = 2 + regexGroupMediaName = 3 + regexGroupAppName = 4 + + listInputSinksCommand = "pacmd list-sink-inputs" +) + +// PulseAudioControlWidget is a widget displaying a recently activated window. +type PulseAudioControlWidget struct { + *ButtonWidget + + appName string + mode string + showTitle bool +} + +type sinkInputData struct { + muted bool + title string + index string +} + +// NewPulseAudioControlWidget returns a new PulseAudioControlWidget. +func NewPulseAudioControlWidget(bw *BaseWidget, opts WidgetConfig) (*PulseAudioControlWidget, error) { + var appName string + if err := ConfigValue(opts.Config["appName"], &appName); err != nil { + return nil, err + } + + var mode string + if err := ConfigValue(opts.Config["mode"], &mode); err != nil { + return nil, err + } + + var showTitle bool + _ = ConfigValue(opts.Config["showTitle"], &showTitle) + widget, err := NewButtonWidget(bw, opts) + + if err != nil { + return nil, err + } + + return &PulseAudioControlWidget{ + ButtonWidget: widget, + appName: appName, + mode: mode, + showTitle: showTitle, + }, nil +} + +// RequiresUpdate returns true when the widget wants to be repainted. +func (w *PulseAudioControlWidget) RequiresUpdate() bool { + //TODO + + return w.BaseWidget.RequiresUpdate() +} + +// Update renders the widget. +func (w *PulseAudioControlWidget) Update() error { + sinkInputData, err := getSinkInputDataForApp(w.appName) + if err != nil { + return err + } + + var icon string + var label string + if sinkInputData != nil { + if sinkInputData.muted { + icon = "assets/muted.png" + } else { + icon = "assets/not_muted.png" + } + if w.showTitle && sinkInputData.title != "" { + label = sinkInputData.title + } else { + label = w.appName + } + } else { + icon = "assets/not_playing.png" + label = w.appName + } + + if err := w.LoadImage(icon); err != nil { + return err + } + + w.label = stripTextTo(10, label) + return w.ButtonWidget.Update() +} + +// TriggerAction gets called when a button is pressed. +func (w *PulseAudioControlWidget) TriggerAction(hold bool) { + if w.mode != "mute" { + fmt.Fprintln(os.Stderr, "unknown mode:", w.mode) + return + } + + sinkInputData, err := getSinkInputDataForApp(w.appName) + + if err != nil { + fmt.Fprintln(os.Stderr, "can't toggle mute for pulseaudio app "+w.appName, err) + } + + if sinkInputData == nil { + fmt.Fprintln(os.Stderr, "No running sink found for pulseaudio app "+w.appName, err) + } + + toggleMute(sinkInputData.index) +} + +func toggleMute(sinkIndex string) { + err := exec.Command("sh", "-c", "pactl set-sink-input-mute "+sinkIndex+" toggle").Run() + + if err != nil { + fmt.Fprintln(os.Stderr, "can't toggle mute for pulseaudio sink index: "+sinkIndex, err) + } +} + +func stripTextTo(maxLength int, text string) string { + runes := []rune(text) + if len(runes) > maxLength { + return string(runes[:maxLength]) + } + return text +} + +func getSinkInputDataForApp(appName string) (*sinkInputData, error) { + output, err := exec.Command("sh", "-c", listInputSinksCommand).Output() + if err != nil { + return nil, fmt.Errorf("can't get pulseaudio sinks. 'pacmd' missing? %s", err) + } + + var regex = regexp.MustCompile(regexExpression) + matches := regex.FindAllStringSubmatch(string(output), -1) + var sinkData *sinkInputData + for match := range matches { + if appName == matches[match][regexGroupAppName] { + sinkData = &sinkInputData{} + sinkData.index = matches[match][regexGroupClientId] + sinkData.muted = yesOrNoToBool(matches[match][regexGroupMuted]) + sinkData.title = matches[match][regexGroupMediaName] + } + } + + return sinkData, nil +} + +func yesOrNoToBool(yesOrNo string) bool { + switch yesOrNo { + case "yes": + return true + case "no": + return false + } + fmt.Fprintln(os.Stderr, "can't convert yes|no to bool: "+yesOrNo) + return false +}