Architecture
The project is split into two python code files. code.py and winamp_helpers.py. We know that code.py is the file that CircuitPython will execute automatically for us. It contains the initialization for the display, touch overlay, and SDCard. It also creates the WinampApplication
object and reads the input data from the touch overlay to decide what actions should be taken and when.
code.py
There are a handful of configuration variables that can be used to control different aspects of the player.
-
PLAYLIST_FILE
- The name of the playlist file that should be played. You can leave the default playlist.json or change it to your own custom playlist file. I like to create multiple playlist files with descriptive names and then rename the one I am in the mood for to playlist.json rather than changing the variable. SKIN_IMAGE
- The name of the BMP image file for the skin that you want to use. The default is base_240x320.bmp, see the next page for instructions on using custom skins.-
SKIN_CONFIG_FILE
- The name of the JSON configuration file for the chosen skin. Default is base_config.json. -
TOUCH_COOLDOWN
- Time in seconds to wait before allowing another touch event to be registered. Leave it as the default0.5
seconds, or lower it if you'd like the app to be able to respond more rapidly to multiple touch events. -
ORIENTATION
- The rotation value for the display. Must be either90
or270
which are the two possible portrait orientations. Default is90
.
The following occur in code.py as setup for various things:
Initializing the touch input based on the ORIENTATION
setting:
if ORIENTATION == 270: # setup touch screen ts = adafruit_touchscreen.Touchscreen( board.TOUCH_YD, board.TOUCH_YU, board.TOUCH_XR, board.TOUCH_XL, calibration=((5200, 59000), (5800, 57000)), size=(240, 320), ) elif ORIENTATION == 90: # setup touch screen ts = adafruit_touchscreen.Touchscreen( board.TOUCH_YU, board.TOUCH_YD, board.TOUCH_XL, board.TOUCH_XR, calibration=((5200, 59000), (5800, 57000)), size=(240, 320), )
Initialize the SDCard so that we can read the song MP3 files from it:
spi = busio.SPI(board.SCK, board.MOSI, board.MISO) sdcard = sdcardio.SDCard(spi, board.SD_CS) vfs = storage.VfsFat(sdcard) storage.mount(vfs, "/sd") sys.path.append("/sd")
Access the built-in display, and set it's orientation based on our ORIENTATION
setting.
# get reference to the display display = board.DISPLAY # set rotation display.rotation = ORIENTATION
Create a WinampApplication
object and set it as the root_group on the display:
# Initialize WinampApplication helper class winamp_application = WinampApplication( playlist_file=PLAYLIST_FILE, skin_image=SKIN_IMAGE, skin_config_file=SKIN_CONFIG_FILE, ) # Add the Group to the Display display.root_group = winamp_application
Main loop
Inside the main loop there are two main tasks that occur:
- Call
winamp_application.update()
to refresh the UI. This will update the clock display, allow the track title bar to scroll, and update the playlist display based on the current track number. It will open and begin playing the next song when the current one has finished playing. - Check for touch events coming from the touch overlay and call the appropriate action functions on the
winamp_application
object when touches are detected.
The Winamp skin is decoration only, the controls within it are too tiny for us to use on the PyPortal without a very precise stylus and perfectly calibrated touch overlay.
There are 3 invisible buttons that are used to control the application. The entire top half of the screen is the pause/resume button. The bottom half of the screen is split vertically, the left half is the previous track button, and the right half is the next track button.
Helper Classes
There are three helper classes inside of winamp_helpers.py that are used in the project. All of these classes extend displayio.Group
so they can be added to other groups and shown on the display.
-
WinampApplication
- This is a high level object that manages the player. It initializes the skin graphics, and other displayio widgets used in the UI. It has action functions which get called from the main loop when touch events occur:pause()
,resume()
,next_track()
, andprevious_track()
. Theupdate()
function must be called once per iteration in the main loop to process all visual updates in the UI and advance to the next track when needed. Theplay_current_track()
function is responsible for opening and playing MP3 files.
-
ClockDisplay
- This is a displayio widget that contains the labels needed to show the elapsed time during playback near the top left of the UI. Time showing is updated via theseconds
property. This custom widget was created because the spacing used for the clock digits in some of the Winamp skins is very specific. A standard Label with the default font would not match the spacing correctly.
-
PlaylistDisplay
- Another displayio widget, this one contains the labels used to show the current track and next two track titles in the bottom part of the UI. It also manages which track is considered the current track for us, which is updated via thecurrent_track_number
property.current_track_title
is a convenience property that will format the current track name into a suitable string to be shown in theScrollingLabel
near the top right of the UI.
Further Detail
The source code is thoroughly commented to explain what the statements are doing. You can read through the code and comments to gain a deeper understanding of how it functions, or modify parts of it to suit your needs.
code.py:
# SPDX-FileCopyrightText: 2022 Tim C, written for Adafruit Industries # # SPDX-License-Identifier: Unlicense """ PyPortal winamp player """ import time import sys import storage import board import busio import adafruit_touchscreen import sdcardio from winamp_helpers import WinampApplication # which playlist to play PLAYLIST_FILE = "playlist.json" # which skin background to use SKIN_IMAGE = "/base_240x320.bmp" # skin configuration for color values SKIN_CONFIG_FILE = "base_config.json" # must wait at least this long between touch events TOUCH_COOLDOWN = 0.5 # seconds # display orientation. Must be 90 or 270. ORIENTATION = 90 PYPORTAL_TITANO = False if not PYPORTAL_TITANO: SIZE = (240, 320) if ORIENTATION == 270: # setup touch screen ts = adafruit_touchscreen.Touchscreen( board.TOUCH_YD, board.TOUCH_YU, board.TOUCH_XR, board.TOUCH_XL, calibration=((5200, 59000), (5800, 57000)), size=(240, 320), ) elif ORIENTATION == 90: # setup touch screen ts = adafruit_touchscreen.Touchscreen( board.TOUCH_YU, board.TOUCH_YD, board.TOUCH_XL, board.TOUCH_XR, calibration=((5200, 59000), (5800, 57000)), size=(240, 320), ) else: raise ValueError("ORIENTATION must be 90 or 270") else: # PyPortal Titano SIZE = (320, 480) if ORIENTATION == 270: # setup touch screen ts = adafruit_touchscreen.Touchscreen( board.TOUCH_YD, board.TOUCH_YU, board.TOUCH_XR, board.TOUCH_XL, calibration=((5200, 59000), (5800, 57000)), size=(320, 480), ) elif ORIENTATION == 90: # setup touch screen ts = adafruit_touchscreen.Touchscreen( board.TOUCH_YU, board.TOUCH_YD, board.TOUCH_XL, board.TOUCH_XR, calibration=((5200, 59000), (5800, 57000)), size=(320, 480), ) # Initializations for SDCard spi = busio.SPI(board.SCK, board.MOSI, board.MISO) sdcard = sdcardio.SDCard(spi, board.SD_CS) vfs = storage.VfsFat(sdcard) storage.mount(vfs, "/sd") sys.path.append("/sd") # debugging, print files that exist on SDCard # print(os.listdir("/sd")) # get reference to the display display = board.DISPLAY # set rotation display.rotation = ORIENTATION # Initialize WinampApplication helper class winamp_application = WinampApplication( playlist_file=PLAYLIST_FILE, skin_image=SKIN_IMAGE, skin_config_file=SKIN_CONFIG_FILE, pyportal_titano=PYPORTAL_TITANO, ) # Add the Group to the Display display.root_group = winamp_application # previous iteration touch events _previous_touch = None # last time a touch occured _previous_touch_time = 0 # main loop while True: # update winamp application winamp_application.update() # check for touch events p = ts.touch_point _now = time.monotonic() # if touch cooldown time has elapsed if _now >= _previous_touch_time + TOUCH_COOLDOWN: # if there is a touch if p and not _previous_touch: # store the time to compare with next iteration _previous_touch_time = _now # if touch is on bottom half if p[1] > SIZE[1] // 2: # if touch is on right half if p[0] >= SIZE[0] // 2: winamp_application.next_track() # if touch is on left half else: winamp_application.previous_track() # if touch is on top half else: # if currently playing song if winamp_application.CURRENT_STATE == winamp_application.STATE_PLAYING: print("pausing") winamp_application.pause() # if song is paused else: print("resuming") winamp_application.resume() # store previous touch event t compare with next iteration _previous_touch = p
# SPDX-FileCopyrightText: 2022 Tim C, written for Adafruit Industries # # SPDX-License-Identifier: Unlicense """ PyPortal winamp displayio widget classes. """ import os import time import json import board import displayio import terminalio from audioio import AudioOut from audiomp3 import MP3Decoder from adafruit_display_text import bitmap_label, scrolling_label class WinampApplication(displayio.Group): """ WinampApplication Helper class that manages song playback and UI components. :param playlist_file: json file containing the playlist of songs :param skin_image: BMP image file for skin background :param skin_config_file: json file containing color values :param pyportal_titano: boolean value. True if using Titano, False otherwise. """ STATE_PLAYING = 0 STATE_PAUSED = 1 # pylint: disable=too-many-statements,too-many-branches def __init__( self, playlist_file="playlist.json", skin_image="/base_240x320.bmp", skin_config_file="base_config.json", pyportal_titano=False, ): self.SKIN_IMAGE = skin_image self.SKIN_CONFIG_FILE = skin_config_file self.PLAYLIST_FILE = playlist_file # read the skin config data into variable f = open(self.SKIN_CONFIG_FILE, "r") self.CONFIG_DATA = json.loads(f.read()) f.close() if self.PLAYLIST_FILE: try: # read the playlist data into variable f = open(self.PLAYLIST_FILE, "r") self.PLAYLIST = json.loads(f.read()) f.close() except OSError: # file not found self.auto_find_tracks() except ValueError: # json parse error self.auto_find_tracks() else: # playlist file argument was None self.auto_find_tracks() if self.PLAYLIST: try: if len(self.PLAYLIST["playlist"]["files"]) == 0: # valid playlist json data, but no tracks self.auto_find_tracks() except KeyError: self.auto_find_tracks() # initialize clock display self.clock_display = ClockDisplay(text_color=self.CONFIG_DATA["time_color"]) if not pyportal_titano: # standard PyPortal and pynt clock display location # and playlist display parameters self.clock_display.x = 44 self.clock_display.y = 22 _max_playlist_display_chars = 30 _rows = 3 else: # PyPortal Titano clock display location # and playlist display parameters self.clock_display.x = 65 self.clock_display.y = 37 _max_playlist_display_chars = 42 _rows = 4 # initialize playlist display self.playlist_display = PlaylistDisplay( text_color=self.CONFIG_DATA["text_color"], max_chars=_max_playlist_display_chars, rows=_rows, ) if not pyportal_titano: # standard PyPortal and pynt playlist display location self.playlist_display.x = 13 self.playlist_display.y = 234 else: # PyPortal Titano playlist display location self.playlist_display.x = 20 self.playlist_display.y = 354 # set playlist into playlist display self.playlist_display.from_files_list(self.PLAYLIST["playlist"]["files"]) self.playlist_display.current_track_number = 1 # get name of current song self.current_song_file_name = self.PLAYLIST["playlist"]["files"][ self.playlist_display.current_track_number - 1 ] if not pyportal_titano: # standard PyPortal and pynt max characters for track title _max_chars = 22 else: # PyPortal Titano max characters for track title _max_chars = 29 # initialize ScrollingLabel for track name self.current_song_lbl = scrolling_label.ScrollingLabel( terminalio.FONT, text=self.playlist_display.current_track_title, color=self.CONFIG_DATA["text_color"], max_characters=_max_chars, ) self.current_song_lbl.anchor_point = (0, 0) if not pyportal_titano: # standard PyPortal and pynt track title location self.current_song_lbl.anchored_position = (98, 19) else: # PyPortal Titano track title location self.current_song_lbl.anchored_position = (130, 33) # Setup the skin image file as the bitmap data source self.background_bitmap = displayio.OnDiskBitmap(self.SKIN_IMAGE) # Create a TileGrid to hold the bitmap self.background_tilegrid = displayio.TileGrid( self.background_bitmap, pixel_shader=self.background_bitmap.pixel_shader ) # initialize parent displayio.Group super().__init__() # Add the TileGrid to the Group self.append(self.background_tilegrid) # add other UI componenets self.append(self.current_song_lbl) self.append(self.clock_display) self.append(self.playlist_display) # Start playing first track self.current_song_file = open(self.current_song_file_name, "rb") self.decoder = MP3Decoder(self.current_song_file) self.audio = AudioOut(board.SPEAKER) self.audio.play(self.decoder) self.CURRENT_STATE = self.STATE_PLAYING # behavior variables. self._start_time = time.monotonic() self._cur_time = time.monotonic() self._pause_time = None self._pause_elapsed = 0 self._prev_time = None self._seconds_elapsed = 0 self._last_increment_time = 0 def auto_find_tracks(self): """ Initialize the song_list by searching for all MP3's within two layers of directories on the SDCard. e.g. It will find all of: /sd/Amazing Song.mp3 /sd/[artist_name]/Amazing Song.mp3 /sd/[artist_name]/[album_name]/Amazing Song.mp3 but won't find: /sd/my_music/[artist_name]/[album_name]/Amazing Song.mp3 :return: None """ # list that holds all files in the root of SDCard _root_sd_all_files = os.listdir("/sd/") # list that will hold all directories in the root of the SDCard. _root_sd_dirs = [] # list that will hold all subdirectories inside of root level directories _second_level_dirs = [] # list that will hold all MP3 file songs that we find _song_list = [] # loop over all files found on SDCard for _file in _root_sd_all_files: try: # Check if the current file is a directory os.listdir("/sd/{}".format(_file)) # add it to a list to look at later _root_sd_dirs.append(_file) except OSError: # current file was not a directory, nothing to do. pass # if current file is an MP3 file if _file.endswith(".mp3"): # we found an MP3 file, add it to the list that will become our playlist _song_list.append("/sd/{}".format(_file)) # loop over root level directories for _dir in _root_sd_dirs: # loop over all files inside of root level directory for _file in os.listdir("/sd/{}".format(_dir)): # check if current file is a directory try: # if it is a directory, loop over all files inside of it for _inner_file in os.listdir("/sd/{}/{}".format(_dir, _file)): # check if inner file is an MP3 if _inner_file.endswith(".mp3"): # we found an MP3 file, add it to the list that will become our playlist _song_list.append( "/sd/{}/{}/{}".format(_dir, _file, _inner_file) ) except OSError: # current file is not a directory pass # if the current file is an MP3 file if _file.endswith(".mp3"): # we found an MP3 file, add it to the list that will become our playlist _song_list.append("/sd/{}/{}".format(_dir, _file)) # format the songs we found into the PLAYLIST data structure self.PLAYLIST = {"playlist": {"files": _song_list}} # print message to user letting them know we auto-generated the playlist print("Auto Generated Playlist from MP3's found on SDCard:") print(json.dumps(self.PLAYLIST)) def update(self): """ Must be called each iteration from the main loop. Responsible for updating all sub UI components and managing song playback :return: None """ self._cur_time = time.monotonic() if self.CURRENT_STATE == self.STATE_PLAYING: # if it's time to increase the time on the ClockDisplay if self._cur_time >= self._last_increment_time + 1: # increase ClockDisplay by 1 second self._seconds_elapsed += 1 self._last_increment_time = self._cur_time self.clock_display.seconds = int(self._seconds_elapsed) # update the track label (scrolling) self.current_song_lbl.update() if self.CURRENT_STATE == self.STATE_PLAYING: # if we are supposed to be playing but aren't # it means the track ended. if not self.audio.playing: # start the next track self.next_track() # store time for comparison later self._prev_time = self._cur_time def play_current_track(self): """ Update the track label and begin playing the song for current track in the playlist. :return: None """ # set the track title self.current_song_lbl.full_text = self.playlist_display.current_track_title # save start time in a variable self._start_time = self._cur_time # if previous song is still playing if self.audio.playing: # stop playing self.audio.stop() # close previous song file self.current_song_file.close() # open new song file self.current_song_file_name = self.PLAYLIST["playlist"]["files"][ self.playlist_display.current_track_number - 1 ] self.current_song_file = open(self.current_song_file_name, "rb") self.decoder.file = self.current_song_file # play new song file self.audio.play(self.decoder) # if user paused the playback if self.CURRENT_STATE == self.STATE_PAUSED: # pause so it's loaded, and ready to resume self.audio.pause() def next_track(self): """ Advance to the next track. :return: None """ # reset ClockDisplay to 0 self._seconds_elapsed = 0 self.clock_display.seconds = int(self._seconds_elapsed) # increment current track number self.playlist_display.current_track_number += 1 try: # start playing track self.play_current_track() except OSError as e: # file not found print("Error playing: {}".format(self.current_song_file_name)) print(e) self.next_track() return def previous_track(self): """ Go back to previous track. :return: None """ # reset ClockDisplay to 0 self._seconds_elapsed = 0 self.clock_display.seconds = int(self._seconds_elapsed) # decrement current track number self.playlist_display.current_track_number -= 1 try: # start playing track self.play_current_track() except OSError as e: # file not found print("Error playing: {}".format(self.current_song_file_name)) print(e) self.previous_track() return def pause(self): """ Stop playing song and wait until resume function. :return: None """ if self.audio.playing: self.audio.pause() self.CURRENT_STATE = self.STATE_PAUSED def resume(self): """ Resume playing song after having been paused. :return: None """ self._last_increment_time = self._cur_time if self.audio.paused: self.audio.resume() self.CURRENT_STATE = self.STATE_PLAYING class PlaylistDisplay(displayio.Group): """ PlaylistDisplay Displayio widget class that shows 3 songs from the playlist. It has functions to help manage which song is currently at the top of the list. :param text_color: Hex color code for the text in the list :param song_list: Song names in the list :param current_track_number: initial track number shown at the top of the list.l :param max_chars: int max number of characters to show in a row. Excess characters are cut. :param rows: how many rows to show. One track per row. Default 3 rows """ def __init__( self, text_color, song_list=None, current_track_number=0, max_chars=30, rows=3 ): super().__init__() self._rows = rows if song_list is None: song_list = [] self._song_list = song_list self._current_track_number = current_track_number self._max_chars = max_chars # the label to show track titles inside of self._label = bitmap_label.Label(terminalio.FONT, color=text_color) # default position, top left inside of the self instance group self._label.anchor_point = (0, 0) self._label.anchored_position = (0, 0) self.append(self._label) # initial refresh to show the songs self.update_display() def update_display(self): """ refresh the label to show the current tracks based on current track number. :return: None """ # get the current track plus the following 2 _showing_songs = self.song_list[ self.current_track_number - 1 : self.current_track_number + self._rows - 1 ] # format the track titles into a single string with newlines _showing_string = "" for index, song in enumerate(_showing_songs): _cur_line = "{}. {}".format( self.current_track_number + index, song[: self._max_chars] ) _showing_string = "{}{}\n".format(_showing_string, _cur_line) # put it into the label self._label.text = _showing_string @property def song_list(self): """ :return: the list of songs """ return self._song_list @song_list.setter def song_list(self, new_song_list): self._song_list = new_song_list self.update_display() def from_files_list(self, files_list): """ Initialize the song_list from a list of filenames. Directories and MP3 file extension will be removed. :param files_list: list of strings containing filenames :return: None """ _song_list = [] for _file in files_list: _song_list.append(_file.split("/")[-1].replace(".mp3", "")) self.song_list = _song_list @property def current_track_number(self): """ Track number is 1 based. Track number 1 is the first one in the playlist. Autowraps from 0 back to last song in the playlist. :return: current track number """ return self._current_track_number @current_track_number.setter def current_track_number(self, new_index): if new_index <= len(self.song_list): if new_index != 0: self._current_track_number = new_index else: self._current_track_number = len(self.song_list) else: self._current_track_number = new_index % len(self.song_list) self.update_display() @property def current_track_title(self): """ :return: Current track title as a formatted string with the track number pre-pended. e.g. "1. The Greatest Song" """ if self.current_track_number == 0: return "1. {}".format(self.song_list[0]) else: return "{}. {}".format( self.current_track_number, self.song_list[self.current_track_number - 1] ) class ClockDisplay(displayio.Group): """ DisplayIO widget to show an incrementing minutes and seconds clock. 2 digits for minutes, and 2 digits for seconds. Values will get zero padded. Does not include colon between the values. :param text_color: Hex color code for the clock text """ def __init__(self, text_color): super().__init__() # seconds elapsed to show on the clock display self._seconds = 0 # Minutes tens digit label self.first_digit = bitmap_label.Label(terminalio.FONT, color=text_color) self.first_digit.anchor_point = (0, 0) self.first_digit.anchored_position = (0, 0) self.append(self.first_digit) # Minutes ones digit label self.second_digit = bitmap_label.Label(terminalio.FONT, color=text_color) self.second_digit.anchor_point = (0, 0) self.second_digit.anchored_position = (10, 0) self.append(self.second_digit) # Seconds tens digit label self.third_digit = bitmap_label.Label(terminalio.FONT, color=text_color) self.third_digit.anchor_point = (0, 0) self.third_digit.anchored_position = (26, 0) self.append(self.third_digit) # Seconds ones digit label self.fourth_digit = bitmap_label.Label(terminalio.FONT, color=text_color) self.fourth_digit.anchor_point = (0, 0) self.fourth_digit.anchored_position = (36, 0) self.append(self.fourth_digit) # initialize showing the display self.update_display() @property def seconds(self): """ :return: the seconds elapsed currently showing """ return self._seconds @seconds.setter def seconds(self, new_seconds_value): """ Save new seconds elapsed and update the display to reflect it. :param new_seconds_value: the new seconds elapsed to show :return: None """ self._seconds = new_seconds_value self.update_display() def update_display(self): """ Update the text in the labels to reflect the current seconds elapsed time. :return: None """ # divide to get number of minutes elapsed _minutes = self.seconds // 60 # modulus to get number of seconds elapsed # for the partial minute _seconds = self.seconds % 60 # zero pad the values and format into strings _minutes_str = f"{_minutes:02}" _seconds_str = f"{_seconds:02}" # update the text in the minutes labels if self.first_digit.text != _minutes_str[0]: self.first_digit.text = _minutes_str[0] if self.second_digit.text != _minutes_str[1]: self.second_digit.text = _minutes_str[1] # update the text in the seconds label if self.third_digit.text != _seconds_str[0]: self.third_digit.text = _seconds_str[0] if self.fourth_digit.text != _seconds_str[1]: self.fourth_digit.text = _seconds_str[1]
Text editor powered by tinymce.