Merge pull request #240 from jodal/feature/audio-state-events

Audio state events
This commit is contained in:
Thomas Adamcik 2012-11-14 15:00:09 -08:00
commit 4d4c560882
9 changed files with 153 additions and 21 deletions

View File

@ -3,3 +3,4 @@ from __future__ import unicode_literals
# flake8: noqa
from .actor import Audio
from .listener import AudioListener
from .constants import PlaybackState

View File

@ -13,6 +13,7 @@ from mopidy import settings
from mopidy.utils import process
from . import mixers
from .constants import PlaybackState
from .listener import AudioListener
logger = logging.getLogger('mopidy.audio')
@ -29,9 +30,11 @@ class Audio(pykka.ThreadingActor):
- :attr:`mopidy.settings.OUTPUT`
- :attr:`mopidy.settings.MIXER`
- :attr:`mopidy.settings.MIXER_TRACK`
"""
#: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState`
state = PlaybackState.STOPPED
def __init__(self):
super(Audio, self).__init__()
@ -164,8 +167,12 @@ class Audio(pykka.ThreadingActor):
bus.remove_signal_watch()
def _on_message(self, bus, message):
if message.type == gst.MESSAGE_EOS:
self._trigger_reached_end_of_stream_event()
if (message.type == gst.MESSAGE_STATE_CHANGED
and message.src == self._playbin):
old_state, new_state, pending_state = message.parse_state_changed()
self._on_playbin_state_changed(old_state, new_state, pending_state)
elif message.type == gst.MESSAGE_EOS:
self._on_end_of_stream()
elif message.type == gst.MESSAGE_ERROR:
error, debug = message.parse_error()
logger.error('%s %s', error, debug)
@ -174,8 +181,37 @@ class Audio(pykka.ThreadingActor):
error, debug = message.parse_warning()
logger.warning('%s %s', error, debug)
def _trigger_reached_end_of_stream_event(self):
logger.debug('Triggering reached end of stream event')
def _on_playbin_state_changed(self, old_state, new_state, pending_state):
if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL:
# XXX: We're not called on the last state change when going down to
# NULL, so we rewrite the second to last call to get the expected
# behavior.
new_state = gst.STATE_NULL
pending_state = gst.STATE_VOID_PENDING
if pending_state != gst.STATE_VOID_PENDING:
return # Ignore intermediate state changes
if new_state == gst.STATE_READY:
return # Ignore READY state as it's GStreamer specific
if new_state == gst.STATE_PLAYING:
new_state = PlaybackState.PLAYING
elif new_state == gst.STATE_PAUSED:
new_state = PlaybackState.PAUSED
elif new_state == gst.STATE_NULL:
new_state = PlaybackState.STOPPED
old_state, self.state = self.state, new_state
logger.debug(
'Triggering event: state_changed(old_state=%s, new_state=%s)',
old_state, new_state)
AudioListener.send('state_changed',
old_state=old_state, new_state=new_state)
def _on_end_of_stream(self):
logger.debug('Triggering reached_end_of_stream event')
AudioListener.send('reached_end_of_stream')
def set_uri(self, uri):

16
mopidy/audio/constants.py Normal file
View File

@ -0,0 +1,16 @@
from __future__ import unicode_literals
class PlaybackState(object):
"""
Enum of playback states.
"""
#: Constant representing the paused state.
PAUSED = 'paused'
#: Constant representing the playing state.
PLAYING = 'playing'
#: Constant representing the stopped state.
STOPPED = 'stopped'

View File

@ -28,3 +28,18 @@ class AudioListener(object):
*MAY* be implemented by actor.
"""
pass
def state_changed(self, old_state, new_state):
"""
Called after the playback state have changed.
Will be called for both immediate and async state changes in GStreamer.
*MAY* be implemented by actor.
:param old_state: the state before the change
:type old_state: string from :class:`mopidy.core.PlaybackState` field
:param new_state: the state after the change
:type new_state: string from :class:`mopidy.core.PlaybackState` field
"""
pass

