diff options
Diffstat (limited to '.config/awesome/services/playerctl.lua')
-rw-r--r-- | .config/awesome/services/playerctl.lua | 612 |
1 files changed, 579 insertions, 33 deletions
diff --git a/.config/awesome/services/playerctl.lua b/.config/awesome/services/playerctl.lua index f6ee71a..c4be74b 100644 --- a/.config/awesome/services/playerctl.lua +++ b/.config/awesome/services/playerctl.lua @@ -1,45 +1,591 @@ -local playerctl = require("lib.bling.signal.playerctl").lib { - player = { "spotify", "%any" }, +-- CREDIT: https://github.com/kosorin/awesome-rice/blob/b6813bd1bbb3fdd697a1b994784b72daaeaf405d/services/media/playerctl.lua + +-- DEPENDENCIES: playerctl + +local type = type +local pairs = pairs +local ipairs = ipairs +local gobject = require "gears.object" +local gtable = require "gears.table" +local gtimer = require "gears.timer" +local lgi_playerctl = require("lgi").Playerctl -- /usr/share/gtk-doc/html/playerctl/index.html + +---@alias Playerctl.selector +---|>nil # Select primary player +---| string # Select player by `instance` +---| "%all" # Select all players + +---@class Playerctl.position_data +---@field position? integer +---@field length? integer + +---@class Playerctl.data +---@field name string +---@field instance string +---@field playback_status lgi.Playerctl.PlaybackStatus +---@field position integer +---@field shuffle boolean +---@field loop_status lgi.Playerctl.LoopStatus +---@field volume number +---@field metadata table<string, any> +---@field package _position_timer? gears.timer + +local playerctl = { + lowest_priority = math.maxinteger, + any = { name = "%any" }, + all = { name = "%all" }, } -playerctl:connect_signal("metadata", function(_, ...) - awesome.emit_signal("services::playerctl::metadata", ...) -end) +---@class Playerctl : gears.object +---@field unit integer # Number of microseconds in a second. +---@field package primary_player_data? Playerctl.data +---@field package player_data table<string, Playerctl.data> +---@field package tracked_metadata table<string, string> +---@field package excluded_players table<string, boolean> +---@field package player_priorities table<string, integer> +---@field package manager lgi.Playerctl.PlayerManager +playerctl.object = { unit = 1000000 } + +---@param player_data? Playerctl.data +---@return Playerctl.position_data|nil +function playerctl.object:get_position_data(player_data) + player_data = player_data or self.primary_player_data + return (player_data and player_data.metadata) + and { + position = player_data.position, + length = player_data.metadata.length, + } +end + +---@param self Playerctl +---@param instance string +---@return lgi.Playerctl.Player|nil +local function find_player_by_instance(self, instance) + for _, player in ipairs(self.manager.players) do + if player.player_instance == instance then + return player + end + end +end + +---@param self Playerctl +---@param player_selector Playerctl.selector +---@param action fun(player: lgi.Playerctl.Player) +local function for_each_player(self, player_selector, action) + local players + if not player_selector then + local player_data = self.primary_player_data + if player_data then + players = { find_player_by_instance(self, player_data.instance) } + end + elseif player_selector == playerctl.all.name then + players = self.manager.players + elseif type(player_selector) == "string" then + players = { find_player_by_instance(self, player_selector) } + end + + if players then + for _, p in ipairs(players) do + action(p) + end + end +end + +---@param player_selector Playerctl.selector +function playerctl.object:play_pause(player_selector) + for_each_player(self, player_selector, function(p) + p:play_pause() + end) +end + +---@param player_selector Playerctl.selector +function playerctl.object:play(player_selector) + for_each_player(self, player_selector, function(p) + p:play() + end) +end + +---@param player_selector Playerctl.selector +function playerctl.object:pause(player_selector) + for_each_player(self, player_selector, function(p) + p:pause() + end) +end + +---@param player_selector Playerctl.selector +function playerctl.object:stop(player_selector) + for_each_player(self, player_selector, function(p) + p:stop() + end) +end + +---@param player_selector Playerctl.selector +function playerctl.object:previous(player_selector) + for_each_player(self, player_selector, function(p) + p:previous() + end) +end + +---@param player_selector Playerctl.selector +function playerctl.object:next(player_selector) + for_each_player(self, player_selector, function(p) + p:next() + end) +end + +---@param offset integer +---@param player_selector Playerctl.selector +function playerctl.object:skip(offset, player_selector) + if offset > 0 then + self:next(player_selector) + else + self:previous(player_selector) + end +end + +---@param offset integer +---@param player_selector Playerctl.selector +function playerctl.object:rewind(offset, player_selector) + self:seek(-offset, player_selector) +end + +---@param offset integer +---@param player_selector Playerctl.selector +function playerctl.object:fast_forward(offset, player_selector) + self:seek(offset, player_selector) +end + +---@param offset integer +---@param player_selector Playerctl.selector +function playerctl.object:seek(offset, player_selector) + for_each_player(self, player_selector, function(p) + p:seek(offset) + end) +end + +---@param loop_status lgi.Playerctl.LoopStatus +---@param player_selector Playerctl.selector +function playerctl.object:set_loop_status(loop_status, player_selector) + loop_status = loop_status:upper() + for_each_player(self, player_selector, function(p) + p:set_loop_status(loop_status) + end) +end + +---@param player_selector Playerctl.selector +function playerctl.object:cycle_loop_status(player_selector) + for_each_player(self, player_selector, function(p) + if p.loop_status == "NONE" then + p:set_loop_status "TRACK" + elseif p.loop_status == "TRACK" then + p:set_loop_status "PLAYLIST" + elseif p.loop_status == "PLAYLIST" then + p:set_loop_status "NONE" + end + end) +end + +---@param position integer +---@param player_selector Playerctl.selector +function playerctl.object:set_position(position, player_selector) + for_each_player(self, player_selector, function(p) + p:set_position(position) + end) +end + +---@param shuffle boolean +---@param player_selector Playerctl.selector +function playerctl.object:set_shuffle(shuffle, player_selector) + for_each_player(self, player_selector, function(p) + p:set_shuffle(shuffle) + end) +end + +---@param player_selector Playerctl.selector +function playerctl.object:toggle_shuffle(player_selector) + for_each_player(self, player_selector, function(p) + p:set_shuffle(not p.shuffle) + end) +end + +---@param volume number +---@param player_selector Playerctl.selector +function playerctl.object:set_volume(volume, player_selector) + for_each_player(self, player_selector, function(p) + p:set_volume(volume) + end) +end + +---@param player_data? Playerctl.data +---@return boolean +function playerctl.object:is_primary_player(player_data) + return self.primary_player_data == player_data +end + +---@return Playerctl.data|nil +function playerctl.object:get_primary_player_data() + return self.primary_player_data +end + +---@param self Playerctl +---@param player? lgi.Playerctl.Player +local function update_primary_player(self, player) + if player then + self.manager:move_player_to_top(player) + end + + local primary_player = self.manager.players[1] + + local old = self.primary_player_data + local new = self.player_data[primary_player and primary_player.player_instance] + if old ~= new then + self.primary_player_data = new + self:emit_signal("player::primary", new, old) + end +end + +---@param player_data Playerctl.data +---@return boolean +function playerctl.object:is_pinned(player_data) + local player_instance = player_data and player_data.instance or nil + return self.pinned_player_instance == player_instance +end + +---@param player_data? Playerctl.data +function playerctl.object:pin(player_data) + local player_instance = player_data and player_data.instance or nil + if self.pinned_player_instance ~= player_instance then + self.pinned_player_instance = player_instance + self:emit_signal("player::pinned", self.pinned_player_instance) + + local player + if player_data then + player = find_player_by_instance(self, player_data.instance) + elseif self.primary_player_data then + player = find_player_by_instance(self, self.primary_player_data.instance) + end + + update_primary_player(self, player) + end +end + +---@return Playerctl.data[] +function playerctl.object:list() + ---@type Playerctl.data[] + local players = {} + for _, p in ipairs(self.manager.players) do + players[#players + 1] = self.player_data[p.player_instance] + end + return players +end + +---@param player_data Playerctl.data +local function refresh_position_timer(player_data) + if player_data.playback_status == "PLAYING" then + player_data._position_timer:again() + else + player_data._position_timer:stop() + end +end + +---@param self Playerctl +---@param player_data Playerctl.data +---@param by_timer? boolean +local function update_position(self, player_data, by_timer) + local player = find_player_by_instance(self, player_data.instance) + if player then + player_data.position = player:get_position() + self:emit_signal("player::position", player_data, by_timer) + end +end -playerctl:connect_signal("position", function(_, ...) - awesome.emit_signal("services::playerctl::position", ...) -end) +---@param self Playerctl +---@param player_data Playerctl.data +---@param metadata lgi.Playerctl.Metadata +---@return boolean +---@return table<string, boolean> +local function update_metadata(self, player_data, metadata) + metadata = metadata and metadata.value or {} -playerctl:connect_signal("playback_status", function(_, ...) - awesome.emit_signal("services::playerctl::playback_status", ...) -end) + local changed = false + local changed_data = {} -playerctl:connect_signal("seeked", function(_, ...) - awesome.emit_signal("services::playerctl::seeked", ...) -end) + local function mark_changed(name) + changed = true + changed_data[name] = true + end -playerctl:connect_signal("volume", function(_, ...) - awesome.emit_signal("services::playerctl::volume", ...) -end) + local target_metadata = player_data.metadata + for name, mpris_name in pairs(self.tracked_metadata) do + local value = metadata[mpris_name] + local value_type = type(value) + if value_type == "nil" or value_type == "boolean" or value_type == "number" or value_type == "string" then + if target_metadata[name] ~= value then + target_metadata[name] = value + mark_changed(name) + end + elseif value_type == "userdata" and value.type == "as" then + local old = target_metadata[name] + if type(old) ~= "table" then + old = {} + end -playerctl:connect_signal("loop_status", function(_, ...) - awesome.emit_signal("services::playerctl::loop_status", ...) -end) + local new = {} + for _, s in value:ipairs() do + new[#new + 1] = s + end -playerctl:connect_signal("shuffle", function(_, ...) - awesome.emit_signal("services::playerctl::shuffle", ...) -end) + target_metadata[name] = new -playerctl:connect_signal("exit", function(_, ...) - awesome.emit_signal("services::playerctl::exit", ...) -end) + if #old ~= #new then + mark_changed(name) + else + for i = 1, #new do + if old[i] ~= new[i] then + mark_changed(name) + break + end + end + end + else + if target_metadata[name] ~= nil then + target_metadata[name] = nil + mark_changed(name) + end + end + end -playerctl:connect_signal("exit", function(_, ...) - awesome.emit_signal("services::playerctl::exit", ...) -end) + return changed, changed_data +end -playerctl:connect_signal("no_players", function() - awesome.emit_signal "services::playerctl::no_players" -end) +---@param self Playerctl +---@param full_name lgi.Playerctl.PlayerName +---@return lgi.Playerctl.Player +local function manage_player(self, full_name) + local new_player = lgi_playerctl.Player.new_from_name(full_name) -return playerctl + function new_player.on_metadata(p, metadata) + local player_data = self.player_data[p.player_instance] + if player_data and update_metadata(self, player_data, metadata) then + self:emit_signal("player::metadata", player_data) + + update_position(self, player_data) + refresh_position_timer(player_data) + end + end + + function new_player.on_playback_status(p, playback_status) + update_primary_player(self, p) + + local player_data = self.player_data[p.player_instance] + if player_data and player_data.playback_status ~= playback_status then + player_data.playback_status = playback_status + self:emit_signal("player::playback_status", player_data) + + update_position(self, player_data) + refresh_position_timer(player_data) + end + end + + function new_player.on_seeked(p, position) + local player_data = self.player_data[p.player_instance] + if player_data and player_data.position ~= position then + player_data.position = position + self:emit_signal("player::position", player_data) + + refresh_position_timer(player_data) + end + end + + function new_player.on_shuffle(p, shuffle) + local player_data = self.player_data[p.player_instance] + if player_data and player_data.shuffle ~= shuffle then + player_data.shuffle = shuffle + self:emit_signal("player::shuffle", player_data) + end + end + + function new_player.on_loop_status(p, loop_status) + local player_data = self.player_data[p.player_instance] + if player_data and player_data.loop_status ~= loop_status then + player_data.loop_status = loop_status + self:emit_signal("player::loop_status", player_data) + end + end + + function new_player.on_volume(p, volume) + local player_data = self.player_data[p.player_instance] + if player_data and player_data.volume ~= volume then + player_data.volume = volume + self:emit_signal("player::volume", player_data) + end + end + + self.manager:manage_player(new_player) + + return new_player +end + +---@param self Playerctl +---@param player_name string +---@return boolean +local function filter_name(self, player_name) + if self.excluded_players[player_name] then + return false + end + if self.player_priorities[playerctl.any] or self.player_priorities[player_name] then + return true + end + return false +end + +---@param self Playerctl +---@param player_a lgi.Playerctl.Player +---@param player_b lgi.Playerctl.Player +---@return sign +local function compare_players(self, player_a, player_b) + if player_a.player_name < player_b.player_name then + return -1 + elseif player_a.player_name > player_b.player_name then + return 1 + else + return 0 + end +end + +---@param self Playerctl +local function initialize_manager(self) + self.player_data = {} + + self.manager = lgi_playerctl.PlayerManager() + self.manager:set_sort_func(function(a, b) + local player_a = lgi_playerctl.Player(a) + local player_b = lgi_playerctl.Player(b) + return compare_players(self, player_a, player_b) + end) + + ---@param full_name lgi.Playerctl.PlayerName + ---@return lgi.Playerctl.Player|nil + local function try_manage(full_name) + if filter_name(self, full_name.name) then + return manage_player(self, full_name) + end + end + + function self.manager.on_name_appeared(_, full_name) + try_manage(full_name) + end + + function self.manager.on_player_appeared(_, player) + ---@type Playerctl.data + local player_data = { + name = player.player_name, + instance = player.player_instance, + playback_status = player.playback_status, + position = player.position, + shuffle = player.shuffle, + loop_status = player.loop_status, + volume = player.volume, + metadata = {}, + } + update_metadata(self, player_data, player.metadata) + + player_data._position_timer = gtimer { + timeout = 1, + callback = function() + update_position(self, player_data, true) + end, + } + refresh_position_timer(player_data) + + self.player_data[player_data.instance] = player_data + self:emit_signal("player::appeared", player_data) + + update_primary_player(self, player) + end + + function self.manager.on_player_vanished(_, player) + update_primary_player(self) + + if self.pinned_player_instance == player.player_instance then + self:pin(nil) + end + + local player_data = self.player_data[player.player_instance] + if player_data then + player_data._position_timer:stop() + self:emit_signal("player::vanished", player_data) + self.player_data[player.player_instance] = nil + end + end + + for _, full_name in ipairs(self.manager.player_names) do + try_manage(full_name) + end + + update_primary_player(self) +end + +---@param self Playerctl +---@param args? Playerctl.new.args +local function parse_args(self, args) + args = args or {} + + self.tracked_metadata = args.metadata or {} + + -- Always track length + self.tracked_metadata.length = "mpris:length" + + local excluded_players = {} + if type(args.excluded_players) == "string" then + excluded_players[args.excluded_players] = true + elseif args.excluded_players then + for _, name in ipairs(args.excluded_players) do + excluded_players[name] = true + end + end + self.excluded_players = excluded_players + + local function get_priority_key(name) + return name == playerctl.any.name and playerctl.any or name + end + local player_priorities + if type(args.players) == "string" then + player_priorities = { [get_priority_key(args.players)] = 1 } + elseif type(args.players) == "table" and #args.players > 0 then + player_priorities = {} + for i, name in ipairs(args.players) do + player_priorities[get_priority_key(name)] = i + end + else + player_priorities = { [playerctl.any] = 1 } + end + self.player_priorities = player_priorities +end + +---@class Playerctl.new.args +---@field players string[] +---@field excluded_players? string[] +---@field metadata table<string, string> + +---@param args? Playerctl.new.args +---@return Playerctl +function playerctl.new(args) + local self = gtable.crush(gobject {}, playerctl.object, true) --[[@as Playerctl]] + + parse_args(self, args) + + initialize_manager(self) + + return self +end + +return playerctl.new { + players = {}, + metadata = { + album = "xesam:album", + title = "xesam:title", + artist = "xesam:artist", + art = "mpris:artUrl", + }, +} |