mopidy / mopidy
1 2
import logging
2 2
import urllib
3

4 2
from pykka.messages import ProxyCall
5

6 2
from mopidy.audio import PlaybackState
7 2
from mopidy.core import listener
8 2
from mopidy.internal import deprecation, models, validation
9

10 2
logger = logging.getLogger(__name__)
11

12

13 2
class PlaybackController:
14 2
    def __init__(self, audio, backends, core):
15
        # TODO: these should be internal
16 2
        self.backends = backends
17 2
        self.core = core
18 2
        self._audio = audio
19

20 2
        self._stream_title = None
21 2
        self._state = PlaybackState.STOPPED
22

23 2
        self._current_tl_track = None
24 2
        self._pending_tl_track = None
25

26 2
        self._pending_position = None
27 2
        self._last_position = None
28 2
        self._previous = False
29

30 2
        self._start_at_position = None
31 2
        self._start_paused = False
32

33 2
        if self._audio:
34 2
            self._audio.set_about_to_finish_callback(
35
                self._on_about_to_finish_callback
36
            )
37

38 2
    def _get_backend(self, tl_track):
39 2
        if tl_track is None:
40 2
            return None
41 2
        uri_scheme = urllib.parse.urlparse(tl_track.track.uri).scheme
42 2
        return self.backends.with_playback.get(uri_scheme, None)
43

44 2
    def get_current_tl_track(self):
45
        """Get the currently playing or selected track.
46

47
        Returns a :class:`mopidy.models.TlTrack` or :class:`None`.
48
        """
49 2
        return self._current_tl_track
50

51 2
    def _set_current_tl_track(self, value):
52
        """Set the currently playing or selected track.
53

54
        *Internal:* This is only for use by Mopidy's test suite.
55
        """
56 2
        self._current_tl_track = value
57

58 2
    def get_current_track(self):
59
        """
60
        Get the currently playing or selected track.
61

62
        Extracted from :meth:`get_current_tl_track` for convenience.
63

64
        Returns a :class:`mopidy.models.Track` or :class:`None`.
65
        """
66 2
        return getattr(self.get_current_tl_track(), "track", None)
67

68 2
    def get_current_tlid(self):
69
        """
70
        Get the currently playing or selected TLID.
71

72
        Extracted from :meth:`get_current_tl_track` for convenience.
73

74
        Returns a :class:`int` or :class:`None`.
75

76
        .. versionadded:: 1.1
77
        """
78 2
        return getattr(self.get_current_tl_track(), "tlid", None)
79

80 2
    def get_stream_title(self):
81
        """Get the current stream title or :class:`None`."""
82 2
        return self._stream_title
83

84 2
    def get_state(self):
85
        """Get The playback state."""
86

87 2
        return self._state
88

89 2
    def set_state(self, new_state):
90
        """Set the playback state.
91

92
        Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`.
93

94
        Possible states and transitions:
95

96
        .. digraph:: state_transitions
97

98
            "STOPPED" -> "PLAYING" [ label="play" ]
99
            "STOPPED" -> "PAUSED" [ label="pause" ]
100
            "PLAYING" -> "STOPPED" [ label="stop" ]
101
            "PLAYING" -> "PAUSED" [ label="pause" ]
102
            "PLAYING" -> "PLAYING" [ label="play" ]
103
            "PAUSED" -> "PLAYING" [ label="resume" ]
104
            "PAUSED" -> "STOPPED" [ label="stop" ]
105
        """
106 2
        validation.check_choice(new_state, validation.PLAYBACK_STATES)
107

108 2
        (old_state, self._state) = (self.get_state(), new_state)
109 2
        logger.debug("Changing state: %s -> %s", old_state, new_state)
110

111 2
        self._trigger_playback_state_changed(old_state, new_state)
112

113 2
    def get_time_position(self):
114
        """Get time position in milliseconds."""
115 2
        if self._pending_position is not None:
116 0
            return self._pending_position
117 2
        backend = self._get_backend(self.get_current_tl_track())
118 2
        if backend:
119
            # TODO: Wrap backend call in error handling.
120 2
            return backend.playback.get_time_position().get()
121
        else:
