feat: add music visualiser
This commit is contained in:
parent
cbf613e42c
commit
17bee0e385
8 changed files with 413 additions and 10 deletions
2
app.tsx
2
app.tsx
|
|
@ -1,5 +1,6 @@
|
||||||
import Bar from "@/modules/bar";
|
import Bar from "@/modules/bar";
|
||||||
import Launcher from "@/modules/launcher";
|
import Launcher from "@/modules/launcher";
|
||||||
|
import MediaDisplay from "@/modules/mediadisplay";
|
||||||
import NavBar from "@/modules/navbar";
|
import NavBar from "@/modules/navbar";
|
||||||
import NotifPopups from "@/modules/notifpopups";
|
import NotifPopups from "@/modules/notifpopups";
|
||||||
import Osds from "@/modules/osds";
|
import Osds from "@/modules/osds";
|
||||||
|
|
@ -78,6 +79,7 @@ App.start({
|
||||||
<Osds />;
|
<Osds />;
|
||||||
<Session />;
|
<Session />;
|
||||||
Monitors.get_default().forEach(m => <NotifPopups monitor={m} />);
|
Monitors.get_default().forEach(m => <NotifPopups monitor={m} />);
|
||||||
|
Monitors.get_default().forEach(m => <MediaDisplay monitor={m} />);
|
||||||
Monitors.get_default().forEach(m => <SideBar monitor={m} />);
|
Monitors.get_default().forEach(m => <SideBar monitor={m} />);
|
||||||
Monitors.get_default().forEach(m => <NavBar monitor={m} />);
|
Monitors.get_default().forEach(m => <NavBar monitor={m} />);
|
||||||
Monitors.get_default().forEach(m => <Bar monitor={m} />);
|
Monitors.get_default().forEach(m => <Bar monitor={m} />);
|
||||||
|
|
|
||||||
139
scss/mediadisplay.scss
Normal file
139
scss/mediadisplay.scss
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
@use "sass:color";
|
||||||
|
@use "scheme";
|
||||||
|
@use "lib";
|
||||||
|
@use "font";
|
||||||
|
|
||||||
|
.mediadisplay {
|
||||||
|
@include font.mono;
|
||||||
|
|
||||||
|
background-color: scheme.$mantle;
|
||||||
|
color: scheme.$text;
|
||||||
|
padding: lib.s(20);
|
||||||
|
min-height: lib.s(200);
|
||||||
|
|
||||||
|
.visualiser {
|
||||||
|
background-color: scheme.$primary; // Visualiser colour
|
||||||
|
margin-right: lib.s(5); // Gaps between bars
|
||||||
|
min-width: lib.s(10); // Bar width
|
||||||
|
color: scheme.$error;
|
||||||
|
font-size: lib.s(24);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-art {
|
||||||
|
@include lib.rounded(10);
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
min-width: lib.s(196);
|
||||||
|
min-height: lib.s(196);
|
||||||
|
font-size: lib.s(96);
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: scheme.$surface0;
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
@include font.title;
|
||||||
|
|
||||||
|
font-size: lib.s(14);
|
||||||
|
margin-top: lib.s(5);
|
||||||
|
margin-left: lib.s(15);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: lib.s(28);
|
||||||
|
font-weight: 500;
|
||||||
|
color: scheme.$text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
font-size: lib.s(18);
|
||||||
|
color: scheme.$secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
@include lib.rounded(1000);
|
||||||
|
@include font.icon;
|
||||||
|
|
||||||
|
margin-top: lib.s(10);
|
||||||
|
background-color: color.change(scheme.$overlay0, $alpha: 0.4);
|
||||||
|
font-size: lib.s(28);
|
||||||
|
padding: lib.s(3) lib.s(8);
|
||||||
|
|
||||||
|
@include lib.spacing;
|
||||||
|
|
||||||
|
& > button {
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: color.mix(scheme.$subtext1, scheme.$subtext0, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-module {
|
||||||
|
@include lib.rounded(20);
|
||||||
|
margin: 0 lib.s(40);
|
||||||
|
background-color: color.change(scheme.$surface1, $alpha: 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
@include lib.rounded(15);
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
background-color: color.change(scheme.$overlay0, $alpha: 0.4);
|
||||||
|
padding: lib.s(8) lib.s(15);
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
@include lib.spacing(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: color.mix(scheme.$subtext1, scheme.$subtext0, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list > button {
|
||||||
|
margin-top: lib.s(5);
|
||||||
|
color: scheme.$subtext1;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: color.mix(scheme.$subtext1, scheme.$subtext0, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
@include lib.rounded(1000);
|
||||||
|
|
||||||
|
font-size: lib.s(16);
|
||||||
|
background-color: color.change(scheme.$overlay0, $alpha: 0.4);
|
||||||
|
padding: lib.s(5) lib.s(10);
|
||||||
|
margin-bottom: lib.s(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/modules/mediadisplay/index.tsx
Normal file
188
src/modules/mediadisplay/index.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
src/modules/mediadisplay/visualiser.tsx
Normal file
76
src/modules/mediadisplay/visualiser.tsx
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Players from "@/services/players";
|
import Players from "@/services/players";
|
||||||
|
import { lengthStr } from "@/utils/strings";
|
||||||
import { bindCurrentTime, osIcon } from "@/utils/system";
|
import { bindCurrentTime, osIcon } from "@/utils/system";
|
||||||
import Slider from "@/widgets/slider";
|
import Slider from "@/widgets/slider";
|
||||||
import { bind, GLib, monitorFile, Variable } from "astal";
|
import { bind, GLib, monitorFile, Variable } from "astal";
|
||||||
|
|
@ -7,11 +8,6 @@ import AstalMpris from "gi://AstalMpris";
|
||||||
import Notifications from "./modules/notifications";
|
import Notifications from "./modules/notifications";
|
||||||
import Upcoming from "./modules/upcoming";
|
import Upcoming from "./modules/upcoming";
|
||||||
|
|
||||||
const lengthStr = (length: number) =>
|
|
||||||
`${Math.floor(length / 60)}:${Math.floor(length % 60)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}`;
|
|
||||||
|
|
||||||
const noNull = (s: string | null) => s ?? "-";
|
const noNull = (s: string | null) => s ?? "-";
|
||||||
|
|
||||||
const FaceFallback = () => (
|
const FaceFallback = () => (
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import Players from "@/services/players";
|
import Players from "@/services/players";
|
||||||
|
import { lengthStr } from "@/utils/strings";
|
||||||
import Slider from "@/widgets/slider";
|
import Slider from "@/widgets/slider";
|
||||||
import { bind, timeout, Variable } from "astal";
|
import { bind, timeout, Variable } from "astal";
|
||||||
import { Gtk } from "astal/gtk3";
|
import { Gtk } from "astal/gtk3";
|
||||||
import AstalMpris from "gi://AstalMpris";
|
import AstalMpris from "gi://AstalMpris";
|
||||||
|
|
||||||
const lengthStr = (length: number) =>
|
|
||||||
`${Math.floor(length / 60)}:${Math.floor(length % 60)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}`;
|
|
||||||
|
|
||||||
const noNull = (s: string | null) => s ?? "-";
|
const noNull = (s: string | null) => s ?? "-";
|
||||||
|
|
||||||
const NoMedia = () => (
|
const NoMedia = () => (
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,8 @@ export const pathToFileName = (path: string, ext?: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
|
||||||
|
export const lengthStr = (length: number) =>
|
||||||
|
`${Math.floor(length / 60)}:${Math.floor(length % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
@use "scss/session";
|
@use "scss/session";
|
||||||
@use "scss/sidebar";
|
@use "scss/sidebar";
|
||||||
@use "scss/navbar";
|
@use "scss/navbar";
|
||||||
|
@use "scss/mediadisplay";
|
||||||
|
|
||||||
* {
|
* {
|
||||||
all: unset; // Remove GTK theme styles
|
all: unset; // Remove GTK theme styles
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue