Add playerctl v2 backends (#142)

* Add playerctl v2 backends

* Player should be the last arguement as it's optional

* Make v2 backwards compatible with v1

* Make cli v2 backwards compatible as well

* Delete playerctl and rename playerctl_v2 to playerctl

* Add deprecation for global signals

* Update the docs

* Player should be optional

* Fix image downloading failing on some cases
This commit is contained in:
Kasper 2022-01-08 07:33:24 +02:00 committed by GitHub
parent 298a6e6c8d
commit 338dba6292
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 838 additions and 355 deletions

View File

@ -17,27 +17,65 @@ This module relies on `playerctl` and `curl`. If you have this module disabled,
### Usage ### Usage
To enable: `bling.signal.playerctl.enable()` To enable: `playerctl = bling.signal.playerctl.lib/cli()`
To disable: `bling.signal.playerctl.disable()` To disable: `playerctl:disable()`
Here are the signals available:
Playerctl_lib signals available:
```lua ```lua
-- bling::playerctl::status -- first line is the signal -- metadata
-- playing (boolean) -- indented lines are function parameters -- title (string)
-- player_name (string) -- artist (string)
-- bling::playerctl::title_artist_album -- album_path (string)
-- title (string) -- album (string)
-- artist (string) -- new (bool)
-- album_path (string) -- player_name (string)
-- player_name (string) -- position
-- bling::playerctl::position -- interval_sec (number)
-- interval_sec (number) -- length_sec (number)
-- length_sec (number) -- player_name (string)
-- player_name (string) -- playback_status
-- bling::playerctl::no_players -- playing (boolean)
-- (No parameters) -- player_name (string)
-- seeked
-- position (number)
-- player_name (string)
-- volume
-- volume (number)
-- player_name (string)
-- loop_status
-- loop_status (string)
-- player_name (string)
-- shuffle
-- shuffle (boolean)
-- player_name (string)
-- exit
-- player_name (string)
-- no_players
-- (No parameters)
```
Playerctl_cli signals available:
```LUA
-- metadata
-- title (string)
-- artist (string)
-- album_path (string)
-- album (string)
-- player_name (string)
-- position
-- interval_sec (number)
-- length_sec (number)
-- playback_status
-- playing (boolean)
-- volume
-- volume (number)
-- loop_status
-- loop_status (string)
-- shuffle
-- shuffle (bool)
-- no_players
-- (No parameters)
``` ```
### Example Implementation ### Example Implementation
@ -74,10 +112,11 @@ local artist_widget = wibox.widget {
} }
-- Get Song Info -- Get Song Info
awesome.connect_signal("bling::playerctl::title_artist_album", playerctl = bling.signal.playerctl.lib()
function(title, artist, art_path, player_name) playerctl:connect_signal("metadata",
function(title, artist, album_path, album, new, player_name)
-- Set art widget -- Set art widget
art:set_image(gears.surface.load_uncached(art_path)) art:set_image(gears.surface.load_uncached(album_path))
-- Set player name, title and artist widgets -- Set player name, title and artist widgets
name_widget:set_markup_silently(player_name) name_widget:set_markup_silently(player_name)
@ -92,9 +131,11 @@ Here's another example in which you get a notification with the album art, title
```lua ```lua
local naughty = require("naughty") local naughty = require("naughty")
awesome.connect_signal("bling::playerctl::title_artist_album", playerctl:connect_signal("metadata",
function(title, artist, art_path, player_name) function(title, artist, album_path, album, new, player_name)
naughty.notify({title = title, text = artist, image = art_path}) if new == true then
naughty.notify({title = title, text = artist, image = album_path})
end
end) end)
``` ```
@ -103,13 +144,11 @@ By default, this module will output signals from the most recently active player
| Option | playerctl_cli | playerctl_lib | | Option | playerctl_cli | playerctl_lib |
| ------------------- | ------------------ | ------------------ | | ------------------- | ------------------ | ------------------ |
| backend | :heavy_check_mark: | :heavy_check_mark: | | ignore | :heavy_check_mark: | :heavy_check_mark: |
| ignore | | :heavy_check_mark: | | player | :heavy_check_mark: | :heavy_check_mark: |
| player | | :heavy_check_mark: |
| update_on_activity | | :heavy_check_mark: | | update_on_activity | | :heavy_check_mark: |
| interval | :heavy_check_mark: | :heavy_check_mark: | | interval | :heavy_check_mark: | :heavy_check_mark: |
| debounce_delay | :heavy_check_mark: | :heavy_check_mark: |
- `backend`: This is a string containing the name of the backend that will be used to produce the playerctl signals, either `playerctl_cli` or `playertl_lib`. `playerctl_cli` is used by default because it is supported on most if not all systems. That said, if the playerctl package for your distribution supports the `playerctl_lib` backend, it is recommended, because it supports all the configuration options as seen in the table above and uses less system resources. If you are not sure if your package supports the `playerctl_lib` backend you can simply try it and will receive an error message from Awesome upon calling `bling.signal.playerctl.enable()` if it is not supported. See the examples below for how to set configuration options.
- `ignore`: This option is either a string with a single name or a table of strings containing names of players that will be ignored by this module. It is empty by default. - `ignore`: This option is either a string with a single name or a table of strings containing names of players that will be ignored by this module. It is empty by default.
@ -119,9 +158,11 @@ By default, this module will output signals from the most recently active player
- `interval`: This option is a number specifying the update interval for fetching the player position. It is 1 by default. - `interval`: This option is a number specifying the update interval for fetching the player position. It is 1 by default.
These options can be set through a call to `bling.signal.playerctl.enable()` or these theme variables: - `debounce_delay`: This option is a number specifying the debounce timer interval. If a new metadata signal gets emitted before debounce_delay has passed, the last signal will be dropped.
This is to help with some players sending multiple signals. It is `0.35` by default.
These options can be set through a call to `bling.signal.playerctl.lib/cli()` or these theme variables:
```lua ```lua
theme.playerctl_backend = "playerctl_cli"
theme.playerctl_ignore = {} theme.playerctl_ignore = {}
theme.playerctl_player = {} theme.playerctl_player = {}
theme.playerctl_update_on_activity = true theme.playerctl_update_on_activity = true
@ -131,15 +172,13 @@ theme.playerctl_position_update_interval = 1
#### Example Configurations #### Example Configurations
```lua ```lua
-- Prioritize ncspot over all other players and ignore firefox players (e.g. YouTube and Twitch tabs) completely -- Prioritize ncspot over all other players and ignore firefox players (e.g. YouTube and Twitch tabs) completely
bling.signal.playerctl.enable { playerctl = bling.signal.playerctl.lib {
backend = "playerctl_lib",
ignore = "firefox", ignore = "firefox",
player = {"ncspot", "%any"} player = {"ncspot", "%any"}
} }
-- OR in your theme file: -- OR in your theme file:
-- Same config as above but with theme variables -- Same config as above but with theme variables
theme.playerctl_backend = "playerctl_lib"
theme.playerctl_ignore = "firefox" theme.playerctl_ignore = "firefox"
theme.playerctl_player = {"ncspot", "%any"} theme.playerctl_player = {"ncspot", "%any"}
@ -148,7 +187,6 @@ theme.playerctl_backend = "playerctl_lib"
theme.playerctl_player = {"vlc", "%any", "spotify"} theme.playerctl_player = {"vlc", "%any", "spotify"}
-- Disable priority of most recently active players -- Disable priority of most recently active players
theme.playerctl_backend = "playerctl_lib"
theme.playerctl_update_on_activity = false theme.playerctl_update_on_activity = false
-- Only emit the position signal every 2 seconds -- Only emit the position signal every 2 seconds

View File

@ -1,4 +1,6 @@
local Gio = require("lgi").Gio local Gio = require("lgi").Gio
local awful = require("awful")
local string = string
local _filesystem = {} local _filesystem = {}
@ -50,4 +52,11 @@ function _filesystem.list_directory_files(path, exts, recursive)
return files return files
end end
function _filesystem.save_image_async_curl(url, filepath, callback)
awful.spawn.with_line_callback(string.format("curl -L -s %s -o %s", url, filepath),
{
exit=callback
})
end
return _filesystem return _filesystem

View File

@ -1 +1,3 @@
return { playerctl = require(... .. ".playerctl") } return {
playerctl = require(... .. ".playerctl"),
}

View File

@ -1,4 +1,7 @@
local awful = require("awful")
local gtimer = require("gears.timer")
local beautiful = require("beautiful") local beautiful = require("beautiful")
local naughty = require("naughty")
-- Use CLI backend as default as it is supported on most if not all systems -- Use CLI backend as default as it is supported on most if not all systems
local backend_config = beautiful.playerctl_backend or "playerctl_cli" local backend_config = beautiful.playerctl_backend or "playerctl_cli"
@ -7,13 +10,37 @@ local backends = {
playerctl_lib = require(... .. ".playerctl_lib"), playerctl_lib = require(... .. ".playerctl_lib"),
} }
local backend = nil
local function enable_wrapper(args) local function enable_wrapper(args)
local open = naughty.action { name = "Open" }
open:connect_signal("invoked", function()
awful.spawn("xdg-open https://blingcorp.github.io/bling/#/signals/pctl")
end)
gtimer.delayed_call(function()
naughty.notify({
title = "Bling Error",
text = "Global signals are deprecated! Please take a look at the playerctl documentation.",
app_name = "Bling Error",
app_icon = "system-error",
actions = { open }
})
end)
backend_config = (args and args.backend) or backend_config backend_config = (args and args.backend) or backend_config
backends[backend_config].enable(args) backend = backends[backend_config](args)
return backend
end end
local function disable_wrapper() local function disable_wrapper()
backends[backend_config].disable() backend:disable()
end end
return { enable = enable_wrapper, disable = disable_wrapper } return {
lib = backends.playerctl_lib,
cli = backends.playerctl_cli,
enable = enable_wrapper,
disable = disable_wrapper
}

View File

@ -1,151 +1,348 @@
-- Playerctl signals
-- --
-- Provides: -- Provides:
-- bling::playerctl::status -- metadata
-- playing (boolean)
-- bling::playerctl::title_artist_album
-- title (string) -- title (string)
-- artist (string) -- artist (string)
-- album_path (string) -- album_path (string)
-- bling::playerctl::position -- album (string)
-- player_name (string)
-- position
-- interval_sec (number) -- interval_sec (number)
-- length_sec (number) -- length_sec (number)
-- bling::playerctl::no_players -- playback_status
-- -- playing (boolean)
-- volume
-- volume (number)
-- loop_status
-- loop_status (string)
-- shuffle
-- shuffle (bool)
-- no_players
-- (No parameters)
local awful = require("awful") local awful = require("awful")
local gobject = require("gears.object")
local gtable = require("gears.table")
local gtimer = require("gears.timer")
local gstring = require("gears.string")
local beautiful = require("beautiful") local beautiful = require("beautiful")
local helpers = require(tostring(...):match(".*bling") .. ".helpers")
local setmetatable = setmetatable
local tonumber = tonumber
local ipairs = ipairs
local type = type
local capi = { awesome = awesome }
local interval = beautiful.playerctl_position_update_interval or 1 local playerctl = { mt = {} }
local function emit_player_status() function playerctl:disable()
local status_cmd = "playerctl status -F" self._private.metadata_timer:stop()
self._private.metadata_timer = nil
-- Follow status awful.spawn.with_shell("killall playerctl")
awful.spawn.easy_async({
"pkill",
"--full",
"--uid",
os.getenv("USER"),
"^playerctl status",
}, function()
awful.spawn.with_line_callback(status_cmd, {
stdout = function(line)
local playing = false
if line:find("Playing") then
playing = true
else
playing = false
end
awesome.emit_signal("bling::playerctl::status", playing)
end,
})
collectgarbage("collect")
end)
end end
local function emit_player_info() function playerctl:pause(player)
local art_script = [[ if player ~= nil then
sh -c ' awful.spawn.with_shell("playerctl --player=" .. player .. " pause")
else
awful.spawn.with_shell(self._private.cmd .. "pause")
end
end
tmp_dir="$XDG_CACHE_HOME/awesome/" function playerctl:play(player)
if player ~= nil then
awful.spawn.with_shell("playerctl --player=" .. player .. " play")
else
awful.spawn.with_shell(self._private.cmd .. "play")
end
end
if [ -z ${XDG_CACHE_HOME} ]; then function playerctl:stop(player)
tmp_dir="$HOME/.cache/awesome/" if player ~= nil then
fi awful.spawn.with_shell("playerctl --player=" .. player .. " stop")
else
awful.spawn.with_shell(self._private.cmd .. "stop")
end
end
tmp_cover_path=${tmp_dir}"cover.png" function playerctl:play_pause(player)
if player ~= nil then
awful.spawn.with_shell("playerctl --player=" .. player .. " play-pause")
else
awful.spawn.with_shell(self._private.cmd .. "play-pause")
end
end
if [ ! -d $tmp_dir ]; then function playerctl:previous(player)
mkdir -p $tmp_dir if player ~= nil then
fi awful.spawn.with_shell("playerctl --player=" .. player .. " previous")
else
awful.spawn.with_shell(self._private.cmd .. "previous")
end
end
link="$(playerctl metadata mpris:artUrl)" function playerctl:next(player)
if player ~= nil then
awful.spawn.with_shell("playerctl --player=" .. player .. " next")
else
awful.spawn.with_shell(self._private.cmd .. "next")
end
end
curl -s "$link" --output $tmp_cover_path function playerctl:set_loop_status(loop_status, player)
if player ~= nil then
awful.spawn.with_shell("playerctl --player=" .. player .. " loop " .. loop_status)
else
awful.spawn.with_shell(self._private.cmd .. "loop " .. loop_status)
end
end
echo "$tmp_cover_path" function playerctl:cycle_loop_status(player)
']] local function set_loop_status(loop_status)
if loop_status == "None" then
self:set_loop_status("Track")
elseif loop_status == "Track" then
self:set_loop_status("Playlist")
elseif loop_status == "Playlist" then
self:set_loop_status("None")
end
end
-- Command that lists artist and title in a format to find and follow if player ~= nil then
local song_follow_cmd = awful.spawn.easy_async_with_shell("playerctl --player=" .. player .. " loop", function(stdout)
"playerctl metadata --format 'artist_{{artist}}title_{{title}}' -F" set_loop_status(stdout)
end)
else
set_loop_status(self._private.loop_status)
end
end
-- Progress Cmds function playerctl:set_position(position, player)
local prog_cmd = "playerctl position" if player ~= nil then
local length_cmd = "playerctl metadata mpris:length" awful.spawn.with_shell("playerctl --player=" .. player .. " position " .. position)
else
awful.spawn.with_shell(self._private.cmd .. "position " .. position)
end
end
awful.widget.watch(prog_cmd, interval, function(_, interval) function playerctl:set_shuffle(shuffle, player)
shuffle = shuffle and "on" or "off"
if player ~= nil then
awful.spawn.with_shell("playerctl --player=" .. player .. " shuffle " .. shuffle)
else
awful.spawn.with_shell(self._private.cmd .. "shuffle " .. shuffle)
end
end
function playerctl:cycle_shuffle(player)
if player ~= nil then
awful.spawn.easy_async_with_shell("playerctl --player=" .. player .. " shuffle", function(stdout)
local shuffle = stdout == "on" and true or false
self:set_shuffle(not self._private.shuffle)
end)
else
self:set_shuffle(not self._private.shuffle)
end
end
function playerctl:set_volume(volume, player)
if player ~= nil then
awful.spawn.with_shell("playerctl --player=" .. player .. " volume " .. volume)
else
awful.spawn.with_shell(self._private.cmd .. "volume " .. volume)
end
end
local function emit_player_metadata(self)
local metadata_cmd = self._private.cmd .. "metadata --format 'title_{{title}}artist_{{artist}}art_url_{{mpris:artUrl}}player_name_{{playerName}}album_{{album}}' -F"
awful.spawn.with_line_callback(metadata_cmd, {
stdout = function(line)
local title = gstring.xml_escape(line:match('title_(.*)artist_')) or ""
local artist = gstring.xml_escape(line:match('artist_(.*)art_url_')) or ""
local art_url = line:match('art_url_(.*)player_name_') or ""
local player_name = line:match('player_name_(.*)album_') or ""
local album = gstring.xml_escape(line:match('album_(.*)')) or ""
art_url = art_url:gsub('%\n', '')
if player_name == "spotify" then
art_url = art_url:gsub("open.spotify.com", "i.scdn.co")
end
if self._private.metadata_timer
and self._private.metadata_timer.started
then
self._private.metadata_timer:stop()
end
self._private.metadata_timer = gtimer {
timeout = self.debounce_delay,
autostart = true,
single_shot = true,
callback = function()
if title and title ~= "" then
if art_url ~= "" then
local art_path = os.tmpname()
helpers.filesystem.save_image_async_curl(art_url, art_path, function()
self:emit_signal("metadata", title, artist, art_path, album, player_name)
capi.awesome.emit_signal("bling::playerctl::title_artist_album", title, artist, art_path)
end)
else
self:emit_signal("metadata", title, artist, "", album, player_name)
capi.awesome.emit_signal("bling::playerctl::title_artist_album", title, artist, "")
end
else
self:emit_signal("no_players")
capi.awesome.emit_signal("bling::playerctl::no_players")
end
end
}
collectgarbage("collect")
end,
})
end
local function emit_player_position(self)
local position_cmd = self._private.cmd .. "position"
local length_cmd = self._private.cmd .. "metadata mpris:length"
awful.widget.watch(position_cmd, self.interval, function(_, interval)
awful.spawn.easy_async_with_shell(length_cmd, function(length) awful.spawn.easy_async_with_shell(length_cmd, function(length)
local length_sec = tonumber(length) -- in microseconds local length_sec = tonumber(length) -- in microseconds
local interval_sec = tonumber(interval) -- in seconds local interval_sec = tonumber(interval) -- in seconds
if length_sec and interval_sec then if length_sec and interval_sec then
if interval_sec >= 0 and length_sec > 0 then if interval_sec >= 0 and length_sec > 0 then
awesome.emit_signal( self:emit_signal("position", interval_sec, length_sec / 1000000)
"bling::playerctl::position", capi.awesome.emit_signal("bling::playerctl::position", interval_sec, length_sec / 1000000)
interval_sec,
length_sec / 1000000
)
end end
end end
end) end)
collectgarbage("collect") collectgarbage("collect")
end) end)
-- Follow title
awful.spawn.easy_async({
"pkill",
"--full",
"--uid",
os.getenv("USER"),
"^playerctl metadata",
}, function()
awful.spawn.with_line_callback(song_follow_cmd, {
stdout = function(line)
local album_path = ""
awful.spawn.easy_async_with_shell(art_script, function(out)
-- Get album path
album_path = out:gsub("%\n", "")
-- Get title and artist
local artist = line:match("artist_(.*)title_")
local title = line:match("title_(.*)")
-- If the title is nil or empty then the players stopped
if title and title ~= "" then
awesome.emit_signal(
"bling::playerctl::title_artist_album",
title,
artist,
album_path
)
else
awesome.emit_signal("bling::playerctl::no_players")
end
end)
collectgarbage("collect")
end,
})
collectgarbage("collect")
end)
end end
-- Emit info local function emit_player_playback_status(self)
-- emit_player_status() local status_cmd = self._private.cmd .. "status -F"
-- emit_player_info()
local enable = function(args) awful.spawn.with_line_callback(status_cmd, {
interval = (args and args.interval) or interval stdout = function(line)
emit_player_status() if line:find("Playing") then
emit_player_info() self:emit_signal("playback_status", true)
capi.awesome.emit_signal("bling::playerctl::status", true)
else
self:emit_signal("playback_status", false)
capi.awesome.emit_signal("bling::playerctl::status", false)
end
end,
})
end end
local disable = function() local function emit_player_volume(self)
awful.spawn.with_shell( local volume_cmd = self._private.cmd .. "volume -F"
"pkill --full --uid " .. os.getenv("USER") .. " '^playerctl status -F'"
)
awful.spawn.with_shell( awful.spawn.with_line_callback(volume_cmd, {
"pkill --full --uid " stdout = function(line)
.. os.getenv("USER") self:emit_signal("volume", tonumber(line))
.. " '^playerctl metadata --format'" end,
) })
end end
return { enable = enable, disable = disable } local function emit_player_loop_status(self)
local loop_status_cmd = self._private.cmd .. "loop -F"
awful.spawn.with_line_callback(loop_status_cmd, {
stdout = function(line)
self._private.loop_status = line
self:emit_signal("loop_status", line:lower())
end,
})
end
local function emit_player_shuffle(self)
local shuffle_cmd = self._private.cmd .. "shuffle -F"
awful.spawn.with_line_callback(shuffle_cmd, {
stdout = function(line)
if line:find("On") then
self._private.shuffle = true
self:emit_signal("shuffle", true)
else
self._private.shuffle = false
self:emit_signal("shuffle", false)
end
end,
})
end
local function parse_args(self, args)
if args.player then
self._private.cmd = self._private.cmd .. "--player="
if type(args.player) == "string" then
self._private.cmd = self._private.cmd .. args.player .. " "
elseif type(args.player) == "table" then
for index, player in ipairs(args.player) do
self._private.cmd = self._private.cmd .. player
if index < #args.player then
self._private.cmd = self._private.cmd .. ","
else
self._private.cmd = self._private.cmd .. " "
end
end
end
end
if args.ignore then
self._private.cmd = self._private.cmd .. "--ignore-player="
if type(args.ignore) == "string" then
self._private.cmd = self._private.cmd .. args.ignore .. " "
elseif type(args.ignore) == "table" then
for index, player in ipairs(args.ignore) do
self._private.cmd = self._private.cmd .. player
if index < #args.ignore then
self._private.cmd = self._private.cmd .. ","
else
self._private.cmd = self._private.cmd .. " "
end
end
end
end
end
local function new(args)
args = args or {}
local ret = gobject{}
gtable.crush(ret, playerctl, true)
ret.interval = args.interval or beautiful.playerctl_position_update_interval or 1
ret.debounce_delay = args.debounce_delay or beautiful.playerctl_debounce_delay or 0.35
ret._private = {}
ret._private.metadata_timer = nil
ret._private.cmd = "playerctl "
parse_args(ret, args)
emit_player_metadata(ret)
emit_player_position(ret)
emit_player_playback_status(ret)
emit_player_volume(ret)
emit_player_loop_status(ret)
emit_player_shuffle(ret)
return ret
end
function playerctl.mt:__call(...)
return new(...)
end
-- On startup instead of on playerctl object init to make it
-- possible to have more than one of these running
awful.spawn.with_shell("killall playerctl")
return setmetatable(playerctl, playerctl.mt)

View File

@ -1,88 +1,209 @@
-- Playerctl signals -- Playerctl signals
-- --
-- Provides: -- Provides:
-- bling::playerctl::status -- metadata
-- playing (boolean)
-- player_name (string)
-- bling::playerctl::title_artist_album
-- title (string) -- title (string)
-- artist (string) -- artist (string)
-- album_path (string) -- album_path (string)
-- album (string)
-- new (bool)
-- player_name (string) -- player_name (string)
-- bling::playerctl::position -- position
-- interval_sec (number) -- interval_sec (number)
-- length_sec (number) -- length_sec (number)
-- player_name (string) -- player_name (string)
-- bling::playerctl::no_players -- playback_status
-- playing (boolean)
-- player_name (string)
-- seeked
-- position (number)
-- player_name (string)
-- volume
-- volume (number)
-- player_name (string)
-- loop_status
-- loop_status (string)
-- player_name (string)
-- shuffle
-- shuffle (boolean)
-- player_name (string)
-- exit
-- player_name (string)
-- no_players
-- (No parameters) -- (No parameters)
local gears = require("gears")
local awful = require("awful") local awful = require("awful")
local gobject = require("gears.object")
local gtable = require("gears.table")
local gtimer = require("gears.timer")
local gstring = require("gears.string")
local beautiful = require("beautiful") local beautiful = require("beautiful")
local Playerctl = nil local helpers = require(tostring(...):match(".*bling") .. ".helpers")
local setmetatable = setmetatable
local ipairs = ipairs
local pairs = pairs
local type = type
local capi = { awesome = awesome }
local manager = nil local playerctl = { mt = {} }
local metadata_timer = nil
local position_timer = nil
local ignore = {} function playerctl:disable()
local priority = {} -- Restore default settings
local update_on_activity = true self.ignore = {}
local interval = 1 self.priority = {}
self.update_on_activity = true
self.interval = 1
self.debounce_delay = 0.35
-- Track position callback -- Reset timers
local last_position = -1 self._private.manager = nil
local last_length = -1 self._private.metadata_timer:stop()
local function position_cb() self._private.metadata_timer = nil
local player = manager.players[1] self._private.position_timer:stop()
self._private.position_timer = nil
-- Reset default values
self._private.last_position = -1
self._private.last_length = -1
self._private.last_player = nil
self._private.last_title = ""
self._private.last_artist = ""
self._private.last_artUrl = ""
end
function playerctl:pause(player)
player = player or self._private.manager.players[1]
if player then if player then
local position = player:get_position() / 1000000 player:pause()
local length = (player.metadata.value["mpris:length"] or 0) / 1000000 end
if position ~= last_position or length ~= last_length then end
awesome.emit_signal(
"bling::playerctl::position", function playerctl:play(player)
position, player = player or self._private.manager.players[1]
length, if player then
player.player_name player:play()
) end
last_position = position end
last_length = length
function playerctl:stop(player)
player = player or self._private.manager.players[1]
if player then
player:stop()
end
end
function playerctl:play_pause(player)
player = player or self._private.manager.players[1]
if player then
player:play_pause()
end
end
function playerctl:previous(player)
player = player or self._private.manager.players[1]
if player then
player:previous()
end
end
function playerctl:next(player)
player = player or self._private.manager.players[1]
if player then
player:next()
end
end
function playerctl:set_loop_status(loop_status, player)
player = player or self._private.manager.players[1]
if player then
player:set_loop_status(loop_status)
end
end
function playerctl:cycle_loop_status(player)
player = player or self._private.manager.players[1]
if player then
if player.loop_status == "NONE" then
player:set_loop_status("TRACK")
elseif player.loop_status == "TRACK" then
player:set_loop_status("PLAYLIST")
elseif player.loop_status == "PLAYLIST" then
player:set_loop_status("NONE")
end end
end end
end end
local function get_album_art(url) function playerctl:set_position(position, player)
return awful.util.shell player = player or self._private.manager.players[1]
.. [[ -c ' if player then
player:set_position(position * 1000000)
tmp_dir="$XDG_CACHE_HOME/awesome/" end
if [ -z "$XDG_CACHE_HOME" ]; then
tmp_dir="$HOME/.cache/awesome/"
fi
tmp_cover_path="${tmp_dir}cover.png"
if [ ! -d "$tmp_dir" ]; then
mkdir -p $tmp_dir
fi
curl -s ']]
.. url
.. [[' --output $tmp_cover_path
echo "$tmp_cover_path"
']]
end end
-- Metadata callback for title, artist, and album art function playerctl:set_shuffle(shuffle, player)
local last_player = nil player = player or self._private.manager.players[1]
local last_title = "" if player then
local last_artist = "" player:set_shuffle(shuffle)
local last_artUrl = "" end
local function metadata_cb(player, metadata) end
if update_on_activity then
manager:move_player_to_top(player) function playerctl:cycle_shuffle(player)
player = player or self._private.manager.players[1]
if player then
player:set_shuffle(not player.shuffle)
end
end
function playerctl:set_volume(volume, player)
player = player or self._private.manager.players[1]
if player then
player:set_volume(volume)
end
end
function playerctl:get_manager()
return self._private.manager
end
function playerctl:get_active_player()
return self._private.manager.players[1]
end
function playerctl:get_player_of_name(name)
for _, player in ipairs(self._private.manager.players[1]) do
if player.name == name then
return player
end
end
return nil
end
local function emit_metadata_signal(self, title, artist, artUrl, album, new, player_name)
title = gstring.xml_escape(title)
artist = gstring.xml_escape(artist)
album = gstring.xml_escape(album)
-- Spotify client doesn't report its art URL's correctly...
if player_name == "spotify" then
artUrl = artUrl:gsub("open.spotify.com", "i.scdn.co")
end
if artUrl ~= "" then
local art_path = os.tmpname()
helpers.filesystem.save_image_async_curl(artUrl, art_path, function()
self:emit_signal("metadata", title, artist, art_path, album, new, player_name)
capi.awesome.emit_signal("bling::playerctl::title_artist_album", title, artist, art_path, player_name)
end)
else
capi.awesome.emit_signal("bling::playerctl::title_artist_album", title, artist, "", player_name)
self:emit_signal("metadata", title, artist, "", album, new, player_name)
end
end
local function metadata_cb(self, player, metadata)
if self.update_on_activity then
self._private.manager:move_player_to_top(player)
end end
local data = metadata.value local data = metadata.value
@ -93,101 +214,136 @@ local function metadata_cb(player, metadata)
artist = artist .. ", " .. data["xesam:artist"][i] artist = artist .. ", " .. data["xesam:artist"][i]
end end
local artUrl = data["mpris:artUrl"] or "" local artUrl = data["mpris:artUrl"] or ""
-- Spotify client doesn't report its art URL's correctly... local album = data["xesam:album"] or ""
if player.player_name == "spotify" then
artUrl = artUrl:gsub("open.spotify.com", "i.scdn.co") if player == self._private.manager.players[1] then
end self._private.active_player = player
if player == manager.players[1] then
-- Callback can be called even though values we care about haven't -- Callback can be called even though values we care about haven't
-- changed, so check to see if they have -- changed, so check to see if they have
if if
player ~= last_player player ~= self._private.last_player
or title ~= last_title or title ~= self._private.last_title
or artist ~= last_artist or artist ~= self._private.last_artist
or artUrl ~= last_artUrl or artUrl ~= self._private.last_artUrl
then then
if title == "" and artist == "" and artUrl == "" then if (title == "" and artist == "" and artUrl == "") then return end
return
if self._private.metadata_timer ~= nil and self._private.metadata_timer.started then
self._private.metadata_timer:stop()
end end
if metadata_timer ~= nil then self._private.metadata_timer = gtimer {
if metadata_timer.started then timeout = self.debounce_delay,
metadata_timer:stop()
end
end
metadata_timer = gears.timer({
timeout = 0.3,
autostart = true, autostart = true,
single_shot = true, single_shot = true,
callback = function() callback = function()
if artUrl ~= "" then emit_metadata_signal(self, title, artist, artUrl, album, true, player.player_name)
awful.spawn.with_line_callback(get_album_art(artUrl), { end
stdout = function(line) }
awesome.emit_signal(
"bling::playerctl::title_artist_album",
title,
artist,
line,
player.player_name
)
end,
})
else
awesome.emit_signal(
"bling::playerctl::title_artist_album",
title,
artist,
"",
player.player_name
)
end
end,
})
-- Re-sync with position timer when track changes -- Re-sync with position timer when track changes
position_timer:again() self._private.position_timer:again()
last_player = player self._private.last_player = player
last_title = title self._private.last_title = title
last_artist = artist self._private.last_artist = artist
last_artUrl = artUrl self._private.last_artUrl = artUrl
end end
end end
end end
-- Playback status callback local function position_cb(self)
-- Reported as PLAYING, PAUSED, or STOPPED local player = self._private.manager.players[1]
local function playback_status_cb(player, status) if player then
if update_on_activity then
manager:move_player_to_top(player) local position = player:get_position() / 1000000
local length = (player.metadata.value["mpris:length"] or 0) / 1000000
if position ~= self._private.last_position or length ~= self._private.last_length then
capi.awesome.emit_signal("bling::playerctl::position", position, length, player.player_name)
self:emit_signal("position", position, length, player.player_name)
self._private.last_position = position
self._private.last_length = length
end
end
end
local function playback_status_cb(self, player, status)
if self.update_on_activity then
self._private.manager:move_player_to_top(player)
end end
if player == manager.players[1] then if player == self._private.manager.players[1] then
self._private.active_player = player
-- Reported as PLAYING, PAUSED, or STOPPED
if status == "PLAYING" then if status == "PLAYING" then
awesome.emit_signal( self:emit_signal("playback_status", true, player.player_name)
"bling::playerctl::status", capi.awesome.emit_signal("bling::playerctl::status", true, player.player_name)
true,
player.player_name
)
else else
awesome.emit_signal( self:emit_signal("playback_status", false, player.player_name)
"bling::playerctl::status", capi.awesome.emit_signal("bling::playerctl::status", false, player.player_name)
false,
player.player_name
)
end end
end end
end end
local function seeked_cb(self, player, position)
if self.update_on_activity then
self._private.manager:move_player_to_top(player)
end
if player == self._private.manager.players[1] then
self._private.active_player = player
self:emit_signal("seeked", position / 1000000, player.player_name)
end
end
local function volume_cb(self, player, volume)
if self.update_on_activity then
self._private.manager:move_player_to_top(player)
end
if player == self._private.manager.players[1] then
self._private.active_player = player
self:emit_signal("volume", volume, player.player_name)
end
end
local function loop_status_cb(self, player, loop_status)
if self.update_on_activity then
self._private.manager:move_player_to_top(player)
end
if player == self._private.manager.players[1] then
self._private.active_player = player
self:emit_signal("loop_status", loop_status:lower(), player.player_name)
end
end
local function shuffle_cb(self, player, shuffle)
if self.update_on_activity then
self._private.manager:move_player_to_top(player)
end
if player == self._private.manager.players[1] then
self._private.active_player = player
self:emit_signal("shuffle", shuffle, player.player_name)
end
end
local function exit_cb(self, player)
if player == self._private.manager.players[1] then
self:emit_signal("exit", player.player_name)
end
end
-- Determine if player should be managed -- Determine if player should be managed
local function name_is_selected(name) local function name_is_selected(self, name)
if ignore[name.name] then if self.ignore[name.name] then
return false return false
end end
if #priority > 0 then if #self.priority > 0 then
for _, arg in pairs(priority) do for _, arg in pairs(self.priority) do
if arg == name.name or arg == "%any" then if arg == name.name or arg == "%any" then
return true return true
end end
@ -199,23 +355,42 @@ local function name_is_selected(name)
end end
-- Create new player and connect it to callbacks -- Create new player and connect it to callbacks
local function init_player(name) local function init_player(self, name)
if name_is_selected(name) then if name_is_selected(self, name) then
local player = Playerctl.Player.new_from_name(name) local player = self._private.lgi_Playerctl.Player.new_from_name(name)
manager:manage_player(player) self._private.manager:manage_player(player)
player.on_playback_status = playback_status_cb player.on_metadata = function(player, metadata)
player.on_metadata = metadata_cb metadata_cb(self, player, metadata)
end
player.on_playback_status = function(player, playback_status)
playback_status_cb(self, player, playback_status)
end
player.on_seeked = function(player, position)
seeked_cb(self, player, position)
end
player.on_volume = function(player, volume)
volume_cb(self, player, volume)
end
player.on_loop_status = function(player, loop_status)
loop_status_cb(self, player, loop_status)
end
player.on_shuffle = function(player, shuffle_status)
shuffle_cb(self, player, shuffle_status)
end
player.on_exit = function(player, shuffle_status)
exit_cb(self, player)
end
-- Start position timer if its not already running -- Start position timer if its not already running
if not position_timer.started then if not self._private.position_timer.started then
position_timer:again() self._private.position_timer:again()
end end
end end
end end
-- Determine if a player name comes before or after another according to the -- Determine if a player name comes before or after another according to the
-- priority order -- priority order
local function player_compare_name(name_a, name_b) local function player_compare_name(self, name_a, name_b)
local any_index = math.huge local any_index = math.huge
local a_match_index = nil local a_match_index = nil
local b_match_index = nil local b_match_index = nil
@ -224,7 +399,7 @@ local function player_compare_name(name_a, name_b)
return 0 return 0
end end
for index, name in ipairs(priority) do for index, name in ipairs(self.priority) do
if name == "%any" then if name == "%any" then
any_index = (any_index == math.huge) and index or any_index any_index = (any_index == math.huge) and index or any_index
elseif name == name_a then elseif name == name_a then
@ -248,103 +423,138 @@ local function player_compare_name(name_a, name_b)
end end
-- Sorting function used by manager if a priority order is specified -- Sorting function used by manager if a priority order is specified
local function player_compare(a, b) local function player_compare(self, a, b)
local player_a = Playerctl.Player(a) local player_a = self._private.lgi_Playerctl.Player(a)
local player_b = Playerctl.Player(b) local player_b = self._private.lgi_Playerctl.Player(b)
return player_compare_name(player_a.player_name, player_b.player_name) return player_compare_name(self, player_a.player_name, player_b.player_name)
end end
local function start_manager() local function get_current_player_info(self, player)
manager = Playerctl.PlayerManager() local title = player:get_title() or ""
if #priority > 0 then local artist = player:get_artist() or ""
manager:set_sort_func(player_compare) local artUrl = player:print_metadata_prop("mpris:artUrl") or ""
local album = player:get_album() or ""
emit_metadata_signal(self, title, artist, artUrl, album, false, player.player_name)
playback_status_cb(self, player, player.playback_status)
volume_cb(self, player, player.volume)
loop_status_cb(self, player, player.loop_status)
shuffle_cb(self, player, player.shuffle)
end
local function start_manager(self)
self._private.manager = self._private.lgi_Playerctl.PlayerManager()
if #self.priority > 0 then
self._private.manager:set_sort_func(function(a, b)
return player_compare(self, a, b)
end)
end end
-- Timer to update track position at specified interval -- Timer to update track position at specified interval
position_timer = gears.timer({ self._private.position_timer = gtimer {
timeout = interval, timeout = self.interval,
callback = position_cb, callback = function()
}) position_cb(self)
end,
}
-- Manage existing players on startup -- Manage existing players on startup
for _, name in ipairs(manager.player_names) do for _, name in ipairs(self._private.manager.player_names) do
init_player(name) init_player(self, name)
end end
if self._private.manager.players[1] then
get_current_player_info(self, self._private.manager.players[1])
end
local _self = self
-- Callback to manage new players -- Callback to manage new players
function manager:on_name_appeared(name) function self._private.manager:on_name_appeared(name)
init_player(name) init_player(_self, name)
end end
-- Callback to check if all players have exited function self._private.manager:on_player_appeared(player)
function manager:on_name_vanished(name) if player == self.players[1] then
if #manager.players == 0 then _self._private.active_player = player
metadata_timer:stop() end
position_timer:stop() end
awesome.emit_signal("bling::playerctl::no_players")
function self._private.manager:on_player_vanished(player)
if #self.players == 0 then
_self._private.metadata_timer:stop()
_self._private.position_timer:stop()
_self:emit_signal("no_players")
capi.awesome.emit_signal("bling::playerctl::no_players")
elseif player == _self._private.active_player then
_self._private.active_player = self.players[1]
get_current_player_info(_self, self.players[1])
end end
end end
end end
-- Parse arguments local function parse_args(self, args)
local function parse_args(args) self.ignore = {}
if args then if type(args.ignore) == "string" then
update_on_activity = args.update_on_activity or update_on_activity self.ignore[args.ignore] = true
interval = args.interval or interval elseif type(args.ignore) == "table" then
for _, name in pairs(args.ignore) do
if type(args.ignore) == "string" then self.ignore[name] = true
ignore[args.ignore] = true
elseif type(args.ignore) == "table" then
for _, name in pairs(args.ignore) do
ignore[name] = true
end
end end
end
if type(args.player) == "string" then self.priority = {}
priority[1] = args.player if type(args.player) == "string" then
elseif type(args.player) == "table" then self.priority[1] = args.player
priority = args.player elseif type(args.player) == "table" then
end self.priority = args.player
end end
end end
local function playerctl_enable(args) local function new(args)
args = args or {} args = args or {}
local ret = gobject{}
gtable.crush(ret, playerctl, true)
-- Grab settings from beautiful variables if not set explicitly -- Grab settings from beautiful variables if not set explicitly
args.ignore = args.ignore or beautiful.playerctl_ignore args.ignore = args.ignore or beautiful.playerctl_ignore
args.player = args.player or beautiful.playerctl_player args.player = args.player or beautiful.playerctl_player
args.update_on_activity = args.update_on_activity ret.update_on_activity = args.update_on_activity or
or beautiful.playerctl_update_on_activity beautiful.playerctl_update_on_activity or true
args.interval = args.interval ret.interval = args.interval or beautiful.playerctl_position_update_interval or 1
or beautiful.playerctl_position_update_interval ret.debounce_delay = args.debounce_delay or beautiful.playerctl_debounce_delay or 0.35
parse_args(args) parse_args(ret, args)
ret._private = {}
-- Metadata callback for title, artist, and album art
ret._private.last_player = nil
ret._private.last_title = ""
ret._private.last_artist = ""
ret._private.last_artUrl = ""
-- Track position callback
ret._private.last_position = -1
ret._private.last_length = -1
-- Grab playerctl library -- Grab playerctl library
Playerctl = require("lgi").Playerctl ret._private.lgi_Playerctl = require("lgi").Playerctl
ret._private.manager = nil
ret._private.metadata_timer = nil
ret._private.position_timer = nil
-- Ensure main event loop has started before starting player manager -- Ensure main event loop has started before starting player manager
gears.timer.delayed_call(start_manager) gtimer.delayed_call(function()
start_manager(ret)
end)
return ret
end end
local function playerctl_disable() function playerctl.mt:__call(...)
-- Remove manager and timer return new(...)
manager = nil
metadata_timer:stop()
metadata_timer = nil
position_timer:stop()
position_timer = nil
-- Restore default settings
ignore = {}
priority = {}
update_on_activity = true
interval = 1
-- Reset default values
last_position = -1
last_length = -1
last_player = nil
last_title = ""
last_artist = ""
last_artUrl = ""
end end
return { enable = playerctl_enable, disable = playerctl_disable } return setmetatable(playerctl, playerctl.mt)