This ensures that we block about-to-finish and the pipeline until the next URI is set, alowing for at least EOS free playback. If the uri is set quickly enough this will also be gapless.
536 lines
17 KiB
Python
536 lines
17 KiB
Python
from __future__ import unicode_literals
|
|
|
|
import logging
|
|
import random
|
|
import urlparse
|
|
|
|
from mopidy.audio import PlaybackState
|
|
|
|
from . import listener
|
|
|
|
|
|
logger = logging.getLogger('mopidy.core')
|
|
|
|
|
|
class PlaybackController(object):
|
|
# pylint: disable = R0902
|
|
# Too many instance attributes
|
|
|
|
pykka_traversable = True
|
|
|
|
def __init__(self, audio, backends, core):
|
|
self.audio = audio
|
|
self.backends = backends
|
|
self.core = core
|
|
|
|
self._state = PlaybackState.STOPPED
|
|
self._shuffled = []
|
|
self._first_shuffle = True
|
|
self._volume = None
|
|
|
|
def _get_backend(self):
|
|
if self.current_tl_track is None:
|
|
return None
|
|
uri = self.current_tl_track.track.uri
|
|
uri_scheme = urlparse.urlparse(uri).scheme
|
|
return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None)
|
|
|
|
### Properties
|
|
|
|
def get_consume(self):
|
|
return getattr(self, '_consume', False)
|
|
|
|
def set_consume(self, value):
|
|
if self.get_consume() != value:
|
|
self._trigger_options_changed()
|
|
return setattr(self, '_consume', value)
|
|
|
|
consume = property(get_consume, set_consume)
|
|
"""
|
|
:class:`True`
|
|
Tracks are removed from the playlist when they have been played.
|
|
:class:`False`
|
|
Tracks are not removed from the playlist.
|
|
"""
|
|
|
|
def get_current_tl_track(self):
|
|
return self.current_tl_track
|
|
|
|
current_tl_track = None
|
|
"""
|
|
The currently playing or selected :class:`mopidy.models.TlTrack`, or
|
|
:class:`None`.
|
|
"""
|
|
|
|
def get_current_track(self):
|
|
return self.current_tl_track and self.current_tl_track.track
|
|
|
|
current_track = property(get_current_track)
|
|
"""
|
|
The currently playing or selected :class:`mopidy.models.Track`.
|
|
|
|
Read-only. Extracted from :attr:`current_tl_track` for convenience.
|
|
"""
|
|
|
|
def get_random(self):
|
|
return getattr(self, '_random', False)
|
|
|
|
def set_random(self, value):
|
|
if self.get_random() != value:
|
|
self._trigger_options_changed()
|
|
return setattr(self, '_random', value)
|
|
|
|
random = property(get_random, set_random)
|
|
"""
|
|
:class:`True`
|
|
Tracks are selected at random from the playlist.
|
|
:class:`False`
|
|
Tracks are played in the order of the playlist.
|
|
"""
|
|
|
|
def get_repeat(self):
|
|
return getattr(self, '_repeat', False)
|
|
|
|
def set_repeat(self, value):
|
|
if self.get_repeat() != value:
|
|
self._trigger_options_changed()
|
|
return setattr(self, '_repeat', value)
|
|
|
|
repeat = property(get_repeat, set_repeat)
|
|
"""
|
|
:class:`True`
|
|
The current playlist is played repeatedly. To repeat a single track,
|
|
select both :attr:`repeat` and :attr:`single`.
|
|
:class:`False`
|
|
The current playlist is played once.
|
|
"""
|
|
|
|
def get_single(self):
|
|
return getattr(self, '_single', False)
|
|
|
|
def set_single(self, value):
|
|
if self.get_single() != value:
|
|
self._trigger_options_changed()
|
|
return setattr(self, '_single', value)
|
|
|
|
single = property(get_single, set_single)
|
|
"""
|
|
:class:`True`
|
|
Playback is stopped after current song, unless in :attr:`repeat`
|
|
mode.
|
|
:class:`False`
|
|
Playback continues after current song.
|
|
"""
|
|
|
|
def get_state(self):
|
|
return self._state
|
|
|
|
def set_state(self, new_state):
|
|
(old_state, self._state) = (self.state, new_state)
|
|
logger.debug('Changing state: %s -> %s', old_state, new_state)
|
|
|
|
self._trigger_playback_state_changed(old_state, new_state)
|
|
|
|
state = property(get_state, set_state)
|
|
"""
|
|
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
|
|
:attr:`STOPPED`.
|
|
|
|
Possible states and transitions:
|
|
|
|
.. digraph:: state_transitions
|
|
|
|
"STOPPED" -> "PLAYING" [ label="play" ]
|
|
"STOPPED" -> "PAUSED" [ label="pause" ]
|
|
"PLAYING" -> "STOPPED" [ label="stop" ]
|
|
"PLAYING" -> "PAUSED" [ label="pause" ]
|
|
"PLAYING" -> "PLAYING" [ label="play" ]
|
|
"PAUSED" -> "PLAYING" [ label="resume" ]
|
|
"PAUSED" -> "STOPPED" [ label="stop" ]
|
|
"""
|
|
|
|
def get_time_position(self):
|
|
backend = self._get_backend()
|
|
if backend:
|
|
return backend.playback.get_time_position().get()
|
|
else:
|
|
return 0
|
|
|
|
time_position = property(get_time_position)
|
|
"""Time position in milliseconds."""
|
|
|
|
def get_tracklist_position(self):
|
|
if self.current_tl_track is None:
|
|
return None
|
|
try:
|
|
return self.core.tracklist.tl_tracks.index(self.current_tl_track)
|
|
except ValueError:
|
|
return None
|
|
|
|
tracklist_position = property(get_tracklist_position)
|
|
"""
|
|
The position of the current track in the tracklist.
|
|
|
|
Read-only.
|
|
"""
|
|
|
|
def get_tl_track_at_eot(self):
|
|
# pylint: disable = R0911
|
|
# Too many return statements
|
|
|
|
tl_tracks = self.core.tracklist.tl_tracks
|
|
|
|
if not tl_tracks:
|
|
return None
|
|
|
|
if self.random and not self._shuffled:
|
|
if self.repeat or self._first_shuffle:
|
|
logger.debug('Shuffling tracks')
|
|
self._shuffled = tl_tracks
|
|
random.shuffle(self._shuffled)
|
|
self._first_shuffle = False
|
|
|
|
if self.random and self._shuffled:
|
|
return self._shuffled[0]
|
|
|
|
if self.current_tl_track is None:
|
|
return tl_tracks[0]
|
|
|
|
if self.repeat and self.single:
|
|
return tl_tracks[self.tracklist_position]
|
|
|
|
if self.repeat and not self.single:
|
|
return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)]
|
|
|
|
try:
|
|
return tl_tracks[self.tracklist_position + 1]
|
|
except IndexError:
|
|
return None
|
|
|
|
tl_track_at_eot = property(get_tl_track_at_eot)
|
|
"""
|
|
The track that will be played at the end of the current track.
|
|
|
|
Read-only. A :class:`mopidy.models.TlTrack`.
|
|
|
|
Not necessarily the same track as :attr:`tl_track_at_next`.
|
|
"""
|
|
|
|
def get_tl_track_at_next(self):
|
|
tl_tracks = self.core.tracklist.tl_tracks
|
|
|
|
if not tl_tracks:
|
|
return None
|
|
|
|
if self.random and not self._shuffled:
|
|
if self.repeat or self._first_shuffle:
|
|
logger.debug('Shuffling tracks')
|
|
self._shuffled = tl_tracks
|
|
random.shuffle(self._shuffled)
|
|
self._first_shuffle = False
|
|
|
|
if self.random and self._shuffled:
|
|
return self._shuffled[0]
|
|
|
|
if self.current_tl_track is None:
|
|
return tl_tracks[0]
|
|
|
|
if self.repeat:
|
|
return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)]
|
|
|
|
try:
|
|
return tl_tracks[self.tracklist_position + 1]
|
|
except IndexError:
|
|
return None
|
|
|
|
tl_track_at_next = property(get_tl_track_at_next)
|
|
"""
|
|
The track that will be played if calling :meth:`next()`.
|
|
|
|
Read-only. A :class:`mopidy.models.TlTrack`.
|
|
|
|
For normal playback this is the next track in the playlist. If repeat
|
|
is enabled the next track can loop around the playlist. When random is
|
|
enabled this should be a random track, all tracks should be played once
|
|
before the list repeats.
|
|
"""
|
|
|
|
def get_tl_track_at_previous(self):
|
|
if self.repeat or self.consume or self.random:
|
|
return self.current_tl_track
|
|
|
|
if self.tracklist_position in (None, 0):
|
|
return None
|
|
|
|
return self.core.tracklist.tl_tracks[self.tracklist_position - 1]
|
|
|
|
tl_track_at_previous = property(get_tl_track_at_previous)
|
|
"""
|
|
The track that will be played if calling :meth:`previous()`.
|
|
|
|
A :class:`mopidy.models.TlTrack`.
|
|
|
|
For normal playback this is the previous track in the playlist. If
|
|
random and/or consume is enabled it should return the current track
|
|
instead.
|
|
"""
|
|
|
|
def get_volume(self):
|
|
if self.audio:
|
|
return self.audio.get_volume().get()
|
|
else:
|
|
# For testing
|
|
return self._volume
|
|
|
|
def set_volume(self, volume):
|
|
if self.audio:
|
|
self.audio.set_volume(volume)
|
|
else:
|
|
# For testing
|
|
self._volume = volume
|
|
|
|
self._trigger_volume_changed(volume)
|
|
|
|
volume = property(get_volume, set_volume)
|
|
"""Volume as int in range [0..100] or :class:`None`"""
|
|
|
|
### Methods
|
|
|
|
def change_track(self, tl_track, on_error_step=1):
|
|
"""
|
|
Change to the given track, keeping the current playback state.
|
|
|
|
:param tl_track: track to change to
|
|
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
|
:param on_error_step: direction to step at play error, 1 for next
|
|
track (default), -1 for previous track
|
|
:type on_error_step: int, -1 or 1
|
|
"""
|
|
old_state = self.state
|
|
self.stop()
|
|
self.current_tl_track = tl_track
|
|
if old_state == PlaybackState.PLAYING:
|
|
self.play(on_error_step=on_error_step)
|
|
elif old_state == PlaybackState.PAUSED:
|
|
self.pause()
|
|
|
|
def on_end_of_stream(self):
|
|
self._trigger_track_playback_ended()
|
|
self.state = PlaybackState.STOPPED
|
|
self.current_tl_track = None
|
|
|
|
def on_end_of_track(self):
|
|
"""
|
|
Tell the playback controller that end of track is reached.
|
|
|
|
Used by event handler in :class:`mopidy.core.Core`.
|
|
"""
|
|
if self.state == PlaybackState.STOPPED:
|
|
return
|
|
|
|
original_tl_track = self.current_tl_track
|
|
|
|
# As noted in mopidy.audio which calls this code, we need to make sure
|
|
# the calls to the backend are blocking or gapless / EOS free playback
|
|
# will break.
|
|
if self.tl_track_at_eot:
|
|
self.current_tl_track = self.tl_track_at_eot
|
|
self._trigger_track_playback_ended()
|
|
self._get_backend().playback.change_track(self.current_track).get()
|
|
self._trigger_track_playback_started()
|
|
|
|
if self.consume:
|
|
self.core.tracklist.remove(tlid=original_tl_track.tlid)
|
|
|
|
def on_tracklist_change(self):
|
|
"""
|
|
Tell the playback controller that the current playlist has changed.
|
|
|
|
Used by :class:`mopidy.core.TracklistController`.
|
|
"""
|
|
self._first_shuffle = True
|
|
self._shuffled = []
|
|
|
|
if (not self.core.tracklist.tl_tracks or
|
|
self.current_tl_track not in
|
|
self.core.tracklist.tl_tracks):
|
|
self.stop(clear_current_track=True)
|
|
|
|
def next(self):
|
|
"""
|
|
Change to the next track.
|
|
|
|
The current playback state will be kept. If it was playing, playing
|
|
will continue. If it was paused, it will still be paused, etc.
|
|
"""
|
|
if self.tl_track_at_next:
|
|
self._trigger_track_playback_ended()
|
|
self.change_track(self.tl_track_at_next)
|
|
else:
|
|
self.stop(clear_current_track=True)
|
|
|
|
def pause(self):
|
|
"""Pause playback."""
|
|
backend = self._get_backend()
|
|
if not backend or backend.playback.pause().get():
|
|
self.state = PlaybackState.PAUSED
|
|
self._trigger_track_playback_paused()
|
|
|
|
def play(self, tl_track=None, on_error_step=1):
|
|
"""
|
|
Play the given track, or if the given track is :class:`None`, play the
|
|
currently active track.
|
|
|
|
:param tl_track: track to play
|
|
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
|
:param on_error_step: direction to step at play error, 1 for next
|
|
track (default), -1 for previous track
|
|
:type on_error_step: int, -1 or 1
|
|
"""
|
|
|
|
if tl_track is not None:
|
|
assert tl_track in self.core.tracklist.tl_tracks
|
|
elif tl_track is None:
|
|
if self.state == PlaybackState.PAUSED:
|
|
return self.resume()
|
|
elif self.current_tl_track is not None:
|
|
tl_track = self.current_tl_track
|
|
elif self.current_tl_track is None and on_error_step == 1:
|
|
tl_track = self.tl_track_at_next
|
|
elif self.current_tl_track is None and on_error_step == -1:
|
|
tl_track = self.tl_track_at_previous
|
|
|
|
if tl_track is not None:
|
|
self.current_tl_track = tl_track
|
|
self.state = PlaybackState.PLAYING
|
|
backend = self._get_backend()
|
|
if not backend or not backend.playback.play(tl_track.track).get():
|
|
# Track is not playable
|
|
if self.random and self._shuffled:
|
|
self._shuffled.remove(tl_track)
|
|
if on_error_step == 1:
|
|
self.next()
|
|
elif on_error_step == -1:
|
|
self.previous()
|
|
|
|
if self.random and self.current_tl_track in self._shuffled:
|
|
self._shuffled.remove(self.current_tl_track)
|
|
|
|
self._trigger_track_playback_started()
|
|
|
|
def previous(self):
|
|
"""
|
|
Change to the previous track.
|
|
|
|
The current playback state will be kept. If it was playing, playing
|
|
will continue. If it was paused, it will still be paused, etc.
|
|
"""
|
|
self._trigger_track_playback_ended()
|
|
self.change_track(self.tl_track_at_previous, on_error_step=-1)
|
|
|
|
def resume(self):
|
|
"""If paused, resume playing the current track."""
|
|
if self.state != PlaybackState.PAUSED:
|
|
return
|
|
backend = self._get_backend()
|
|
if backend and backend.playback.resume().get():
|
|
self.state = PlaybackState.PLAYING
|
|
self._trigger_track_playback_resumed()
|
|
|
|
def seek(self, time_position):
|
|
"""
|
|
Seeks to time position given in milliseconds.
|
|
|
|
:param time_position: time position in milliseconds
|
|
:type time_position: int
|
|
:rtype: :class:`True` if successful, else :class:`False`
|
|
"""
|
|
if not self.core.tracklist.tracks:
|
|
return False
|
|
|
|
if self.state == PlaybackState.STOPPED:
|
|
self.play()
|
|
elif self.state == PlaybackState.PAUSED:
|
|
self.resume()
|
|
|
|
if time_position < 0:
|
|
time_position = 0
|
|
elif time_position > self.current_track.length:
|
|
self.next()
|
|
return True
|
|
|
|
backend = self._get_backend()
|
|
if not backend:
|
|
return False
|
|
|
|
success = backend.playback.seek(time_position).get()
|
|
if success:
|
|
self._trigger_seeked(time_position)
|
|
return success
|
|
|
|
def stop(self, clear_current_track=False):
|
|
"""
|
|
Stop playing.
|
|
|
|
:param clear_current_track: whether to clear the current track _after_
|
|
stopping
|
|
:type clear_current_track: boolean
|
|
"""
|
|
if self.state != PlaybackState.STOPPED:
|
|
backend = self._get_backend()
|
|
if not backend or backend.playback.stop().get():
|
|
self._trigger_track_playback_ended()
|
|
self.state = PlaybackState.STOPPED
|
|
if clear_current_track:
|
|
self.current_tl_track = None
|
|
|
|
def _trigger_track_playback_paused(self):
|
|
logger.debug('Triggering track playback paused event')
|
|
if self.current_track is None:
|
|
return
|
|
listener.CoreListener.send(
|
|
'track_playback_paused',
|
|
tl_track=self.current_tl_track, time_position=self.time_position)
|
|
|
|
def _trigger_track_playback_resumed(self):
|
|
logger.debug('Triggering track playback resumed event')
|
|
if self.current_track is None:
|
|
return
|
|
listener.CoreListener.send(
|
|
'track_playback_resumed',
|
|
tl_track=self.current_tl_track, time_position=self.time_position)
|
|
|
|
def _trigger_track_playback_started(self):
|
|
logger.debug('Triggering track playback started event')
|
|
if self.current_tl_track is None:
|
|
return
|
|
listener.CoreListener.send(
|
|
'track_playback_started',
|
|
tl_track=self.current_tl_track)
|
|
|
|
def _trigger_track_playback_ended(self):
|
|
logger.debug('Triggering track playback ended event')
|
|
if self.current_tl_track is None:
|
|
return
|
|
listener.CoreListener.send(
|
|
'track_playback_ended',
|
|
tl_track=self.current_tl_track, time_position=self.time_position)
|
|
|
|
def _trigger_playback_state_changed(self, old_state, new_state):
|
|
logger.debug('Triggering playback state change event')
|
|
listener.CoreListener.send(
|
|
'playback_state_changed',
|
|
old_state=old_state, new_state=new_state)
|
|
|
|
def _trigger_options_changed(self):
|
|
logger.debug('Triggering options changed event')
|
|
listener.CoreListener.send('options_changed')
|
|
|
|
def _trigger_volume_changed(self, volume):
|
|
logger.debug('Triggering volume changed event')
|
|
listener.CoreListener.send('volume_changed', volume=volume)
|
|
|
|
def _trigger_seeked(self, time_position):
|
|
logger.debug('Triggering seeked event')
|
|
listener.CoreListener.send('seeked', time_position=time_position)
|