Files
miniplayer/bin/miniplayer
GuardKenzie 916d96c4b6 Added a new window for the playlist
Added a new config option `show_playlist` to specify if the playlist should be drawn
Added a feature where when the window proportions exceed 1:3, 40% will be used for album art and time info while the remaining 60% will be used for playlist display
2021-05-11 01:37:57 +00:00

777 lines
22 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/python
import curses
import os
from mpd import MPDClient
import mpd
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"] = {"music_directory": "~/Music",
"font_width": 11,
"font_height": 24,
"image_method": "pixcat",
"album_art_only": False,
"volume_step": 5,
"auto_close": False,
"show_playlist": True,
}
if "mpd" not in config.sections():
config["mpd"] = {"host": "localhost",
"port": "6600",
"pass": False
}
# Initialise keybindings
default_bindings = {">": "next_track",
"<": "last_track",
"+": "volume_up",
"-": "volume_down",
"p": "play_pause",
"q": "quit",
"h": "help",
"i": "toggle_info"
}
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"]
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)
)
# Music directory
MUSICDIR = player_config.get("music_directory", "~/Music")
MUSICDIR = os.path.expanduser(MUSICDIR)
# 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 = player_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
def drawPlaylist(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(height, width):
"""
A function that calculates the album art window height and
width based on the window height and width
"""
if drawPlaylist(height, width):
return height, round(width * 2/5)
else:
return height, width
class Player:
def __init__(self):
# Curses initialisation
self.stdscr = curses.initscr()
self.stdscr.nodelay(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 = albumArtWinWidth(*maxyx)
self.art_win = curses.newwin(
self.art_window_height, self.art_window_width,
0, 0
)
# Playlist window
if drawPlaylist(*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
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:
if drawPlaylist(window_height, window_width) and not self.album_art_only:
self.draw_playlist = True
else:
self.draw_playlist = False
# Album art window
self.art_window_height, self.art_window_width = 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 extracts the album art from song_file and
saves it to self.album_art_loc
"""
song_file_abs = os.path.join(MUSICDIR, 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:
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)# fill=foregroundCol)
# d.ellipse(internalxy, fill=backgroundCol)
art.resize((512, 512))
art.save(self.album_art_loc, "PNG")
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()
try:
self.album = song["album"]
except KeyError:
self.album = ""
try:
self.artist = song["artist"]
except KeyError:
self.artist = ""
try:
self.title = song["title"]
except KeyError:
# If no title, use base file name
aux = song["file"]
aux = os.path.basename(aux)
aux = os.path.splitext(aux)[0]
self.title = aux
self.last_song = 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.art_win.clear()
self.updateWindowSize(force_update=True)
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"]
if self.checkSongUpdate() == 1:
stopped = True
else:
stopped = False
# Get key
key = self.stdscr.getch()
while key > 0:
# Resolve every key in buffer
keyChar = chr(key).lower()
# 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
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()
# Draw playlist
if self.draw_playlist:
playlist = self.client.playlistinfo()
currentsong = self.client.currentsong()
currentpos = int(currentsong["pos"])
# Determine where to start the playlist
if currentpos > self.playlist_window_height // 2 and len(playlist) > self.playlist_window_height:
start = currentpos - (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
if playlist_item == currentsong:
pair = curses.A_REVERSE | curses.color_pair(2)
else:
pair = 0
# Move and write text
self.playlist_win.move(line, 0)
if playlist_item is not None:
self.playlist_win.addstr(
f"{playlist_item['artist']} - {playlist_item['title']}"[:self.playlist_window_width],
pair
)
self.playlist_win.clrtoeol()
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
center_y, center_x = (self.art_window_height // 2, self.art_window_width // 2)
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()
return
self.has_music_been_played = True
# Draw the window
if not self.album_art_only:
self.drawInfo()
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
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}")