1
# -*- coding: utf-8 -*-
3
# Authors: Ingelrest François (Francois.Ingelrest@gmail.com)
4
# Jendrik Seipp (jendrikseipp@web.de)
6
# This program is free software; you can redistribute it and/or modify
7
# it under the terms of the GNU General Public License as published by
8
# the Free Software Foundation; either version 2 of the License, or
9
# (at your option) any later version.
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
# GNU Library General Public License for more details.
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
26
import gui, media, modules, tools
28
from gui import fileChooser
29
from tools import consts, icons, prefs, pickleLoad, pickleSave, log
30
from gettext import gettext as _
31
from gobject import TYPE_STRING, TYPE_INT, TYPE_PYOBJECT
32
from gui.widgets import TrackTreeView
34
MOD_INFO = ('Tracktree', 'Tracktree', '', [], True, False)
36
# The format of a row in the treeview
40
ROW_TRK, # The track object
44
PREFS_DEFAULT_REPEAT_STATUS = False
46
# Internal d'n'd (reordering)
47
DND_REORDERING_ID = 1024
48
DND_INTERNAL_TARGET = ('extListview-internal', gtk.TARGET_SAME_WIDGET, DND_REORDERING_ID)
51
class Tracktree(modules.Module):
52
""" This module manages the tracklist """
57
consts.MSG_CMD_NEXT: self.jumpToNext,
58
consts.MSG_EVT_PAUSED: lambda: self.onPausedToggled(icons.pauseMenuIcon()),
59
consts.MSG_EVT_STOPPED: self.onStopped,
60
consts.MSG_EVT_UNPAUSED: lambda: self.onPausedToggled(icons.playMenuIcon()),
61
consts.MSG_CMD_PREVIOUS: self.jumpToPrevious,
62
consts.MSG_EVT_NEED_BUFFER: self.onBufferingNeeded,
63
consts.MSG_EVT_APP_STARTED: self.onAppStarted,
64
consts.MSG_EVT_APP_QUIT: self.onAppQuit,
65
consts.MSG_CMD_TOGGLE_PAUSE: self.togglePause,
66
consts.MSG_CMD_TRACKLIST_DEL: self.remove,
67
consts.MSG_CMD_TRACKLIST_ADD: self.insert,
68
consts.MSG_CMD_TRACKLIST_SET: self.set,
69
consts.MSG_CMD_TRACKLIST_CLR: lambda: self.set(None, None),
70
consts.MSG_EVT_TRACK_ENDED_OK: lambda: self.onTrackEnded(False),
71
#consts.MSG_CMD_TRACKLIST_REPEAT: self.setRepeat,
72
consts.MSG_EVT_TRACK_ENDED_ERROR: lambda: self.onTrackEnded(True),
73
#consts.MSG_CMD_TRACKLIST_SHUFFLE: self.shuffleTracklist,
74
consts.MSG_CMD_FILE_EXPLORER_DRAG_BEGIN: self.onDragBegin,
77
modules.Module.__init__(self, handlers)
80
def getTreeDump(self, path=None):
81
""" Recursively dump the given tree starting at path (None for the root of the tree) """
84
for child in self.tree.iterChildren(path):
85
row = self.tree.getRow(child)
87
if self.tree.getNbChildren(child) == 0: grandChildren = None
88
#elif self.tree.row_expanded(child): grandChildren = self.getTreeDump(child)
89
#else: grandChildren = []
90
else: grandChildren = self.getTreeDump(child)
92
name = row[ROW_NAME].replace('<b>', '').replace('</b>', '')
94
list.append([(name, row[ROW_TRK]), grandChildren])
99
def restoreTreeDump(self, dump, parent=None):
100
""" Recursively restore the dump under the given parent (None for the root of the tree) """
101
if not type(dump) == list:
102
# This dump is from version 0.1 where we saved the TrackDir
106
(name, track) = item[0]
109
self.tree.appendRow((icons.nullMenuIcon(), name, track), parent)
111
newNode = self.tree.appendRow((icons.mediaDirMenuIcon(), name, None), parent)
113
if item[1] is not None:
114
if len(item[1]) != 0:
115
# We must expand the row before adding the real children, but this works only if there is already at least one child
116
self.restoreTreeDump(item[1], newNode)
119
def getTracks(self, rows):
123
track = self.tree.getTrack(row)
129
def getTrackDir(self, root=None):
130
flat = False if root else True
131
name = self.tree.getLabel(root) if root else 'playtree'
132
name = name.replace('<b>', '').replace('</b>', '')
133
trackdir = media.TrackDir(name=name, flat=flat)
135
for iter in self.tree.iter_children(root):
136
track = self.tree.getTrack(iter)
138
trackdir.tracks.append(track)
140
subdir = self.getTrackDir(iter)
141
trackdir.subdirs.append(subdir)
146
def __getNextTrackIter(self):
147
""" Return the index of the next track, or -1 if there is none """
150
next = self.tree.get_next_iter(next)
155
error = self.tree.getItem(next, ROW_ICO) == icons.errorMenuIcon()
156
track = self.tree.getItem(next, ROW_TRK)
157
if track and not error:
158
# Row is not a directory
162
def __hasNextTrack(self):
163
""" Return whether there is a next track """
164
return self.__getNextTrackIter() is not None
167
def __getPreviousTrackIter(self):
168
""" Return the index of the previous track, or -1 if there is none """
171
prev = self.tree.get_prev_iter(prev)
176
error = self.tree.getItem(prev, ROW_ICO) == icons.errorMenuIcon()
177
track = self.tree.getItem(prev, ROW_TRK)
178
if track and not error:
179
# Row is not a directory
183
def __hasPreviousTrack(self):
184
""" Return whether there is a previous track """
185
return self.__getPreviousTrackIter() is not None
188
def jumpToNext(self):
189
""" Jump to the next track, if any """
190
where = self.__getNextTrackIter()
195
def jumpToPrevious(self):
196
""" Jump to the previous track, if any """
197
where = self.__getPreviousTrackIter()
202
def set_track_playing(self, iter, playing):
205
track = self.tree.getTrack(iter)
209
for parent in self.tree.get_all_parents(iter):
210
parent_label = self.tree.getLabel(parent)
211
parent_label = tools.htmlUnescape(parent_label)
212
is_bold = parent_label.startswith('<b>') and parent_label.endswith('</b>')
213
if playing and not is_bold:
214
parent_label = tools.htmlEscape(parent_label)
215
self.tree.setLabel(parent, '<b>%s</b>' % parent_label)
216
elif not playing and is_bold:
217
parent_label = tools.htmlEscape(parent_label[3:-4])
218
self.tree.setLabel(parent, parent_label)
220
parent = self.tree.store.iter_parent(iter)
221
parent_label = self.tree.getLabel(parent) if parent else None
222
label = track.get_label(parent_label=parent_label, playing=playing)
224
self.tree.setLabel(iter, label)
225
self.tree.setItem(iter, ROW_ICO, icons.playMenuIcon())
226
self.tree.expand(iter)
228
self.tree.setLabel(iter, label)
229
icon = self.tree.getItem(iter, ROW_ICO)
230
has_error = (icon == icons.errorMenuIcon())
231
is_dir = (icon == icons.mediaDirMenuIcon())
232
if not is_dir and not has_error:
233
self.tree.setItem(iter, ROW_ICO, icons.nullMenuIcon())
236
def jumpTo(self, iter, sendPlayMsg=True):
237
""" Jump to the track located at the given iter """
241
mark = self.tree.getMark()
243
self.set_track_playing(mark, False)
245
self.tree.setMark(iter)
246
self.tree.scroll(iter)
249
track = self.tree.getTrack(iter)
251
# Row may be a directory
252
self.jumpTo(self.__getNextTrackIter())
255
self.set_track_playing(iter, True)
258
modules.postMsg(consts.MSG_CMD_PLAY, {'uri': track.getURI()})
260
modules.postMsg(consts.MSG_EVT_NEW_TRACK, {'track': track})
261
modules.postMsg(consts.MSG_EVT_TRACK_MOVED, {'hasPrevious': self.__hasPreviousTrack(), 'hasNext': self.__hasNextTrack()})
264
def insert(self, tracks, target=None, drop_mode=None, playNow=True, highlight=False):
265
if type(tracks) == list:
266
trackdir = media.TrackDir(None, flat=True)
267
trackdir.tracks = tracks
270
self.tree.get_selection().unselect_all()
271
self.insertDir(tracks, target, drop_mode, highlight)
272
self.onListModified()
275
# TODO: playNow wanted? Buggy in current state
278
dest = self.tree.get_last_root()
280
dest = self.tree.get_last_child_iter(parent)
284
def insertDir(self, trackdir, target=None, drop_mode=None, highlight=False):
286
Insert a directory recursively, return the iter of the first
289
model = self.tree.store
293
string = trackdir.dirname.replace('_', ' ')
294
string = tools.htmlEscape(string)
295
source_row = (icons.mediaDirMenuIcon(), string, None)
297
new = self.tree.insert(target, source_row, drop_mode)
298
drop_mode = gtk.TREE_VIEW_DROP_INTO_OR_AFTER
300
self.tree.select(new)
303
for index, subdir in enumerate(trackdir.subdirs):
304
drop = drop_mode if index == 0 else gtk.TREE_VIEW_DROP_AFTER
305
dest = self.insertDir(subdir, dest, drop, highlight)
308
for index, track in enumerate(trackdir.tracks):
309
drop = drop_mode if index == 0 else gtk.TREE_VIEW_DROP_AFTER
310
highlight &= trackdir.flat
311
dest = self.insertTrack(track, dest, drop, highlight)
313
if not trackdir.flat:
314
# Open albums on the first layer
315
if target is None or model.iter_depth(new) == 0:
316
self.tree.expand(new)
321
def insertTrack(self, track, target=None, drop_mode=None, highlight=False):
323
Insert a new track into the tracktree under parentPath
325
length = track.getLength()
326
self.playtime += length
328
name = track.get_label()
330
row = (icons.nullMenuIcon(), name, track)
331
new_iter = self.tree.insert(target, row, drop_mode)
332
parent = self.tree.store.iter_parent(new_iter)
334
# adjust track label to parent
335
parent_label = self.tree.getLabel(parent)
336
new_label = track.get_label(parent_label)
337
self.tree.setLabel(new_iter, new_label)
339
self.tree.select(new_iter)
343
def set(self, tracks, playNow):
344
""" Replace the tracklist, clear it if tracks is None """
347
if type(tracks) == list:
348
trackdir = media.TrackDir(None, flat=True)
349
trackdir.tracks = tracks
352
if self.tree.hasMark() and ((not playNow) or (tracks is None) or tracks.empty()):
353
modules.postMsg(consts.MSG_CMD_STOP)
357
if tracks is not None and not tracks.empty():
360
self.tree.collapse_all()
362
self.onListModified()
365
def savePlaylist(self):
366
""" Save the current tracklist to a playlist """
367
outFile = fileChooser.save(self.window, _('Save playlist'), 'playlist.m3u')
369
if outFile is not None:
370
allFiles = [row[ROW_TRK].getFilePath() for row in self.tree.iterAllRows()]
371
media.playlist.save(allFiles, outFile)
374
def remove(self, iter=None):
375
""" Remove the given track, or the selection if iter is None """
376
hadMark = self.tree.hasMark()
378
iters = [iter] if iter else list(self.tree.iterSelectedRows())
380
# reverse list, so that we remove children before their fathers
381
for iter in reversed(iters):
382
track = self.tree.getTrack(iter)
384
self.playtime -= track.getLength()
385
self.tree.removeRow(iter)
387
self.tree.selection.unselect_all()
389
if hadMark and not self.tree.hasMark():
390
modules.postMsg(consts.MSG_CMD_STOP)
392
self.onListModified()
395
def onShowPopupMenu(self, tree, button, time, path):
396
""" The index parameter may be None """
400
iter = tree.store.get_iter(path)
405
remove = gtk.ImageMenuItem(gtk.STOCK_REMOVE)
409
remove.set_sensitive(False)
411
remove.connect('activate', lambda item: self.remove())
413
#popup.append(gtk.SeparatorMenuItem())
416
clear = gtk.ImageMenuItem(_('Clear Playlist'))
417
clear.set_image(gtk.image_new_from_stock(gtk.STOCK_CLEAR, gtk.ICON_SIZE_MENU))
420
if len(tree.store) == 0:
421
clear.set_sensitive(False)
423
clear.connect('activate', lambda item: modules.postMsg(consts.MSG_CMD_TRACKLIST_CLR))
426
popup.popup(None, None, None, button, time)
429
def togglePause(self):
430
""" Start playing if not already playing """
431
if len(self.tree) != 0 and not self.tree.hasMark():
432
if self.tree.selection.count_selected_rows() > 0:
433
model, sel_rows_list = self.tree.selection.get_selected_rows()
434
first_sel_iter = self.tree.store.get_iter(sel_rows_list[0])
435
self.jumpTo(first_sel_iter)
437
self.jumpTo(self.tree.get_first_iter())
440
def restore_expanded_rows(self):
441
for path in self.tree.expanded_rows:
442
self.tree.expand(path)
443
self.tree.expanded_rows = None
446
# --== Message handlers ==--
449
def onAppStarted(self):
450
""" This is the real initialization function, called when the module has been loaded """
451
wTree = tools.prefs.getWidgetsTree()
453
self.bufferedTrack = None
454
self.previousTracklist = None
456
self.window = wTree.get_widget('win-main')
457
self.btnClear = wTree.get_widget('btn-tracklistClear')
458
self.btnRepeat = wTree.get_widget('btn-tracklistRepeat')
459
self.btnShuffle = wTree.get_widget('btn-tracklistShuffle')
461
columns = (('', [(gtk.CellRendererPixbuf(), gtk.gdk.Pixbuf), (gtk.CellRendererText(), TYPE_STRING)], True),
462
(None, [(None, TYPE_PYOBJECT)], False),
465
self.tree = TrackTreeView(columns, use_markup=True)
466
self.tree.enableDNDReordering()
467
#self.tree.setDNDSources([consts.DND_TARGETS[consts.DND_POGO_TRACKS]])
468
self.tree.setDNDSources([DND_INTERNAL_TARGET])
470
wTree.get_widget('scrolled-tracklist').add(self.tree)
473
self.tree.connect('exttreeview-button-pressed', self.onMouseButton)
474
self.tree.connect('tracktreeview-dnd', self.onDND)
475
self.tree.connect('key-press-event', self.onKeyboard)
476
#self.tree.connect('extlistview-modified', self.onListModified)
477
#self.tree.connect('button-pressed', self.onButtonPressed)
478
#self.btnClear.connect('clicked', lambda widget: modules.postMsg(consts.MSG_CMD_TRACKLIST_CLR))
479
#self.btnRepeat.connect('toggled', self.onButtonRepeat)
480
#self.btnShuffle.connect('clicked', lambda widget: modules.postMsg(consts.MSG_CMD_TRACKLIST_SHUFFLE))
481
# Restore preferences
482
#self.btnRepeat.set_active(tools.prefs.get(__name__, 'repeat-status', PREFS_DEFAULT_REPEAT_STATUS))
484
#wTree.get_widget('img-repeat').set_from_icon_name('stock_repeat', gtk.ICON_SIZE_BUTTON)
485
#wTree.get_widget('img-shuffle').set_from_icon_name('stock_shuffle', gtk.ICON_SIZE_BUTTON)
487
# Populate the playlist with commandline args or the saved playlist
488
(options, args) = prefs.getCmdLine()
490
self.savedPlaylist = os.path.join(consts.dirCfg, 'saved-playlist')
493
log.logger.info('[%s] Filling playlist with files given on command line' % MOD_INFO[modules.MODINFO_NAME])
494
# make paths absolute
495
paths = map(os.path.abspath, args)
496
print 'Appending to the playlist:'
497
print '\n'.join(paths)
498
modules.postMsg(consts.MSG_CMD_TRACKLIST_SET, {'tracks': media.getTracks(paths), 'playNow': True})
501
if os.path.exists(self.savedPlaylist):
503
dump = pickleLoad(self.savedPlaylist)
505
msg = '[%s] Unable to restore playlist from %s\n\n%s'
506
log.logger.error(msg % (MOD_INFO[modules.MODINFO_NAME],
507
self.savedPlaylist, traceback.format_exc()))
510
self.restoreTreeDump(dump)
511
log.logger.info('[%s] Restored playlist' % MOD_INFO[modules.MODINFO_NAME])
512
self.tree.collapse_all()
513
self.onListModified()
517
""" The module is going to be unloaded """
518
dump = self.getTreeDump()
519
logging.info('Saving playlist')
520
pickleSave(self.savedPlaylist, dump)
523
def onTrackEnded(self, withError):
524
""" The current track has ended, jump to the next one if any """
525
current_iter = self.tree.getMark()
527
# If an error occurred with the current track, flag it as such
528
if withError and current_iter:
529
self.tree.setItem(current_iter, ROW_ICO, icons.errorMenuIcon())
531
# Find the next 'playable' track (not already flagged)
532
next = self.__getNextTrackIter()
536
track_name = self.tree.getTrack(current_iter).getURI()
537
send_play_msg = (track_name != self.bufferedTrack)
538
self.jumpTo(next, sendPlayMsg=send_play_msg)
539
self.bufferedTrack = None
542
self.bufferedTrack = None
543
modules.postMsg(consts.MSG_CMD_STOP)
546
def onBufferingNeeded(self):
547
""" The current track is close to its end, so we try to buffer the next one to avoid gaps """
548
where = self.__getNextTrackIter()
550
self.bufferedTrack = self.tree.getItem(where, ROW_TRK).getURI()
551
modules.postMsg(consts.MSG_CMD_BUFFER, {'uri': self.bufferedTrack})
555
""" Playback has been stopped """
556
if self.tree.hasMark():
557
currTrack = self.tree.getMark()
558
self.set_track_playing(currTrack, False)
559
self.tree.clearMark()
562
def onPausedToggled(self, icon):
563
""" Switch between paused and unpaused """
564
if self.tree.hasMark():
565
self.tree.setItem(self.tree.getMark(), ROW_ICO, icon)
568
def onDragBegin(self, paths):
569
dir_selected = any(map(os.path.isdir, paths))
570
self.tree.dir_selected = dir_selected
573
#def expanded(treeview, path):
574
# row = self.tree.store.get_iter(path)
575
# self.tree.expanded_rows.append(row)
577
#self.tree.expanded_rows = []
578
#self.tree.map_expanded_rows(expanded)
580
self.tree.collapse_all()
583
# --== GTK handlers ==--
586
def onMouseButton(self, tree, event, path):
587
""" A mouse button has been pressed """
588
if event.button == 1 and event.type == gtk.gdk._2BUTTON_PRESS and path is not None:
589
self.jumpTo(self.tree.store.get_iter(path))
590
elif event.button == 3:
591
self.onShowPopupMenu(tree, event.button, event.time, path)
594
def onKeyboard(self, list, event):
595
""" Keyboard shortcuts """
596
keyname = gtk.gdk.keyval_name(event.keyval)
598
if keyname == 'Delete': self.remove()
599
elif keyname == 'Return': self.jumpTo(self.tree.getFirstSelectedRow())
600
elif keyname == 'space': modules.postMsg(consts.MSG_CMD_TOGGLE_PAUSE)
601
elif keyname == 'Escape': modules.postMsg(consts.MSG_CMD_STOP)
602
elif keyname == 'Left': modules.postMsg(consts.MSG_CMD_STEP, {'seconds': -5})
603
elif keyname == 'Right': modules.postMsg(consts.MSG_CMD_STEP, {'seconds': 5})
606
def onListModified(self):
607
""" Some rows have been added/removed/moved """
608
# Getting the trackdir takes virtually no time, so we can do it on every
610
tracks = self.getTrackDir()
611
self.playtime = tracks.get_playtime()
613
modules.postMsg(consts.MSG_EVT_NEW_TRACKLIST, {'tracks': tracks, 'playtime': self.playtime})
615
if self.tree.hasMark():
616
modules.postMsg(consts.MSG_EVT_TRACK_MOVED, {'hasPrevious': self.__hasPreviousTrack(), 'hasNext': self.__hasNextTrack()})
619
def onDND(self, list, context, x, y, dragData, dndId, time):
620
""" External Drag'n'Drop """
623
if dragData.data == '':
624
context.finish(False, False, time)
627
# A list of filenames, without 'file://' at the beginning
628
if dndId == consts.DND_POGO_URI:
629
tracks = media.getTracks([urllib.url2pathname(uri) for uri in dragData.data.split()])
630
# A list of filenames starting with 'file://'
631
elif dndId == consts.DND_URI:
632
tracks = media.getTracks([urllib.url2pathname(uri)[7:] for uri in dragData.data.split()])
634
elif dndId == consts.DND_POGO_TRACKS:
635
tracks = [media.track.unserialize(serialTrack) for serialTrack in dragData.data.split('\n')]
637
# dropInfo is tuple (path, drop_pos)
638
dropInfo = list.get_dest_row_at_pos(x, y)
640
# Insert the tracks, but beware of the AFTER/BEFORE mechanism used by GTK
642
self.insert(tracks, highlight=True)
644
path, drop_mode = dropInfo
645
iter = self.tree.store.get_iter(path)
646
self.insert(tracks, iter, drop_mode, highlight=True)
648
#self.restore_expanded_rows()
650
# We want to allow dropping tracks only when we are sure that no dir is
651
# selected. This is needed for dnd from nautilus.
652
self.tree.dir_selected = True
654
context.finish(True, False, time)