Files
miniplayer/bin/miniplayer

636 lines
17 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
}
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)
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)
# 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
# Curses window
self.window_height, self.window_width = self.stdscr.getmaxyx()
self.win = curses.newwin(self.window_height, self.window_width, 0, 0)
self.text_start = int(self.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.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
# Album art only flag
self.album_art_only = player_config.getboolean("album_art_only", 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.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
"""
new_height, new_width = self.stdscr.getmaxyx()
if (new_height, new_width) != (self.window_height, self.window_width) or force_update:
self.win.clear()
# Curses window
self.window_height, self.window_width = self.stdscr.getmaxyx()
# Check if we are drawing info
if self.album_art_only:
self.text_start = int(self.window_height)
self.album_space = self.text_start - 1
else:
self.text_start = int(self.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.window_width)
self.image_y_pos = (self.album_space - self.image_height) // 2 + 1
# Resize the window
self.win.resize(self.window_height, self.window_width)
self.last_song = None
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.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.win.clear()
self.updateWindowSize(force_update=True)
self.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.win.addstr(self.text_start, 0, f"{title}")
self.win.addstr(self.text_start + 1, 0, f"{artist}{seperator}{album}")
elif state == 1:
# Too wide
self.win.addstr(self.text_start - 1, 0, f"{title}")
self.win.addstr(self.text_start, 0, f"{album}")
self.win.addstr(self.text_start + 1, 0, f"{artist}")
else:
# No album
self.win.addstr(self.text_start, 0, f"{title}")
self.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.win.addstr(
self.text_start + 2, 0,
"-"*(int((self.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.win.addstr(
self.text_start + 3, 0,
f"{time_string:>{self.window_width}}",
curses.color_pair(2)
)
self.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.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.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.window_width / 2 - len(string) / 2
self.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.window_width * lr_margin_pct)
# Actual space for text
x_space = self.window_width - 2 * (lr_margin)
# Check if window has been cleared
if not self.cleared:
self.win.clear()
self.cleared = True
# Figure out center, y_start and x_start
center_y, center_x = (self.window_height // 2, self.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.win.addstr(y_start, x_start, f"{key} {sep} {desc}")
self.win.refresh()
def draw(self):
"""
The function that draws the now playing window
"""
if not self.cleared:
self.win.clear()
self.cleared = True
# Force window nings
self.win.redrawln(0, 1)
self.win.addstr(0, 0, "")
# Get mpd state
state = self.checkSongUpdate()
# Check if state is stop
if state == 1:
self.win.clear()
self.hideAlbumArt()
infomsg = "Put some beats on!"
self.win.addstr(self.window_height // 2, (self.window_width - len(infomsg)) // 2, infomsg)
self.win.refresh()
return
# 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}")