View File

@ -4,7 +4,7 @@ import itertools
import pykka
from mopidy.audio import AudioListener
from mopidy.audio import AudioListener, PlaybackState
from .library import LibraryController
from .playback import PlaybackController
@ -55,6 +55,18 @@ class Core(pykka.ThreadingActor, AudioListener):
def reached_end_of_stream(self):
self.playback.on_end_of_track()
def state_changed(self, old_state, new_state):
# XXX: This is a temporary fix for issue #232 while we wait for a more
# permanent solution with the implementation of issue #234. When the
# Spotify play token is lost, the Spotify backend pauses audio
# playback, but mopidy.core doesn't know this, so we need to update
# mopidy.core's state to match the actual state in mopidy.audio. If we
# don't do this, clients will think that we're still playing.
if (new_state == PlaybackState.PAUSED
and self.playback.state != PlaybackState.PAUSED):
self.playback.state = new_state
self.playback._trigger_track_playback_paused()
class Backends(list):
def __init__(self, backends):

View File

@ -4,6 +4,8 @@ import logging
import random
import urlparse
from mopidy.audio import PlaybackState
from . import listener
@ -24,21 +26,6 @@ def option_wrapper(name, default):
return property(get_option, set_option)
class PlaybackState(object):
"""
Enum of playback states.
"""
#: Constant representing the paused state.
PAUSED = 'paused'
#: Constant representing the playing state.
PLAYING = 'playing'
#: Constant representing the stopped state.
STOPPED = 'stopped'
class PlaybackController(object):
# pylint: disable = R0902
# Too many instance attributes

0
tests/audio/__init__.py Normal file
View File

View File

@ -1,5 +1,9 @@
from __future__ import unicode_literals
import pygst
pygst.require('0.10')
import gst
from mopidy import audio, settings
from mopidy.utils.path import path_to_uri
@ -63,3 +67,48 @@ class AudioTest(unittest.TestCase):
@unittest.SkipTest
def test_invalid_output_raises_error(self):
pass # TODO
class AudioStateTest(unittest.TestCase):
def setUp(self):
self.audio = audio.Audio()
def test_state_starts_as_stopped(self):
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
def test_state_does_not_change_when_in_gst_ready_state(self):
self.audio._on_playbin_state_changed(
gst.STATE_NULL, gst.STATE_READY, gst.STATE_VOID_PENDING)
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
def test_state_changes_from_stopped_to_playing_on_play(self):
self.audio._on_playbin_state_changed(
gst.STATE_NULL, gst.STATE_READY, gst.STATE_PLAYING)
self.audio._on_playbin_state_changed(
gst.STATE_READY, gst.STATE_PAUSED, gst.STATE_PLAYING)
self.audio._on_playbin_state_changed(
gst.STATE_PAUSED, gst.STATE_PLAYING, gst.STATE_VOID_PENDING)
self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state)
def test_state_changes_from_playing_to_paused_on_pause(self):
self.audio.state = audio.PlaybackState.PLAYING
self.audio._on_playbin_state_changed(
gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_VOID_PENDING)
self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state)
def test_state_changes_from_playing_to_stopped_on_stop(self):
self.audio.state = audio.PlaybackState.PLAYING
self.audio._on_playbin_state_changed(
gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_NULL)
self.audio._on_playbin_state_changed(
gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL)
# We never get the following call, so the logic must work without it
#self.audio._on_playbin_state_changed(
# gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING)
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)

View File

@ -0,0 +1,16 @@
from __future__ import unicode_literals
from mopidy import audio
from tests import unittest
class AudioListenerTest(unittest.TestCase):
def setUp(self):
self.listener = audio.AudioListener()
def test_listener_has_default_impl_for_reached_end_of_stream(self):
self.listener.reached_end_of_stream()
def test_listener_has_default_impl_for_state_changed(self):
self.listener.state_changed(None, None)