Unverified Commit 3bc2b5e5 authored by Erik Reider's avatar Erik Reider Committed by GitHub

Mpris Media Player widget (#157)

* Initial Mpris widget code * Fixed carousel not updating widgets when CC is hidden * Scroll to newest player when new player appears * Updated widget style * Clamp image radius * Use generic type when getting config value * Use Application icon as fallback * Use Appname as fallback * Show first artist in subtitle * Button opacity is now applied only to the image child * Treat Stopped state as paused * Fixed linting issues * Updated README * Updated config and config schema * Simplified player check * Updated Man page * Enable scrolling * Updated css to include title and subtitle
parent 5d7836a8
......@@ -34,6 +34,7 @@ These widgets can be customized, added, removed and even reordered
- Do Not Disturb
- Notifications (Will always be visible)
- Label
- Mpris
## Planned Features
......
......@@ -169,9 +169,15 @@ config file to be able to detect config errors
optional: true ++
*label*++
optional: true ++
description: Which order and which widgets to display. ++
If the \"notifications\" widget isn't specified, it ++
will be placed at the bottom. ++
*mpris*++
optional: true ++
description: ++
Which order and which widgets to display. ++
If the \"notifications\" widget isn't specified, it ++
will be placed at the bottom. ++
multiple of same widget: ++
Append a # with any value to the end of the widget name. ++
Example: "title#TheMainTitle" ++
example:
```
{
......@@ -186,6 +192,9 @@ config file to be able to detect config errors
*widget-config* ++
type: object ++
description: Configure specific widget properties. ++
multiple of same widget: ++
Append a # with any value to the end of the widget name. ++
Example: "title#TheMainTitle" ++
Widgets to customize: ++
*title*++
type: object ++
......@@ -232,6 +241,25 @@ config file to be able to detect config errors
default: 5 ++
description: The maximum lines ++
description: A generic widget that allows the user to add custom text. ++
*mpris*++
type: object ++
css classes: ++
widget-mpris ++
widget-mpris-player ++
widget-mpris-title ++
widget-mpris-subtitle ++
properties: ++
image-size: ++
type: integer ++
optional: true ++
default: 96 ++
description: The size of the album art. ++
image-radius: ++
type: integer ++
optional: true ++
default: 12 ++
description: The border radius of the album art. ++
description: A widget that displays multiple music players. ++
example:
```
{
......@@ -247,6 +275,10 @@ config file to be able to detect config errors
"label": {
"max-lines": 5,
"text": "Label Text"
},
"mpris": {
"image-size": 96,
"image-radius": 12
}
}
}
......
......@@ -52,6 +52,10 @@
"label": {
"max-lines": 5,
"text": "Label Text"
},
"mpris": {
"image-size": 96,
"image-radius": 12
}
}
}
......@@ -239,6 +239,9 @@
},
"^label(#[a-zA-Z0-9_-]{1,}){0,1}?$": {
"$ref": "#/widgets/label"
},
"^mpris(#[a-zA-Z0-9_-]{1,}){0,1}?$": {
"$ref": "#/widgets/mpris"
}
}
}
......@@ -295,6 +298,23 @@
"default": 5
}
}
},
"mpris": {
"type": "object",
"description": "A widget that displays multiple music players",
"additionalProperties": false,
"properties": {
"image-size": {
"type": "integer",
"description": "The size of the album art",
"default": 96
},
"image-radius": {
"type": "integer",
"description": "The border radius of the album art",
"default": 12
}
}
}
}
}
......@@ -20,13 +20,15 @@ namespace SwayNotificationCenter {
private bool list_reverse = false;
private Gtk.Align list_align = Gtk.Align.START;
private Array<Gtk.Widget> widgets = new Array<Gtk.Widget> ();
private Array<Widgets.BaseWidget> widgets = new Array<Widgets.BaseWidget> ();
private const string[] DEFAULT_WIDGETS = { "title", "dnd", "notifications" };
public ControlCenter (SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
this.swaync_daemon = swaync_daemon;
this.noti_daemon = noti_daemon;
this.swaync_daemon.reloading_css.connect (reload_notifications_style);
if (!GtkLayerShell.is_supported ()) {
stderr.printf ("GTKLAYERSHELL IS NOT SUPPORTED!\n");
stderr.printf ("Swaync only works on Wayland!\n");
......@@ -164,13 +166,12 @@ namespace SwayNotificationCenter {
continue;
}
// Add the widget if it is valid
Gtk.Widget ? widget = Widgets.get_widget_from_key (key,
swaync_daemon,
noti_daemon);
if (widget == null || !(widget is Widgets.BaseWidget)) continue;
Widgets.BaseWidget ? widget = Widgets.get_widget_from_key (
key, swaync_daemon, noti_daemon);
if (widget == null) continue;
widgets.append_val (widget);
box.pack_start (
widgets.index (widgets.length - 1), false, true, 0);
box.pack_start (widgets.index (widgets.length - 1),
false, true, 0);
}
if (!has_notification) {
warning ("Notification widget not included in \"widgets\" config. Using default bottom position");
......@@ -318,6 +319,11 @@ namespace SwayNotificationCenter {
}
private void on_visibility_change () {
// Updates all widgets on visibility change
foreach (var widget in widgets) {
widget.on_cc_visibility_change (visible);
}
if (this.visible) {
// Focus the first notification
list_position = list_reverse ?
......@@ -393,7 +399,7 @@ namespace SwayNotificationCenter {
}
/** Forces each notification EventBox to reload its style_context #27 */
public void reload_notifications_style () {
private void reload_notifications_style () {
foreach (var c in list_box.get_children ()) {
Notification noti = (Notification) c;
if (noti != null) noti.reload_style_context ();
......
......@@ -2,6 +2,12 @@ namespace SwayNotificationCenter.Widgets {
public abstract class BaseWidget : Gtk.Box {
public abstract string widget_name { get; }
public weak string css_class_name {
owned get {
return "widget-%s".printf (widget_name);
}
}
public string key { get; private set; }
public string suffix { get; private set; }
......@@ -14,7 +20,7 @@ namespace SwayNotificationCenter.Widgets {
this.swaync_daemon = swaync_daemon;
this.noti_daemon = noti_daemon;
get_style_context ().add_class ("widget-%s".printf (widget_name));
get_style_context ().add_class (css_class_name);
if (suffix.length > 0) get_style_context ().add_class (suffix);
}
......@@ -31,6 +37,8 @@ namespace SwayNotificationCenter.Widgets {
return props;
}
public virtual void on_cc_visibility_change (bool value) {}
protected void get_prop<T> (Json.Object config, string value_key, ref T value) {
if (!config.has_member (value_key)) {
warning ("%s: Config doesn't have key: %s!\n", key, value_key);
......@@ -51,19 +59,17 @@ namespace SwayNotificationCenter.Widgets {
member.get_value_type ().name ());
return;
}
switch (typeof (T)) {
switch (generic_base_type) {
case Type.STRING:
value = member.get_string ();
break;
case Type.INT:
return;
case Type.INT64:
value = member.get_int ();
break;
return;
case Type.BOOLEAN:
value = member.get_boolean ();
break;
return;
}
return;
}
}
}
namespace SwayNotificationCenter.Widgets {
public static Gtk.Widget ? get_widget_from_key (owned string key,
public static BaseWidget ? get_widget_from_key (owned string key,
SwayncDaemon swaync_daemon,
NotiDaemon noti_daemon) {
string[] key_seperated = key.split ("#");
......@@ -17,11 +17,14 @@ namespace SwayNotificationCenter.Widgets {
case "label":
widget = new Label (suffix, swaync_daemon, noti_daemon);
break;
case "mpris":
widget = new Mpris.Mpris (suffix, swaync_daemon, noti_daemon);
break;
default:
warning ("Could not find widget: \"%s\"!", key);
return null;
}
message ("Loading widget: %s", widget.key);
message ("Loading widget: %s", widget.widget_name);
return widget;
}
}
namespace SwayNotificationCenter.Widgets.Mpris {
public class MprisSource : Object {
public MprisMediaPlayer media_player { private set; get; }
private DbusPropChange props;
public signal void properties_changed (string iface,
HashTable<string, Variant> changed,
string[] invalid);
public const string INTERFACE_PATH = "/org/mpris/MediaPlayer2";
private MprisSource (MprisMediaPlayer _meddia_player, DbusPropChange _props) {
this.media_player = _meddia_player;
this.props = _props;
this.props.properties_changed.connect (
(i, c, inv) => properties_changed (i, c, inv));
}
public static MprisSource ? get_player (string bus_name) {
MprisMediaPlayer ? player;
DbusPropChange ? props;
try {
player = Bus.get_proxy_sync (BusType.SESSION, bus_name, INTERFACE_PATH);
} catch (Error e) {
message (e.message);
return null;
}
try {
props = Bus.get_proxy_sync (BusType.SESSION, bus_name, INTERFACE_PATH);
} catch (Error e) {
message (e.message);
return null;
}
if (player == null || props == null) return null;
return new MprisSource (player, props);
}
public Variant ? get_mpris_player_prop (string property_name) {
try {
return props.get ("org.mpris.MediaPlayer2.Player", property_name);
} catch (Error e) {}
return null;
}
public Variant ? get_mpris_prop (string property_name) {
try {
return props.get ("org.mpris.MediaPlayer2", property_name);
} catch (Error e) {}
return null;
}
}
/** MPRIS uses properties_changed for player changes */
[DBus (name = "org.freedesktop.DBus.Properties")]
public interface DbusPropChange : Object {
public signal void properties_changed (string iface,
HashTable<string, Variant> changed,
string[] invalid);
public abstract Variant get (string iface_name, string property_name) throws Error;
}
[DBus (name = "org.mpris.MediaPlayer2")]
public interface MprisProps : Object {
public abstract string desktop_entry { owned get; }
public abstract string identity { owned get; }
}
[DBus (name = "org.mpris.MediaPlayer2.Player")]
public interface MprisMediaPlayer : MprisProps {
public abstract void next () throws Error;
public abstract void previous () throws Error;
public abstract void pause () throws Error;
public abstract void play_pause () throws Error;
public abstract void stop () throws Error;
public abstract void play () throws Error;
public abstract string playback_status { owned get; }
public abstract HashTable<string, Variant> metadata { owned get; }
public abstract bool can_go_next { owned get; }
public abstract bool can_go_previous { owned get; }
public abstract bool can_play { owned get; }
public abstract bool can_pause { owned get; }
public abstract bool can_control { owned get; }
public abstract bool can_seek { owned get; }
public abstract bool shuffle { owned get; set; }
public abstract string loop_status { owned get; set; }
}
[DBus (name = "org.freedesktop.DBus")]
public interface DBusInterface : Object {
public abstract string[] list_names () throws GLib.Error;
public signal void name_owner_changed (string name,
string old_owner,
string new_owner);
}
}
namespace SwayNotificationCenter.Widgets.Mpris {
public struct Config {
int image_size;
int image_radius;
}
public class Mpris : BaseWidget {
public override string widget_name {
get {
return "mpris";
}
}
const string MPRIS_PREFIX = "org.mpris.MediaPlayer2.";
HashTable<string, MprisPlayer> players = new HashTable<string, MprisPlayer> (str_hash, str_equal);
DBusInterface dbus_iface;
Hdy.Carousel carousel;
Hdy.CarouselIndicatorDots carousel_dots;
// Default config values
Config mpris_config = Config () {
image_size = 96,
image_radius = 12,
};
public Mpris (string suffix, SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
base (suffix, swaync_daemon, noti_daemon);
set_orientation (Gtk.Orientation.VERTICAL);
set_valign (Gtk.Align.START);
set_vexpand (false);
carousel = new Hdy.Carousel ();
#if HAVE_LATEST_LIBHANDY
carousel.allow_scroll_wheel = true;
#endif
add (carousel);
carousel_dots = new Hdy.CarouselIndicatorDots ();
carousel_dots.set_carousel (carousel);
carousel_dots.show ();
add (carousel_dots);
// Config
Json.Object ? config = get_config (this);
if (config != null) {
// Get image-size
get_prop<int> (config, "image-size", ref mpris_config.image_size);
// Get image-border-radius
get_prop<int> (config, "image-radius", ref mpris_config.image_radius);
// Clamp the radius
mpris_config.image_radius = mpris_config.image_radius.clamp (
0, (int) (mpris_config.image_size * 0.5));
}
hide ();
try {
setup_mpris ();
} catch (Error e) {
error ("MPRIS Widget error: %s", e.message);
}
}
/**
* Forces the carousel to reload its style_context.
* Fixes carousel items not redrawing when window isn't visible.
* Probably related to: https://gitlab.gnome.org/GNOME/libhandy/-/issues/363
*/
public override void on_cc_visibility_change (bool value) {
if (!value) return;
carousel.get_style_context ().changed ();
foreach (var child in carousel.get_children ()) {
child.get_style_context ().changed ();
}
}
private void setup_mpris () throws Error {
dbus_iface = Bus.get_proxy_sync (BusType.SESSION,
"org.freedesktop.DBus",
"/org/freedesktop/DBus");
string[] names = dbus_iface.list_names ();
foreach (string name in names) {
if (!name.has_prefix (MPRIS_PREFIX)) continue;
if (check_player_exists (name)) return;
MprisSource ? source = MprisSource.get_player (name);
if (source != null) add_player (name, source);
}
dbus_iface.name_owner_changed.connect ((name, old_owner, new_owner) => {
if (!name.has_prefix (MPRIS_PREFIX)) return;
if (old_owner != "") {
remove_player (name);
return;
}
if (check_player_exists (name)) return;
MprisSource ? source = MprisSource.get_player (name);
if (source != null) add_player (name, source);
});
}
private bool check_player_exists (string name) {
foreach (string name_check in players.get_keys_as_array ()) {
if (name_check.has_prefix (name)
|| name.has_prefix (name_check)) return true;
}
return false;
}
private void add_player (string name, MprisSource source) {
MprisPlayer player = new MprisPlayer (source, mpris_config);
player.get_style_context ().add_class ("%s-player".printf (css_class_name));
carousel.prepend (player);
players.set (name, player);
if (!visible) show ();
// Scroll to the new player
carousel.scroll_to (player);
}
private void remove_player (string name) {
string ? key;
MprisPlayer ? player;
bool result = players.lookup_extended (name, out key, out player);
if (!result || key == null || player == null) return;
player.before_destroy ();
player.destroy ();
players.remove (name);
if (carousel.get_children ().length () == 0) hide ();
}
}
}
......@@ -184,5 +184,83 @@ namespace SwayNotificationCenter {
}
return type;
}
/** Scales the pixbuf to fit the given dimensions */
public static Gdk.Pixbuf scale_round_pixbuf (Gdk.Pixbuf pixbuf,
int buffer_width,
int buffer_height,
int img_scale,
int radius) {
Cairo.Surface surface = new Cairo.ImageSurface (Cairo.Format.ARGB32,
buffer_width,
buffer_height);
var cr = new Cairo.Context (surface);
// Border radius
const double DEGREES = Math.PI / 180.0;
cr.new_sub_path ();
cr.arc (buffer_width - radius, radius, radius, -90 * DEGREES, 0 * DEGREES);
cr.arc (buffer_width - radius, buffer_height - radius, radius, 0 * DEGREES, 90 * DEGREES);
cr.arc (radius, buffer_height - radius, radius, 90 * DEGREES, 180 * DEGREES);
cr.arc (radius, radius, radius, 180 * DEGREES, 270 * DEGREES);
cr.close_path ();
cr.set_source_rgb (0, 0, 0);
cr.clip ();
cr.paint ();
cr.save ();
Cairo.Surface scale_surf = Gdk.cairo_surface_create_from_pixbuf (pixbuf,
img_scale,
null);
int width = pixbuf.width;
int height = pixbuf.height;
double window_ratio = (double) buffer_width / buffer_height;
double bg_ratio = width / height;
if (window_ratio > bg_ratio) { // Taller wallpaper than monitor
double scale = (double) buffer_width / width;
if (scale * height < buffer_height) {
draw_scale_wide (buffer_width, width, buffer_height, height, cr, scale_surf);
} else {
draw_scale_tall (buffer_width, width, buffer_height, height, cr, scale_surf);
}
} else { // Wider wallpaper than monitor
double scale = (double) buffer_height / height;
if (scale * width < buffer_width) {
draw_scale_tall (buffer_width, width, buffer_height, height, cr, scale_surf);
} else {
draw_scale_wide (buffer_width, width, buffer_height, height, cr, scale_surf);
}
}
cr.paint ();
cr.restore ();
scale_surf.finish ();
return Gdk.pixbuf_get_from_surface (surface, 0, 0, buffer_width, buffer_height);
}
private static void draw_scale_tall (int buffer_width,
int width,
int buffer_height,
int height,
Cairo.Context cr,
Cairo.Surface surface) {
double scale = (double) buffer_width / width;
cr.scale (scale, scale);
cr.set_source_surface (surface,
0, (double) buffer_height / 2 / scale - height / 2);
}
private static void draw_scale_wide (int buffer_width,
int width,
int buffer_height,
int height,
Cairo.Context cr,
Cairo.Surface surface) {
double scale = (double) buffer_height / height;
cr.scale (scale, scale);
cr.set_source_surface (
surface,
(double) buffer_width / 2 / scale - width / 2, 0);
}
}
}
......@@ -26,10 +26,16 @@ widget_sources = [
# Helpers
'controlCenter/widgets/baseWidget.vala',
'controlCenter/widgets/factory.vala',
# Widgets
# Widget: Title
'controlCenter/widgets/title/title.vala',
# Widget: Dnd
'controlCenter/widgets/dnd/dnd.vala',
# Widget: Label
'controlCenter/widgets/label/label.vala',
# Widget: MPRIS
'controlCenter/widgets/mpris/mpris.vala',
'controlCenter/widgets/mpris/interfaces.vala',
'controlCenter/widgets/mpris/mpris_player.vala',
]
app_sources = [
......
......@@ -223,3 +223,19 @@
.widget-label > label {
font-size: 1.1rem;
}
/* Mpris widget */
.widget-mpris {
/* The parent to all players */
}
.widget-mpris-player {
padding: 8px;
margin: 8px;
}
.widget-mpris-title {
font-weight: bold;
font-size: 1.25rem;
}
.widget-mpris-subtitle {
font-size: 1.1rem;
}
......@@ -5,5 +5,6 @@
<file>notification/notification.ui</file>
<file>controlCenter/controlCenter.ui</file>
<file>controlCenter/topAction/topAction.ui</file>
<file>controlCenter/widgets/mpris/mpris_player.ui</file>
</gresource>
</gresources>
......@@ -12,6 +12,9 @@ namespace SwayNotificationCenter {
private Array<BlankWindow> blank_windows = new Array<BlankWindow> ();
private unowned Gdk.Display ? display = Gdk.Display.get_default ();
[DBus (visible = false)]
public signal void reloading_css ();
public SwayncDaemon () {
// Init noti_daemon
this.noti_daemon = new NotiDaemon (this);
......@@ -110,7 +113,7 @@ namespace SwayNotificationCenter {
}
[DBus (visible = false)]
public void show_blank_windows (Gdk.Monitor? monitor) {
public void show_blank_windows (Gdk.Monitor ? monitor) {
foreach (unowned BlankWindow win in blank_windows.data) {
if (win.monitor != monitor) win.show ();
}
......@@ -144,7 +147,7 @@ namespace SwayNotificationCenter {
/** Reloads the CSS file */
public bool reload_css () throws Error {
bool result = Functions.load_css (style_path);
if (result) noti_daemon.control_center.reload_notifications_style ();
if (result) reloading_css ();
return result;
}
......
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