local custom = require "assets.custom" local gdebug = require "gears.debug" local phosphor = require "assets.phosphor" local playerctl = require "services.playerctl" local qcolor = require "quarrel.color" local qpersistent = require "quarrel.persistent" local qui = require "quarrel.ui" local qvars = require "quarrel.vars" local simpleicons = require "assets.simpleicons" local wibox = require "wibox" local M = {} local client_icons = { -- this is used to map the mpris clients to icons firefox = simpleicons.librewolf, -- librewolf uses the same player id as firefox spotify = simpleicons.spotify, mpd = custom.vinyl_record_fill, __generic = phosphor.waveform_fill, -- used for any client not in the map } local DEFAULTS = { ---@type string title = "Nothing's playing", artist = {}, ---@type string album = "", progresstext = { ---@type string position = "~", ---@type string length = "~", }, ---@type number position = 0, ---@type number length = math.huge, client_icon = client_icons.__generic, art = phosphor.music_notes_fill, } local bg_icon = qui.icon { icon = DEFAULTS.client_icon, widget = { forced_width = 0, forced_height = 0, }, color = qcolor.palette.bg.highest, } local function to_hms(time) local format = "%i:%02i" local h = math.floor(time / 60 ^ 2) local m = math.floor((time / 60) % 60) local s = math.floor(time % 60) if h > 0 then format = "%i:" .. format return string.format(format, h, m, s) end return string.format(format, m, s) end local function is_empty(str) return (str == nil or #str == 0) end --- mirror of playerctl:list() --- why bother mirroring? simple: we need to track changes to the player list --- but vanished and appeared simply don't give us an old version to compare with ---@type Playerctl.data[] local players = {} ---@type number M.active_player_index = qpersistent.get "active_player_index" --[[@as number]] if not M.active_player_index then M.active_player_index = 1 gdebug.print_error "failed to get active_player_index from qpersistent, falling back..." qpersistent.store("active_player_index", M.active_player_index) end --- the reason we do this instead of some other hack is this is the only way to draw the icons *without* resizing the mpris container --- this happens because in the panel, height is unlimited, and imagebox grows until a hard limit ---@class wibox.widget.base local client_background = wibox.container.background() function client_background:before_draw_children(_, cr, width, height) cr:save() cr:translate(width - (height / 1.25), -(height * 0.125)) ---@diagnostic disable-next-line: missing-parameter wibox.widget.draw_to_cairo_context(bg_icon, cr, height * 1.25, height * 1.25) cr:restore() end M.widget = wibox.widget(qui.styled { widget = wibox.container.background, bg = qcolor.palette.bg.high, { widget = client_background, { widget = wibox.container.margin, margins = qui.BIG_PADDING, { nil, { { { widget = wibox.container.background, bg = qcolor.palette.bg(), shape = qui.shape, { widget = wibox.widget.imagebox, image = DEFAULTS.art, forced_height = qui.CHAR_HEIGHT * 5, forced_width = qui.CHAR_HEIGHT * 5, valign = "center", halign = "center", stylesheet = qui.recolor(qcolor.palette.bg.highest), id = "cover", }, }, { widget = wibox.container.margin, right = qui.BIG_PADDING, left = qui.BIG_PADDING, { { widget = wibox.container.constraint, height = qui.CHAR_HEIGHT * 2.5, strategy = "max", { widget = wibox.widget.textbox, text = DEFAULTS.title, -- Song id = "song", valign = "top", }, }, { widget = wibox.container.constraint, height = qui.CHAR_HEIGHT * 2.5, strategy = "max", { widget = wibox.container.background, fg = qcolor.palette.fg.low, { widget = wibox.widget.textbox, text = DEFAULTS.artist_album, -- Artist - Album Name id = "artist_album", valign = "top", }, }, }, layout = wibox.layout.fixed.vertical, }, }, layout = wibox.layout.fixed.horizontal, }, nil, { widget = wibox.container.margin, top = qui.BIG_PADDING, right = qui.BIG_PADDING, { { widget = wibox.widget.textbox, text = DEFAULTS.progresstext.position .. " / " .. DEFAULTS.progresstext.length, -- position / length id = "progresstext", }, { widget = wibox.container.place, { widget = wibox.widget.progressbar, forced_height = qui.PADDING, color = qcolor.palette.yellow(), value = DEFAULTS.position, max_value = DEFAULTS.length, background_color = qcolor.palette.bg.lowest, bar_shape = qui.shape, shape = qui.shape, id = "progressbar", }, }, layout = wibox.layout.fixed.horizontal, spacing = qui.BIG_PADDING, }, }, layout = wibox.layout.align.vertical, }, { layout = wibox.layout.flex.vertical, spacing = qui.BIG_PADDING, id = "client_list", }, layout = wibox.layout.align.horizontal, }, }, }, }) local layout = M.widget:get_children_by_id("client_list")[1] --[[@as wibox.layout.flex]] local progressbar = M.widget:get_children_by_id("progressbar")[1] --[[@as wibox.widget.progressbar]] local progresstext = M.widget:get_children_by_id("progresstext")[1] --[[@as wibox.widget.textbox]] local song = M.widget:get_children_by_id("song")[1] --[[@as wibox.widget.textbox]] local artist_album = M.widget:get_children_by_id("artist_album")[1] --[[@as wibox.widget.textbox]] local cover = M.widget:get_children_by_id("cover")[1] --[[@as wibox.widget.imagebox]] local function mirror_player_list() players = playerctl:list() end mirror_player_list() local function handle_metadata(_, player) local title, album, artist, art if player ~= playerctl:list()[M.active_player_index] then return end if player then if player.metadata.title then title = player.metadata.title else title = DEFAULTS.title end if player.metadata.album then album = player.metadata.album else album = DEFAULTS.album end if player.metadata.artist then artist = player.metadata.artist else artist = DEFAULTS.artist end if player.metadata.art and player.metadata.art:match "^file://" then art = player.metadata.art:gsub("^file://", "") else art = DEFAULTS.art end else title = DEFAULTS.title album = DEFAULTS.album artist = DEFAULTS.artist art = DEFAULTS.art end artist_album.text = table.concat(artist, ", ") .. ((is_empty(artist) or is_empty(album)) and "" or " - ") .. album song.text = title ---@diagnostic disable-next-line:inject-field cover.image = art end local function handle_position(_, player) if player ~= playerctl:list()[M.active_player_index] then return end local position, length local content = "" if player then if player.position then position = player.position / playerctl.unit content = content .. to_hms(position) else position = DEFAULTS.position content = content .. DEFAULTS.progresstext.position end content = content .. " / " if player.metadata.length then length = player.metadata.length / playerctl.unit content = content .. to_hms(length) else length = DEFAULTS.length content = content .. DEFAULTS.progresstext.length end else position = DEFAULTS.position length = DEFAULTS.length content = DEFAULTS.progresstext.position .. " / " .. DEFAULTS.progresstext.length end progresstext.text = content ---@diagnostic disable-next-line:inject-field progressbar.value = position ---@diagnostic disable-next-line:inject-field progressbar.max_value = length end playerctl:connect_signal("player::metadata", handle_metadata) playerctl:connect_signal("player::position", handle_position) local function update_player(player) handle_metadata(nil, player) handle_position(nil, player) qpersistent.store("active_player_index", M.active_player_index) local client_icon = client_icons[(player or { name = "__generic" }).name] bg_icon.image = client_icon or client_icons.__generic client_background:emit_signal "widget::redraw_needed" for i, child in ipairs(layout.children) do ---@diagnostic disable-next-line:undefined-field child:index_handler(i) end end function M.next_player() local players_length = #layout.children if players_length == 0 then return end if M.active_player_index + 1 > players_length then M.active_player_index = 1 else M.active_player_index = M.active_player_index + 1 end update_player(playerctl:list()[M.active_player_index]) end function M.previous_player() local players_length = #layout.children if players_length == 0 then return end if M.active_player_index - 1 < 1 then M.active_player_index = players_length else M.active_player_index = M.active_player_index - 1 end update_player(playerctl:list()[M.active_player_index]) end ---@param diff_player Playerctl.data local function recalculate_active_player(diff_player, vanished) if type(diff_player) ~= "table" then return end if #layout.children == 0 then M.active_player_index = 1 update_player() return end local active_player = players[M.active_player_index] if not active_player then -- we're recovering from a state with no players update_player(diff_player) return end if diff_player.instance == active_player.instance and vanished then -- active player vanished; fall back to previous player M.previous_player() else -- non-active player appeared/vanished; try to find active player for i, p in ipairs(playerctl:list()) do if p.instance == active_player.instance then M.active_player_index = i update_player(p) return end end gdebug.print_warning( "failed to find active player:\n " .. gdebug.dump_return(active_player, nil, 2):gsub("\n", "\n ") ) M.active_player_index = 1 update_player(playerctl:list()[M.active_player_index]) end end local function register_player(player) local widget = wibox.widget { widget = wibox.container.constraint, width = qui.PADDING, strategy = "min", { widget = wibox.container.background, shape = qui.shape, bg = qcolor.palette.bg.lowest, }, index_handler = function(self, index) if M.active_player_index == index then self.widget.bg = qcolor.palette.yellow() else self.widget.bg = qcolor.palette.bg.lowest end end, } ---@diagnostic disable-next-line:undefined-field layout:add(widget) recalculate_active_player(player, false) mirror_player_list() end local function unregister_player(player) ---@diagnostic disable-next-line:undefined-field layout:remove(#layout.children) recalculate_active_player(player, true) mirror_player_list() end for _, player in ipairs(playerctl:list()) do register_player(player) end -- recover state ---@diagnostic disable-next-line:undefined-field local last_active_player = playerctl:list()[M.active_player_index] update_player(last_active_player) playerctl:connect_signal("player::appeared", function(_, player) register_player(player) end) playerctl:connect_signal("player::vanished", function(_, player) unregister_player(player) end) return M