summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scss/sidebar.scss110
-rw-r--r--src/modules/sidebar/audio.tsx3
-rw-r--r--src/modules/sidebar/modules/deviceselector.tsx126
3 files changed, 239 insertions, 0 deletions
diff --git a/scss/sidebar.scss b/scss/sidebar.scss
index 369ad25..2a71995 100644
--- a/scss/sidebar.scss
+++ b/scss/sidebar.scss
@@ -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);
diff --git a/src/modules/sidebar/audio.tsx b/src/modules/sidebar/audio.tsx
index a5c1651..20a6551 100644
--- a/src/modules/sidebar/audio.tsx
+++ b/src/modules/sidebar/audio.tsx
@@ -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>
);
diff --git a/src/modules/sidebar/modules/deviceselector.tsx b/src/modules/sidebar/modules/deviceselector.tsx
new file mode 100644
index 0000000..03acc97
--- /dev/null
+++ b/src/modules/sidebar/modules/deviceselector.tsx
@@ -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>
+ );
+};