Merge pull request #240 from jodal/feature/audio-state-events
Audio state events
This commit is contained in:
commit
4d4c560882
@ -3,3 +3,4 @@ from __future__ import unicode_literals
|
||||
# flake8: noqa
|
||||
from .actor import Audio
|
||||
from .listener import AudioListener
|
||||
from .constants import PlaybackState
|
||||
|
||||
@ -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
16
mopidy/audio/constants.py
Normal 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'
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
0
tests/audio/__init__.py
Normal 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)
|
||||
16
tests/audio/listener_test.py
Normal file
16
tests/audio/listener_test.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user