479 lines
13 KiB
Python
Executable File
479 lines
13 KiB
Python
Executable File
#!/bin/python
|
||
import curses
|
||
import os
|
||
from mpd import MPDClient
|
||
import mpd
|
||
import ffmpeg
|
||
import pixcat
|
||
import time
|
||
import configparser
|
||
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
|
||
}
|
||
|
||
playerconfig = config["player"]
|
||
|
||
# Image ratio
|
||
# Change this to match the (width, height) of your font.
|
||
IMAGERATIO = (playerconfig.getint("font_width", 11),
|
||
playerconfig.getint("font_height", 24)
|
||
)
|
||
|
||
# Music directory
|
||
MUSICDIR = playerconfig.get("music_directory", "~/Music")
|
||
MUSICDIR = os.path.expanduser(MUSICDIR)
|
||
|
||
|
||
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("localhost", 6600)
|
||
|
||
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
|
||
|
||
|
||
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):
|
||
"""
|
||
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):
|
||
self.win.clear()
|
||
|
||
# Curses window
|
||
self.window_height, self.window_width = self.stdscr.getmaxyx()
|
||
|
||
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 = ""
|
||
|
||
self.artist = song["artist"]
|
||
self.title = song["title"]
|
||
|
||
self.last_song = song
|
||
|
||
self.getAlbumArt(song["file"])
|
||
self.last_song = song
|
||
|
||
return 0
|
||
|
||
else:
|
||
return 2
|
||
|
||
|
||
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 = ["q", "h"]
|
||
|
||
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()
|
||
|
||
if stopped and keyChar not in anytime_keys:
|
||
key = self.stdscr.getch()
|
||
continue
|
||
|
||
if keyChar == ">":
|
||
self.client.next()
|
||
|
||
elif keyChar == "<":
|
||
self.client.previous()
|
||
|
||
elif keyChar == "p":
|
||
self.client.pause()
|
||
|
||
elif keyChar == "+":
|
||
self.client.volume("5")
|
||
|
||
elif keyChar == "-":
|
||
self.client.volume("-5")
|
||
|
||
elif keyChar == "q":
|
||
raise KeyboardInterrupt
|
||
|
||
elif keyChar == "h":
|
||
self.help = not self.help
|
||
self.cleared = False
|
||
|
||
key = self.stdscr.getch()
|
||
|
||
def drawInfo(self):
|
||
"""
|
||
A function to draw the info below the album art
|
||
"""
|
||
state, album, artist, title = self.fitText()
|
||
if state == 0:
|
||
# Everything fits
|
||
self.win.addstr(self.text_start, 0, f"{title}")
|
||
self.win.addstr(self.text_start + 1, 0, f"{artist} - {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 drawAlbumArt(self):
|
||
"""
|
||
A function to draw the album art
|
||
"""
|
||
(
|
||
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)
|
||
|
||
# Keymap
|
||
keymap = {">": "Next track",
|
||
"<": "Last track",
|
||
"+": "Volume +5",
|
||
"-": "Volume -5",
|
||
"p": "Play/pause",
|
||
"q": "Quit",
|
||
"h": "Help"
|
||
}
|
||
|
||
# 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 keymap.items():
|
||
y_start += 1
|
||
sep = "." * (x_space - len(key) - len(desc) - 2)
|
||
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()
|
||
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
|
||
self.drawInfo()
|
||
self.drawAlbumArt()
|
||
|
||
|
||
def loop(self):
|
||
try:
|
||
i = 0
|
||
while True:
|
||
s = time.perf_counter()
|
||
|
||
self.handleKeypress()
|
||
if i == 0:
|
||
# Checko for window size update
|
||
self.updateWindowSize()
|
||
|
||
if not self.help:
|
||
self.draw()
|
||
|
||
else:
|
||
self.drawHelp()
|
||
|
||
e = time.perf_counter()
|
||
|
||
sleeptime = abs(0.1 - (e-s))
|
||
|
||
time.sleep(sleeptime)
|
||
i = (i + 1) % 10
|
||
|
||
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)
|
||
|
||
|
||
player = Player()
|
||
player.loop()
|
||
|