Initial commit
This commit is contained in:
461
bin/miniplayer
Executable file
461
bin/miniplayer
Executable file
@ -0,0 +1,461 @@
|
|||||||
|
#!/bin/python
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
from mpd import MPDClient
|
||||||
|
import ffmpeg
|
||||||
|
import pixcat
|
||||||
|
import time
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
# Image ratio
|
||||||
|
# Change this to match the (width, height) of your font.
|
||||||
|
IMAGERATIO = (11, 24)
|
||||||
|
|
||||||
|
# Music directory
|
||||||
|
MUSICDIR = "~/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()
|
||||||
|
|
0
miniplayer/__init__.py
Normal file
0
miniplayer/__init__.py
Normal file
17
setup.py
Normal file
17
setup.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(name="miniplayer",
|
||||||
|
version="1.0",
|
||||||
|
description="An mpd client with album art and basic functionality written for use with the kitty terminal.",
|
||||||
|
url="https://github.com/GuardKenzie/miniplayer",
|
||||||
|
author="Tristan Ferrua",
|
||||||
|
author_email="tristanferrua@gmail.com",
|
||||||
|
license="MIT",
|
||||||
|
scripts=["bin/miniplayer"],
|
||||||
|
install_requires=[
|
||||||
|
"python-mpd2",
|
||||||
|
"ffmpeg",
|
||||||
|
"pixcat",
|
||||||
|
"pillow"
|
||||||
|
])
|
||||||
|
|
Reference in New Issue
Block a user