summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-03-27 16:45:25 +1100
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-03-27 16:45:25 +1100
commit6b0c9af07c6c8a08ac11522e2a8c7af60c210deb (patch)
treed205c6a39421b65ddf738fbb5ef63395375ff56f
parentsidebar: fix child not found in stack errors (diff)
downloadcaelestia-shell-6b0c9af07c6c8a08ac11522e2a8c7af60c210deb.tar.gz
caelestia-shell-6b0c9af07c6c8a08ac11522e2a8c7af60c210deb.tar.bz2
caelestia-shell-6b0c9af07c6c8a08ac11522e2a8c7af60c210deb.zip
sidebar: streams module
-rw-r--r--scss/sidebar.scss66
-rw-r--r--src/modules/sidebar/audio.tsx2
-rw-r--r--src/modules/sidebar/modules/streams.tsx111
3 files changed, 179 insertions, 0 deletions
diff --git a/scss/sidebar.scss b/scss/sidebar.scss
index 9f72ca8..369ad25 100644
--- a/scss/sidebar.scss
+++ b/scss/sidebar.scss
@@ -443,6 +443,70 @@
}
}
+ .no-wp-prompt {
+ font-size: lib.s(16);
+ color: scheme.$error;
+ margin-top: lib.s(8);
+ }
+
+ .streams {
+ .list {
+ @include lib.spacing(10, true);
+ }
+
+ .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);
@@ -450,6 +514,7 @@
.network {
@include lib.rounded(20);
+ @include lib.element-decel;
background-color: color.change(scheme.$surface1, $alpha: 0.4);
padding: lib.s(10) lib.s(15);
@@ -493,6 +558,7 @@
.device {
@include lib.rounded(20);
+ @include lib.element-decel;
background-color: color.change(scheme.$surface1, $alpha: 0.4);
padding: lib.s(10) lib.s(15);
diff --git a/src/modules/sidebar/audio.tsx b/src/modules/sidebar/audio.tsx
index 2b4c6e9..a5c1651 100644
--- a/src/modules/sidebar/audio.tsx
+++ b/src/modules/sidebar/audio.tsx
@@ -1,8 +1,10 @@
import Media from "./modules/media";
+import Streams from "./modules/streams";
export default () => (
<box vertical className="pane audio" name="audio">
<Media />
<box className="separator" />
+ <Streams />
</box>
);
diff --git a/src/modules/sidebar/modules/streams.tsx b/src/modules/sidebar/modules/streams.tsx
new file mode 100644
index 0000000..a7b27cb
--- /dev/null
+++ b/src/modules/sidebar/modules/streams.tsx
@@ -0,0 +1,111 @@
+import { bind, execAsync, Variable } from "astal";
+import { Gtk } from "astal/gtk3";
+import AstalWp from "gi://AstalWp";
+
+interface IStream {
+ stream: AstalWp.Endpoint;
+ playing: boolean;
+}
+
+const header = (audio: AstalWp.Audio, key: "streams" | "speakers" | "recorders") =>
+ `${audio[key].length} ${audio[key].length === 1 ? key.slice(0, -1) : key}`;
+
+const sortStreams = (a: IStream, b: IStream) => {
+ if (a.playing || b.playing) return a.playing ? -1 : 1;
+ return 0;
+};
+
+const Stream = ({ stream, playing }: IStream) => (
+ <box className={`stream ${playing ? "playing" : ""}`}>
+ <icon className="icon" icon={bind(stream, "icon")} />
+ <box vertical hexpand>
+ <label truncate xalign={0} label={bind(stream, "name")} />
+ <label truncate xalign={0} className="sublabel" label={bind(stream, "description")} />
+ </box>
+ <button valign={Gtk.Align.CENTER} cursor="pointer" onClicked={() => (stream.volume -= 0.05)} label="-" />
+ <slider
+ showFillLevel
+ restrictToFillLevel={false}
+ fillLevel={2 / 3}
+ cursor="pointer"
+ value={bind(stream, "volume").as(v => v * (2 / 3))}
+ setup={self => self.connect("value-changed", () => stream.set_volume(self.value * 1.5))}
+ />
+ <button valign={Gtk.Align.CENTER} cursor="pointer" onClicked={() => (stream.volume += 0.05)} label="+" />
+ </box>
+);
+
+const List = ({ audio }: { audio: AstalWp.Audio }) => {
+ const streams = Variable<IStream[]>([]);
+
+ const update = async () => {
+ const paStreams = JSON.parse(await execAsync("pactl -f json list sink-inputs"));
+ streams.set(
+ audio.streams.map(s => ({
+ stream: s,
+ playing: paStreams.find((p: any) => p.properties["object.serial"] == s.serial)?.corked === false,
+ }))
+ );
+ };
+
+ streams.watch("pactl -f json subscribe", out => {
+ if (JSON.parse(out).on === "sink-input") update().catch(console.error);
+ return streams.get();
+ });
+ audio.connect("notify::streams", () => update().catch(console.error));
+
+ return (
+ <box vertical valign={Gtk.Align.START} className="list" onDestroy={() => streams.drop()}>
+ {bind(streams).as(ps => ps.sort(sortStreams).map(s => <Stream stream={s.stream} playing={s.playing} />))}
+ </box>
+ );
+};
+
+const NoSources = ({ icon, label }: { icon: string; label: string }) => (
+ <box homogeneous name="empty">
+ <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="empty">
+ <label className="icon" label={icon} />
+ <label label={label} />
+ </box>
+ </box>
+);
+
+const NoWp = () => (
+ <box vexpand homogeneous>
+ <box vertical valign={Gtk.Align.CENTER}>
+ <NoSources icon="no_sound" label="Audio module 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 />;
+
+ const label = Variable("");
+
+ label.observe(
+ ["streams", "speakers", "recorders"].map(k => [audio, `notify::${k}`]),
+ () => `${header(audio, "streams")} • ${header(audio, "speakers")} • ${header(audio, "recorders")}`
+ );
+
+ return (
+ <box vertical className="streams" onDestroy={() => label.drop()}>
+ <box className="header-bar">
+ <label label={bind(label)} />
+ </box>
+ <stack
+ transitionType={Gtk.StackTransitionType.CROSSFADE}
+ transitionDuration={200}
+ shown={bind(audio, "streams").as(s => (s.length > 0 ? "list" : "empty"))}
+ >
+ <NoSources icon="stream" label="No audio sources" />
+ <scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
+ <List audio={audio} />
+ </scrollable>
+ </stack>
+ </box>
+ );
+};