#!/bin/python import curses import os import posixpath import requests from mpd import MPDClient import ffmpeg import pixcat import time import configparser import ueberzug.lib.v0 as ueberzug from PIL import Image, ImageDraw # Get config config = configparser.ConfigParser() config.read(os.path.expanduser("~/.config/miniplayer/config")) if "player" not in config.sections(): config["player"] = {"font_width": 11, "font_height": 24, "album_art_only": False, "volume_step": 5, "auto_close": False, "show_playlist": True, } if "art" not in config.sections(): config["art"] = {"music_directory": "~/Music", "image_method": "pixcat", } if "mpd" not in config.sections(): config["mpd"] = {"host": "localhost", "port": "6600", } # Initialise keybindings default_bindings = {">": "next_track", "<": "last_track", "+": "volume_up", "-": "volume_down", "p": "play_pause", "q": "quit", "h": "help", "i": "toggle_info", "down": "select_down", "up": "select_up", "enter": "select" } if "keybindings" not in config.sections(): config["keybindings"] = default_bindings # Load configured keybindings keybindings = config["keybindings"] # Unbound actions get initialised with their default keys # except if the keys are being used for something else for key, action in default_bindings.items(): if ( action not in keybindings.values() and key not in keybindings.keys() ): keybindings[key] = action player_config = config["player"] art_config = config["art"] mpd_config = config["mpd"] # FPS FPS = 20 # Image ratio # Change this to match the (width, height) of your font. IMAGERATIO = (player_config.getint("font_width", 11), player_config.getint("font_height", 24) ) # MPD config MPDHOST = mpd_config.get("host", "localhost") MPDPORT = mpd_config.getint("port", 6600) MPDPASS = mpd_config.get("pass", False) # What to use to draw images IMAGEMETHOD = art_config.get("image_method", "pixcat") # Volume step VOLUMESTEP = player_config.getint("volume_step", 5) # Autoclose boolean AUTOCLOSE = player_config.getboolean("auto_close", False) # Playlist padding PLAYLISTMARGIN = 4 # Config option to display the playlist DISABLEPLAYLIST = not player_config.getboolean("show_playlist", True) def albumArtSize(album_space, window_width): """ Calculates the album art size given the window width and the height of the album art space """ if window_width * IMAGERATIO[0] > album_space * IMAGERATIO[1]: image_width_px = album_space * IMAGERATIO[1] else: image_width_px = window_width * IMAGERATIO[0] image_width = int(image_width_px // IMAGERATIO[0]) image_height = int(image_width_px // IMAGERATIO[1]) return image_width_px, image_width, image_height class Player: def __init__(self): # Curses initialisation self.stdscr = curses.initscr() self.stdscr.nodelay(True) self.stdscr.keypad(True) # Curses config curses.noecho() curses.curs_set(0) curses.cbreak() curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_GREEN, -1) curses.init_pair(2, curses.COLOR_YELLOW, -1) # MPD init self.client = MPDClient() self.client.connect(MPDHOST, MPDPORT) if MPDPASS: self.client.password(MPDPASS) self.last_song = None # Album art only flag self.album_art_only = player_config.getboolean("album_art_only", False) # Screen size maxyx = self.stdscr.getmaxyx() self.screen_height, self.screen_width = maxyx # Album art window self.art_window_height, self.art_window_width = self.albumArtWinWidth(*maxyx) self.art_win = curses.newwin( self.art_window_height, self.art_window_width, 0, 0 ) # Playlist window if self.playlistFits(*maxyx) and not self.album_art_only: self.draw_playlist = True self.playlist_window_width = maxyx[1] - self.art_window_width - PLAYLISTMARGIN self.playlist_window_height = maxyx[0] self.playlist_win = curses.newwin( self.playlist_window_height, self.playlist_window_width, 0, self.art_window_width + PLAYLISTMARGIN ) else: self.draw_playlist = False self.playlist_win = None self.text_start = int(self.art_window_height - 5) self.album_space = self.text_start - 2 # Calculate the size of the image self.image_width_px, self.image_width, self.image_height = albumArtSize(self.album_space, self.art_window_width) self.image_y_pos = (self.album_space - self.image_height) // 2 + 1 # Album art location self.album_art_loc = "/tmp/aartminip.png" # Toggle for help menu self.help = False self.cleared = False # Ueberzug placement self.art_placement = None # Update needed flag self.update_needed = False # Flag to check if any music has been played self.has_music_been_played = False # A counter to check how long since playlist has moved self.control_cycle = 0 # Selected song in playlist self.selected_song = 0 def playlistFits(self, height, width): """ A function that checks if the playlist display should be drawn based on the provided height and width """ return height / width < 1/3 and not DISABLEPLAYLIST def albumArtWinWidth(self, height, width): """ A function that calculates the album art window height and width based on the window height and width """ if self.playlistFits(height, width) and not self.album_art_only: return height, round(width * 2/5) else: return height, width def fitText(self): """ A function that fits album name, artist name and song name to the screen with the given width. """ state = 0 song = self.title album = self.album artist = self.artist width = self.art_window_width if len(song) > width: song = song[:width - len(song)] song = song[:-4].strip() + "..." if len(album) == 0: sep = 0 else: sep = 3 if len(artist) + len(album) + sep > width: state = 1 if len(artist) > width: artist = artist[:width - len(artist)] artist = artist[:-4].strip() + "..." if len(album) > width: album = album[:width - len(album)] album = album[:-4].strip() + "..." if len(album) == 0: state = 2 return (state, album, artist, song) def updateWindowSize(self, force_update=False): """ A function to check if the window size changed """ window_height, window_width = self.stdscr.getmaxyx() if (window_height, window_width) != (self.screen_height, self.screen_width) or force_update: self.draw_playlist = self.playlistFits(window_height, window_width) and not self.album_art_only # Album art window self.art_window_height, self.art_window_width = self.albumArtWinWidth(window_height, window_width) # Playlist window if self.draw_playlist: self.playlist_window_width = window_width - self.art_window_width - PLAYLISTMARGIN self.playlist_window_height = window_height # Close the playlist window if it exists elif self.playlist_win is not None: del self.playlist_win self.playlist_win = None # Check if we are drawing info if self.album_art_only: self.text_start = int(self.art_window_height) self.album_space = self.text_start - 1 else: self.text_start = int(self.art_window_height - 5) self.album_space = self.text_start - 2 # Calculate the size of the image self.image_width_px, self.image_width, self.image_height = albumArtSize(self.album_space, self.art_window_width) self.image_y_pos = (self.album_space - self.image_height) // 2 + 1 # Check if playlist window exists and if we are drawing it if self.playlist_win is not None and self.draw_playlist: self.playlist_win.clear() self.playlist_win.refresh() self.playlist_win.resize( self.playlist_window_height, self.playlist_window_width ) self.playlist_win.mvwin(0, self.art_window_width + PLAYLISTMARGIN) elif self.draw_playlist: self.playlist_win = curses.newwin( self.playlist_window_height, self.playlist_window_width, 0, self.art_window_width + PLAYLISTMARGIN ) self.last_song = None # Resize the window self.art_win.clear() self.art_win.resize(self.art_window_height, self.art_window_width) self.screen_height, self.screen_width = window_height, window_width def getAlbumArt(self, song_file): """ A function that fetches the album art and saves it to self.album_art_loc """ http_base_url = art_config.get("http_base_url") if http_base_url: self._getAlbumArtFromHttpServer(http_base_url, song_file) else: self._getAlbumArtFromFile(song_file) def _getAlbumArtFromHttpServer(self, base_url, song_file): """ A function that fetches the album art from the configured HTTP server, and saves it to self.album_art_loc """ album = os.path.dirname(song_file) album_art_url = posixpath.join(base_url, album, "cover.jpg") try: album_art_resp = requests.get(album_art_url) except requests.RequestException: # If any exception occurs, simply give up and show default art. self.drawDefaultAlbumArt() if album_art_resp.ok: with open(self.album_art_loc, "wb") as f: f.write(album_art_resp.content) else: self.drawDefaultAlbumArt() def _getAlbumArtFromFile(self, song_file): """ A function that extracts the album art from song_file and saves it to self.album_art_loc """ music_dir = os.path.expanduser( art_config.get("music_directory", "~/Music")) song_file_abs = os.path.join(music_dir, song_file) process = ( ffmpeg .input(song_file_abs) .output(self.album_art_loc) ) try: process.run(quiet=True, overwrite_output=True) except ffmpeg._run.Error: self.drawDefaultAlbumArt() def drawDefaultAlbumArt(self): foregroundCol = "#D8DEE9" backgroundCol = "#262A33" size = 512*4 art = Image.new("RGB", (size, size), color=backgroundCol) d = ImageDraw.Draw(art) for i in range(4): offset = (i - 2) * 70 external = size/3 x0 = round(external) - offset y0 = round(external) + offset x1 = round(external*2) - offset y1 = round(external*2) + offset externalyx = [(x0, y0), (x1, y1)] d.rectangle(externalyx, outline=foregroundCol, width=40) art.resize((512, 512)) art.save(self.album_art_loc, "PNG") def getSongInfo(self, song): """ A function that returns a tuple of the given songs album, artist, title if they do not exist, the function will return "", "", filename respectively """ try: album = song["album"] except KeyError: album = "" try: artist = song["artist"] except KeyError: artist = "" try: title = song["title"] except KeyError: # If no title, use base file name aux = song["file"] aux = os.path.basename(aux) title = aux return album, artist, title def checkSongUpdate(self): """ Checks if there is a new song playing Returns: 1 -- if song state is "stop" 0 -- if there is no change 2 -- if there is a new song """ status = self.client.status() if status["state"] == "stop": return 1 song = self.client.currentsong() self.elapsed = float(status["elapsed"]) self.duration = float(status["duration"]) self.progress = self.elapsed/self.duration if self.last_song != song: self.art_win.clear() # Move selected_song to the currently playing one if self.control_cycle == 0: self.selected_song = int(song["pos"]) self.album, self.artist, self.title = self.getSongInfo(song) self.getAlbumArt(song["file"]) self.last_song = song return 0 else: return 2 def toggleInfo(self): """ A function that toggles the display of track info """ self.album_art_only = not self.album_art_only self.updateWindowSize(force_update=True) self.art_win.clear() self.art_win.refresh() def handleKeypress(self): """ A function to handle keypresses Keys: '>' -- Next track '<' -- Last track '+' -- Volume up +5 '-' -- Volume down -5 'p' -- Play/pause 'q' -- Quit 'h' -- Help """ anytime_keys = ["quit", "help", "select_up", "select_down", "select"] special_key_map = {curses.KEY_UP: "up", curses.KEY_DOWN: "down", curses.KEY_LEFT: "left", curses.KEY_RIGHT: "right", curses.KEY_ENTER: "enter", 10: "enter", 32: "space" } if self.checkSongUpdate() == 1: stopped = True else: stopped = False # Get key key = self.stdscr.getch() while key > 0: # Resolve every key in buffer if key in special_key_map.keys(): keyChar = special_key_map[key] else: keyChar = chr(key).lower() # Get playlist length playlist_length = len(self.client.playlist()) # Parse key if keyChar not in keybindings.keys(): key = self.stdscr.getch() continue else: action = keybindings[keyChar] if stopped and action not in anytime_keys: key = self.stdscr.getch() continue if action == "next_track": self.client.next() self.update_needed = True elif action == "last_track": self.client.previous() self.update_needed = True elif action == "play_pause": self.client.pause() elif action == "volume_up": self.client.volume(str(VOLUMESTEP)) elif action == "volume_down": self.client.volume(str(-VOLUMESTEP)) elif action == "quit": raise KeyboardInterrupt elif action == "help": self.help = not self.help self.cleared = False self.update_needed = True elif action == "toggle_info": self.toggleInfo() self.update_needed = True elif action == "select_up": self.control_cycle = 1 if playlist_length > 0: self.selected_song = (self.selected_song - 1) % playlist_length self.update_needed = True elif action == "select_down": self.control_cycle = 1 if playlist_length > 0: self.selected_song = (self.selected_song + 1) % playlist_length self.update_needed = True elif action == "select": self.control_cycle = 1 if playlist_length > 0: self.client.play(self.selected_song % playlist_length) self.update_needed = True self.last_song = None key = self.stdscr.getch() def drawInfo(self): """ A function to draw the info below the album art """ state, album, artist, title = self.fitText() if len(self.artist) == 0: seperator = "" else: seperator = " - " if state == 0: # Everything fits self.art_win.addstr(self.text_start, 0, f"{title}") self.art_win.addstr(self.text_start + 1, 0, f"{artist}{seperator}{album}") elif state == 1: # Too wide self.art_win.addstr(self.text_start - 1, 0, f"{title}") self.art_win.addstr(self.text_start, 0, f"{album}") self.art_win.addstr(self.text_start + 1, 0, f"{artist}") else: # No album self.art_win.addstr(self.text_start, 0, f"{title}") self.art_win.addstr(self.text_start + 1, 0, f"{artist}") # Progress bar song_duration = (int(self.duration / 60), round(self.duration % 60)) song_elapsed = (int(self.elapsed / 60), round(self.elapsed % 60)) self.art_win.addstr( self.text_start + 2, 0, "-"*(int((self.art_window_width - 1) * self.progress)) + ">", curses.color_pair(1) ) # Duration string time_string = f"{song_elapsed[0]}:{song_elapsed[1]:02d}/{song_duration[0]}:{song_duration[1]:02d}" self.art_win.addstr( self.text_start + 3, 0, f"{time_string:>{self.art_window_width}}", curses.color_pair(2) ) self.art_win.refresh() def drawPlaylist(self): """ A function that draws the playlist """ # Draw playlist if not self.draw_playlist: return playlist = self.client.playlistinfo() current_song = self.client.currentsong() # selected_pos = int(current_song["pos"]) playlist_length = len(self.client.playlist()) if playlist_length == 0: selected_pos = 0 self.playlist_win.erase() self.playlist_win.refresh() return selected_pos = self.selected_song % len(playlist) # Determine where to start the playlist if selected_pos > self.playlist_window_height // 2 and len(playlist) > self.playlist_window_height: start = selected_pos - (self.playlist_window_height - 1) // 2 else: start = 0 start = min(abs(len(playlist) - self.playlist_window_height), start) line = 0 while line < self.playlist_window_height: # Check if playlist is empty if line + start < len(playlist): playlist_item = playlist[start + line] else: playlist_item = None # Decide color pair = 0 if playlist_item == current_song: pair = curses.color_pair(2) if playlist_item == playlist[selected_pos]: pair = curses.color_pair(2) | curses.A_REVERSE # Move and write text try: self.playlist_win.move(line, 0) if playlist_item is not None: _, artist, title = self.getSongInfo(playlist_item) if artist == "": sep = "" else: sep = " - " self.playlist_win.addstr( f"{artist}{sep}{title}"[:self.playlist_window_width - 1], pair ) self.playlist_win.clrtoeol() except curses.error: return line += 1 self.playlist_win.refresh() def hideAlbumArt(self): """ A function that hides the album art """ if IMAGEMETHOD == "ueberzug": self.art_placement.visibility = ueberzug.Visibility.INVISIBLE def drawAlbumArt(self): """ A function to draw the album art """ if IMAGEMETHOD == "ueberzug": # Figure out new placement self.art_placement.x = (self.art_window_width - self.image_width)//2 self.art_placement.y = self.image_y_pos # Figure out height and width self.art_placement.width = self.image_width self.art_placement.height = self.album_space # Update image self.art_placement.path = self.album_art_loc # Display image self.art_placement.visibility = ueberzug.Visibility.VISIBLE elif IMAGEMETHOD == "pixcat": ( pixcat.Image(self.album_art_loc) .thumbnail(self.image_width_px) .show(x=(self.art_window_width - self.image_width)//2, y=self.image_y_pos) ) def centerText(self, y: int, string: str): """ A function that draws centered text in the window given a string and a line. Arguments: y -- The y position to draw the string string -- The string to draw """ x_pos = self.art_window_width / 2 - len(string) / 2 self.art_win.addstr(y, int(x_pos), string) def drawHelp(self): """ The function that draws the keymap help """ # Top vspace top_vspace = 3 # Left and right margin pct lr_margin_pct = 0.1 lr_margin = round(self.art_window_width * lr_margin_pct) # Actual space for text x_space = self.art_window_width - 2 * (lr_margin) # Check if window has been cleared if not self.cleared: self.art_win.clear() self.cleared = True # Figure out center, y_start and x_start y_start = top_vspace x_start = int(lr_margin) # Draw title self.centerText(y_start, "Keymap") # Draw help for key, desc in keybindings.items(): y_start += 1 sep = "." * (x_space - len(key) - len(desc) - 2) desc = desc.replace("_", " ").capitalize() self.art_win.addstr(y_start, x_start, f"{key} {sep} {desc}") self.art_win.refresh() def draw(self): """ The function that draws the now playing window """ if not self.cleared: self.art_win.clear() self.cleared = True # Force window nings self.art_win.redrawln(0, 1) self.art_win.addstr(0, 0, " ") # Get mpd state state = self.checkSongUpdate() # Check if state is stop if state == 1: if self.has_music_been_played and AUTOCLOSE: # Check if the playlist has concluded and if we should close raise KeyboardInterrupt self.art_win.clear() self.hideAlbumArt() infomsg = "Put some beats on!" self.art_win.addstr(self.art_window_height // 2, (self.art_window_width - len(infomsg)) // 2, infomsg) self.art_win.refresh() self.drawPlaylist() return self.has_music_been_played = True # Draw the window if not self.album_art_only: self.drawInfo() self.drawPlaylist() self.drawAlbumArt() @ueberzug.Canvas() def loop(self, canvas): """ The main program loop """ if self.art_placement is None and IMAGEMETHOD == "ueberzug": # Create album art placement if we are using ueberzug self.art_placement = canvas.create_placement( "art", scaler=ueberzug.ScalerOption.FIT_CONTAIN.value ) # Check if we need to recalculate window size # because of album art only initially if self.album_art_only: self.updateWindowSize(force_update=True) try: i = 0 while True: s = time.perf_counter() self.handleKeypress() if i == 0 or self.update_needed: # Checko for window size update self.updateWindowSize() if not self.help: self.draw() else: self.hideAlbumArt() self.drawHelp() self.update_needed = False # Update control_cycle once a second if it is not 0 if i == 0 and self.control_cycle != 0: self.control_cycle = (self.control_cycle + 1) % 30 e = time.perf_counter() sleeptime = abs(1/FPS - (e-s)) time.sleep(sleeptime) i = (i + 1) % FPS except KeyboardInterrupt: error = False except pixcat.terminal.KittyAnswerTimeout: error = "Kitty did not answer in time. Are you using Kitty?" finally: curses.nocbreak() curses.endwin() self.client.close() self.client.disconnect() if error: print(error) try: player = Player() player.loop() except ConnectionRefusedError: curses.nocbreak() curses.endwin() print(f"Could not connect to mpd on {MPDHOST}:{MPDPORT}")