I'm working on an audio player with a terminal interface. I'm designing it for eventual use on a Raspberry Pi with a 480x320px display.
I've broken the code into three main files with the intention of keeping the business logic (player.py) and graphics (view.py) separated and connected only through the main file. I've also included a configuration file where I'm storing future user options and text strings.
I have more functionality planned, so I'd like to make sure I have a solid foundation now while the complexity is low.
Any comments on the architecture, style, or efficacy of the code are welcome. The code is also available on on github.
main.py
import sys, os
from pynput import keyboard
from functools import partial
import cfg
from view import View
from player import Player
exit_signal: bool = False
def on_press(key: keyboard.KeyCode, view: View, player: Player):
"""Handle input"""
global exit_signal
key = str(key).strip('\'')
if str(key) == 'p':
view.notify('Playing...')
player.play()
elif key == 'a':
view.notify('Paused')
player.pause()
elif key == 'n':
view.notify('Skipping Forward...')
player.skip_forward()
elif key == 'l':
view.notify('Skipping Back...')
player.skip_back()
elif key == 'q':
view.notify('Exiting...')
exit_signal = True
del player
del view
return
view.update_ui(player.get_metadata())
def tick(view: View, player:Player):
"""For functions run periodically"""
metadata = player.get_metadata()
view.update_ui(metadata)
view = View()
player = Player()
view.notify("Ready!")
with keyboard.Listener(on_press=partial(on_press, view=view, player=player)) as listener:
while exit_signal == False:
tick(view, player)
listener.join() # merge to one thread
os.system('reset') # clean up the console
player.py
import os
import vlc # swiss army knife of media players
import cfg
from mutagen.easyid3 import EasyID3 as ID3 # audio file metadata
music_path = os.path.abspath(os.path.join(__file__, '../../music'))
playlist_path = os.path.abspath(os.path.join(__file__, '../../playlists'))
class Player:
"""Track player state, wrap calls to the VLC plugin, and handle scanning for media."""
def __init__(self):
self.song_list = []
self.song_idx = 0
self.song_idx_max = 0
self.vlc = vlc.Instance()
self._scan_library()
self.player = self.vlc.media_player_new(self.song_list[self.song_idx])
self._update_song()
def __del__(self):
del self.vlc
return
def _scan_library(self):
for dirpath, _, files in os.walk(music_path):
for file in files:
filepath = os.path.join(dirpath, file)
_, ext = os.path.splitext(filepath)
if ext in cfg.file_types:
self.song_list.append(filepath)
self.song_idx_max += 1
def _get_id3(self, key: str):
metadata = ID3(self.song_list[self.song_idx])
return str(metadata[key]).strip('[\']')
def _update_song(self):
if self.song_list and self.song_idx < len(self.song_list):
self.media = self.vlc.media_new(self.song_list[self.song_idx])
self.media.get_mrl()
self.player.set_media(self.media)
def get_metadata(self):
"""Return a dictionary of title, artist, current runtime and total runtime."""
if (not self.song_list) or (self.song_idx < 0):
return None
else:
# default states when not playing a track are negative integers
curr_time = self.player.get_time() / 1000 # time returned in ms
if (curr_time < 0):
curr_time = 0
run_time = self.player.get_length() / 1000
if (run_time < 0):
run_time = 0
playing = False
else:
playing = True
info = {"playing": playing,
"title": self._get_id3('title'),
"artist": self._get_id3('artist'),
"curr_time": curr_time,
"run_time": run_time}
return info
def play(self):
"""Start playing the current track."""
self.player.play()
def pause(self):
"""Pause the current track. Position is preserved."""
self.player.pause()
def skip_forward(self):
"""Skip the the beginning of the next track and start playing."""
if (self.song_idx < self.song_idx_max):
self.song_idx += 1
self._update_song()
self.play()
def skip_back(self):
"""Skip to the beginning of the last track and start playing."""
if (self.song_idx > 0):
self.song_idx -= 1
self._update_song()
self.play()
view.py
import cfg # settings
import curses # textual user interface
from datetime import timedelta
class View:
"""Wrap the python Curses library and handle all aspects of the TUI."""
def __init__(self):
self.screen = curses.initscr()
self.max_y_chars, self.max_x_chars = self.screen.getmaxyx()
self._draw_border()
self.line1 = self.max_y_chars - 4
self.line2 = self.max_y_chars - 3
self.line3 = self.max_y_chars - 2
self.screen.refresh()
def __del__(self):
"""Restore the previous state of the terminal"""
curses.endwin()
def _draw_border(self):
self.screen.border(0)
self.screen.addstr(0, (self.max_x_chars - len(cfg.title)) // 2, cfg.title)
def _set_cursor(self):
self.screen.move(2, len(cfg.prompt_en) + 2)
def _clear_line(self, line: int):
self.screen.move(line, 1)
self.screen.clrtoeol()
self._draw_border()
def _strfdelta(self, tdelta: timedelta):
"""Format a timedelta into a string"""
days = tdelta.days
hours, rem = divmod(tdelta.seconds, 3600)
minutes, seconds = divmod(rem, 60)
time_str = ''
if days > 0:
time_str += str(days) + ' days, '
if hours > 0:
time_str += str(hours) + ' hours '
time_str += str(minutes)
time_str += ':'
if (seconds < 10):
time_str += '0'
time_str += str(seconds)
return time_str
def _update_progress_info(self, metadata):
if metadata is None:
return
run_time = metadata["run_time"]
curr_time = metadata["curr_time"]
if run_time == 0:
return
percent = int((curr_time / run_time) * 100)
run_time_str = self._strfdelta(timedelta(seconds=run_time))
curr_time_str = self._strfdelta(timedelta(seconds=curr_time))
time_str = curr_time_str + cfg.time_sep_en + run_time_str + ' (' + str(percent) + '%)'
# two border characters
progress_bar_chars = self.max_x_chars - 2
fill_count = int(progress_bar_chars * curr_time / run_time)
progress_fill = cfg.prog_fill * fill_count
progress_void = ' ' * (progress_bar_chars - fill_count)
progress_bar = progress_fill + progress_void
self.screen.addstr(self.line2, 1, time_str)
self.screen.addstr(self.line3, 1, progress_bar)
def notify(self, string: str):
"""Add a string to the top of the window."""
self._clear_line(1)
self.screen.addstr(1, 1, string)
self._set_cursor()
self.screen.refresh()
def update_ui(self, metadata: dict):
"""Update track metadata and progress indicators."""
self._clear_line(self.line1)
self._clear_line(self.line2)
self._clear_line(self.line3)
if metadata is None:
return
else:
if metadata['playing']:
info_line = metadata['title'] + cfg.song_sep_en + metadata['artist']
self.screen.addstr(self.line1, 1, info_line)
self.screen.addstr(2, 1, cfg.prompt_en)
self._update_progress_info(metadata)
self._draw_border()
self._set_cursor()
self.screen.refresh()
cfg.py
# text strings
title = 'OpenDAP v0.0.0.1'
no_media_en = "Nothing is playing!"
no_load_en = "..."
no_progress_en = "Cannot display progress."
prompt_en = "[P]lay, P[a]use, [N]ext, [L]ast, [Q]uit ▶ "
song_sep_en = " by "
time_sep_en = " of "
# file types to scan for
file_types = {'.mp3', '.flac'}
# progress bar filler
prog_fill = '▒'
Thank you for your time.