sidebar: streams module

This commit is contained in:
2 * r + 2 * t 2025-03-27 16:45:25 +11:00
parent 19942b8fca
commit 6b0c9af07c
3 changed files with 179 additions and 0 deletions

View file

@ -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);

View file

@ -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>
);

View file

@ -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>
);
};