Commit d83b7de3 authored by Jean Felder's avatar Jean Felder Committed by Jean Felder

player: Refactor player playlist

Separate player logic from the playlist logic.
Rename discovery logic to validation as this is more accurate of the
underlying logic.
PlayerPlaylist object handles the playlist and songs validation
logic. Player object acts as a glue for the ui and the underlying
logic (playlist and validation).
Use Gobject Properties.

There are 4 ways to launch a song:
1. Click on a song in the current view. The set_playlist method is
called.
2. At the end of the current song. The next method is automatically called.
3. click on the next buttom from the PlayerToolbar. The next method is called.
4. click on the previous buttom from the PlayerToolbar. The previous
method is called.

Validation is a very expensive operation, so only do it when it's
needed. See commit message from 6f1cb8d4.

The Validation logic brings 3 features:
- display an error icon if a song cannot be played
- do not load a song that cannot be played
- when the song changes, try to load the next possible one: if the
next song cannot be loaded, try the one after, etc.

In "set_playlist" method, if the song has already been played the
validation information is already known, nothing to add. If the song
has never been played, there is no information yet. In that case,
validate_current_song and validate_next_song need to be called to
trigger the validation mechanism.

In "next" method, call validate_next_song to continue the validation
mechanism if there is a next song.

In "previous" method, call validate_previous_song to continue the validation
mechanism if there is a previous song.