122 2
            return 0
123

124 2
    def _on_end_of_stream(self):
125 2
        self.set_state(PlaybackState.STOPPED)
126 2
        if self._current_tl_track:
127 2
            self._trigger_track_playback_ended(self.get_time_position())
128 2
        self._set_current_tl_track(None)
129

130 2
    def _on_stream_changed(self, uri):
131 2
        if self._last_position is None:
132 2
            position = self.get_time_position()
133
        else:
134
            # This code path handles the stop() case, uri should be none.
135 2
            position, self._last_position = self._last_position, None
136

137 2
        if self._pending_position is None:
138 2
            self._trigger_track_playback_ended(position)
139

140 2
        self._stream_title = None
141 2
        if self._pending_tl_track:
142 2
            self._set_current_tl_track(self._pending_tl_track)
143 2
            self._pending_tl_track = None
144

145 2
            if self._pending_position is None:
146 2
                self.set_state(PlaybackState.PLAYING)
147 2
                self._trigger_track_playback_started()
148 2
                seek_ok = False
149 2
                if self._start_at_position:
150 0
                    seek_ok = self.seek(self._start_at_position)
151 0
                    self._start_at_position = None
152 2
                if not seek_ok and self._start_paused:
153 0
                    self.pause()
154 0
                    self._start_paused = False
155
            else:
156 0
                self._seek(self._pending_position)
157

158 2
    def _on_position_changed(self, position):
159 2
        if self._pending_position is not None:
160 0
            self._trigger_seeked(self._pending_position)
161 0
            self._pending_position = None
162 0
            if self._start_paused:
163 0
                self._start_paused = False
164 0
                self.pause()
165

166 2
    def _on_about_to_finish_callback(self):
167
        """Callback that performs a blocking actor call to the real callback.
168

169
        This is passed to audio, which is allowed to call this code from the
170
        audio thread. We pass execution into the core actor to ensure that
171
        there is no unsafe access of state in core. This must block until
172
        we get a response.
173
        """
174 0
        self.core.actor_ref.ask(
175
            ProxyCall(
176
                attr_path=["playback", "_on_about_to_finish"],
177
                args=[],
178
                kwargs={},
179
            )
180
        )
181

182 2
    def _on_about_to_finish(self):
183 2
        if self._state == PlaybackState.STOPPED:
184 2
            return
185

186
        # Unless overridden by other calls (e.g. next / previous / stop) this
187
        # will be the last position recorded until the track gets reassigned.
188
        # TODO: Check if case when track.length isn't populated needs to be
189
        # handled.
190 2
        self._last_position = self._current_tl_track.track.length
191

192 2
        pending = self.core.tracklist.eot_track(self._current_tl_track)
193
        # avoid endless loop if 'repeat' is 'true' and no track is playable
194
        # * 2 -> second run to get all playable track in a shuffled playlist
195 2
        count = self.core.tracklist.get_length() * 2
196

197 2
        while pending:
198 2
            backend = self._get_backend(pending)
199 2
            if backend:
200 2
                try:
201 2
                    if backend.playback.change_track(pending.track).get():
202 2
                        self._pending_tl_track = pending
203 2
                        break
204 2
                except Exception:
205 2
                    logger.exception(
206
                        "%s backend caused an exception.",
207
                        backend.actor_ref.actor_class.__name__,
208
                    )
209

210 2
            self.core.tracklist._mark_unplayable(pending)
211 2
            pending = self.core.tracklist.eot_track(pending)
212 2
            count -= 1
213 2
            if not count:
214 2
                logger.info("No playable track in the list.")
215 2
                break
216

217 2
    def _on_tracklist_change(self):
218
        """
219
        Tell the playback controller that the current playlist has changed.
220

221
        Used by :class:`mopidy.core.TracklistController`.
222
        """
223 2
        tl_tracks = self.core.tracklist.get_tl_tracks()
224 2
        if not tl_tracks:
225 2
            self.stop()
226 2
            self._set_current_tl_track(None)
227 2
        elif self.get_current_tl_track() not in tl_tracks:
228 2
            self._set_current_tl_track(None)
229

230 2
    def next(self):
