diff options
| -rw-r--r-- | scss/sidebar.scss | 66 | ||||
| -rw-r--r-- | src/modules/sidebar/audio.tsx | 2 | ||||
| -rw-r--r-- | src/modules/sidebar/modules/streams.tsx | 111 |
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> + ); +}; |