sidebar: audio device selector module

This commit is contained in:
2 * r + 2 * t 2025-03-27 18:02:40 +11:00
parent 11c8a14a22
commit 72e349e368
3 changed files with 239 additions and 0 deletions

View file

@ -507,6 +507,116 @@
}
}
.device-selector {
@include lib.spacing(10, true);
.selector {
@include lib.rounded(20);
background-color: color.change(scheme.$surface1, $alpha: 0.4);
padding: lib.s(10) lib.s(15);
.icon {
font-size: lib.s(20);
}
.separator {
margin-bottom: lib.s(8);
margin-top: lib.s(5);
background-color: if(scheme.$light, scheme.$overlay1, scheme.$overlay0);
}
.list {
color: scheme.$subtext0;
@include lib.spacing(3, true);
}
.device {
@include lib.spacing;
}
.selected {
color: scheme.$text;
@include lib.spacing(10);
.icon {
font-size: lib.s(32);
}
.sublabel {
color: scheme.$subtext0;
}
}
button {
@include lib.element-decel;
&:hover,
&:focus {
color: scheme.$subtext1;
}
&:active {
color: scheme.$text;
}
}
}
.stream {
@include lib.rounded(20);
@include lib.element-decel;
background-color: color.change(scheme.$surface1, $alpha: 0.4);
padding: lib.s(10) lib.s(15);
@include lib.spacing(5);
&.playing {
background-color: color.change(color.mix(scheme.$surface1, scheme.$primary, 50%), $alpha: 0.4);
}
.icon {
font-size: lib.s(28);
margin-right: lib.s(12);
}
.sublabel {
font-size: lib.s(14);
color: scheme.$subtext0;
}
trough {
@include lib.rounded(10);
min-width: lib.s(100);
min-height: lib.s(10);
background-color: color.change(scheme.$error, $alpha: 0.3);
fill {
@include lib.rounded(10);
background-color: color.change(scheme.$overlay0, $alpha: 1);
}
highlight {
@include lib.rounded(10);
background-color: scheme.$subtext1;
}
}
& > button {
@include media-button;
font-size: lib.s(18);
min-width: lib.s(20);
min-height: lib.s(20);
}
}
}
.networks {
.list {
@include lib.spacing(10, true);

View file

@ -1,3 +1,4 @@
import DeviceSelector from "./modules/deviceselector";
import Media from "./modules/media";
import Streams from "./modules/streams";
@ -6,5 +7,7 @@ export default () => (
<Media />
<box className="separator" />
<Streams />
<box className="separator" />
<DeviceSelector />
</box>
);

View file

@ -0,0 +1,126 @@
import { bind, execAsync, Variable, type Binding } from "astal";
import { Astal, Gtk } from "astal/gtk3";
import AstalWp from "gi://AstalWp";
const Device = ({
input,
defaultDevice,
showDropdown,
device,
}: {
input?: boolean;
defaultDevice: Binding<AstalWp.Endpoint>;
showDropdown: Variable<boolean>;
device: AstalWp.Endpoint;
}) => (
<button
visible={defaultDevice.get().id !== device.id}
cursor="pointer"
onClicked={() => {
execAsync(`wpctl set-default ${device.id}`).catch(console.error);
showDropdown.set(false);
}}
setup={self => {
let last: { d: AstalWp.Endpoint; id: number } | null = {
d: defaultDevice.get(),
id: defaultDevice
.get()
.connect("notify::id", () => self.set_visible(defaultDevice.get().id !== device.id)),
};
self.hook(defaultDevice, (_, d) => {
last?.d.disconnect(last.id);
self.set_visible(d.id !== device.id);
last = {
d,
id: d.connect("notify::id", () => self.set_visible(d.id !== device.id)),
};
});
self.connect("destroy", () => last?.d.disconnect(last.id));
}}
>
<box className="device">
{bind(device, "icon").as(i =>
Astal.Icon.lookup_icon(i) ? (
<icon className="icon" icon={device.icon} />
) : (
<label className="icon" label={input ? "mic" : "media_output"} />
)
)}
<label label={bind(device, "description")} />
</box>
</button>
);
const DefaultDevice = ({ input, device }: { input?: boolean; device: AstalWp.Endpoint }) => (
<box className="selected">
<label className="icon" label={input ? "mic" : "media_output"} />
<box vertical>
<label
truncate
xalign={0}
label={bind(device, "description").as(d => (input ? "[In] " : "[Out] ") + (d ?? "Unknown"))}
/>
<label
xalign={0}
className="sublabel"
label={bind(device, "volume").as(v => `Volume ${Math.round(v * 100)}%`)}
/>
</box>
</box>
);
const Selector = ({ input, audio }: { input?: boolean; audio: AstalWp.Audio }) => {
const showDropdown = Variable(false);
const defaultDevice = bind(audio, input ? "defaultMicrophone" : "defaultSpeaker");
return (
<box vertical className="selector">
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
transitionDuration={150}
revealChild={bind(showDropdown)}
>
<box vertical className="list">
{bind(audio, input ? "microphones" : "speakers").as(ds =>
ds.map(d => (
<Device
input={input}
defaultDevice={defaultDevice}
showDropdown={showDropdown}
device={d}
/>
))
)}
<box className="separator" />
</box>
</revealer>
<button cursor="pointer" onClick={() => showDropdown.set(!showDropdown.get())}>
{defaultDevice.as(d => (
<DefaultDevice input={input} device={d} />
))}
</button>
</box>
);
};
const NoWp = () => (
<box vexpand homogeneous>
<box vertical valign={Gtk.Align.CENTER}>
<label label="Device selector unavailable" />
<label className="no-wp-prompt" label="WirePlumber is required for this module" />
</box>
</box>
);
export default () => {
const audio = AstalWp.get_default()?.get_audio();
if (!audio) return <NoWp />;
return (
<box vertical className="device-selector">
<Selector input audio={audio} />
<Selector audio={audio} />
</box>
);
};