summaryrefslogtreecommitdiff
path: root/src/modules/mediadisplay
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-04-13 22:15:25 +1000
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-04-13 22:15:25 +1000
commit17bee0e3854163c9ce142b502d8dad30caa52c5d (patch)
treee8e34d1335fddcd66a196b84ef71bc64fc7a8480 /src/modules/mediadisplay
parentnavbar: config show labels (diff)
downloadcaelestia-shell-17bee0e3854163c9ce142b502d8dad30caa52c5d.tar.gz
caelestia-shell-17bee0e3854163c9ce142b502d8dad30caa52c5d.tar.bz2
caelestia-shell-17bee0e3854163c9ce142b502d8dad30caa52c5d.zip
feat: add music visualiser
Diffstat (limited to 'src/modules/mediadisplay')
-rw-r--r--src/modules/mediadisplay/index.tsx188
-rw-r--r--src/modules/mediadisplay/visualiser.tsx76
2 files changed, 264 insertions, 0 deletions
diff --git a/src/modules/mediadisplay/index.tsx b/src/modules/mediadisplay/index.tsx
new file mode 100644
index 0000000..307087c
--- /dev/null
+++ b/src/modules/mediadisplay/index.tsx
@@ -0,0 +1,188 @@
+import type { Monitor } from "@/services/monitors";
+import Players from "@/services/players";
+import { lengthStr } from "@/utils/strings";
+import { bind, Variable } from "astal";
+import { App, Astal, Gtk } from "astal/gtk3";
+import AstalMpris from "gi://AstalMpris";
+import Visualiser from "./visualiser";
+
+type Selected = Variable<AstalMpris.Player | null>;
+
+const bindIcon = (player: AstalMpris.Player) =>
+ bind(player, "identity").as(i => {
+ const icon = `caelestia-${i?.toLowerCase().replaceAll(" ", "-")}-symbolic`;
+ return Astal.Icon.lookup_icon(icon) ? icon : "caelestia-media-generic-symbolic";
+ });
+
+const PlayerButton = ({
+ player,
+ selected,
+ showDropdown,
+}: {
+ player: AstalMpris.Player;
+ selected: Selected;
+ showDropdown: Variable<boolean>;
+}) => (
+ <button
+ cursor="pointer"
+ onClicked={() => {
+ showDropdown.set(false);
+ selected.set(player);
+ }}
+ >
+ <box className="identity" halign={Gtk.Align.CENTER}>
+ <label label={bind(player, "identity").as(i => i ?? "-")} />
+ <label label="•" />
+ <label label={bind(player, "title").as(t => t ?? "-")} />
+ </box>
+ </button>
+);
+
+const Selector = ({ player, selected }: { player?: AstalMpris.Player; selected: Selected }) => {
+ const showDropdown = Variable(false);
+
+ return (
+ <box vertical valign={Gtk.Align.START} className="selector">
+ <button
+ sensitive={bind(Players.get_default(), "list").as(ps => ps.length > 1)}
+ cursor="pointer"
+ onClicked={() => showDropdown.set(!showDropdown.get())}
+ >
+ <box className="identity" halign={Gtk.Align.CENTER}>
+ <icon icon={player ? bindIcon(player) : "caelestia-media-none-symbolic"} />
+ <label label={player ? bind(player, "identity").as(i => i ?? "") : "No media"} />
+ </box>
+ </button>
+ <revealer
+ transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
+ transitionDuration={150}
+ revealChild={bind(showDropdown)}
+ >
+ <box vertical className="list">
+ {bind(Players.get_default(), "list").as(ps =>
+ ps
+ .filter(p => p !== player)
+ .map(p => <PlayerButton player={p} selected={selected} showDropdown={showDropdown} />)
+ )}
+ </box>
+ </revealer>
+ </box>
+ );
+};
+
+const NoMedia = ({ selected }: { selected: Selected }) => (
+ <box>
+ <box homogeneous halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className="cover-art">
+ <label xalign={0.36} label="" />
+ </box>
+ <box>
+ <box vertical className="details">
+ <label truncate xalign={0} className="title" label="No media" />
+ <label truncate xalign={0} className="artist" label="Try play something!" />
+ <box halign={Gtk.Align.START} className="controls">
+ <button sensitive={false} label="skip_previous" />
+ <button sensitive={false} label="play_arrow" />
+ <button sensitive={false} label="skip_next" />
+ </box>
+ </box>
+ <box className="center-module">
+ <overlay
+ expand
+ overlay={<label halign={Gtk.Align.CENTER} valign={Gtk.Align.END} className="time" label="-1:-1" />}
+ >
+ <Visualiser />
+ </overlay>
+ </box>
+ <Selector selected={selected} />
+ </box>
+ </box>
+);
+
+const Player = ({ player, selected }: { player: AstalMpris.Player; selected: Selected }) => {
+ const time = Variable.derive(
+ [bind(player, "position"), bind(player, "length")],
+ (p, l) => lengthStr(p) + " / " + lengthStr(l)
+ );
+
+ return (
+ <box>
+ <box
+ homogeneous
+ halign={Gtk.Align.CENTER}
+ valign={Gtk.Align.CENTER}
+ className="cover-art"
+ css={bind(player, "coverArt").as(a => `background-image: url("${a}");`)}
+ >
+ {bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.36} label="" />))}
+ </box>
+ <box>
+ <box vertical className="details">
+ <label truncate xalign={0} className="title" label={bind(player, "title").as(t => t ?? "-")} />
+ <label truncate xalign={0} className="artist" label={bind(player, "artist").as(t => t ?? "-")} />
+ <box halign={Gtk.Align.START} className="controls">
+ <button
+ sensitive={bind(player, "canGoPrevious")}
+ cursor="pointer"
+ onClicked={() => player.next()}
+ label="skip_previous"
+ />
+ <button
+ sensitive={bind(player, "canControl")}
+ cursor="pointer"
+ onClicked={() => player.play_pause()}
+ label={bind(player, "playbackStatus").as(s =>
+ s === AstalMpris.PlaybackStatus.PLAYING ? "pause" : "play_arrow"
+ )}
+ />
+ <button
+ sensitive={bind(player, "canGoNext")}
+ cursor="pointer"
+ onClicked={() => player.next()}
+ label="skip_next"
+ />
+ </box>
+ </box>
+ <box className="center-module">
+ <overlay
+ expand
+ overlay={
+ <label
+ halign={Gtk.Align.CENTER}
+ valign={Gtk.Align.END}
+ className="time"
+ label={bind(time)}
+ onDestroy={() => time.drop()}
+ />
+ }
+ >
+ <Visualiser />
+ </overlay>
+ </box>
+ <Selector player={player} selected={selected} />
+ </box>
+ </box>
+ );
+};
+
+export default ({ monitor }: { monitor: Monitor }) => {
+ const selected = Variable(Players.get_default().lastPlayer);
+ selected.observe(Players.get_default(), "notify::last-player", () => Players.get_default().lastPlayer);
+
+ return (
+ <window
+ application={App}
+ name={`mediadisplay${monitor.id}`}
+ namespace="caelestia-mediadisplay"
+ monitor={monitor.id}
+ anchor={Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM}
+ exclusivity={Astal.Exclusivity.EXCLUSIVE}
+ visible={false}
+ >
+ <box className="mediadisplay" onDestroy={() => selected.drop()}>
+ {bind(selected).as(p =>
+ p ? <Player player={p} selected={selected} /> : <NoMedia selected={selected} />
+ )}
+ </box>
+ </window>
+ );
+};
diff --git a/src/modules/mediadisplay/visualiser.tsx b/src/modules/mediadisplay/visualiser.tsx
new file mode 100644
index 0000000..fa1adc6
--- /dev/null
+++ b/src/modules/mediadisplay/visualiser.tsx
@@ -0,0 +1,76 @@
+import { Gtk } from "astal/gtk3";
+import cairo from "cairo";
+import AstalCava from "gi://AstalCava";
+import PangoCairo from "gi://PangoCairo";
+
+export default () => (
+ <drawingarea
+ className="visualiser"
+ setup={self => {
+ const cava = AstalCava.get_default();
+
+ if (cava) {
+ cava.set_stereo(true);
+ cava.set_noise_reduction(0.77);
+ cava.set_input(AstalCava.Input.PIPEWIRE);
+
+ self.hook(cava, "notify::values", () => self.queue_draw());
+ self.connect("size-allocate", () => {
+ const width = self.get_allocated_width();
+ const barWidth = self
+ .get_style_context()
+ .get_property("min-width", Gtk.StateFlags.NORMAL) as number;
+ const gaps = self.get_style_context().get_margin(Gtk.StateFlags.NORMAL).right;
+ const bars = Math.floor((width - gaps) / (barWidth + gaps));
+ if (bars > 0) cava.set_bars(bars);
+ });
+ }
+
+ self.connect("draw", (_, cr: cairo.Context) => {
+ const { width, height } = self.get_allocation();
+
+ if (!cava) {
+ // Show error text if cava unavailable
+ const fg = self.get_style_context().get_color(Gtk.StateFlags.NORMAL);
+ cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha);
+ const layout = self.create_pango_layout("Visualiser unavailable");
+ const [w, h] = layout.get_pixel_size();
+ cr.moveTo((width - w) / 2, (height - h) / 2);
+ cr.setAntialias(cairo.Antialias.BEST);
+ PangoCairo.show_layout(cr, layout);
+
+ return;
+ }
+
+ const bg = self.get_style_context().get_background_color(Gtk.StateFlags.NORMAL);
+ cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
+ const barWidth = self.get_style_context().get_property("min-width", Gtk.StateFlags.NORMAL) as number;
+ const gaps = self.get_style_context().get_margin(Gtk.StateFlags.NORMAL).right;
+
+ const values = cava.get_values();
+ const len = values.length - 1;
+ const radius = barWidth / 2;
+ const xOff = (width - len * (barWidth + gaps) - gaps) / 2 - radius;
+ const center = height / 2;
+ const half = Math.floor(len / 2);
+
+ // Render channels facing each other
+ for (let i = half - 1; i >= 0; i--) {
+ const x = (half - i) * (barWidth + gaps) + xOff;
+ const value = center * values[i];
+ cr.arc(x, center + value, radius, 0, Math.PI);
+ cr.arc(x, center - value, radius, Math.PI, Math.PI * 2);
+ cr.fill();
+ }
+
+ for (let i = half; i < len; i++) {
+ const x = (i + 1) * (barWidth + gaps) + xOff;
+ const value = center * values[i];
+ cr.arc(x, center + value, radius, 0, Math.PI);
+ cr.arc(x, center - value, radius, Math.PI, Math.PI * 2);
+ cr.fill();
+ }
+ });
+ }}
+ />
+);