-- Playerctl signals -- -- Provides: -- 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) 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 ipairs = ipairs local pairs = pairs local type = type local capi = { awesome = awesome } local playerctl = { mt = {} } function playerctl:disable() -- Restore default settings self.ignore = {} self.priority = {} self.update_on_activity = true self.interval = 1 self.debounce_delay = 0.35 -- 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_metadata_table = { album = "", albumArtist = "", artist = "", asText = "", audioBPM = "", autoRating = "", comment = "", composer = "", contentCreated = "", discNumber = "", firstUsed = "", genre = "", lastUsed = "", lyricist = "", title = "", trackNumber = "", url = "", useCount = "", userRating = "", } end function playerctl:pause(player) player = player or self._private.manager.players[1] if player then 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 function playerctl:set_position(position, player) player = player or self._private.manager.players[1] if player then player:set_position(position * 1000000) end end 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) do if player.player_name == name then return player end end return nil end local function join_table_of_strings(t) if type(t) ~= "table" then return "" end local out = t[1] for i = 2, #t do out = out .. ", " .. t[i] end return out end local function emit_metadata_signal(self, metadata, new, player_name) for i, v in pairs(metadata) do if i ~= "artUrl" and type(v) ~= "number" then metadata[i] = gstring.xml_escape(v) end end -- Spotify client doesn't report its art URL's correctly... if player_name == "spotify" then metadata.artUrl = metadata.artUrl:gsub("open.spotify.com", "i.scdn.co") end if metadata.artUrl ~= "" then local art_path = os.tmpname() helpers.filesystem.save_image_async_curl(metadata.artUrl, art_path, function() if self.metadata_v2 then self:emit_signal("metadata", metadata, art_path, new, player_name) capi.awesome.emit_signal("bling::playerctl::title_artist_album", metadata, art_path, player_name) elseif not self.metadata_v2 then self:emit_signal("metadata", metadata.title, metadata.artist, art_path, metadata.album, new, player_name) capi.awesome.emit_signal("bling::playerctl::title_artist_album", metadata.title, metadata.artist, art_path, player_name) end end) else if self.metadata_v2 then self:emit_signal("metadata", metadata, "", new, player_name) capi.awesome.emit_signal("bling::playerctl::title_artist_album", metadata, "", player_name) elseif not self.metadata_v2 then self:emit_signal("metadata", metadata.title, metadata.artist, "", metadata.album, new, player_name) capi.awesome.emit_signal("bling::playerctl::title_artist_album", metadata.title, metadata.artist, "", player_name) end 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 local metadata_table = { album = data["xesam:album"] or "", artUrl = data["mpris:artUrl"] or "", asText = data["xesam:asText"] or "", audioBPM = data["xesam:audioBPM"] or "", autoRating = data["xesam:autoRating"] or "", contentCreated = data["xesam:contentCreated"] or "", discNumber = data["xesam:discNumber"] or "", firstUsed = data["xesam:firstUsed"] or "", lastUsed = data["xesam:lastUsed"] or "", title = data["xesam:title"] or "", trackNumber = data["xesam:trackNumber"] or "", url = data["xesam:url"] or "", useCount = data["xesam:useCount"] or "", userRating = data["xesam:userRating"] or "", } metadata_table.albumArtist = join_table_of_strings(data["xesam:albumArtist"]) metadata_table.artist = join_table_of_strings(data["xesam:artist"]) metadata_table.comment = join_table_of_strings(data["xesam:comment"]) metadata_table.composer = join_table_of_strings(data["xesam:composer"]) metadata_table.genre = join_table_of_strings(data["xesam:genre"]) metadata_table.lyricist = join_table_of_strings(data["xesam:lyricist"]) if player == self._private.manager.players[1] then self._private.active_player = player -- Callback can be called even though values we care about haven't -- changed, so check to see if they have if player ~= self._private.last_player or metadata_table ~= self._private.last_metadata_table then if (metadata_table.title == "" and metadata_table.artist == "" and metadata_table.artUrl == "") then return end if self._private.metadata_timer ~= nil 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() emit_metadata_signal(self, metadata_table, true, player.player_name) end } -- Re-sync with position timer when track changes self._private.position_timer:again() self._private.last_player = player self._private.last_metadata_table = metadata_table end end end 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 == self._private.manager.players[1] then self._private.active_player = player -- Reported as PLAYING, PAUSED, or STOPPED if status == "PLAYING" then self:emit_signal("playback_status", true, player.player_name) capi.awesome.emit_signal("bling::playerctl::status", true, player.player_name) else 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(self, name) if self.ignore[name.name] then return false end if #self.priority > 0 then for _, arg in pairs(self.priority) do if arg == name.name or arg == "%any" then return true end end return false end return true end -- Create new player and connect it to callbacks 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 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(self, 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(self.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(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 get_current_player_info(self, player) local metadata = player.metadata local artUrl = player:print_metadata_prop("mpris:artUrl") or "" metadata_cb(self, metadata, artUrl, 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 self._private.position_timer = gtimer { timeout = self.interval, callback = function() position_cb(self) end, } -- Manage existing players on startup 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 self._private.manager:on_name_appeared(name) init_player(_self, name) end 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 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 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 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 if args.update_on_activity ~= nil then ret.update_on_activity = args.update_on_activity else ret.update_on_activity = beautiful.playerctl_update_on_activity ~= false end if args.metadata_v2 ~= nil then ret.metadata_v2 = args.metadata_v2 else ret.metadata_v2 = false -- Default to the old way of passing metadata for backwards compatibility end 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_metadata_table = { album = "", albumArtist = "", artist = "", asText = "", audioBPM = "", autoRating = "", comment = "", composer = "", contentCreated = "", discNumber = "", firstUsed = "", genre = "", lastUsed = "", lyricist = "", title = "", trackNumber = "", url = "", useCount = "", userRating = "", } -- Track position callback ret._private.last_position = -1 ret._private.last_length = -1 -- Grab playerctl library 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 gtimer.delayed_call(function() start_manager(ret) end) return ret end function playerctl.mt:__call(...) return new(...) end return setmetatable(playerctl, playerctl.mt)