diff --git a/docs/signals/pctl.md b/docs/signals/pctl.md index 9fb09c6..9dae973 100644 --- a/docs/signals/pctl.md +++ b/docs/signals/pctl.md @@ -17,27 +17,65 @@ This module relies on `playerctl` and `curl`. If you have this module disabled, ### Usage -To enable: `bling.signal.playerctl.enable()` +To enable: `playerctl = bling.signal.playerctl.lib/cli()` -To disable: `bling.signal.playerctl.disable()` - -Here are the signals available: +To disable: `playerctl:disable()` +Playerctl_lib signals available: ```lua --- bling::playerctl::status -- first line is the signal --- playing (boolean) -- indented lines are function parameters --- player_name (string) --- bling::playerctl::title_artist_album --- title (string) --- artist (string) --- album_path (string) --- player_name (string) --- bling::playerctl::position --- interval_sec (number) --- length_sec (number) --- player_name (string) --- bling::playerctl::no_players --- (No parameters) +-- metadata +-- title (string) +-- artist (string) +-- album_path (string) +-- album (string) +-- new (bool) +-- player_name (string) +-- position +-- interval_sec (number) +-- length_sec (number) +-- player_name (string) +-- 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) +``` + +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 @@ -74,10 +112,11 @@ local artist_widget = wibox.widget { } -- Get Song Info -awesome.connect_signal("bling::playerctl::title_artist_album", - function(title, artist, art_path, player_name) +playerctl = bling.signal.playerctl.lib() +playerctl:connect_signal("metadata", + function(title, artist, album_path, album, new, player_name) -- 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 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 local naughty = require("naughty") -awesome.connect_signal("bling::playerctl::title_artist_album", - function(title, artist, art_path, player_name) - naughty.notify({title = title, text = artist, image = art_path}) +playerctl:connect_signal("metadata", + function(title, artist, album_path, album, new, player_name) + if new == true then + naughty.notify({title = title, text = artist, image = album_path}) + end end) ``` @@ -103,13 +144,11 @@ By default, this module will output signals from the most recently active player | Option | playerctl_cli | playerctl_lib | | ------------------- | ------------------ | ------------------ | -| backend | :heavy_check_mark: | :heavy_check_mark: | -| ignore | | :heavy_check_mark: | -| player | | :heavy_check_mark: | +| ignore | :heavy_check_mark: | :heavy_check_mark: | +| player | :heavy_check_mark: | :heavy_check_mark: | | update_on_activity | | :heavy_check_mark: | | interval | :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. +| debounce_delay | :heavy_check_mark: | :heavy_check_mark: | - `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. -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 -theme.playerctl_backend = "playerctl_cli" theme.playerctl_ignore = {} theme.playerctl_player = {} theme.playerctl_update_on_activity = true @@ -131,15 +172,13 @@ theme.playerctl_position_update_interval = 1 #### Example Configurations ```lua -- Prioritize ncspot over all other players and ignore firefox players (e.g. YouTube and Twitch tabs) completely -bling.signal.playerctl.enable { - backend = "playerctl_lib", +playerctl = bling.signal.playerctl.lib { ignore = "firefox", player = {"ncspot", "%any"} } -- OR in your theme file: -- Same config as above but with theme variables -theme.playerctl_backend = "playerctl_lib" theme.playerctl_ignore = "firefox" theme.playerctl_player = {"ncspot", "%any"} @@ -148,7 +187,6 @@ theme.playerctl_backend = "playerctl_lib" theme.playerctl_player = {"vlc", "%any", "spotify"} -- Disable priority of most recently active players -theme.playerctl_backend = "playerctl_lib" theme.playerctl_update_on_activity = false -- Only emit the position signal every 2 seconds diff --git a/helpers/filesystem.lua b/helpers/filesystem.lua index 9f65d0e..f06139a 100644 --- a/helpers/filesystem.lua +++ b/helpers/filesystem.lua @@ -1,4 +1,6 @@ local Gio = require("lgi").Gio +local awful = require("awful") +local string = string local _filesystem = {} @@ -50,4 +52,11 @@ function _filesystem.list_directory_files(path, exts, recursive) return files 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 diff --git a/signal/init.lua b/signal/init.lua index a953d59..c513381 100644 --- a/signal/init.lua +++ b/signal/init.lua @@ -1 +1,3 @@ -return { playerctl = require(... .. ".playerctl") } +return { + playerctl = require(... .. ".playerctl"), +} diff --git a/signal/playerctl/init.lua b/signal/playerctl/init.lua index 1d586ed..357b02c 100644 --- a/signal/playerctl/init.lua +++ b/signal/playerctl/init.lua @@ -1,4 +1,7 @@ +local awful = require("awful") +local gtimer = require("gears.timer") local beautiful = require("beautiful") +local naughty = require("naughty") -- Use CLI backend as default as it is supported on most if not all systems local backend_config = beautiful.playerctl_backend or "playerctl_cli" @@ -7,13 +10,37 @@ local backends = { playerctl_lib = require(... .. ".playerctl_lib"), } +local backend = nil + 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 - backends[backend_config].enable(args) + backend = backends[backend_config](args) + return backend end local function disable_wrapper() - backends[backend_config].disable() + backend:disable() end -return { enable = enable_wrapper, disable = disable_wrapper } +return { + lib = backends.playerctl_lib, + cli = backends.playerctl_cli, + enable = enable_wrapper, + disable = disable_wrapper +} \ No newline at end of file diff --git a/signal/playerctl/playerctl_cli.lua b/signal/playerctl/playerctl_cli.lua index 93959f7..d091407 100644 --- a/signal/playerctl/playerctl_cli.lua +++ b/signal/playerctl/playerctl_cli.lua @@ -1,151 +1,348 @@ +-- Playerctl signals -- -- Provides: --- bling::playerctl::status --- playing (boolean) --- bling::playerctl::title_artist_album +-- metadata -- title (string) --- artist (string) +-- artist (string) -- album_path (string) --- bling::playerctl::position +-- album (string) +-- player_name (string) +-- position -- interval_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 gobject = require("gears.object") +local gtable = require("gears.table") +local gtimer = require("gears.timer") +local gstring = require("gears.string") 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() - local status_cmd = "playerctl status -F" - - -- Follow status - 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) +function playerctl:disable() + self._private.metadata_timer:stop() + self._private.metadata_timer = nil + awful.spawn.with_shell("killall playerctl") end -local function emit_player_info() - local art_script = [[ -sh -c ' +function playerctl:pause(player) + if player ~= nil then + 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 - tmp_dir="$HOME/.cache/awesome/" -fi +function playerctl:stop(player) + if player ~= nil then + 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 - mkdir -p $tmp_dir -fi +function playerctl:previous(player) + if player ~= nil then + 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 - local song_follow_cmd = - "playerctl metadata --format 'artist_{{artist}}title_{{title}}' -F" + if player ~= nil then + awful.spawn.easy_async_with_shell("playerctl --player=" .. player .. " loop", function(stdout) + set_loop_status(stdout) + end) + else + set_loop_status(self._private.loop_status) + end +end - -- Progress Cmds - local prog_cmd = "playerctl position" - local length_cmd = "playerctl metadata mpris:length" +function playerctl:set_position(position, player) + if player ~= nil then + 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) local length_sec = tonumber(length) -- in microseconds local interval_sec = tonumber(interval) -- in seconds if length_sec and interval_sec then if interval_sec >= 0 and length_sec > 0 then - awesome.emit_signal( - "bling::playerctl::position", - interval_sec, - length_sec / 1000000 - ) + self:emit_signal("position", interval_sec, length_sec / 1000000) + capi.awesome.emit_signal("bling::playerctl::position", interval_sec, length_sec / 1000000) end end end) collectgarbage("collect") 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 --- Emit info --- emit_player_status() --- emit_player_info() +local function emit_player_playback_status(self) + local status_cmd = self._private.cmd .. "status -F" -local enable = function(args) - interval = (args and args.interval) or interval - emit_player_status() - emit_player_info() + awful.spawn.with_line_callback(status_cmd, { + stdout = function(line) + if line:find("Playing") then + 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 -local disable = function() - awful.spawn.with_shell( - "pkill --full --uid " .. os.getenv("USER") .. " '^playerctl status -F'" - ) +local function emit_player_volume(self) + local volume_cmd = self._private.cmd .. "volume -F" - awful.spawn.with_shell( - "pkill --full --uid " - .. os.getenv("USER") - .. " '^playerctl metadata --format'" - ) + awful.spawn.with_line_callback(volume_cmd, { + stdout = function(line) + self:emit_signal("volume", tonumber(line)) + 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) diff --git a/signal/playerctl/playerctl_lib.lua b/signal/playerctl/playerctl_lib.lua index 5e67f1f..1df1e1f 100644 --- a/signal/playerctl/playerctl_lib.lua +++ b/signal/playerctl/playerctl_lib.lua @@ -1,88 +1,209 @@ -- Playerctl signals -- -- Provides: --- bling::playerctl::status --- playing (boolean) --- player_name (string) --- bling::playerctl::title_artist_album +-- metadata -- title (string) -- artist (string) -- album_path (string) +-- album (string) +-- new (bool) -- player_name (string) --- bling::playerctl::position +-- position -- interval_sec (number) -- length_sec (number) -- 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) -local gears = require("gears") 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 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 metadata_timer = nil -local position_timer = nil +local playerctl = { mt = {} } -local ignore = {} -local priority = {} -local update_on_activity = true -local interval = 1 +function playerctl:disable() + -- Restore default settings + self.ignore = {} + self.priority = {} + self.update_on_activity = true + self.interval = 1 + self.debounce_delay = 0.35 --- Track position callback -local last_position = -1 -local last_length = -1 -local function position_cb() - local player = manager.players[1] + -- Reset timers + self._private.manager = nil + self._private.metadata_timer:stop() + self._private.metadata_timer = nil + 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 - local position = player:get_position() / 1000000 - local length = (player.metadata.value["mpris:length"] or 0) / 1000000 - if position ~= last_position or length ~= last_length then - awesome.emit_signal( - "bling::playerctl::position", - position, - length, - player.player_name - ) - last_position = position - last_length = length + player:pause() + end +end + +function playerctl:play(player) + player = player or self._private.manager.players[1] + if player then + player:play() + end +end + +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 -local function get_album_art(url) - return awful.util.shell - .. [[ -c ' - -tmp_dir="$XDG_CACHE_HOME/awesome/" - -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" -']] +function playerctl:set_position(position, player) + player = player or self._private.manager.players[1] + if player then + player:set_position(position * 1000000) + end end --- Metadata callback for title, artist, and album art -local last_player = nil -local last_title = "" -local last_artist = "" -local last_artUrl = "" -local function metadata_cb(player, metadata) - if update_on_activity then - manager:move_player_to_top(player) +function playerctl:set_shuffle(shuffle, player) + player = player or self._private.manager.players[1] + if player then + player:set_shuffle(shuffle) + end +end + +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 local data = metadata.value @@ -93,101 +214,136 @@ local function metadata_cb(player, metadata) artist = artist .. ", " .. data["xesam:artist"][i] end local artUrl = data["mpris:artUrl"] or "" - -- Spotify client doesn't report its art URL's correctly... - if player.player_name == "spotify" then - artUrl = artUrl:gsub("open.spotify.com", "i.scdn.co") - end + local album = data["xesam:album"] or "" + + if player == self._private.manager.players[1] then + self._private.active_player = player - if player == manager.players[1] then -- Callback can be called even though values we care about haven't -- changed, so check to see if they have if - player ~= last_player - or title ~= last_title - or artist ~= last_artist - or artUrl ~= last_artUrl + player ~= self._private.last_player + or title ~= self._private.last_title + or artist ~= self._private.last_artist + or artUrl ~= self._private.last_artUrl then - if title == "" and artist == "" and artUrl == "" then - return + if (title == "" and artist == "" and artUrl == "") then return end + + if self._private.metadata_timer ~= nil and self._private.metadata_timer.started then + self._private.metadata_timer:stop() end - if metadata_timer ~= nil then - if metadata_timer.started then - metadata_timer:stop() - end - end - - metadata_timer = gears.timer({ - timeout = 0.3, + self._private.metadata_timer = gtimer { + timeout = self.debounce_delay, autostart = true, single_shot = true, callback = function() - if artUrl ~= "" then - awful.spawn.with_line_callback(get_album_art(artUrl), { - 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, - }) + emit_metadata_signal(self, title, artist, artUrl, album, true, player.player_name) + end + } -- Re-sync with position timer when track changes - position_timer:again() - last_player = player - last_title = title - last_artist = artist - last_artUrl = artUrl + self._private.position_timer:again() + self._private.last_player = player + self._private.last_title = title + self._private.last_artist = artist + self._private.last_artUrl = artUrl end end end --- Playback status callback --- Reported as PLAYING, PAUSED, or STOPPED -local function playback_status_cb(player, status) - if update_on_activity then - manager:move_player_to_top(player) +local function position_cb(self) + local player = self._private.manager.players[1] + if player then + + 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 - 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 - awesome.emit_signal( - "bling::playerctl::status", - true, - player.player_name - ) + self:emit_signal("playback_status", true, player.player_name) + capi.awesome.emit_signal("bling::playerctl::status", true, player.player_name) else - awesome.emit_signal( - "bling::playerctl::status", - false, - player.player_name - ) + self:emit_signal("playback_status", false, player.player_name) + capi.awesome.emit_signal("bling::playerctl::status", false, player.player_name) 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 -local function name_is_selected(name) - if ignore[name.name] then +local function name_is_selected(self, name) + if self.ignore[name.name] then return false end - if #priority > 0 then - for _, arg in pairs(priority) do + if #self.priority > 0 then + for _, arg in pairs(self.priority) do if arg == name.name or arg == "%any" then return true end @@ -199,23 +355,42 @@ local function name_is_selected(name) end -- Create new player and connect it to callbacks -local function init_player(name) - if name_is_selected(name) then - local player = Playerctl.Player.new_from_name(name) - manager:manage_player(player) - player.on_playback_status = playback_status_cb - player.on_metadata = metadata_cb +local function init_player(self, name) + if name_is_selected(self, name) then + local player = self._private.lgi_Playerctl.Player.new_from_name(name) + self._private.manager:manage_player(player) + player.on_metadata = function(player, metadata) + 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 - if not position_timer.started then - position_timer:again() + if not self._private.position_timer.started then + self._private.position_timer:again() end end end -- Determine if a player name comes before or after another according to the -- 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 a_match_index = nil local b_match_index = nil @@ -224,7 +399,7 @@ local function player_compare_name(name_a, name_b) return 0 end - for index, name in ipairs(priority) do + for index, name in ipairs(self.priority) do if name == "%any" then any_index = (any_index == math.huge) and index or any_index elseif name == name_a then @@ -248,103 +423,138 @@ local function player_compare_name(name_a, name_b) end -- Sorting function used by manager if a priority order is specified -local function player_compare(a, b) - local player_a = Playerctl.Player(a) - local player_b = Playerctl.Player(b) - return player_compare_name(player_a.player_name, player_b.player_name) +local function player_compare(self, a, b) + local player_a = self._private.lgi_Playerctl.Player(a) + local player_b = self._private.lgi_Playerctl.Player(b) + return player_compare_name(self, player_a.player_name, player_b.player_name) end -local function start_manager() - manager = Playerctl.PlayerManager() - if #priority > 0 then - manager:set_sort_func(player_compare) +local function get_current_player_info(self, player) + local title = player:get_title() or "" + local artist = player:get_artist() or "" + 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 -- Timer to update track position at specified interval - position_timer = gears.timer({ - timeout = interval, - callback = position_cb, - }) + self._private.position_timer = gtimer { + timeout = self.interval, + callback = function() + position_cb(self) + end, + } -- Manage existing players on startup - for _, name in ipairs(manager.player_names) do - init_player(name) + for _, name in ipairs(self._private.manager.player_names) do + init_player(self, name) 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 - function manager:on_name_appeared(name) - init_player(name) + function self._private.manager:on_name_appeared(name) + init_player(_self, name) end - -- Callback to check if all players have exited - function manager:on_name_vanished(name) - if #manager.players == 0 then - metadata_timer:stop() - position_timer:stop() - awesome.emit_signal("bling::playerctl::no_players") + function self._private.manager:on_player_appeared(player) + if player == self.players[1] then + _self._private.active_player = player + end + end + + 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 --- Parse arguments -local function parse_args(args) - if args then - update_on_activity = args.update_on_activity or update_on_activity - interval = args.interval or interval - - if type(args.ignore) == "string" then - ignore[args.ignore] = true - elseif type(args.ignore) == "table" then - for _, name in pairs(args.ignore) do - ignore[name] = true - end +local function parse_args(self, args) + self.ignore = {} + if type(args.ignore) == "string" then + self.ignore[args.ignore] = true + elseif type(args.ignore) == "table" then + for _, name in pairs(args.ignore) do + self.ignore[name] = true end + end - if type(args.player) == "string" then - priority[1] = args.player - elseif type(args.player) == "table" then - priority = args.player - end + self.priority = {} + if type(args.player) == "string" then + self.priority[1] = args.player + elseif type(args.player) == "table" then + self.priority = args.player end end -local function playerctl_enable(args) +local function new(args) args = args or {} + + local ret = gobject{} + gtable.crush(ret, playerctl, true) + -- Grab settings from beautiful variables if not set explicitly args.ignore = args.ignore or beautiful.playerctl_ignore args.player = args.player or beautiful.playerctl_player - args.update_on_activity = args.update_on_activity - or beautiful.playerctl_update_on_activity - args.interval = args.interval - or beautiful.playerctl_position_update_interval - parse_args(args) + ret.update_on_activity = args.update_on_activity or + beautiful.playerctl_update_on_activity or 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 + 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 - 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 - gears.timer.delayed_call(start_manager) + gtimer.delayed_call(function() + start_manager(ret) + end) + + return ret end -local function playerctl_disable() - -- Remove manager and timer - 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 = "" +function playerctl.mt:__call(...) + return new(...) end -return { enable = playerctl_enable, disable = playerctl_disable } +return setmetatable(playerctl, playerctl.mt)