From ef474e97422aa339086a1617d922a4a273a4540a Mon Sep 17 00:00:00 2001 From: retrozinndev Date: Wed, 23 Apr 2025 12:03:36 -0300 Subject: [PATCH] :sparkles: ags(control-center/mixer): add per-app volume control to sliders --- ags/widget/control-center/Sliders.ts | 115 +++++++++++------------ ags/widget/control-center/pages/Mixer.ts | 72 ++++++++++++++ 2 files changed, 129 insertions(+), 58 deletions(-) create mode 100644 ags/widget/control-center/pages/Mixer.ts diff --git a/ags/widget/control-center/Sliders.ts b/ags/widget/control-center/Sliders.ts index 276d43e..c2e4200 100644 --- a/ags/widget/control-center/Sliders.ts +++ b/ags/widget/control-center/Sliders.ts @@ -1,62 +1,61 @@ import { bind } from "astal"; import { Gtk, Widget } from "astal/gtk3"; import { Wireplumber } from "../../scripts/volume"; +import { Pages } from "./Pages"; +import { PageMixer } from "./pages/Mixer"; -export const Sliders = () => new Widget.Box({ - className: "sliders", - orientation: Gtk.Orientation.VERTICAL, - expand: true, - children: [ - new Widget.Box({ - className: "sink speaker", - children: [ - new Widget.Label({ - className: "nf icon", - label: "󰕾" - } as Widget.LabelProps), - new Widget.Slider({ - drawValue: false, - hexpand: true, - setup: (slider) => slider.set_value(Wireplumber.getDefault().getSinkVolume()), - value: bind(Wireplumber.getDefault().getDefaultSink(), "volume").as((volume: number) => - Math.floor(volume * 100)), - max: Wireplumber.getDefault().getMaxSinkVolume(), - onDragged: (slider: Gtk.Scale) => Wireplumber.getDefault().setSinkVolume(slider.get_value()) - } as Widget.SliderProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "source microphone", - children: [ - new Widget.Label({ - className: "nf icon", - label: "󰍬" - } as Widget.LabelProps), - new Widget.Slider({ - drawValue: false, - hexpand: true, - setup: (slider) => slider.set_value(Wireplumber.getDefault().getSourceVolume()), - value: bind(Wireplumber.getDefault().getDefaultSource(), "volume").as((volume: number) => - Math.floor(volume * 100)), - max: Wireplumber.getDefault().getMaxSourceVolume(), - onDragged: (slider: Gtk.Scale) => Wireplumber.getDefault().setSourceVolume(slider.get_value()) - } as Widget.SliderProps) - ] - } as Widget.BoxProps), - /*new Widget.Box({ - className: "brightness", - children: [ - new Widget.Label({ - className: "icon nf", - label: "󰃠" - } as Widget.LabelProps), - new Widget.Slider({ - drawValue: false, - hexpand: true, - value: 216, - max: 255 - } as Widget.SliderProps) - ] - } as Widget.BoxProps)*/ - ] -} as Widget.BoxProps); +export function Sliders() { + const slidersPages = new Pages(); + + return new Widget.Box({ + className: "sliders", + orientation: Gtk.Orientation.VERTICAL, + expand: true, + children: [ + new Widget.Box({ + className: "sink speaker", + children: bind(Wireplumber.getWireplumber(), "defaultSpeaker").as((sink) => [ + new Widget.Button({ + className: "nf", + label: bind(sink, "mute").as((muted) => !muted ? "󰕾" : "󰖁"), + onClick: () => Wireplumber.getDefault().toggleMuteSink() + } as Widget.ButtonProps), + new Widget.Slider({ + drawValue: false, + hexpand: true, + setup: (slider) => slider.value = Math.floor(sink.volume * 100), + value: bind(sink, "volume").as((vol) => Math.floor(vol * 100)), + max: Wireplumber.getDefault().getMaxSinkVolume(), + onDragged: (slider) => sink.volume = slider.value / 100 + } as Widget.SliderProps), + new Widget.Button({ + className: "more", + image: new Widget.Icon({ + icon: "go-next-symbolic", + } as Widget.IconProps), + onClick: (_) => slidersPages.toggle(PageMixer()) + } as Widget.ButtonProps) + ]) + } as Widget.BoxProps), + new Widget.Box({ + className: "source microphone", + children: bind(Wireplumber.getWireplumber(), "defaultMicrophone").as((source) => [ + new Widget.Button({ + className: "nf", + label: bind(source, "mute").as((muted) => !muted ? "󰍬" : "󰍭"), + onClick: () => Wireplumber.getDefault().toggleMuteSource() + } as Widget.ButtonProps), + new Widget.Slider({ + drawValue: false, + hexpand: true, + setup: (slider) => slider.set_value(Math.floor(source.volume * 100)), + value: bind(source, "volume").as((vol) => Math.floor(vol * 100)), + max: Wireplumber.getDefault().getMaxSourceVolume(), + onDragged: (slider) => source.volume = slider.value / 100 + } as Widget.SliderProps) + ]) + } as Widget.BoxProps), + slidersPages + ] + } as Widget.BoxProps); +} diff --git a/ags/widget/control-center/pages/Mixer.ts b/ags/widget/control-center/pages/Mixer.ts new file mode 100644 index 0000000..22a0ec5 --- /dev/null +++ b/ags/widget/control-center/pages/Mixer.ts @@ -0,0 +1,72 @@ +import { Page, PageProps } from "./Page"; +import { bind } from "astal"; +import { Gtk, Widget } from "astal/gtk3"; +import AstalWp from "gi://AstalWp"; +import { getAppIcon } from "../../../scripts/apps"; +import { Wireplumber } from "../../../scripts/volume"; + +export function PageMixer(): Page { + return new Page({ + id: "mixer", + title: "Mixer", + description: "Control per-application volume!", + children: bind(Wireplumber.getWireplumber(), "endpoints").as((endpoints) => [ + ...endpoints.filter((ep) => ep.mediaClass === AstalWp.MediaClass.AUDIO_STREAM || + ep.mediaClass === AstalWp.MediaClass.VIDEO_STREAM).map((ep) => + new Widget.EventBox({ + hexpand: true, + setup: (eventbox) => { + const connections: Array = []; + eventbox.add(new Widget.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + children: [ + new Widget.Icon({ + icon: getStreamIcon(ep) ?? "application-x-executable-symbolic", + css: "font-size: 18px; margin-right: 6px;" + } as Widget.IconProps), + new Widget.Box({ + orientation: Gtk.Orientation.VERTICAL, + hexpand: true, + children: [ + new Widget.Revealer({ + transitionDuration: 180, + transitionType: Gtk.RevealerTransitionType.SLIDE_DOWN, + setup: (self) => connections.push( + eventbox.connect("hover", () => self.revealChild = true), + eventbox.connect("hover-lost", () => self.revealChild = false) + ), + onDestroy: () => connections.map(id => eventbox.disconnect(id)), + child: new Widget.Label({ + label: ep.name || "Unknown", + className: "name", + xalign: 0 + } as Widget.LabelProps) + } as Widget.RevealerProps), + new Widget.Slider({ + min: 0, + drawValue: false, + max: 100, + setup: (self) => self.value = Math.floor(ep.volume * 100), + value: bind(ep, "volume").as((vol) => Math.floor(vol * 100)), + onDragged: (self) => ep.volume = self.value / 100 + } as Widget.SliderProps) + ] + } as Widget.BoxProps) + ] + } as Widget.BoxProps)) + } + } as Widget.EventBoxProps) + ) + ]) + } as PageProps); +} + +function getStreamIcon(endpoint: AstalWp.Endpoint): (string|undefined) { + let icon = getAppIcon(endpoint.icon); + if(icon) return icon; + + icon = getAppIcon(endpoint.name.split(' ')[0]); + if(icon) return icon; + + return undefined; +}