231
        """
232
        Change to the next track.
233

234
        The current playback state will be kept. If it was playing, playing
235
        will continue. If it was paused, it will still be paused, etc.
236
        """
237 2
        state = self.get_state()
238 2
        current = self._pending_tl_track or self._current_tl_track
239
        # avoid endless loop if 'repeat' is 'true' and no track is playable
240
        # * 2 -> second run to get all playable track in a shuffled playlist
241 2
        count = self.core.tracklist.get_length() * 2
242

243 2
        while current:
244 2
            pending = self.core.tracklist.next_track(current)
245 2
            if self._change(pending, state):
246 2
                break
247
            else:
248 2
                self.core.tracklist._mark_unplayable(pending)
249
            # TODO: this could be needed to prevent a loop in rare cases
250
            # if current == pending:
251
            #     break
252 2
            current = pending
253 2
            count -= 1
254 2
            if not count:
255 2
                logger.info("No playable track in the list.")
256 2
                break
257

258
        # TODO return result?
259

260 2
    def pause(self):
261
        """Pause playback."""
262 2
        backend = self._get_backend(self.get_current_tl_track())
263
        # TODO: Wrap backend call in error handling.
264 2
        if not backend or backend.playback.pause().get():
265
            # TODO: switch to:
266
            # backend.track(pause)
267
            # wait for state change?
268 2
            self.set_state(PlaybackState.PAUSED)
269 2
            self._trigger_track_playback_paused()
270

271 2
    def play(self, tl_track=None, tlid=None):
272
        """
273
        Play the given track, or if the given tl_track and tlid is
274
        :class:`None`, play the currently active track.
275

276
        Note that the track **must** already be in the tracklist.
277

278
        .. deprecated:: 3.0
279
            The ``tl_track`` argument. Use ``tlid`` instead.
280

281
        :param tl_track: track to play
282
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
283
        :param tlid: TLID of the track to play
284
        :type tlid: :class:`int` or :class:`None`
285
        """
286 2
        if sum(o is not None for o in [tl_track, tlid]) > 1:
287 0
            raise ValueError('At most one of "tl_track" and "tlid" may be set')
288

289 2
        tl_track is None or validation.check_instance(tl_track, models.TlTrack)
290 2
        tlid is None or validation.check_integer(tlid, min=1)
291

292 2
        if tl_track:
293 2
            deprecation.warn("core.playback.play:tl_track_kwarg")
294

295 2
        if tl_track is None and tlid is not None:
296 2
            for tl_track in self.core.tracklist.get_tl_tracks():
297 2
                if tl_track.tlid == tlid:
298 2
                    break
299
            else:
300 0
                tl_track = None
301

302 2
        if tl_track is not None:
303
            # TODO: allow from outside tracklist, would make sense given refs?
304 2
            if tl_track not in self.core.tracklist.get_tl_tracks():
305 0
                raise AssertionError
306 2
        elif tl_track is None and self.get_state() == PlaybackState.PAUSED:
307 0
            self.resume()
308 0
            return
309

310 2
        current = self._pending_tl_track or self._current_tl_track
311 2
        pending = tl_track or current or self.core.tracklist.next_track(None)
312
        # avoid endless loop if 'repeat' is 'true' and no track is playable
313
        # * 2 -> second run to get all playable track in a shuffled playlist
314 2
        count = self.core.tracklist.get_length() * 2
315

316 2
        while pending:
317 2
            if self._change(pending, PlaybackState.PLAYING):
318 2
                break
319
            else:
320 2
                self.core.tracklist._mark_unplayable(pending)
321 2
            current = pending
322 2
            pending = self.core.tracklist.next_track(current)
323 2
            count -= 1
324 2
            if not count:
325 2
                logger.info("No playable track in the list.")
326 2
                break
327

328
        # TODO return result?
329

330 2
    def _change(self, pending_tl_track, state):
331 2
        self._pending_tl_track = pending_tl_track
332

333 2
        if not pending_tl_track:
334 2
            self.stop()
335 2
            self._on_end_of_stream()  # pretend an EOS happened for cleanup
336 2
            return True
337

338 2
        backend = self._get_backend(pending_tl_track)
