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 ...@@ -34,6 +34,7 @@ These widgets can be customized, added, removed and even reordered
- Do Not Disturb - Do Not Disturb
- Notifications (Will always be visible) - Notifications (Will always be visible)
- Label - Label
- Mpris
## Planned Features ## Planned Features
......
...@@ -169,9 +169,15 @@ config file to be able to detect config errors ...@@ -169,9 +169,15 @@ config file to be able to detect config errors
optional: true ++ optional: true ++
*label*++ *label*++
optional: true ++ optional: true ++
description: Which order and which widgets to display. ++ *mpris*++
If the \"notifications\" widget isn't specified, it ++ optional: true ++
will be placed at the bottom. ++ 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: example:
``` ```
{ {
...@@ -186,6 +192,9 @@ config file to be able to detect config errors ...@@ -186,6 +192,9 @@ config file to be able to detect config errors
*widget-config* ++ *widget-config* ++
type: object ++ type: object ++
description: Configure specific widget properties. ++ 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: ++ Widgets to customize: ++
*title*++ *title*++
type: object ++ type: object ++
...@@ -232,6 +241,25 @@ config file to be able to detect config errors ...@@ -232,6 +241,25 @@ config file to be able to detect config errors
default: 5 ++ default: 5 ++
description: The maximum lines ++ description: The maximum lines ++
description: A generic widget that allows the user to add custom text. ++ 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: example:
``` ```
{ {
...@@ -247,6 +275,10 @@ config file to be able to detect config errors ...@@ -247,6 +275,10 @@ config file to be able to detect config errors
"label": { "label": {
"max-lines": 5, "max-lines": 5,
"text": "Label Text" "text": "Label Text"
},
"mpris": {
"image-size": 96,
"image-radius": 12
} }
} }
} }
......
...@@ -52,6 +52,10 @@ ...@@ -52,6 +52,10 @@
"label": { "label": {
"max-lines": 5, "max-lines": 5,
"text": "Label Text" "text": "Label Text"
},
"mpris": {
"image-size": 96,
"image-radius": 12
} }
} }
} }
...@@ -239,6 +239,9 @@ ...@@ -239,6 +239,9 @@
}, },
"^label(#[a-zA-Z0-9_-]{1,}){0,1}?$": { "^label(#[a-zA-Z0-9_-]{1,}){0,1}?$": {
"$ref": "#/widgets/label" "$ref": "#/widgets/label"
},
"^mpris(#[a-zA-Z0-9_-]{1,}){0,1}?$": {
"$ref": "#/widgets/mpris"
} }
} }
} }
...@@ -295,6 +298,23 @@ ...@@ -295,6 +298,23 @@
"default": 5 "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 { ...@@ -20,13 +20,15 @@ namespace SwayNotificationCenter {
private bool list_reverse = false; private bool list_reverse = false;
private Gtk.Align list_align = Gtk.Align.START; 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" }; private const string[] DEFAULT_WIDGETS = { "title", "dnd", "notifications" };
public ControlCenter (SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) { public ControlCenter (SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
this.swaync_daemon = swaync_daemon; this.swaync_daemon = swaync_daemon;
this.noti_daemon = noti_daemon; this.noti_daemon = noti_daemon;
this.swaync_daemon.reloading_css.connect (reload_notifications_style);
if (!GtkLayerShell.is_supported ()) { if (!GtkLayerShell.is_supported ()) {
stderr.printf ("GTKLAYERSHELL IS NOT SUPPORTED!\n"); stderr.printf ("GTKLAYERSHELL IS NOT SUPPORTED!\n");
stderr.printf ("Swaync only works on Wayland!\n"); stderr.printf ("Swaync only works on Wayland!\n");
...@@ -164,13 +166,12 @@ namespace SwayNotificationCenter { ...@@ -164,13 +166,12 @@ namespace SwayNotificationCenter {
continue; continue;
} }
// Add the widget if it is valid // Add the widget if it is valid
Gtk.Widget ? widget = Widgets.get_widget_from_key (key, Widgets.BaseWidget ? widget = Widgets.get_widget_from_key (
swaync_daemon, key, swaync_daemon, noti_daemon);
noti_daemon); if (widget == null) continue;
if (widget == null || !(widget is Widgets.BaseWidget)) continue;
widgets.append_val (widget); widgets.append_val (widget);
box.pack_start ( box.pack_start (widgets.index (widgets.length - 1),
widgets.index (widgets.length - 1), false, true, 0); false, true, 0);
} }
if (!has_notification) { if (!has_notification) {
warning ("Notification widget not included in \"widgets\" config. Using default bottom position"); warning ("Notification widget not included in \"widgets\" config. Using default bottom position");
...@@ -318,6 +319,11 @@ namespace SwayNotificationCenter { ...@@ -318,6 +319,11 @@ namespace SwayNotificationCenter {
} }
private void on_visibility_change () { 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) { if (this.visible) {
// Focus the first notification // Focus the first notification
list_position = list_reverse ? list_position = list_reverse ?
...@@ -393,7 +399,7 @@ namespace SwayNotificationCenter { ...@@ -393,7 +399,7 @@ namespace SwayNotificationCenter {
} }
/** Forces each notification EventBox to reload its style_context #27 */ /** 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 ()) { foreach (var c in list_box.get_children ()) {
Notification noti = (Notification) c; Notification noti = (Notification) c;
if (noti != null) noti.reload_style_context (); if (noti != null) noti.reload_style_context ();
......
...@@ -2,6 +2,12 @@ namespace SwayNotificationCenter.Widgets { ...@@ -2,6 +2,12 @@ namespace SwayNotificationCenter.Widgets {
public abstract class BaseWidget : Gtk.Box { public abstract class BaseWidget : Gtk.Box {
public abstract string widget_name { get; } 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 key { get; private set; }
public string suffix { get; private set; } public string suffix { get; private set; }
...@@ -14,7 +20,7 @@ namespace SwayNotificationCenter.Widgets { ...@@ -14,7 +20,7 @@ namespace SwayNotificationCenter.Widgets {
this.swaync_daemon = swaync_daemon; this.swaync_daemon = swaync_daemon;
this.noti_daemon = noti_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); if (suffix.length > 0) get_style_context ().add_class (suffix);
} }
...@@ -31,6 +37,8 @@ namespace SwayNotificationCenter.Widgets { ...@@ -31,6 +37,8 @@ namespace SwayNotificationCenter.Widgets {
return props; 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) { protected void get_prop<T> (Json.Object config, string value_key, ref T value) {
if (!config.has_member (value_key)) { if (!config.has_member (value_key)) {
warning ("%s: Config doesn't have key: %s!\n", key, value_key); warning ("%s: Config doesn't have key: %s!\n", key, value_key);
...@@ -51,19 +59,17 @@ namespace SwayNotificationCenter.Widgets { ...@@ -51,19 +59,17 @@ namespace SwayNotificationCenter.Widgets {
member.get_value_type ().name ()); member.get_value_type ().name ());
return; return;
} }
switch (typeof (T)) { switch (generic_base_type) {
case Type.STRING: case Type.STRING:
value = member.get_string (); value = member.get_string ();
break; return;
case Type.INT:
case Type.INT64: case Type.INT64:
value = member.get_int (); value = member.get_int ();
break; return;
case Type.BOOLEAN: case Type.BOOLEAN:
value = member.get_boolean (); value = member.get_boolean ();
break; return;
} }
return;
} }
} }
} }
namespace SwayNotificationCenter.Widgets { 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, SwayncDaemon swaync_daemon,
NotiDaemon noti_daemon) { NotiDaemon noti_daemon) {
string[] key_seperated = key.split ("#"); string[] key_seperated = key.split ("#");
...@@ -17,11 +17,14 @@ namespace SwayNotificationCenter.Widgets { ...@@ -17,11 +17,14 @@ namespace SwayNotificationCenter.Widgets {
case "label": case "label":
widget = new Label (suffix, swaync_daemon, noti_daemon); widget = new Label (suffix, swaync_daemon, noti_daemon);
break; break;
case "mpris":
widget = new Mpris.Mpris (suffix, swaync_daemon, noti_daemon);
break;
default: default:
warning ("Could not find widget: \"%s\"!", key); warning ("Could not find widget: \"%s\"!", key);
return null; return null;
} }
message ("Loading widget: %s", widget.key); message ("Loading widget: %s", widget.widget_name);
return widget; 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 { ...@@ -184,5 +184,83 @@ namespace SwayNotificationCenter {
} }
return type; 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 = [ ...@@ -26,10 +26,16 @@ widget_sources = [
# Helpers # Helpers
'controlCenter/widgets/baseWidget.vala', 'controlCenter/widgets/baseWidget.vala',
'controlCenter/widgets/factory.vala', 'controlCenter/widgets/factory.vala',
# Widgets # Widget: Title
'controlCenter/widgets/title/title.vala', 'controlCenter/widgets/title/title.vala',
# Widget: Dnd
'controlCenter/widgets/dnd/dnd.vala', 'controlCenter/widgets/dnd/dnd.vala',
# Widget: Label
'controlCenter/widgets/label/label.vala', '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 = [ app_sources = [
......
...@@ -223,3 +223,19 @@ ...@@ -223,3 +223,19 @@
.widget-label > label { .widget-label > label {
font-size: 1.1rem; 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 @@ ...@@ -5,5 +5,6 @@
<file>notification/notification.ui</file> <file>notification/notification.ui</file>
<file>controlCenter/controlCenter.ui</file> <file>controlCenter/controlCenter.ui</file>
<file>controlCenter/topAction/topAction.ui</file> <file>controlCenter/topAction/topAction.ui</file>
<file>controlCenter/widgets/mpris/mpris_player.ui</file>
</gresource> </gresource>
</gresources> </gresources>
...@@ -12,6 +12,9 @@ namespace SwayNotificationCenter { ...@@ -12,6 +12,9 @@ namespace SwayNotificationCenter {
private Array<BlankWindow> blank_windows = new Array<BlankWindow> (); private Array<BlankWindow> blank_windows = new Array<BlankWindow> ();
private unowned Gdk.Display ? display = Gdk.Display.get_default (); private unowned Gdk.Display ? display = Gdk.Display.get_default ();
[DBus (visible = false)]
public signal void reloading_css ();
public SwayncDaemon () { public SwayncDaemon () {
// Init noti_daemon // Init noti_daemon
this.noti_daemon = new NotiDaemon (this); this.noti_daemon = new NotiDaemon (this);
...@@ -110,7 +113,7 @@ namespace SwayNotificationCenter { ...@@ -110,7 +113,7 @@ namespace SwayNotificationCenter {
} }
[DBus (visible = false)] [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) { foreach (unowned BlankWindow win in blank_windows.data) {
if (win.monitor != monitor) win.show (); if (win.monitor != monitor) win.show ();
} }
...@@ -144,7 +147,7 @@ namespace SwayNotificationCenter { ...@@ -144,7 +147,7 @@ namespace SwayNotificationCenter {
/** Reloads the CSS file */ /** Reloads the CSS file */
public bool reload_css () throws Error { public bool reload_css () throws Error {
bool result = Functions.load_css (style_path); bool result = Functions.load_css (style_path);
if (result) noti_daemon.control_center.reload_notifications_style (); if (result) reloading_css ();
return result; 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