Home » Odeon Blogs » Stefan Talpalaru, CTO »

bbc_player.py - an exercise in simplicity with Python and Qt

bbc_player.py - an exercise in simplicity with Python and Qt

BBC radio is available worldwide through a Flash player that even works on Linux so what brought me to write my own script to replace it? Flash sucks. The web player is slow and buggy (once stopped, it will resume playback on its own in about an hour, right when you’re having a VoIP call).

So I wanted a replacement. Something simple - select the radio station from a list and launch VLC with the corresponding ASX playlist URL. Luckily, Neil Cooper already compiled such a list. While we’re at it, let’s make it a GUI interface and get a taste of Qt through the PySide Python binding. Nothing fancy, no icons, no systray, no menus, not even a “close” button - the one provided by the window manager will do just fine.

Long story short: get bbc_player.py from github, chmod and execute it. Long story even longer: hang around to see the complexity behind these simple requirements.

The PySide API reference is hosted (temporarily?) on github so that’s where I went digging for info after getting through the beginner tutorials. Some vertically stacked buttons with the radio name as labels is the simplest UI I came up with. Since we have 56 stations, the container for those buttons needs to be scrollable. Simple, right? No need to mess with the Qt designer for this one, we’ll do it in code.

Our class inherits from QMainWindow and in the __init__() we set a title and a minimum size for our window:

  1. self.setWindowTitle(WIN_TITLE)
  2. self.setMinimumSize(300, 600)
OK, now to the fun part: widgets. Our window will have a child QScrollArea widget which will contain a generic QWidget with a QVBoxLayout layout which will hold 56 QPushButton buttons. A bit unintuitive but worth the effort for getting all the benefits of Qt. At least until you hit the quirks, that is: the QScrollArea object doesn’t show its content if you don’t call setWidgetResizable(True) on it; ‘&’ has a special meaning in the button label (it sets the shortcut) so we need to name.replace(‘&’, ‘&&’). It’s probably a rite of passage finding solutions for all these problems. Maybe new projects will be easier.

To get our buttons to do something we need to connect the ‘clicked’ signal to a function of our own. We cannot pass arguments to this function, but we can get the clicked button object with self.sender() so we can pass what we need as custom attributes in the button. Not very elegant but good enough. Here’s our UI construction:

  1. self.scroll_area = QScrollArea()
  2. self.widget = QWidget()
  3. self.layout = QVBoxLayout()
  4. self.widget.setLayout(self.layout)
  5. self.scroll_area.setWidgetResizable(True)
  6. self.scroll_area.setWidget(self.widget)
  7. self.setCentralWidget(self.scroll_area)
  8. for name, url in STATIONS:
  9. button = QPushButton(name.replace('&', '&&'))
  10. button.args = {
  11. 'name': name,
  12. 'url': url,
  13. }
  14. button.clicked.connect(self.listen)
  15. self.layout.addWidget(button)
In the listen() method we launch the player with the URL as an argument. The perfect job for subprocess.Popen . If the execution fails, we show the error message in a QMessageBox. At this point a new requirement pops up - we need some visual cue for the station currently playing. button.setEnabled(False) does the trick and also doubles as a guard against accidental double clicks. We also need the ‘enabled’ state reset when another button is pressed. Storing the previously pressed button somewhere is possible, but iterating through all the buttons and checking their state is more fun.

While we’re at it, let’s give the user the possibility to specify another player instead of VLC and any arguments that need to go with it. The argparse module provides all the features we need and then some. When we’re done with argument parsing we’ll have the results in self.player_prog and self.player_args:

  1. def listen(self):
  2. pressed_button = self.sender()
  3. for button in self.widget.findChildren(QPushButton):
  4. if button != pressed_button and not button.isEnabled():
  5. button.setEnabled(True)
  6. break
  7. pressed_button.setEnabled(False)
  8. # stop the running player instance before starting another one
  9. if self.player:
  10. if self.player.poll() is None:
  11. self.player.terminate()
  12. self.player.wait()
  13. cmd = [self.player_prog]
  14. cmd.extend(self.player_args)
  15. cmd.append(pressed_button.args['url'])
  16. try:
  17. self.player = subprocess.Popen(cmd)
  18. except Exception, e:
  19. msg_box = QMessageBox()
  20. msg_box.setText('Couldn\'t launch\n"%s"' % ' '.join(cmd))
  21. msg_box.setInformativeText(unicode(e))
  22. msg_box.exec_()
  23. pressed_button.setEnabled(True)
  24. self.setWindowTitle('%s - %s' % (pressed_button.args['name'], WIN_TITLE))
Controlling the player once started is outside the scope of this program. Even killing it when our script exits is not something desirable. What we really need is to be informed of the player’s exit so we can re-enable the button. We do this with a QTimer object that calls check_player() every 200 ms after the player has been started:
  1. def check_player(self):
  2. if self.player and self.player.poll() is not None:
  3. # the player has been stopped
  4. self.player = None
  5. self.timer.stop()
  6. self.setWindowTitle(WIN_TITLE)
  7. for button in self.widget.findChildren(QPushButton):
  8. if not button.isEnabled():
  9. button.setEnabled(True)
  10. break
That’s all, folks. Get the script from github. The light-weight site scraping needed to get the list of stations was done with mechanize and pyquery(1.2.4) in fetch_links.py - available for your viewing pleasure in the same repo. Enjoy.


Category: Python

Discussion

  1. Nice post. Very concise and useful. I like your approach of attaching custom attributes to the QPushButton.

    I tend to do this sort of thing using lambda. I'm not sure if one way is better than the other, just an observation:

    button.clicked.connect(lambda: self.listen(name, url))

    http://codrspace.com/durden/using-lambda-with-pyqt-signals/

    I've also seen people solve this problem with functools.partial:
    http://eli.thegreenplace.net/2011/04/25/passing-extra-arguments-to-pyqt-slot/


  2. Yes, using the lambda is a valid alternative but self.sender() no longer works inside self.listen() - or inside the lambda for that matter.


  3. One other thing: since the lambda's body is not evalued until the signal is triggered, a naive implementation would make all the buttons play the last radio station (because 'name' and 'url' point to it at the end of the loop).

    The only way it works is something like this:

    button.clicked.connect(lambda _button=button, _name=name, _url=url: self.listen(_button, _name, _url))

    Yes, it's ugly...




Leave a Comment :

(required)


(required)




(required)








Page generated in: 0.18s