339 2
        if not backend:
340 2
            return False
341

342
        # This must happen before prepare_change gets called, otherwise the
343
        # backend flushes the information of the track.
344 2
        self._last_position = self.get_time_position()
345

346
        # TODO: Wrap backend call in error handling.
347 2
        backend.playback.prepare_change()
348

349 2
        try:
350 2
            if not backend.playback.change_track(pending_tl_track.track).get():
351 2
                return False
352 2
        except Exception:
353 2
            logger.exception(
354
                "%s backend caused an exception.",
355
                backend.actor_ref.actor_class.__name__,
356
            )
357 2
            return False
358

359
        # TODO: Wrap backend calls in error handling.
360 2
        if state == PlaybackState.PLAYING:
361 2
            try:
362 2
                return backend.playback.play().get()
363 2
            except TypeError:
364
                # TODO: check by binding against underlying play method using
365
                # inspect and otherwise re-raise?
366 2
                logger.error(
367
                    "%s needs to be updated to work with this "
368
                    "version of Mopidy.",
369
                    backend,
370
                )
371 2
                return False
372 2
        elif state == PlaybackState.PAUSED:
373 2
            return backend.playback.pause().get()
374 2
        elif state == PlaybackState.STOPPED:
375
            # TODO: emit some event now?
376 2
            self._current_tl_track = self._pending_tl_track
377 2
            self._pending_tl_track = None
378 2
            return True
379

380 0
        raise Exception(f"Unknown state: {state}")
381

382 2
    def previous(self):
383
        """
384
        Change to the previous track.
385

386
        The current playback state will be kept. If it was playing, playing
387
        will continue. If it was paused, it will still be paused, etc.
388
        """
389 2
        self._previous = True
390 2
        state = self.get_state()
391 2
        current = self._pending_tl_track or self._current_tl_track
392
        # avoid endless loop if 'repeat' is 'true' and no track is playable
393
        # * 2 -> second run to get all playable track in a shuffled playlist
394 2
        count = self.core.tracklist.get_length() * 2
395

396 2
        while current:
397 2
            pending = self.core.tracklist.previous_track(current)
398 2
            if self._change(pending, state):
399 2
                break
400
            else:
401 2
                self.core.tracklist._mark_unplayable(pending)
402
            # TODO: this could be needed to prevent a loop in rare cases
403
            # if current == pending:
404
            #     break
405 2
            current = pending
406 2
            count -= 1
407 2
            if not count:
408 2
                logger.info("No playable track in the list.")
409 2
                break
410

411
        # TODO: no return value?
412

413 2
    def resume(self):
414
        """If paused, resume playing the current track."""
415 2
        if self.get_state() != PlaybackState.PAUSED:
416 0
            return
417 2
        backend = self._get_backend(self.get_current_tl_track())
418
        # TODO: Wrap backend call in error handling.
419 2
        if backend and backend.playback.resume().get():
420 2
            self.set_state(PlaybackState.PLAYING)
421
            # TODO: trigger via gst messages
422 2
            self._trigger_track_playback_resumed()
423
        # TODO: switch to:
424
        # backend.resume()
425
        # wait for state change?
426

427 2
    def seek(self, time_position):
428
        """
429
        Seeks to time position given in milliseconds.
430

431
        :param time_position: time position in milliseconds
432
        :type time_position: int
433
        :rtype: :class:`True` if successful, else :class:`False`
434
        """
435
        # TODO: seek needs to take pending tracks into account :(
436 2
        validation.check_integer(time_position)
437

438 2
        if time_position < 0:
439 0
            logger.debug("Client seeked to negative position. Seeking to zero.")
440 0
            time_position = 0
441

442 2
        if not self.core.tracklist.get_length():
443 2
            return False
444

445 2
        if self.get_state() == PlaybackState.STOPPED:
446 0
            self.play()
447

448
        # We need to prefer the still playing track, but if nothing is playing
449
        # we fall back to the pending one.
450 2
        tl_track = self._current_tl_track or self._pending_tl_track
451 2
        if tl_track and tl_track.track.length is None:
452 2
            return False
453

454 2
        if time_position < 0:
455 0
            time_position = 0
