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