Closes: #60, #154
parent 1ea45617
......@@ -86,12 +86,12 @@ class InhibitSuspend(GObject.GObject):
if self._player.get_playback_status() == Playback.PLAYING:
self._inhibit_suspend()
# TODO: The additional check for has_next() is necessary
# TODO: The additional check for has_next property is necessary
# since after a track is done, the player
# goes into STOPPED state before it goes back to PLAYING.
# To be simplified when the player's behavior is corrected.
if (self._player.get_playback_status() == Playback.PAUSED
or (self._player.get_playback_status() == Playback.STOPPED
and not self._player.has_next())):
and not self._player.props.has_next)):
self._uninhibit_suspend()
......@@ -26,7 +26,7 @@
import codecs
from gnomemusic.gstplayer import Playback
from gnomemusic.player import RepeatMode
from gnomemusic.player import PlayerField, PlayerPlaylist, RepeatMode
from gnomemusic.grilo import grilo
from gnomemusic.playlists import Playlists
from gnomemusic.utils import View
......@@ -238,9 +238,6 @@ class MediaPlayer2Service(Server):
playlists.connect('playlist-deleted', self._on_playlists_count_changed)
grilo.connect('ready', self._on_grilo_ready)
self.playlists = []
self.playlist = None
self.playlist_insert_handler = 0
self.playlist_delete_handler = 0
self.first_song_handler = 0
@log
......@@ -255,9 +252,9 @@ class MediaPlayer2Service(Server):
@log
def _get_loop_status(self):
if self.player.repeat == RepeatMode.NONE:
if self.player.props.repeat_mode == RepeatMode.NONE:
return 'None'
elif self.player.repeat == RepeatMode.SONG:
elif self.player.props.repeat_mode == RepeatMode.SONG:
return 'Track'
else:
return 'Playlist'
......@@ -265,7 +262,7 @@ class MediaPlayer2Service(Server):
@log
def _get_metadata(self, media=None):
if not media:
media = self.player.get_current_media()
media = self.player.props.current_song
if not media:
return {}
......@@ -353,17 +350,17 @@ class MediaPlayer2Service(Server):
@log
def _get_media_from_id(self, track_id):
for track in self.player.playlist:
media = track[self.player.Field.SONG]
for track in self.player.get_songs():
media = track[PlayerField.SONG]
if track_id == self._get_media_id(media):
return media
return None
@log
def _get_track_list(self):
if self.player.playlist:
return [self._get_media_id(track[self.player.Field.SONG])
for track in self.player.playlist]
if self.player.props.playing:
return [self._get_media_id(song[PlayerField.SONG])
for song in self.player.get_songs()]
else:
return []
......@@ -400,16 +397,19 @@ class MediaPlayer2Service(Server):
@log
def _get_active_playlist(self):
playlist = self._get_playlist_from_id(self.player.playlist_id) \
if self.player.playlist_type == 'Playlist' else None
playlistName = utils.get_media_title(playlist) \
if playlist else ''
return (playlist is not None,
(self._get_playlist_path(playlist), playlistName, ''))
playlist = None
playlist_name = ''
if self.player.get_playlist_type() == PlayerPlaylist.Type.PLAYLIST:
playlist = self._get_playlist_from_id(
self.player.get_playlist_id())
playlist_name = utils.get_media_title(playlist)
path = self._get_playlist_path(playlist)
return (playlist is not None, (path, playlist_name, ''))
@log
def _on_current_song_changed(self, player, current_iter, data=None):
if self.player.repeat == RepeatMode.SONG:
def _on_current_song_changed(self, player, position):
if self.player.props.repeat_mode == RepeatMode.SONG:
self.Seeked(0)
self.PropertiesChanged(MediaPlayer2Service.MEDIA_PLAYER2_PLAYER_IFACE,
......@@ -441,7 +441,7 @@ class MediaPlayer2Service(Server):
self.PropertiesChanged(MediaPlayer2Service.MEDIA_PLAYER2_PLAYER_IFACE,
{
'LoopStatus': GLib.Variant('s', self._get_loop_status()),
'Shuffle': GLib.Variant('b', self.player.repeat == RepeatMode.SHUFFLE),
'Shuffle': GLib.Variant('b', self.player.props.repeat_mode == RepeatMode.SHUFFLE),
},
[])
......@@ -457,8 +457,8 @@ class MediaPlayer2Service(Server):
def _on_prev_next_invalidated(self, player, data=None):
self.PropertiesChanged(MediaPlayer2Service.MEDIA_PLAYER2_PLAYER_IFACE,
{
'CanGoNext': GLib.Variant('b', self.player.has_next()),
'CanGoPrevious': GLib.Variant('b', self.player.has_previous()),
'CanGoNext': GLib.Variant('b', self.player.props.has_next),
'CanGoPrevious': GLib.Variant('b', self.player.props.has_previous),
},
[])
......@@ -467,7 +467,7 @@ class MediaPlayer2Service(Server):
if self.first_song_handler:
model.disconnect(self.first_song_handler)
self.first_song_handler = 0
self.player.set_playlist('Songs', None, model, iter_)
self.player.set_playlist(PlayerPlaylist.Type.SONG, None, model, iter_)
self.player.play()
@log
......@@ -476,13 +476,6 @@ class MediaPlayer2Service(Server):
@log
def _on_playlist_changed(self, player, data=None):
if self.playlist:
if self.playlist_insert_handler:
self.playlist.disconnect(self.playlist_insert_handler)
if self.playlist_delete_handler:
self.playlist.disconnect(self.playlist_delete_handler)
self.playlist = self.player.playlist
self._on_playlist_modified()
self.PropertiesChanged(MediaPlayer2Service.MEDIA_PLAYER2_PLAYLISTS_IFACE,
......@@ -491,18 +484,12 @@ class MediaPlayer2Service(Server):
},
[])
self.playlist_insert_handler = \
self.playlist.connect('row-inserted', self._on_playlist_modified)
self.playlist_delete_handler = \
self.playlist.connect('row-deleted', self._on_playlist_modified)
@log
def _on_playlist_modified(self, path=None, _iter=None, data=None):
if self.player.current_song and self.player.current_song.valid():
path = self.player.current_song.get_path()
current_song = self.player.playlist[path][self.player.Field.SONG]
if self.player.props.current_song:
track_list = self._get_track_list()
self.TrackListReplaced(track_list, self._get_media_id(current_song))
self.TrackListReplaced(
track_list, self._get_media_id(self.player.props.current_song))
self.PropertiesChanged(MediaPlayer2Service.MEDIA_PLAYER2_TRACKLIST_IFACE,
{
'Tracks': GLib.Variant('ao', track_list),
......@@ -551,7 +538,7 @@ class MediaPlayer2Service(Server):
self.player.stop()
def Play(self):
if self.player.playlist is not None:
if self.player.get_songs():
self.player.play()
elif self.first_song_handler == 0:
window = self.app.get_active_window()
......@@ -594,13 +581,9 @@ class MediaPlayer2Service(Server):
pass
def GoTo(self, track_id):
for track in self.player.playlist:
media = track[self.player.Field.SONG]
if track_id == self._get_media_id(media):
self.player.set_playlist(
self.player.playlist_type, self.player.playlist_id,
self.player.playlist, track.iter)
self.player.play()
for index, song in enumerate(self.player.get_songs()):
if track_id == self._get_media_id(song[PlayerField.SONG]):
self.player_play(index)
return
def TrackListReplaced(self, tracks, current_song):
......@@ -682,16 +665,16 @@ class MediaPlayer2Service(Server):
'PlaybackStatus': GLib.Variant('s', self._get_playback_status()),
'LoopStatus': GLib.Variant('s', self._get_loop_status()),
'Rate': GLib.Variant('d', 1.0),
'Shuffle': GLib.Variant('b', self.player.repeat == RepeatMode.SHUFFLE),
'Shuffle': GLib.Variant('b', self.player.props.repeat_mode == RepeatMode.SHUFFLE),
'Metadata': GLib.Variant('a{sv}', self._get_metadata()),
'Volume': GLib.Variant('d', self.player.get_volume()),
'Position': GLib.Variant('x', self.player.get_position()),
'MinimumRate': GLib.Variant('d', 1.0),
'MaximumRate': GLib.Variant('d', 1.0),
'CanGoNext': GLib.Variant('b', self.player.has_next()),
'CanGoPrevious': GLib.Variant('b', self.player.has_previous()),
'CanPlay': GLib.Variant('b', self.player.current_song is not None),
'CanPause': GLib.Variant('b', self.player.current_song is not None),
'CanGoNext': GLib.Variant('b', self.player.props.has_next),
'CanGoPrevious': GLib.Variant('b', self.player.props.has_previous),
'CanPlay': GLib.Variant('b', self.player.props.current_song is not None),
'CanPause': GLib.Variant('b', self.player.props.current_song is not None),
'CanSeek': GLib.Variant('b', True),
'CanControl': GLib.Variant('b', True),
}
......@@ -727,16 +710,16 @@ class MediaPlayer2Service(Server):
self.player.set_volume(new_value)
elif property_name == 'LoopStatus':
if new_value == 'None':
self.player.set_repeat_mode(RepeatMode.NONE)
self.player.props.repeat_mode = RepeatMode.NONE
elif new_value == 'Track':
self.player.set_repeat_mode(RepeatMode.SONG)
self.player.props.repeat_mode = RepeatMode.SONG
elif new_value == 'Playlist':
self.player.set_repeat_mode(RepeatMode.ALL)
self.player.props.repeat_mode = RepeatMode.ALL
elif property_name == 'Shuffle':
if new_value:
self.player.set_repeat_mode(RepeatMode.SHUFFLE)
self.player.props.repeat_mode = RepeatMode.SHUFFLE
else:
self.player.set_repeat_mode(RepeatMode.NONE)
self.player.props.repeat_mode = RepeatMode.NONE
else:
raise Exception(
'org.mpris.MediaPlayer2.GnomeMusic',
......
......@@ -22,9 +22,9 @@
# code, but you are not obligated to do so. If you do not wish to do so,
# delete this exception statement from your version.
from collections import deque
from collections import defaultdict
from enum import IntEnum
from random import randint
from random import shuffle, randrange
import logging
import time
......@@ -32,7 +32,7 @@ import gi
gi.require_version('Gst', '1.0')
gi.require_version('GstAudio', '1.0')
gi.require_version('GstPbutils', '1.0')
from gi.repository import Gtk, GLib, Gio, GObject, Gst, GstPbutils
from gi.repository import Gio, GLib, GObject, Grl, Gst, GstPbutils
from gnomemusic import log
from gnomemusic.gstplayer import GstPlayer, Playback
......@@ -54,407 +54,546 @@ class RepeatMode(IntEnum):
SHUFFLE = 3
class DiscoveryStatus:
class ValidationStatus(IntEnum):
"""Enum for song validation"""
PENDING = 0
FAILED = 1
SUCCEEDED = 2
class Player(GObject.GObject):
"""Main Player object
class PlayerField(IntEnum):
"""Enum for player model fields"""
SONG = 0
VALIDATION = 1
Contains the logic of playing a song with Music.
class PlayerPlaylist(GObject.GObject):
"""PlayerPlaylist object
Contains the logic to validate a song, handle RepeatMode and the
list of songs being played.
"""
class Field(IntEnum):
"""Enum for player model fields"""
SONG = 0
DISCOVERY_STATUS = 1
class Type(IntEnum):
"""Type of playlist."""
SONGS = 0
ALBUM = 1
ARTIST = 2
PLAYLIST = 3
SEARCH_RESULT = 4
__gsignals__ = {
'clock-tick': (GObject.SignalFlags.RUN_FIRST, None, (int,)),
'playlist-changed': (GObject.SignalFlags.RUN_FIRST, None, ()),
'song-changed': (
GObject.SignalFlags.RUN_FIRST, None, (Gtk.TreeModel, Gtk.TreeIter)
),
'playback-status-changed': (GObject.SignalFlags.RUN_FIRST, None, ()),
'repeat-mode-changed': (GObject.SignalFlags.RUN_FIRST, None, ()),
'volume-changed': (GObject.SignalFlags.RUN_FIRST, None, ()),
'prev-next-invalidated': (GObject.SignalFlags.RUN_FIRST, None, ()),
'seeked': (GObject.SignalFlags.RUN_FIRST, None, (int,)),
'song-validated': (GObject.SignalFlags.RUN_FIRST, None, (int, int)),
}
def __repr__(self):
return '<Player>'
return '<PlayerPlayList>'
@log
def __init__(self, parent_window):
def __init__(self):
super().__init__()
self._songs = []
self._shuffle_indexes = []
self._current_index = 0
self._parent_window = parent_window
self.playlist = None
self.playlist_type = None
self.playlist_id = None
self.playlist_field = None
self.current_song = None
self._next_song = None
self._shuffle_history = deque(maxlen=10)
self._new_clock = True
self._type = -1
self._id = -1
Gst.init(None)
GstPbutils.pb_utils_init()
self._settings = Gio.Settings.new('org.gnome.Music')
self._settings.connect(
'changed::repeat', self._on_repeat_setting_changed)
self._repeat = self._settings.get_enum('repeat')
self._validation_indexes = None
self._discoverer = GstPbutils.Discoverer()
self._discoverer.connect('discovered', self._on_discovered)
self._discoverer.start()
self._discovering_urls = {}
self._settings = Gio.Settings.new('org.gnome.Music')
self._settings.connect(
'changed::repeat', self._on_repeat_setting_changed)
self.repeat = self._settings.get_enum('repeat')
@log
def set_playlist(self, playlist_type, playlist_id, model, model_iter):
"""Set a new playlist or change the song being played
self.playlist_insert_handler = 0
self.playlist_delete_handler = 0
:param PlayerPlaylist.Type playlist_type: playlist type
:param string playlist_id: unique identifer to recognize the playlist
:param GtkListStore model: list of songs to play
:param GtkTreeIter model_iter: requested song
self._player = GstPlayer()
self._player.connect('clock-tick', self._on_clock_tick)
self._player.connect('eos', self._on_eos)
:return: True if the playlist has been updated. False otherwise
:rtype: bool
"""
path = model.get_path(model_iter)
self._current_index = int(path.to_string())
self._validation_indexes = defaultdict(list)
# Playlist is the same. Check that the requested song is valid.
# If not, try to get the next valid one
if (playlist_type == self._type
and playlist_id == self._id):
if not self._current_song_is_valid():
self.next()
else:
self._validate_song(self._current_index)
self._validate_next_song()
return False
root_window = parent_window.get_toplevel()
self._inhibit_suspend = InhibitSuspend(root_window, self)
self._type = playlist_type
self._id = playlist_id
self._lastfm = LastFmScrobbler()
self._songs = []
for row in model:
self._songs.append([row[5], row[11]])
if self._repeat == RepeatMode.SHUFFLE:
self._shuffle_indexes = list(range(len(self._songs)))
shuffle(self._shuffle_indexes)
self._shuffle_indexes.remove(self._current_index)
self._shuffle_indexes.insert(0, self._current_index)
# If the playlist has already been played, check that the requested
# song is valid. If it has never been played, validate the current
# song and the next song to display an error icon on failure.
if not self._current_song_is_valid():
self.next()
else:
self._validate_song(self._current_index)
self._validate_next_song()
return True
@log
def _discover_item(self, item, callback, data=None):
url = item.get_url()
if not url:
logger.warning(
"The item {} doesn't have a URL set.".format(item))
def set_song(self, song_index):
"""Change playlist index.
:param int song_index: requested song index
:return: True if the index has changed. False otherwise.
:rtype: bool
"""
if song_index >= len(self._songs):
return False
self._current_index = song_index
return True
@log
def change_position(self, prev_pos, new_pos):
"""Change order of a song in the playlist
:param int prev_pos: previous position
:param int new_pos: new position
:return: new index of the song being played. -1 if unchanged
:rtype: int
"""
current_item = self._songs[self._current_index]
current_song_id = current_item[PlayerField.SONG].get_id()
changed_song = self._songs.pop(prev_pos)
self._songs.insert(new_pos, changed_song)
# Update current_index if necessary.
return_index = -1
first_pos = min(prev_pos, new_pos)
last_pos = max(prev_pos, new_pos)
if (self._current_index >= first_pos
and self._current_index <= last_pos):
for index, item in enumerate(self._songs[first_pos:last_pos + 1]):
if item[PlayerField.SONG].get_id() == current_song_id:
self._current_index = first_pos + index
return_index = self._current_index
break
if self._repeat == RepeatMode.SHUFFLE:
index_l = self._shuffle_indexes.index(last_pos)
self._shuffle_indexes.pop(index_l)
self._shuffle_indexes = [
index + 1 if (index < last_pos and index >= first_pos)
else index
for index in self._shuffle_indexes]
self._shuffle_indexes.insert(index_l, first_pos)
return return_index
@log
def add_song(self, song, song_index):
"""Add a song to the playlist.
:param Grl.Media song: new song
:param int song_index: song position
"""
item = [song, ValidationStatus.PENDING]
self._songs.insert(song_index, item)
if song_index >= self._current_index:
self._current_index += 1
self._validate_song(song_index)
# In the shuffle case, insert song at a random position which
# has not been played yet.
if self._repeat == RepeatMode.SHUFFLE:
index = self._shuffle_indexes.index(self._current_index)
new_song_index = randrange(index, len(self._shuffle_indexes))
self._shuffle_indexes.insert(new_song_index, song_index)
@log
def remove_song(self, song_index):
"""Remove a song from the playlist.
:param int song_index: index of the song to remove
"""
self._songs.pop(song_index)
if song_index < self._current_index:
self._current_index -= 1
if self._repeat == RepeatMode.SHUFFLE:
self._shuffle_indexes.remove(song_index)
self._shuffle_indexes = [
index - 1 if index > song_index else index
for index in self._shuffle_indexes]
@log
def _on_repeat_setting_changed(self, settings, value):
self.props.repeat_mode = settings.get_enum('repeat')
@log
def _on_discovered(self, discoverer, info, error):
url = info.get_uri()
field = PlayerField.VALIDATION
index = self._validation_indexes[url].pop(0)
if not self._validation_indexes[url]:
self._validation_indexes.pop(url)
if error:
logger.warning("Info {}: error: {}".format(info, error))
self._songs[index][field] = ValidationStatus.FAILED
else:
self._songs[index][field] = ValidationStatus.SUCCEEDED
self.emit('song-validated', index, self._songs[index][field])
@log
def _validate_song(self, index):
item = self._songs[index]
# Song has already been processed, nothing to do.
if item[PlayerField.VALIDATION] != ValidationStatus.PENDING:
return
song = item[PlayerField.SONG]
url = song.get_url()
if not url:
logger.warning("The item {} doesn't have a URL set.".format(song))
return
if not url.startswith("file://"):
logger.debug(
"Skipping discovery of {} as not a local file".format(url))
"Skipping validation of {} as not a local file".format(url))
return
obj = (callback, data)
self._validation_indexes[url].append(index)
self._discoverer.discover_uri_async(url)
if url in self._discovering_urls:
self._discovering_urls[url] += [obj]
@log
def _get_next_index(self):
if not self.has_next():
return -1
if self._repeat == RepeatMode.SONG:
return self._current_index
if (self._repeat == RepeatMode.ALL
and self._current_index == (len(self._songs) - 1)):
return 0
if self._repeat == RepeatMode.SHUFFLE:
index = self._shuffle_indexes.index(self._current_index)
return self._shuffle_indexes[index + 1]
else:
self._discovering_urls[url] = [obj]
self._discoverer.discover_uri_async(url)
return self._current_index + 1
@log
def _on_discovered(self, discoverer, info, error):
try:
cbs = self._discovering_urls[info.get_uri()]
del(self._discovering_urls[info.get_uri()])
for callback, data in cbs:
if data is not None:
callback(info, error, data)
else:
callback(info, error)
except KeyError:
# Not something we're interested in
def _get_previous_index(self):
if not self.has_previous():
return -1
if self._repeat == RepeatMode.SONG:
return self._current_index
if (self._repeat == RepeatMode.ALL
and self._current_index == 0):
return len(self._songs) - 1
if self._repeat == RepeatMode.SHUFFLE:
index = self._shuffle_indexes.index(self._current_index)
return self._shuffle_indexes[index - 1]
else:
return self._current_index - 1
@log
def _validate_next_song(self):
if self._repeat == RepeatMode.SONG:
return
next_index = self._get_next_index()
if next_index >= 0:
self._validate_song(next_index)
@log
def _on_repeat_setting_changed(self, settings, value):
self.repeat = settings.get_enum('repeat')
self.emit('repeat-mode-changed')
self.emit('prev-next-invalidated')
self._validate_next_song()
def _validate_previous_song(self):
if self._repeat == RepeatMode.SONG:
return
previous_index = self._get_previous_index()
if previous_index >= 0:
self._validate_song(previous_index)
@log
def _on_glib_idle(self):
self.current_song = self._next_song
self.play()
def has_next(self):
"""Test if there is a song after the current one.
:return: True if there is a song. False otherwise.
:rtype: bool
"""
if (self._repeat == RepeatMode.SHUFFLE
and self._shuffle_indexes):
index = self._shuffle_indexes.index(self._current_index)
return index < (len(self._shuffle_indexes) - 1)
if self._repeat != RepeatMode.NONE:
return True
return self._current_index < (len(self._songs) - 1)
@log
def add_song(self, model, path, _iter):
"""Add a song to current playlist
def has_previous(self):
"""Test if there is a song before the current one.
:param GtkListStore model: TreeModel
:param GtkTreePath path: song position
:param GtkTreeIter_iter: song iter
:return: True if there is a song. False otherwise.
:rtype: bool
"""
new_row = model[_iter]
self.playlist.insert_with_valuesv(
int(path.to_string()),
[self.Field.SONG, self.Field.DISCOVERY_STATUS],
[new_row[5], new_row[11]])
self._validate_next_song()
self.emit('prev-next-invalidated')
if (self._repeat == RepeatMode.SHUFFLE
and self._shuffle_indexes):
index = self._shuffle_indexes.index(self._current_index)
return index > 0
if self._repeat != RepeatMode.NONE:
return True
return self._current_index > 0
@log
def remove_song(self, model, path):
"""Remove a song from current playlist
def next(self):
"""Go to the next song in the playlist.
:param GtkListStore model: TreeModel
:param GtkTreePath path: song position
:return: True if the operation succeeded. False otherwise.
:rtype: bool
"""
iter_remove = self.playlist.get_iter_from_string(path.to_string())
if (self.current_song.get_path().to_string() == path.to_string()):
if self.has_next():
self.next()
elif self.has_previous():
self.previous()
next_index = self._get_next_index()
if next_index >= 0:
self._current_index = next_index
if self._current_song_is_valid():
self._validate_next_song()
return True
else:
self.stop()
self.playlist.remove(iter_remove)
self._validate_next_song()
self.emit('prev-next-invalidated')
return self.next()
return False
@log
def _get_random_iter(self, current_song):
first_iter = self.playlist.get_iter_first()
if not current_song:
current_song = first_iter
if not current_song:
return None
if (hasattr(self.playlist, "iter_is_valid")
and not self.playlist.iter_is_valid(current_song)):
return None
current_path = int(self.playlist.get_path(current_song).to_string())
rows = self.playlist.iter_n_children(None)
if rows == 1:
return current_song
rand = current_path
while rand == current_path:
<