456 2
        elif time_position > tl_track.track.length:
457
            # TODO: GStreamer will trigger a about-to-finish for us, use that?
458 0
            self.next()
459 0
            return True
460

461
        # Store our target position.
462 2
        self._pending_position = time_position
463

464
        # Make sure we switch back to previous track if we get a seek while we
465
        # have a pending track.
466 2
        if self._current_tl_track and self._pending_tl_track:
467 0
            self._change(self._current_tl_track, self.get_state())
468
        else:
469 2
            return self._seek(time_position)
470

471 2
    def _seek(self, time_position):
472 2
        backend = self._get_backend(self.get_current_tl_track())
473 2
        if not backend:
474 0
            return False
475
        # TODO: Wrap backend call in error handling.
476 2
        return backend.playback.seek(time_position).get()
477

478 2
    def stop(self):
479
        """Stop playing."""
480 2
        if self.get_state() != PlaybackState.STOPPED:
481 2
            self._last_position = self.get_time_position()
482 2
            backend = self._get_backend(self.get_current_tl_track())
483
            # TODO: Wrap backend call in error handling.
484 2
            if not backend or backend.playback.stop().get():
485 2
                self.set_state(PlaybackState.STOPPED)
486

487 2
    def _trigger_track_playback_paused(self):
488 2
        logger.debug("Triggering track playback paused event")
489 2
        if self.get_current_tl_track() is None:
490 2
            return
491 2
        listener.CoreListener.send(
492
            "track_playback_paused",
493
            tl_track=self.get_current_tl_track(),
494
            time_position=self.get_time_position(),
495
        )
496

497 2
    def _trigger_track_playback_resumed(self):
498 2
        logger.debug("Triggering track playback resumed event")
499 2
        if self.get_current_tl_track() is None:
500 0
            return
501 2
        listener.CoreListener.send(
502
            "track_playback_resumed",
503
            tl_track=self.get_current_tl_track(),
504
            time_position=self.get_time_position(),
505
        )
506

507 2
    def _trigger_track_playback_started(self):
508 2
        if self.get_current_tl_track() is None:
509 0
            return
510

511 2
        logger.debug("Triggering track playback started event")
512 2
        tl_track = self.get_current_tl_track()
513 2
        self.core.tracklist._mark_playing(tl_track)
514 2
        self.core.history._add_track(tl_track.track)
515 2
        listener.CoreListener.send("track_playback_started", tl_track=tl_track)
516

517 2
    def _trigger_track_playback_ended(self, time_position_before_stop):
518 2
        tl_track = self.get_current_tl_track()
519 2
        if tl_track is None:
520 2
            return
521

522 2
        logger.debug("Triggering track playback ended event")
523

524 2
        if not self._previous:
525 2
            self.core.tracklist._mark_played(self._current_tl_track)
526 2
        self._previous = False
527

528
        # TODO: Use the lowest of track duration and position.
529 2
        listener.CoreListener.send(
530
            "track_playback_ended",
531
            tl_track=tl_track,
532
            time_position=time_position_before_stop,
533
        )
534

535 2
    def _trigger_playback_state_changed(self, old_state, new_state):
536 2
        logger.debug("Triggering playback state change event")
537 2
        listener.CoreListener.send(
538
            "playback_state_changed", old_state=old_state, new_state=new_state
539
        )
540

541 2
    def _trigger_seeked(self, time_position):
542
        # TODO: Trigger this from audio events?
543 0
        logger.debug("Triggering seeked event")
544 0
        listener.CoreListener.send("seeked", time_position=time_position)
545

546 2
    def _save_state(self):
547 2
        return models.PlaybackState(
548
            tlid=self.get_current_tlid(),
549
            time_position=self.get_time_position(),
550
            state=self.get_state(),
551
        )
552

553 2
    def _load_state(self, state, coverage):
554 2
        if state and "play-last" in coverage and state.tlid is not None:
555 2
            if state.state == PlaybackState.PAUSED:
556 2
                self._start_paused = True
557 2
            if state.state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
558 2
                self._start_at_position = state.time_position
559 2
                self.play(tlid=state.tlid)

Read our documentation on viewing source code .

Loading