Initial commit

parents
build/
builddir/
install_data('ximper-shell-osd.css',
install_dir: get_option('sysconfdir') / 'xdg' / 'ximper-shell' / 'osd',
rename: 'style.css',
)
.osd-window {
background: var(--window-bg-color);
color: var(--window-fg-color);
border-radius: 999px;
padding: 10px 15px;
}
.osd-progress trough {
min-height: 10px;
background: alpha(var(--accent-bg-color), 0.2);
}
.osd-progress trough progress {
min-height: 10px;
background: var(--accent-bg-color);
}
project('ximper-shell-osd', ['vala', 'c'],
version: '0.1.0',
meson_version: '>= 0.62.0',
)
subdir('src')
subdir('data')
conf_data = configuration_data()
conf_data.set('bindir', join_paths(get_option('prefix'), get_option('bindir')))
configure_file(
configuration: conf_data,
input: 'services/dbus/ru.ximperlinux.shell.OSD.service.in',
output: 'ru.ximperlinux.shell.OSD.service',
install_dir: get_option('datadir') / 'dbus-1' / 'services',
)
option('pulse-audio', type: 'boolean', value: true, description: 'Monitor PulseAudio volume changes.')
[D-BUS Service]
Name=ru.ximperlinux.shell.OSD
Exec=@bindir@/ximper-shell-osd
namespace XimperShellOsd {
public static string get_volume_icon (double percent) {
if (percent <= 0) return "audio-volume-muted-symbolic";
if (percent <= 33) return "audio-volume-low-symbolic";
if (percent <= 66) return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
public class Application : Adw.Application {
public ConfigManager config { get; private set; }
private OsdWindow? osd_window;
private OsdDBusService? dbus_service;
#if HAVE_PULSE_AUDIO
private PulseMonitor? pulse_monitor;
#endif
public Application () {
Object (
application_id: "ru.ximperlinux.shell.OSD",
flags: ApplicationFlags.FLAGS_NONE
);
}
protected override void startup () {
base.startup ();
config = new ConfigManager ();
config.load ();
load_css ();
}
protected override bool dbus_register (DBusConnection conn, string object_path) throws Error {
base.dbus_register (conn, object_path);
dbus_service = new OsdDBusService (this);
conn.register_object ("/ru/ximperlinux/shell/OSD", dbus_service);
return true;
}
protected override void activate () {
if (osd_window != null) return;
osd_window = new OsdWindow (this);
add_window (osd_window);
#if HAVE_PULSE_AUDIO
if (config.monitor_volume) {
pulse_monitor = new PulseMonitor ();
pulse_monitor.volume_changed.connect ((percent, is_muted) => {
if (is_muted) {
osd_window.show_osd (OsdType.VOLUME_MUTE, 0, "Muted");
} else {
var icon = get_volume_icon (percent);
osd_window.show_osd (OsdType.VOLUME, percent / 100.0, "%d%%".printf ((int) percent), icon);
}
});
pulse_monitor.start ();
}
#endif
hold ();
}
public void show_osd_external (OsdType type, double value, string label, string? icon_name = null) {
if (osd_window != null) {
osd_window.show_osd (type, value, label, icon_name);
}
}
protected override void shutdown () {
#if HAVE_PULSE_AUDIO
if (pulse_monitor != null) {
pulse_monitor.close ();
pulse_monitor = null;
}
#endif
base.shutdown ();
}
private void load_css () {
var css_path = find_css_path ();
if (css_path == null) {
warning ("Could not find CSS file!");
return;
}
var css_provider = new Gtk.CssProvider ();
css_provider.load_from_path (css_path);
Gtk.StyleContext.add_provider_for_display (
Gdk.Display.get_default (),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
);
}
private static string? find_css_path () {
string[] paths = {};
paths += Path.build_filename (
Environment.get_user_config_dir (), "ximper-shell", "osd", "style.css"
);
foreach (var dir in Environment.get_system_config_dirs ()) {
paths += Path.build_filename (dir, "ximper-shell", "osd", "style.css");
}
foreach (var path in paths) {
if (FileUtils.test (path, FileTest.EXISTS)) {
return path;
}
}
return null;
}
}
}
namespace XimperShellOsd {
public class ConfigManager : Object {
public int timeout_ms { get; set; default = 1500; }
public int margin_bottom { get; set; default = 80; }
public int width { get; set; default = 300; }
public bool monitor_volume { get; set; default = true; }
private string config_path;
public ConfigManager () {
config_path = Path.build_filename (
Environment.get_user_config_dir (), "ximper-shell", "osd", "config.json"
);
}
public void load () {
if (!FileUtils.test (config_path, FileTest.EXISTS)) {
return;
}
try {
var parser = new Json.Parser ();
parser.load_from_file (config_path);
var root = parser.get_root ().get_object ();
if (root.has_member ("timeout_ms"))
timeout_ms = (int) root.get_int_member ("timeout_ms");
if (root.has_member ("margin_bottom"))
margin_bottom = (int) root.get_int_member ("margin_bottom");
if (root.has_member ("width"))
width = (int) root.get_int_member ("width");
if (root.has_member ("monitor_volume"))
monitor_volume = root.get_boolean_member ("monitor_volume");
} catch (Error e) {
warning ("Failed to load OSD config: %s", e.message);
}
}
}
}
namespace XimperShellOsd {
[DBus (name = "ru.ximperlinux.shell.OSD")]
public class OsdDBusService : Object {
private weak Application app;
public OsdDBusService (Application app) {
this.app = app;
}
public void show_volume_osd (double percent, bool muted) throws DBusError, IOError {
if (muted) {
app.show_osd_external (OsdType.VOLUME_MUTE, 0, "Muted");
} else {
app.show_osd_external (
OsdType.VOLUME, percent / 100.0,
"%d%%".printf ((int) percent),
get_volume_icon (percent)
);
}
}
}
}
int main (string[] args) {
var app = new XimperShellOsd.Application ();
return app.run (args);
}
osd_deps = [
dependency('gtk4', version: '>= 4.12'),
dependency('libadwaita-1', version: '>= 1.4'),
dependency('gtk4-layer-shell-0', version: '>= 1.0'),
dependency('json-glib-1.0'),
dependency('gio-unix-2.0'),
]
osd_sources = files(
'main.vala',
'application.vala',
'config.vala',
'osd-window.vala',
'osd-content.vala',
'dbus-service.vala',
)
if get_option('pulse-audio')
add_project_arguments('-D', 'HAVE_PULSE_AUDIO', language: 'vala')
osd_deps += [
dependency('libpulse'),
dependency('libpulse-mainloop-glib'),
]
osd_sources += files(
'monitors/pulse-monitor.vala',
)
endif
executable('ximper-shell-osd', osd_sources,
dependencies: osd_deps,
c_args: ['-w'],
install: true,
)
using PulseAudio;
namespace XimperShellOsd {
public class PulseMonitor : Object {
private Context? context;
private GLibMainLoop mainloop;
private bool quitting = false;
private string? default_sink_name;
private double last_volume = -1;
private bool last_muted = false;
public signal void volume_changed (double percent, bool is_muted);
construct {
mainloop = new GLibMainLoop ();
}
public void start () {
connect_context ();
}
public void close () {
quitting = true;
if (context != null) {
context.disconnect ();
context = null;
}
}
~PulseMonitor () {
close ();
}
private void connect_context () {
var ctx = new Context (mainloop.get_api (), null);
ctx.set_state_callback ((ctx) => {
switch (ctx.get_state ()) {
case Context.State.READY:
ctx.set_subscribe_callback (on_subscription);
ctx.subscribe (
Context.SubscriptionMask.SINK |
Context.SubscriptionMask.SERVER
);
ctx.get_server_info (on_server_info);
break;
case Context.State.TERMINATED:
case Context.State.FAILED:
if (quitting) {
quitting = false;
break;
}
warning ("PulseAudio connection lost. Retrying...");
connect_context ();
break;
default:
break;
}
});
if (ctx.connect (null, Context.Flags.NOFAIL, null) < 0) {
warning ("pa_context_connect() failed: %s",
PulseAudio.strerror (ctx.errno ()));
}
this.context = ctx;
}
private void on_subscription (Context ctx,
Context.SubscriptionEventType t,
uint32 index) {
var type = t & Context.SubscriptionEventType.FACILITY_MASK;
var event = t & Context.SubscriptionEventType.TYPE_MASK;
switch (type) {
case Context.SubscriptionEventType.SINK:
if (event == Context.SubscriptionEventType.NEW ||
event == Context.SubscriptionEventType.CHANGE) {
ctx.get_sink_info_by_index (index, on_sink_info);
}
break;
case Context.SubscriptionEventType.SERVER:
ctx.get_server_info (on_server_info);
break;
default:
break;
}
}
private void on_server_info (Context ctx, ServerInfo? info) {
if (info == null) return;
default_sink_name = info.default_sink_name;
ctx.get_sink_info_list (on_sink_info);
}
private void on_sink_info (Context ctx, SinkInfo? info, int eol) {
if (info == null || eol != 0) return;
if (info.name != default_sink_name) return;
bool is_muted = info.mute == 1;
double volume = volume_to_double (info.volume.max ());
if (volume != last_volume || is_muted != last_muted) {
last_volume = volume;
last_muted = is_muted;
volume_changed (volume, is_muted);
}
}
private static double volume_to_double (PulseAudio.Volume vol) {
double tmp = (double) (vol - PulseAudio.Volume.MUTED);
return 100 * tmp / (double) (PulseAudio.Volume.NORM - PulseAudio.Volume.MUTED);
}
}
}
namespace XimperShellOsd {
public enum OsdType {
VOLUME,
VOLUME_MUTE;
}
public class OsdContent : Gtk.Box {
private Gtk.Image icon;
private Gtk.ProgressBar progress;
private Gtk.Label label;
public OsdContent () {
Object (
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
halign: Gtk.Align.FILL,
valign: Gtk.Align.CENTER,
hexpand: true
);
icon = new Gtk.Image ();
icon.pixel_size = 24;
icon.add_css_class ("osd-icon");
append (icon);
progress = new Gtk.ProgressBar ();
progress.hexpand = true;
progress.valign = Gtk.Align.CENTER;
progress.add_css_class ("osd-progress");
append (progress);
label = new Gtk.Label ("");
label.add_css_class ("osd-label");
label.halign = Gtk.Align.END;
label.width_chars = 4;
label.xalign = 1.0f;
append (label);
}
public void update (OsdType type, double value, string text, string? icon_name = null) {
if (type == OsdType.VOLUME_MUTE) {
icon.icon_name = "audio-volume-muted-symbolic";
} else {
icon.icon_name = icon_name ?? "audio-volume-high-symbolic";
}
progress.fraction = value.clamp (0.0, 1.0);
label.label = text;
}
}
}
namespace XimperShellOsd {
public class OsdWindow : Gtk.Window {
private weak Application app;
private OsdContent content;
private uint hide_timeout_id = 0;
private Adw.TimedAnimation? fade_in_anim;
private Adw.TimedAnimation? fade_out_anim;
private bool is_showing = false;
public OsdWindow (Application app) {
this.app = app;
add_css_class ("osd-window");
GtkLayerShell.init_for_window (this);
GtkLayerShell.set_layer (this, GtkLayerShell.Layer.OVERLAY);
GtkLayerShell.set_anchor (this, GtkLayerShell.Edge.BOTTOM, true);
GtkLayerShell.set_anchor (this, GtkLayerShell.Edge.LEFT, false);
GtkLayerShell.set_anchor (this, GtkLayerShell.Edge.RIGHT, false);
GtkLayerShell.set_margin (this, GtkLayerShell.Edge.BOTTOM, app.config.margin_bottom);
GtkLayerShell.set_exclusive_zone (this, -1);
GtkLayerShell.set_keyboard_mode (this, GtkLayerShell.KeyboardMode.NONE);
GtkLayerShell.set_namespace (this, "ximper-shell-osd");
set_size_request (app.config.width, -1);
content = new OsdContent ();
set_child (content);
set_visible (false);
opacity = 0;
}
public void show_osd (OsdType type, double value, string label, string? icon_name = null) {
content.update (type, value, label, icon_name);
cancel_hide ();
if (!is_showing) {
is_showing = true;
set_visible (true);
if (fade_out_anim != null) {
fade_out_anim.pause ();
fade_out_anim = null;
}
var target = new Adw.PropertyAnimationTarget (this, "opacity");
fade_in_anim = new Adw.TimedAnimation (this, opacity, 1.0, 150, target);
fade_in_anim.play ();
}
hide_timeout_id = Timeout.add (app.config.timeout_ms, () => {
hide_timeout_id = 0;
hide_animated ();
return Source.REMOVE;
});
}
private void hide_animated () {
if (fade_in_anim != null) {
fade_in_anim.pause ();
fade_in_anim = null;
}
var target = new Adw.PropertyAnimationTarget (this, "opacity");
fade_out_anim = new Adw.TimedAnimation (this, opacity, 0.0, 200, target);
fade_out_anim.done.connect (() => {
set_visible (false);
is_showing = false;
fade_out_anim = null;
});
fade_out_anim.play ();
}
private void cancel_hide () {
if (hide_timeout_id != 0) {
Source.remove (hide_timeout_id);
hide_timeout_id = 0;
}
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment