From 9848c3b8f00f0b529b4cd34c8369fff2523290c8 Mon Sep 17 00:00:00 2001 From: HumblePresent <60856003+HumblePresent@users.noreply.github.com> Date: Sun, 18 Apr 2021 18:33:35 -0500 Subject: [PATCH] Playerctl signals backend rewritten with playerctl library (#43) * Playerctl signals backend rewritten with playerctl library * Removed collectgarbage() calls and updated docs * All docs updated --- docs/signals/pctl.md | 63 +++++++- docs/theme.md | 3 + signal/playerctl.lua | 341 ++++++++++++++++++++++++++++++----------- theme-var-template.lua | 3 + 4 files changed, 314 insertions(+), 96 deletions(-) diff --git a/docs/signals/pctl.md b/docs/signals/pctl.md index dd2ee48..9d96575 100644 --- a/docs/signals/pctl.md +++ b/docs/signals/pctl.md @@ -19,20 +19,25 @@ This module relies on `playerctl` and `curl`. If you have this module disabled, To enable: `bling.signal.playerctl.enable()` +To disable: `bling.signal.playerctl.disable()` + Here are the 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) +-- album_path (string) +-- player_name (string) -- bling::playerctl::position -- interval_sec (number) -- length_sec (number) --- bling::playerctl::player_stopped --- (No params) +-- player_name (string) +-- bling::playerctl::no_players +-- (No parameters) ``` ### Example Implementation @@ -47,6 +52,13 @@ local art = wibox.widget { widget = wibox.widget.imagebox } +local name_widget = wibox.widget { + markup = 'No players', + align = 'center', + valign = 'center', + widget = wibox.widget.textbox +} + local title_widget = wibox.widget { markup = 'Nothing Playing', align = 'center', @@ -63,11 +75,12 @@ local artist_widget = wibox.widget { -- Get Song Info awesome.connect_signal("bling::playerctl::title_artist_album", - function(title, artist, art_path) + function(title, artist, art_path, player_name) -- Set art widget art:set_image(gears.surface.load_uncached(art_path)) - -- Set title and artist widgets + -- Set player name, title and artist widgets + name_widget:set_markup_silently(player_name) title_widget:set_markup_silently(title) artist_widget:set_markup_silently(artist) end) @@ -80,12 +93,46 @@ Here's another example in which you get a notification with the album art, title local naughty = require("naughty") awesome.connect_signal("bling::playerctl::title_artist_album", - function(title, artist, art_path) + function(title, artist, art_path, player_name) naughty.notify({title = title, text = artist, image = art_path}) end) ``` -### Theme Variables +### Theme Variables and Configuration +By default, this module will output signals from the most recently active player. If you wish to customize the behavior furthur, the following configuration options are available. + +- `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. + +- `player`: This option is either a string with a single name or a table of strings containing names of players this module will emit signals for. It also acts as a way to prioritize certain players over others with players listed earlier in the table being preferred over players listed later. The special name `%any` can be used once to match any player not found in the list. It is empty by default. + +- `update_on_activity`: This option is a boolean that, when true, will cause the module to output signals from the most recently active player while still adhering to the player priority specified with the `player` option. If `false`, the module will output signals from the player that started first, again, while still adhering to the player priority. It is `true` 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: ```lua -theme.playerctl_position_update_interval = 1 -- the update interval for fetching the position from playerctl +theme.playerctl_ignore = {} +theme.playerctl_player = {} +theme.playerctl_update_on_activity = true +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 { + ignore = "firefox", + player = {"ncspot", "%any"} +} + +-- OR in your theme file: +-- Same config as above but with theme variables +theme.playerctl_ignore = "firefox" +theme.playerctl_player = {"ncspot", "%any"} + +-- Prioritize vlc over all other players and deprioritize spotify +theme.playerctl_player = {"vlc", "%any", "spotify"} + +-- Disable priority of most recently active players +theme.playerctl_update_on_activity = false ``` diff --git a/docs/theme.md b/docs/theme.md index 8e4e6dc..811585f 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -15,6 +15,9 @@ theme.flash_focus_start_opacity = 0.6 -- the starting opacity theme.flash_focus_step = 0.01 -- the step of animation -- playerctl signal +theme.playerctl_ignore = {} -- list of players to be ignored +theme.playerctl_player = {} -- list of players to be used in priority order +theme.playerctl_update_on_activity = true -- whether to prioritize the most recently active players or not theme.playerctl_position_update_interval = 1 -- the update interval for fetching the position from playerctl -- tabbed diff --git a/signal/playerctl.lua b/signal/playerctl.lua index 6aa0463..128796b 100644 --- a/signal/playerctl.lua +++ b/signal/playerctl.lua @@ -1,132 +1,297 @@ +-- Playerctl signals -- -- Provides: -- bling::playerctl::status -- playing (boolean) +-- player_name (string) -- bling::playerctl::title_artist_album -- title (string) --- artist (string) +-- artist (string) -- album_path (string) +-- player_name (string) -- bling::playerctl::position -- interval_sec (number) -- length_sec (number) --- bling::playerctl::player_stopped --- +-- player_name (string) +-- bling::playerctl::no_players +-- (No parameters) + +local gears = require("gears") local awful = require("awful") local beautiful = require("beautiful") +local Playerctl = require("lgi").Playerctl -local interval = beautiful.playerctl_position_update_interval or 1 +local manager = nil +local position_timer = nil -local function emit_player_status() - local status_cmd = "playerctl status -F" +local ignore = {} +local priority = {} +local update_on_activity = true +local interval = 1 - -- 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) +-- Track position callback +local last_position = -1 +local last_length = -1 +function position_cb() + local player = manager.players[1] + if player then + local position = player:get_position() / 1000000 + local length = player.metadata.value["mpris:length"] / 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 + end + end end -local function emit_player_info() - local art_script = [[ -sh -c ' +local function get_album_art(url) + return awful.util.shell .. [[ -c ' tmp_dir="$XDG_CACHE_HOME/awesome/" -if [ -z ${XDG_CACHE_HOME} ]; then +if [ -z "$XDG_CACHE_HOME" ]; then tmp_dir="$HOME/.cache/awesome/" fi -tmp_cover_path=${tmp_dir}"cover.png" +tmp_cover_path="${tmp_dir}cover.png" -if [ ! -d $tmp_dir ]; then +if [ ! -d "$tmp_dir" ]; then mkdir -p $tmp_dir fi -link="$(playerctl metadata mpris:artUrl)" - -curl -s "$link" --output $tmp_cover_path +curl -s ']] .. url .. [[' --output $tmp_cover_path echo "$tmp_cover_path" ']] +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" +-- Metadata callback for title, artist, and album art +local last_player = nil +local last_title = "" +local last_artist = "" +local last_artUrl = "" +function metadata_cb(player, metadata) + if update_on_activity then + manager:move_player_to_top(player) + end - -- Progress Cmds - local prog_cmd = "playerctl position" - local length_cmd = "playerctl metadata mpris:length" + local data = metadata.value - awful.widget.watch(prog_cmd, 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) + local title = data["xesam:title"] or "" + local artist = data["xesam:artist"][1] or "" + for i = 2, #data["xesam:artist"] do + 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 + + 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 + then + awful.spawn.easy_async_with_shell(get_album_art(artUrl), + function(stdout) + art_path = stdout:gsub('%\n', '') + awesome.emit_signal("bling::playerctl::title_artist_album", + title, + artist, + art_path, + 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 + end + end +end + +-- Playback status callback +-- Reported as PLAYING, PAUSED, or STOPPED +function playback_status_cb(player, status) + if update_on_activity then + manager:move_player_to_top(player) + end + + if player == manager.players[1] then + if status == "PLAYING" then + awesome.emit_signal("bling::playerctl::status", true, player.player_name) + else + awesome.emit_signal("bling::playerctl::status", false, player.player_name) + end + end +end + +-- Determine if player should be managed +function name_is_selected(name) + if ignore[name.name] then + return false + end + + if #priority > 0 then + for _, arg in pairs(priority) do + if arg == name.name or arg == "%any" then + return true end - end) - collectgarbage("collect") - end) + end + return false + 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::player_stopped") - end - end) - collectgarbage("collect") + return true +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 + + -- Start position timer if its not already running + if not position_timer.started then + 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 any_index = math.huge + local a_match_index = nil + local b_match_index = nil + + if name_a == name_b then + return 0 + end + + for index, name in ipairs(priority) do + if name == "%any" then + any_index = (any_index == math.huge) and index or any_index + elseif name == name_a then + a_match_index = a_match_index or index + elseif name == name_b then + b_match_index = b_match_index or index + end + end + + if not a_match_index and not b_match_index then + return 0 + elseif not a_match_index then + return (b_match_index < any_index) and 1 or -1 + elseif not b_match_index then + return (a_match_index < any_index) and -1 or 1 + elseif a_match_index == b_match_index then + return 0 + else + return (a_match_index < b_match_index) and -1 or 1 + end +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) +end + +local function start_manager() + manager = Playerctl.PlayerManager() + if #priority > 0 then + manager:set_sort_func(player_compare) + end + + -- Timer to update track position at specified interval + position_timer = gears.timer { + timeout = interval, + callback = position_cb + } + + -- Manage existing players on startup + for _, name in ipairs(manager.player_names) do + init_player(name) + end + + -- Callback to manage new players + function manager:on_name_appeared(name) + init_player(name) + end + + -- Callback to check if all players have exited + function manager:on_name_vanished(name) + if #manager.players == 0 then + position_timer:stop() + awesome.emit_signal("bling::playerctl::no_players") + 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 - }) - collectgarbage("collect") - end) + end + + if type(args.player) == "string" then + priority[1] = args.player + elseif type(args.player) == "table" then + priority = args.player + end + end end --- Emit info --- emit_player_status() --- emit_player_info() +local function playerctl_enable(args) + args = args or {} + -- 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) -local enable = function() - emit_player_status() - emit_player_info() + -- Ensure main event loop has started before starting player manager + gears.timer.delayed_call(start_manager) end -local disable = function() - awful.spawn.with_shell("pkill --full --uid " .. os.getenv("USER") .. - " '^playerctl status -F'") - - awful.spawn.with_shell("pkill --full --uid " .. os.getenv("USER") .. - " '^playerctl metadata --format'") +local function playerctl_disable() + -- Remove manager and timer + manager = 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 -return {enable = enable, disable = disable} +return {enable = playerctl_enable, disable = playerctl_disable} diff --git a/theme-var-template.lua b/theme-var-template.lua index e0f42f6..bbd4bc4 100644 --- a/theme-var-template.lua +++ b/theme-var-template.lua @@ -15,6 +15,9 @@ theme.flash_focus_start_opacity = 0.6 -- the starting opacity theme.flash_focus_step = 0.01 -- the step of animation -- playerctl signal +theme.playerctl_ignore = {} -- list of players to be ignored +theme.playerctl_player = {} -- list of players to be used in priority order +theme.playerctl_update_on_activity = true -- whether to prioritize the most recently active players or not theme.playerctl_position_update_interval = 1 -- the update interval for fetching the position from playerctl -- tabbed