1# Copyright © 2018 The GNOME Music developers
2#
3# GNOME Music is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2 of the License, or
6# (at your option) any later version.
7#
8# GNOME Music is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License along
14# with GNOME Music; if not, write to the Free Software Foundation, Inc.,
15# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
16#
17# The GNOME Music authors hereby grant permission for non-GPL compatible
18# GStreamer plugins to be used and distributed together with GStreamer
19# and GNOME Music.  This permission is above and beyond the permissions
20# granted by the GPL license by which GNOME Music is covered.  If you
21# modify this code, you may extend this exception to your version of the
22# code, but you are not obligated to do so.  If you do not wish to do so,
23# delete this exception statement from your version.
24
25from enum import Enum, IntEnum
26from gettext import gettext as _
27from random import randint, randrange
28import time
29import typing
30
31import gi
32gi.require_version('GstPbutils', '1.0')
33from gi.repository import GObject, GstPbutils
34
35from gnomemusic.coresong import CoreSong
36from gnomemusic.gstplayer import GstPlayer, Playback
37from gnomemusic.widgets.songwidget import SongWidget
38import gnomemusic.utils as utils
39
40
41class RepeatMode(Enum):
42    """Enum for player repeat mode"""
43
44    # Translators: "shuffle" causes tracks to play in random order.
45    SHUFFLE = 0, "media-playlist-shuffle-symbolic", _("Shuffle")
46    SONG = 1, "media-playlist-repeat-song-symbolic", _("Repeat Song")
47    ALL = 2, "media-playlist-repeat-symbolic", _("Repeat All")
48    NONE = 3, "media-playlist-consecutive-symbolic", _("Shuffle/Repeat Off")
49
50    # The type checking is necessary to avoid false positives
51    # See: https://github.com/python/mypy/issues/1021
52    if typing.TYPE_CHECKING:
53        icon: str
54        label: str
55
56    def __new__(
57            cls, value: int, icon: str = "", label: str = "") -> "RepeatMode":
58        obj = object.__new__(cls)
59        obj._value_ = value
60        obj.icon = icon
61        obj.label = label
62        return obj
63
64
65class PlayerPlaylist(GObject.GObject):
66    """PlayerPlaylist object
67
68    Contains the logic to validate a song, handle RepeatMode and the
69    list of songs being played.
70    """
71
72    class Type(IntEnum):
73        """Type of playlist."""
74        SONGS = 0
75        ALBUM = 1
76        ARTIST = 2
77        PLAYLIST = 3
78        SEARCH_RESULT = 4
79
80    repeat_mode = GObject.Property(type=object)
81
82    def __init__(self, application):
83        super().__init__()
84
85        GstPbutils.pb_utils_init()
86
87        self._app = application
88        self._log = application.props.log
89        self._position = 0
90
91        self._validation_songs = {}
92        self._discoverer = GstPbutils.Discoverer()
93        self._discoverer.connect("discovered", self._on_discovered)
94        self._discoverer.start()
95
96        self._coremodel = self._app.props.coremodel
97        self._model = self._coremodel.props.playlist_sort
98        self._model_recent = self._coremodel.props.recent_playlist
99
100        self.connect("notify::repeat-mode", self._on_repeat_mode_changed)
101
102    def has_next(self):
103        """Test if there is a song after the current one.
104
105        :return: True if there is a song. False otherwise.
106        :rtype: bool
107        """
108        if (self.props.repeat_mode == RepeatMode.SONG
109                or self.props.repeat_mode == RepeatMode.ALL
110                or self.props.position < self._model.get_n_items() - 1):
111            return True
112
113        return False
114
115    def has_previous(self):
116        """Test if there is a song before the current one.
117
118        :return: True if there is a song. False otherwise.
119        :rtype: bool
120        """
121        if (self.props.repeat_mode == RepeatMode.SONG
122                or self.props.repeat_mode == RepeatMode.ALL
123                or (self.props.position <= self._model.get_n_items() - 1
124                    and self.props.position > 0)):
125            return True
126
127        return False
128
129    def get_next(self):
130        """Get the next song in the playlist.
131
132        :return: The next CoreSong or None.
133        :rtype: CoreSong
134        """
135        if not self.has_next():
136            return None
137
138        if self.props.repeat_mode == RepeatMode.SONG:
139            next_position = self.props.position
140        elif (self.props.repeat_mode == RepeatMode.ALL
141                and self.props.position == self._model.get_n_items() - 1):
142            next_position = 0
143        else:
144            next_position = self.props.position + 1
145
146        return self._model[next_position]
147
148    def next(self):
149        """Go to the next song in the playlist.
150
151        :return: True if the operation succeeded. False otherwise.
152        :rtype: bool
153        """
154        if not self.has_next():
155            return False
156
157        if self.props.repeat_mode == RepeatMode.SONG:
158            next_position = self.props.position
159        elif (self.props.repeat_mode == RepeatMode.ALL
160                and self.props.position == self._model.get_n_items() - 1):
161            next_position = 0
162        else:
163            next_position = self.props.position + 1
164
165        self._model[self.props.position].props.state = SongWidget.State.PLAYED
166        self._position = next_position
167
168        next_song = self._model[next_position]
169        if next_song.props.validation == CoreSong.Validation.FAILED:
170            return self.next()
171
172        self._update_model_recent()
173        next_song.props.state = SongWidget.State.PLAYING
174        self._validate_next_song()
175        return True
176
177    def previous(self):
178        """Go to the previous song in the playlist.
179
180        :return: True if the operation succeeded. False otherwise.
181        :rtype: bool
182        """
183        if not self.has_previous():
184            return False
185
186        if self.props.repeat_mode == RepeatMode.SONG:
187            previous_position = self.props.position
188        elif (self.props.repeat_mode == RepeatMode.ALL
189                and self.props.position == 0):
190            previous_position = self._model.get_n_items() - 1
191        else:
192            previous_position = self.props.position - 1
193
194        self._model[self.props.position].props.state = SongWidget.State.PLAYED
195        self._position = previous_position
196
197        previous_song = self._model[previous_position]
198        if previous_song.props.validation == CoreSong.Validation.FAILED:
199            return self.previous()
200
201        self._update_model_recent()
202        self._model[previous_position].props.state = SongWidget.State.PLAYING
203        self._validate_previous_song()
204        return True
205
206    @GObject.Property(type=int, default=0, flags=GObject.ParamFlags.READABLE)
207    def position(self):
208        """Gets current song index.
209
210        :returns: position of the current song in the playlist.
211        :rtype: int
212        """
213        return self._position
214
215    @GObject.Property(
216        type=CoreSong, default=None, flags=GObject.ParamFlags.READABLE)
217    def current_song(self):
218        """Get current song.
219
220        :returns: the song being played or None if there are no songs
221        :rtype: CoreSong
222        """
223        n_items = self._model.get_n_items()
224        if (n_items != 0
225                and n_items > self._position):
226            current_song = self._model[self._position]
227            if current_song.props.state == SongWidget.State.PLAYING:
228                return current_song
229
230        for idx, coresong in enumerate(self._model):
231            if coresong.props.state == SongWidget.State.PLAYING:
232                self._position = idx
233                self._update_model_recent()
234                return coresong
235
236        return None
237
238    def set_song(self, song):
239        """Sets current song.
240
241        If no song is provided, a song is automatically selected.
242
243        :param CoreSong song: song to set
244        :returns: The selected song
245        :rtype: CoreSong
246        """
247        if self._model.get_n_items() == 0:
248            return None
249
250        if song is None:
251            if self.props.repeat_mode == RepeatMode.SHUFFLE:
252                position = randrange(0, self._model.get_n_items())
253            else:
254                position = 0
255            song = self._model.get_item(position)
256            song.props.state = SongWidget.State.PLAYING
257            self._position = position
258            self._validate_song(song)
259            self._validate_next_song()
260            self._update_model_recent()
261            return song
262
263        for idx, coresong in enumerate(self._model):
264            if coresong == song:
265                coresong.props.state = SongWidget.State.PLAYING
266                self._position = idx
267                self._validate_song(song)
268                self._validate_next_song()
269                self._update_model_recent()
270                return song
271
272        return None
273
274    def _update_model_recent(self):
275        recent_size = self._coremodel.props.recent_playlist_size
276        offset = max(0, self._position - recent_size)
277        self._model_recent.set_offset(offset)
278
279    def _on_repeat_mode_changed(self, klass, param):
280        # FIXME: This shuffle is too simple.
281        def _shuffle_sort(song_a, song_b):
282            return randint(-1, 1)
283
284        if self.props.repeat_mode == RepeatMode.SHUFFLE:
285            self._model.set_sort_func(
286                utils.wrap_list_store_sort_func(_shuffle_sort))
287        elif self.props.repeat_mode in [RepeatMode.NONE, RepeatMode.ALL]:
288            self._model.set_sort_func(None)
289
290    def _validate_song(self, coresong):
291        # Song is being processed or has already been processed.
292        # Nothing to do.
293        if coresong.props.validation > CoreSong.Validation.PENDING:
294            return
295
296        url = coresong.props.url
297        if not url:
298            self._log.warning(
299                "The item {} doesn't have a URL set.".format(coresong))
300            return
301        if not url.startswith("file://"):
302            self._log.debug(
303                "Skipping validation of {} as not a local file".format(url))
304            return
305
306        coresong.props.validation = CoreSong.Validation.IN_PROGRESS
307        self._validation_songs[url] = coresong
308        self._discoverer.discover_uri_async(url)
309
310    def _validate_next_song(self):
311        if self.props.repeat_mode == RepeatMode.SONG:
312            return
313
314        current_position = self.props.position
315        next_position = current_position + 1
316        if next_position == self._model.get_n_items():
317            if self.props.repeat_mode != RepeatMode.ALL:
318                return
319            next_position = 0
320
321        self._validate_song(self._model[next_position])
322
323    def _validate_previous_song(self):
324        if self.props.repeat_mode == RepeatMode.SONG:
325            return
326
327        current_position = self.props.position
328        previous_position = current_position - 1
329        if previous_position < 0:
330            if self.props.repeat_mode != RepeatMode.ALL:
331                return
332            previous_position = self._model.get_n_items() - 1
333
334        self._validate_song(self._model[previous_position])
335
336    def _on_discovered(self, discoverer, info, error):
337        url = info.get_uri()
338        coresong = self._validation_songs[url]
339
340        if error:
341            self._log.warning("Info {}: error: {}".format(info, error))
342            coresong.props.validation = CoreSong.Validation.FAILED
343        else:
344            coresong.props.validation = CoreSong.Validation.SUCCEEDED
345
346
347class Player(GObject.GObject):
348    """Main Player object
349
350    Contains the logic of playing a song with Music.
351    """
352
353    __gsignals__ = {
354        'seek-finished': (GObject.SignalFlags.RUN_FIRST, None, ()),
355        'song-changed': (GObject.SignalFlags.RUN_FIRST, None, ())
356    }
357
358    state = GObject.Property(type=int, default=Playback.STOPPED)
359    duration = GObject.Property(type=float, default=-1.)
360
361    def __init__(self, application):
362        """Initialize the player
363
364        :param Application application: Application object
365        """
366        super().__init__()
367
368        self._app = application
369        # In the case of gapless playback, both 'about-to-finish'
370        # and 'eos' can occur during the same stream. 'about-to-finish'
371        # already sets self._playlist to the next song, so doing it
372        # again on eos would skip a song.
373        # TODO: Improve playlist handling so this hack is no longer
374        # needed.
375        self._gapless_set = False
376        self._log = application.props.log
377        self._playlist = PlayerPlaylist(self._app)
378
379        self._playlist_model = self._app.props.coremodel.props.playlist_sort
380        self._playlist_model.connect(
381            "items-changed", self._on_playlist_model_items_changed)
382
383        self._settings = application.props.settings
384        self._repeat = RepeatMode(self._settings.get_enum("repeat"))
385        self.bind_property(
386            'repeat-mode', self._playlist, 'repeat-mode',
387            GObject.BindingFlags.SYNC_CREATE)
388
389        self._new_clock = True
390
391        self._gst_player = GstPlayer(application)
392        self._gst_player.connect("about-to-finish", self._on_about_to_finish)
393        self._gst_player.connect('clock-tick', self._on_clock_tick)
394        self._gst_player.connect('eos', self._on_eos)
395        self._gst_player.connect("error", self._on_error)
396        self._gst_player.connect('seek-finished', self._on_seek_finished)
397        self._gst_player.connect("stream-start", self._on_stream_start)
398        self._gst_player.bind_property(
399            'duration', self, 'duration', GObject.BindingFlags.SYNC_CREATE)
400        self._gst_player.bind_property(
401            'state', self, 'state', GObject.BindingFlags.SYNC_CREATE)
402
403        self._lastfm = application.props.lastfm_scrobbler
404
405    @GObject.Property(
406        type=bool, default=False, flags=GObject.ParamFlags.READABLE)
407    def has_next(self):
408        """Test if the playlist has a next song.
409
410        :returns: True if the current song is not the last one.
411        :rtype: bool
412        """
413        return self._playlist.has_next()
414
415    @GObject.Property(
416        type=bool, default=False, flags=GObject.ParamFlags.READABLE)
417    def has_previous(self):
418        """Test if the playlist has a previous song.
419
420        :returns: True if the current song is not the first one.
421        :rtype: bool
422        """
423        return self._playlist.has_previous()
424
425    @GObject.Property(
426        type=bool, default=False, flags=GObject.ParamFlags.READABLE)
427    def playing(self):
428        """Test if a song is currently played.
429
430        :returns: True if a song is currently played.
431        :rtype: bool
432        """
433        return self.props.state == Playback.PLAYING
434
435    def _on_playlist_model_items_changed(self, model, pos, removed, added):
436        if (removed > 0
437                and model.get_n_items() == 0):
438            self.stop()
439
440    def _on_about_to_finish(self, klass):
441        if self.props.has_next:
442            self._log.debug("Song is about to finish, loading the next one.")
443            next_coresong = self._playlist.get_next()
444            new_url = next_coresong.props.url
445            self._gst_player.props.url = new_url
446            self._gapless_set = True
447
448    def _on_eos(self, klass):
449        self._playlist.next()
450
451        if self._gapless_set:
452            # After 'eos' in the gapless case, the pipeline needs to be
453            # hard reset.
454            self._log.debug("Song finished, loading the next one.")
455            self.stop()
456            self.play(self.props.current_song)
457        else:
458            self._log.debug("End of the playlist, stopping the player.")
459            self.stop()
460
461        self._gapless_set = False
462
463    def _on_error(self, klass=None):
464        self.stop()
465        self._gapless_set = False
466
467        current_song = self.props.current_song
468        current_song.props.validation = CoreSong.Validation.FAILED
469        if (self.has_next
470                and self.props.repeat_mode != RepeatMode.SONG):
471            self.next()
472
473    def _on_stream_start(self, klass):
474        if self._gapless_set:
475            self._playlist.next()
476
477        self._gapless_set = False
478        self._time_stamp = int(time.time())
479
480        self.emit("song-changed")
481
482    def _load(self, coresong):
483        self._log.debug("Loading song {}".format(coresong.props.title))
484        self._gst_player.props.state = Playback.LOADING
485        self._time_stamp = int(time.time())
486        self._gst_player.props.url = coresong.props.url
487
488    def play(self, coresong=None):
489        """Play a song.
490
491        Start playing a song, a specific CoreSong if supplied and
492        available or a song in the playlist decided by the play mode.
493
494        If a song is paused, a subsequent play call without a CoreSong
495        supplied will continue playing the paused song.
496
497        :param CoreSong coresong: The CoreSong to play or None.
498        """
499        if self.props.current_song is None:
500            coresong = self._playlist.set_song(coresong)
501
502        if (coresong is not None
503                and coresong.props.validation == CoreSong.Validation.FAILED
504                and self.props.repeat_mode != RepeatMode.SONG):
505            self._on_error()
506            return
507
508        if coresong is not None:
509            self._load(coresong)
510
511        if self.props.current_song is not None:
512            self._gst_player.props.state = Playback.PLAYING
513
514    def pause(self):
515        """Pause"""
516        self._gst_player.props.state = Playback.PAUSED
517
518    def stop(self):
519        """Stop"""
520        self._gst_player.props.state = Playback.STOPPED
521
522    def next(self):
523        """"Play next song
524
525        Play the next song of the playlist, if any.
526        """
527        if self._gapless_set:
528            self.set_position(0.0)
529        elif self._playlist.next():
530            self.play(self._playlist.props.current_song)
531
532    def previous(self):
533        """Play previous song
534
535        Play the previous song of the playlist, if any.
536        """
537        position = self._gst_player.props.position
538        if self._gapless_set:
539            self.stop()
540
541        if (position < 5
542                and self._playlist.has_previous()):
543            self._playlist.previous()
544            self._gapless_set = False
545            self.play(self._playlist.props.current_song)
546        # This is a special case for a song that is very short and the
547        # first song in the playlist. It can trigger gapless, but
548        # has_previous will return False.
549        elif (position < 5
550                and self._playlist.props.position == 0):
551            self.set_position(0.0)
552            self._gapless_set = False
553            self.play(self._playlist.props.current_song)
554        else:
555            self.set_position(0.0)
556
557    def play_pause(self):
558        """Toggle play/pause state"""
559        if self.props.state == Playback.PLAYING:
560            self.pause()
561        else:
562            self.play()
563
564    def _on_clock_tick(self, klass, tick):
565        self._log.debug("Clock tick {}, player at {} seconds".format(
566            tick, self._gst_player.props.position))
567
568        current_song = self._playlist.props.current_song
569
570        if tick == 0:
571            self._new_clock = True
572            self._lastfm.now_playing(current_song)
573
574        if self.props.duration == -1.:
575            return
576
577        position = self._gst_player.props.position
578        if position > 0:
579            percentage = tick / self.props.duration
580            if (not self._lastfm.props.scrobbled
581                    and self.props.duration > 30.
582                    and (percentage > 0.5 or tick > 4 * 60)):
583                self._lastfm.scrobble(current_song, self._time_stamp)
584
585            if (percentage > 0.5
586                    and self._new_clock):
587                self._new_clock = False
588                # FIXME: we should not need to update smart
589                # playlists here but removing it may introduce
590                # a bug. So, we keep it for the time being.
591                # FIXME: Not using Playlist class anymore.
592                # playlists.update_all_smart_playlists()
593                current_song.bump_play_count()
594                current_song.set_last_played()
595
596    @GObject.Property(type=object)
597    def repeat_mode(self) -> RepeatMode:
598        """Gets current repeat mode.
599
600        :returns: current repeat mode
601        :rtype: RepeatMode
602        """
603        return self._repeat
604
605    @repeat_mode.setter  # type: ignore
606    def repeat_mode(self, mode):
607        if mode == self._repeat:
608            return
609
610        self._repeat = mode
611        self._settings.set_enum("repeat", mode.value)
612
613    @GObject.Property(type=int, default=0, flags=GObject.ParamFlags.READABLE)
614    def position(self):
615        """Gets current song index.
616
617        :returns: position of the current song in the playlist.
618        :rtype: int
619        """
620        return self._playlist.props.position
621
622    @GObject.Property(
623        type=CoreSong, default=None, flags=GObject.ParamFlags.READABLE)
624    def current_song(self):
625        """Get the current song.
626
627        :returns: The song being played. None if there is no playlist.
628        :rtype: CoreSong
629        """
630        return self._playlist.props.current_song
631
632    def get_position(self):
633        """Get player position.
634
635        Player position in seconds.
636        :returns: position
637        :rtype: float
638        """
639        return self._gst_player.props.position
640
641    # TODO: used by MPRIS
642    def set_position(self, position_second):
643        """Change GstPlayer position.
644
645        If the position if negative, set it to zero.
646        If the position if greater than song duration, do nothing
647        :param float position_second: requested position in second
648        """
649        if position_second < 0.0:
650            position_second = 0.0
651
652        duration_second = self._gst_player.props.duration
653        if position_second <= duration_second:
654            self._gst_player.seek(position_second)
655
656    def _on_seek_finished(self, klass):
657        # FIXME: Just a proxy
658        self.emit('seek-finished')
659