Time to listen to music!
Now, we have all the pieces we need to start building the terminal player. We have the pytify module, which provides a wrapper around the Spotify RESP API and will allow us to search for artists, albums, tracks, and even control the Spotify client running on a mobile phone or a computer.
The pytify module also provides two different types of authentication—client credentials and authorization code—and in the previous sections, we implemented all the infrastructures necessary to build an application using curses. So, let's glue all the parts together and listen to some good music.
On the musicterminal directory, create a file called app.py; this is going to be the entry point for our application. We start by adding import statements:
import curses
import curses.panel
from curses import wrapper
from curses.textpad import Textbox
from curses.textpad import rectangle
from client import Menu
from client import DataManager
We need to import curses and curses.panel of course, and this time, we are also importing wrapper. This is used for debugging purposes. When developing curses applications, they are extremely hard to debug, and when something goes wrong and some exception is thrown, the terminal will not go back to its original state.
The wrapper takes a callable and it returns the terminal original state when the callable function returns.
The wrapper will run the callable within a try-catch block and it will restore the terminal in case something goes wrong. It is great for us while developing the application. Let's use the wrapper so we can see any kind of problem that may occur.
We are going to import two new functions, Textbox and rectangle. We are going to use those to create a search box where the users can search for their favorite artist.
Lastly, we import the Menu class and the DataManager that we implemented in the previous sections.
Let's start implementing some helper functions; the first one is show_search_screen:
def show_search_screen(stdscr):
curses.curs_set(1)
stdscr.addstr(1, 2, "Artist name: (Ctrl-G to search)")
editwin = curses.newwin(1, 40, 3, 3)
rectangle(stdscr, 2, 2, 4, 44)
stdscr.refresh()
box = Textbox(editwin)
box.edit()
criteria = box.gather()
return criteria
It gets an instance of the window as an argument, so we can print text and add our textbox on the screen.
The curses.curs_set function turns the cursor on and off; when set to 1, the cursor will be visible on the screen. We want that in the search screen so the user knows where he/she can start typing the search criteria. Then, we print help text so the user knows that the name of the artist should be entered; then, to finish, they can press Ctrl + G or just Enter to perform the search.
To create the textbox, we create a new small window with a height that equals 1 and a width that equals 40, and it starts at line 3, column 3 of the terminal screen. After that, we use the rectangle function to draw a rectangle around the new window and we refresh the screen so the changes we made take effect.
Then, we create the Textbox object, passing the window that we just created, and call the method edit, which will set the box to the textbox and enter edit mode. That will stop the application and let the user enter some text in the textbox; it will exit when the user clicks Ctrl + G or Enter.
When the user is done editing the text, we call the gather method that will collect the data entered by the user and assign it to the criteria variable, and finally, we return criteria.
We also need a function to clean the screen easily Let's create another function called clean_screen:
def clear_screen(stdscr):
stdscr.clear()
stdscr.refresh()
Great! Now, we can start with the main entry point of our application, and create a function called main with the following contents:
def main(stdscr):
curses.cbreak()
curses.noecho()
stdscr.keypad(True)
_data_manager = DataManager()
criteria = show_search_screen(stdscr)
height, width = stdscr.getmaxyx()
albums_panel = Menu('List of albums for the selected artist',
(height, width, 0, 0))
tracks_panel = Menu('List of tracks for the selected album',
(height, width, 0, 0))
artist = _data_manager.search_artist(criteria)
albums = _data_manager.get_artist_albums(artist['id'])
clear_screen(stdscr)
albums_panel.items = albums
albums_panel.init()
albums_panel.update()
albums_panel.show()
current_panel = albums_panel
is_running = True
while is_running:
curses.doupdate()
curses.panel.update_panels()
key = stdscr.getch()
action = current_panel.handle_events(key)
if action is not None:
action_result = action()
if current_panel == albums_panel and action_result is
not None:
_id, uri = action_result
tracks = _data_manager.get_album_tracklist(_id)
current_panel.hide()
current_panel = tracks_panel
current_panel.items = tracks
current_panel.init()
current_panel.show()
elif current_panel == tracks_panel and action_result is
not None:
_id, uri = action_result
_data_manager.play(uri)
if key == curses.KEY_F2:
current_panel.hide()
criteria = show_search_screen(stdscr)
artist = _data_manager.search_artist(criteria)
albums = _data_manager.get_artist_albums(artist['id'])
clear_screen(stdscr)
current_panel = albums_panel
current_panel.items = albums
current_panel.init()
current_panel.show()
if key == ord('q') or key == ord('Q'):
is_running = False
current_panel.update()
try:
wrapper(main)
except KeyboardInterrupt:
print('Thanks for using this app, bye!')
Let's break this down into its constituent parts:
curses.cbreak()
curses.noecho()
stdscr.keypad(True)
Here, we do some initialization. Usually, curses don't register the key immediately. When it is typed, this is called buffered mode; the user has to type something and then hit Enter. In our application, we don't want this behavior; we want the key to be registered right after the user types it. This is what cbreak does; it turns off the curses buffered mode.
We also use the noecho function to be able the read the keys and to control when we want to show them on the screen.
The last curses setup we do is to turn on the keypad so curses will do the job of reading and processing the keys accordingly, and returning constant values representing the key that has been pressed. This is much cleaner and easy to read than trying to handle it yourself and test key code numbers.
We create an instance of the DataManager class so we can get the data we need to be displayed on the menus and perform authentication:
_data_manager = DataManager()
Now, we create the search dialog:
criteria = show_search_screen(stdscr)
We call the show_search_screen function, passing the instance of the window; it will render the search field on the screen and return the results to us. When the user is done typing, the user input will be stored in the criteria variable.
After we get the criteria, we call get_artist_albums, which will first search an artist and then get a list of the artist's albums and return a list of MenuItem objects.
When the list of albums is returned, we can create the other panels with the menus:
height, width = stdscr.getmaxyx()
albums_panel = Menu('List of albums for the selected artist',
(height, width, 0, 0))
tracks_panel = Menu('List of tracks for the selected album',
(height, width, 0, 0))
artist = _data_manager.search_artist(criteria)
albums = _data_manager.get_artist_albums(artist['id'])
clear_screen(stdscr)
Here, we get the height and the width of the main window so we can create panels with the same dimensions. albums_panel will display the albums and tracks_panel will display the tracks; as I mentioned before, it will have the same dimensions as the main window and both panels will start at row 0, column 0.
After that, we call clear_screen to prepare the window to render the menu window with the albums:
albums_panel.items = albums
albums_panel.init()
albums_panel.update()
albums_panel.show()
current_panel = albums_panel
is_running = True
We first set the item's properties with the results of the albums search. We also call init on the panel, which will internally run _initialize_items, format the labels and set the currently selected item. We also call the update method, which will do the actual work of printing the menu items in the window; lastly, we show how to set the panel to visible.
We also define the current_panel variable, which will hold the instance of the panel that is currently being displayed on the terminal.
The is_running flag is set to True and it will be used in the application's main loop. We will set it to False when we want to stop the application's execution.
Now, we enter the main loop of the application:
while is_running:
curses.doupdate()
curses.panel.update_panels()
key = stdscr.getch()
action = current_panel.handle_events(key)
To start off, we call doupdate and update_panels:
- doupdate: Curses keeps two data structures representing the physical screen (the one you see on the terminal screen) and a virtual screen (the one keeping the next updated). doupdate updates the physical screen so it matches the virtual screen.
- update_panels: Updates the virtual screen after changes in the panel stack, changes like hiding, show panels, and so on.
After updating the screen, we wait until a key is pressed using the getch function, and assign the key pressed value to the key variable. The key variable is then passed to the current panel's handle_events method.
If you remember the implementation of handle_events in the Menu class, it looks like this:
def handle_events(self, key):
if key == curses.KEY_UP:
self.previous()
elif key == curses.KEY_DOWN:
self.next()
elif key == curses.KEY_ENTER or key == NEW_LINE or key ==
CARRIAGE_RETURN:
selected_item = self.get_selected()
return selected_item.action
It handles KEY_DOWN, KEY_UP, and KEY_ENTER. If the key is KEY_UP or KEY_DOWN, it will just update the position in the menu and set a newly selected item, and that will be updated on the screen on the next loop interaction. If the key is KEY_ENTER, we get the selected item and return its action function.
Remember that, for both panels, it will return a function that, when executed, will return a tuple containing the item id and the item URI.
Moving on, we handle if the action is returned:
if action is not None:
action_result = action()
if current_panel == albums_panel and action_result is not None:
_id, uri = action_result
tracks = _data_manager.get_album_tracklist(_id)
current_panel.hide()
current_panel = tracks_panel
current_panel.items = tracks
current_panel.init()
current_panel.show()
elif current_panel == tracks_panel and action_result is not
None:
_id, uri = action_result
_data_manager.play(uri)
If the handle_events method of the current panel returned a callable action, we execute it and get the result. Then, we check if the active panel is the first panel (with the albums). In this case, we need to get a list of tracks for the selected album, so we call get_album_tracklist in the DataManager instance.
We hide the current_panel, switch the current panel to the second panel (the tracks panel), set the items property with the list of tracks, call the init method so the items are formatted properly and a first item in the list is set as selected, and finally we call show so the track's panel is visible.
In the event the current panel is the tracks_panel, we get the action results and invoke play on the DataManager, passing the track URI. It will request the selected track to be played on the device you have active on Spotify.
Now, we want a way of returning to the search screen. We do that when the user hits the F12 function key:
if key == curses.KEY_F2:
current_panel.hide()
criteria = show_search_screen(stdscr)
artist = _data_manager.search_by_artist_name(criteria)
albums = _data_manager.get_artist_albums(artist['id'])
clear_screen(stdscr)
current_panel = albums_panel
current_panel.items = albums
current_panel.init()
current_panel.show()
For the if statement above, test if the user pressed the F12 function key; in this case, we want to return to the search screen so that the user can search for a new artist. When the F12 key is pressed, we hide the current panel. Then, we call the show_search_screen function so the search screen is rendered and the textbox will enter in edit mode, waiting for the user's input.
When the user is done typing and hits Ctrl+ G or Enter, we search the artist. Then, we get the artist's albums and we show the panel with a list of albums.
The last event that we want to handle is when the user press either the q or Q key, which sets the is_running variable to False and the application closes:
if key == ord('q') or key == ord('Q'):
is_running = False
Finally, we call update on the current panel, so we redraw the items to reflect the changes on the screen:
current_panel.update()
Outside the main function, we have the code snippet where we actually execute the main function:
try:
wrapper(main)
except KeyboardInterrupt:
print('Thanks for using this app, bye!')
We surround it with a try catch so if the user presses Ctrl + C, a KeyboardInterrupt exception will be raised and we just finish the application gracefully without throwing the exception on the screen.
We are all done! Let's try it out!
Open a terminal and type the command—python app.py.
The first screen you will see is the search screen:
Let me search for one of my favorite artists:
After pressing Enter or Ctrl + G, you should see a list of albums:
Here, you can use the arrow keys (Up and Down) to navigate albums, and press Enter to select an album. Then, you will see the screen showing all the tracks of the selected album:
If this screen is the same, you can use the arrow keys (Up and Down) to select the track, and Enter will send a request to play the song on the device you have Spotify active on.