root/branches/0.3/src/playlist_control.py

Revision 811, 66.1 kB (checked in by nicfit, 1 year ago)

Removed sort URI sort experiment

Line 
1 ################################################################################
2 #  Copyright (C) 2006  Travis Shirk <travis@pobox.com>
3 #
4 #  This program is free software; you can redistribute it and/or modify
5 #  it under the terms of the GNU General Public License as published by
6 #  the Free Software Foundation; either version 2 of the License, or
7 #  (at your option) any later version.
8 #
9 #  This program is distributed in the hope that it will be useful,
10 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
11 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 #  GNU General Public License for more details.
13 #
14 #  You should have received a copy of the GNU General Public License
15 #  along with this program; if not, write to the Free Software
16 #  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17 #
18 #  $Id$
19 ################################################################################
20 import os
21 import datetime, tempfile, shutil
22
23 import gobject, gtk, gtk.gdk, gtk.glade
24 import pango
25
26 import mesk
27 import mesk.utils, mesk.gtk_utils, mesk.uri, mesk.playlist
28 from mesk.i18n import _
29
30 import config, control
31 from mesk.audio import UnsupportedFormat
32 from mesk.audio.source import UnsupportedScheme
33
34 # Data model.  These do not have to correspond to the column order, nor
35 # are they all meant to be displayed values.
36 (MODEL_STATE,
37  MODEL_STATUS_IMG,
38  MODEL_STATUS_TEXT,
39  MODEL_URI,
40  MODEL_NUM,
41  MODEL_TITLE,
42  MODEL_ARTIST,
43  MODEL_ALBUM,
44  MODEL_TIME,
45  MODEL_YEAR,
46 ) = range(10)
47
48 MODEL_STATE_ACTIVE   = 0
49 MODEL_STATE_INACTIVE = 1
50
51 MAX_ROWS_PENDING = 20
52
53 class PlaylistControl(control.Control):
54
55     def __init__(self, name, status_bar):
56         control.Control.__init__(self)
57         self.name = name
58         self._status_bar = status_bar
59         self._pl_config = config.PlaylistConfig(self.name)
60
61         # A signal for knowing nothing more than the playlist has changed
62         if gobject.signal_lookup('playlist_changed', PlaylistControl) == 0:
63             gobject.signal_new('playlist_changed', PlaylistControl,
64                                gobject.SIGNAL_RUN_LAST,
65                                gobject.TYPE_NONE, [])
66
67         self._playlist = None
68         self._playlist_save_id = None
69         self._initial_active = False
70         self._audio_control = None
71         self._row_activated_awaiting_active = None
72         # Used to deselect when a sected row is clicked on
73         self._current_selection_path = None
74         self._last_row_status = None
75
76         # Playlist stats
77         self._list_len = 0
78         self._list_bytes = long(0)
79         self._list_secs = long(0)
80
81         # Setup tab label
82         self.tab_label_xml = mesk.gtk_utils.get_glade('playlist_tab_ebox',
83                                                       'main_window.glade')
84         self.tab_widget = self.tab_label_xml.get_widget('playlist_tab_ebox')
85         self.tab_widget.connect('button-press-event',
86                                 self._on_playlist_tab_ebox_button_press_event)
87         self.tab_label_label = \
88             self.tab_label_xml.get_widget('playlist_tab_label')
89         self.tab_label_label.set_max_width_chars(12)
90         self.tab_label_label.set_markup(self.name)
91
92         # The central widget for this control
93         self.widget_xml = mesk.gtk_utils.get_glade('playlist_control',
94                                                    'main_window.glade')
95         self.widget = self.widget_xml.get_widget('playlist_control')
96         self.widget_xml.signal_autoconnect(self)
97
98         img = gtk.Image()
99         pix = gtk.gdk.pixbuf_new_from_file('data/images/stock_shuffle.png')
100         img.set_from_pixbuf(pix)
101         self.widget_xml.get_widget('shuffle_togglebutton').set_image(img)
102
103         img = gtk.Image()
104         pix = gtk.gdk.pixbuf_new_from_file('data/images/stock_repeat.png')
105         img.set_from_pixbuf(pix)
106         self.widget_xml.get_widget('repeat_togglebutton').set_image(img)
107
108         # Playlist treeview data store
109         self._pl_view = self.widget_xml.get_widget('playlist_view')
110         # Although this seems nice it prevents of DnD operations
111         self._pl_view.set_reorderable(False)
112         # A search UI is provided, so disable the native handler
113         self._pl_view.set_enable_search(False)
114         # Allow multi-select
115         self._pl_view.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
116         self._pl_view.set_rules_hint(True)
117
118         # Clipboard for cut/paste of playlist entires
119         self._drag_data_get_handler = None
120         self._drag_data_recv_handler = None
121
122         self._clipboard = gtk.clipboard_get(gtk.gdk.atom_intern('_MESK_PL'))
123         self.PL_ROW_TARGET_ID     = 0
124         self.URI_LIST_TARGET_ID   = 1
125         self.TEXT_PLAIN_TARGET_ID = 2
126         self._targets = [
127             # Target for playlist row reordering
128             ('_PL_TREE_MODEL_ROW', gtk.TARGET_SAME_WIDGET,
129              self.PL_ROW_TARGET_ID),
130             # A list of uris for internal cut/paste and drops from
131             # external apps
132             ('text/uri-list', 0, self.URI_LIST_TARGET_ID),
133             # LCD
134             ('text/plain', 0, self.TEXT_PLAIN_TARGET_ID),
135         ]
136         self._pl_view.enable_model_drag_source(gtk.gdk.BUTTON1_MASK,
137                                                self._targets,
138                                                gtk.gdk.ACTION_DEFAULT |
139                                                gtk.gdk.ACTION_MOVE)
140         self._pl_view.enable_model_drag_dest(self._targets,
141                                              gtk.gdk.ACTION_DEFAULT)
142
143         # Connect for notification of scrolled window events
144         scroll_win = self.widget_xml.get_widget('playlist_scrolledwindow')
145         scroll_win.get_vadjustment().connect('value-changed',
146                                              self._on_playlist_vscroll)
147         scroll_win = self.widget_xml.get_widget('playlist_scrolledwindow')
148         # A value of None disables this value from updating, set to 0 to enable
149         self._last_vscroll = 0
150         self.AUTO_SCROLL_TIMEOUT = 15 # seconds
151
152         # Hide search box
153         self._search_widget = self.widget_xml.get_widget('playlist_search_hbox')
154         self._search_widget.hide()
155         # Get search widget refs
156         self._search_entry = self.widget_xml.get_widget('search_entry')
157         self._search_next = self.widget_xml.get_widget('search_next_button')
158         self._search_next.set_sensitive(False)
159         self._search_prev = self.widget_xml.get_widget('search_prev_button')
160         self._search_prev.set_sensitive(False)
161
162         # Playlist data model
163         self._pl_model = gtk.ListStore(int, # Column state
164                                        str, # Status pixbuf
165                                        str, # Status text
166                                        str, # Source URI
167                                        int, # Source #
168                                        str, # Source title
169                                        str, # Source artist
170                                        str, # Source album
171                                        str, # Source time (formatted)
172                                        str, # Source year
173                                       )
174
175         # Helper for making text columns
176         def append_text_column(title, model_index, expand = True):
177             col = TextColumn(title, expand = expand)
178             col.add_attribute(col.txt_renderer, 'markup', model_index)
179             col.add_attribute(col.txt_renderer, 'strikethrough', MODEL_STATE)
180             self._pl_view.append_column(col)
181
182         # Status column
183         col = StatusColumn()
184         self._pl_view.append_column(col)
185         # Text columns
186         append_text_column(_('Title'), MODEL_TITLE)
187         append_text_column(_('Artist'), MODEL_ARTIST)
188         append_text_column(_('Album'), MODEL_ALBUM)
189
190         append_text_column(mesk.utils.pad_string(_('#'), 3), MODEL_NUM,
191                            expand=False)
192         append_text_column(mesk.utils.pad_string(_('Year'), 6), MODEL_YEAR,
193                            expand=False)
194         append_text_column(mesk.utils.pad_string(_('Time'), 7), MODEL_TIME,
195                            expand=False)
196
197         if mesk.log.getLogger().isEnabledFor(mesk.log.DEBUG):
198             # Debugging load time
199             import time
200             t1 = time.time()
201         # Load playlist
202         pl = mesk.playlist.load(self._pl_config.uri, name=self._pl_config.name)
203         if mesk.log.getLogger().isEnabledFor(mesk.log.DEBUG):
204             # Debugging load time
205             t2 = time.time()
206             mesk.log.debug("Playlist '%s' loaded in %fs" %
207                            (self._pl_config.name, t2 - t1))
208
209         self._set_playlist(pl)
210         self._set_read_only(pl.read_only)
211
212         # Reset since no _manual_ scrolling has been done yet
213         self._last_vscroll = 0
214         self.widget.show()
215
216     def has_playlist(self):
217         return True
218     def is_playlist_saved(self):
219         return True
220
221     def _save_playlist(self, interval = 10000):
222         def _save_cb():
223             self._pl_config.update()
224
225             # Write to a tempfile to avoid corruptions on errors
226             (tmp_fd, tmp_filename) = tempfile.mkstemp()
227             temp_file = os.fdopen(tmp_fd, 'wb+')
228             mesk.playlist.xspf.save(temp_file, self._playlist)
229             temp_file.close()
230             # Move temp file over playlist
231             shutil.copyfile(tmp_filename,
232                             mesk.uri.unescape(self._pl_config.uri.path))
233             os.unlink(tmp_filename)
234
235             # XXX: For debugging
236             #self._debug_show_playlist()
237
238             self._playlist_save_id = None
239             return False # No more callbacks
240
241         if self._playlist_save_id:
242             gobject.source_remove(self._playlist_save_id)
243         if interval:
244             self._playlist_save_id = gobject.timeout_add(interval, _save_cb)
245         else:
246             _save_cb()
247
248     def shutdown(self):
249         if self._audio_control:
250             self._audio_control.stop()
251         self._save_playlist(interval = 0)
252
253     def _get_model_metadata(self, src):
254         row_data = {}
255
256         if src.meta_data is None:
257             return row_data
258
259         row_data[MODEL_TITLE] = \
260           mesk.gtk_utils.escape_pango_markup(src.meta_data.title)
261         row_data[MODEL_ARTIST] = \
262           mesk.gtk_utils.escape_pango_markup(src.meta_data.artist)
263         row_data[MODEL_ALBUM] = \
264           mesk.gtk_utils.escape_pango_markup(src.meta_data.album)
265
266         if src.meta_data.year:
267             year = unicode(src.meta_data.year)
268         else:
269             year = u''
270         row_data[MODEL_YEAR] = year
271
272         track_num = src.meta_data.track_num
273         if track_num is None:
274             track_num = 0
275         row_data[MODEL_NUM] = track_num
276
277         duration = mesk.utils.format_track_time(src.meta_data.time_secs)
278         row_data[MODEL_TIME] = duration
279
280         return row_data
281
282     def _new_model_row(self, src):
283         model_data = self._get_model_metadata(src)
284         return [MODEL_STATE_ACTIVE, None, None,
285                 str(src.uri),
286                 model_data[MODEL_NUM],
287                 model_data[MODEL_TITLE],
288                 model_data[MODEL_ARTIST],
289                 model_data[MODEL_ALBUM],
290                 model_data[MODEL_TIME],
291                 model_data[MODEL_YEAR],
292                ]
293
294     def set_active(self, active=True, audio_ctrl=None):
295         control.Control.set_active(self, active, audio_ctrl)
296
297         if not active:
298             if self._audio_control:
299                 self._audio_control.stop()
300             self._audio_control = None
301         else:
302             self._audio_control = audio_ctrl
303
304         # Connect events for audio_control on first activation
305         if self._is_active and not self._initial_active:
306             assert(self._audio_control)
307             self._initial_active = True
308             self._audio_control.connect('play', self._on_audio_playing)
309             self._audio_control.connect('pause', self._on_audio_paused)
310             self._audio_control.connect('stopped', self._on_audio_stopped)
311             self._audio_control.connect('next', self._on_audio_next)
312             self._audio_control.connect('prev', self._on_audio_prev)
313             self._audio_control.connect('source-changed',
314                                         self._on_audio_source_changed)
315             self._error_count = 0
316             self._audio_control.connect('error', self._on_audio_error)
317             self._audio_control.connect('playlist-reset',
318                                         self._on_playlist_reset)
319             self._audio_control.connect('tag-update', self._on_audio_tag_update)
320
321         self._update_tab_label()
322
323         if self._row_activated_awaiting_active is not None:
324             assert(self._audio_control)
325             self._activate_row_cb(self._row_activated_awaiting_active)
326             self._row_activated_awaiting_active = None
327
328     def _update_tab_label(self):
329         # Bold label for active tab
330         label = self.name
331         if self._is_active:
332             label = '<b>%s</b>' % label
333         self.tab_label_label.set_markup(label)
334
335     def set_focused(self, focused = True):
336         if focused:
337             self._pl_view.grab_focus()
338
339     def _set_read_only(self, read_only=True):
340         self._playlist.read_only = read_only
341
342         # Tweak for for when the playlist is read-only
343         if read_only:
344             self.widget_xml.get_widget('add_button').hide()
345
346             if self._drag_data_get_handler:
347                 self._pl_view.disconnect(self._drag_data_get_handler)
348             if self._drag_data_recv_handler:
349                 self._pl_view.disconnect(self._drag_data_recv_handler)
350
351             self.widget_xml.get_widget('read_only_image_eventbox').show()
352         else:
353             self.widget_xml.get_widget('add_button').show()
354
355             self._drag_data_get_handler = \
356                 self._pl_view.connect("drag-data-get", self._on_drag_data_get)
357             self._drag_data_recv_handler = \
358                 self._pl_view.connect("drag-data-received",
359                                       self._on_drag_data_received)
360
361             self.widget_xml.get_widget('read_only_image_eventbox').hide()
362
363     def _set_playlist(self, playlist):
364         self._playlist = playlist
365
366         self._pl_model.clear()
367         for src in self._playlist:
368             self._pl_model.append(self._new_model_row(src))
369         self._pl_view.set_model(self._pl_model)
370
371         # Mark current position in playlist
372         if self._playlist.get_curr_index() < 0 and self._playlist.has_next():
373             self._set_row_status(gtk.STOCK_MEDIA_STOP, 0);
374         else:
375             self._set_row_status(gtk.STOCK_MEDIA_STOP);
376
377         shuffled = self._playlist.is_shuffled()
378         repeating = self._playlist.is_repeating()
379         self.widget_xml.get_widget('shuffle_togglebutton').set_active(shuffled)
380         self.widget_xml.get_widget('repeat_togglebutton').set_active(repeating)
381
382         self._update_playlist_stats()
383         self.emit('playlist_changed')
384
385     def get_playlist(self):
386         '''NOTE: Treat as read-only (for now)'''
387         return self._playlist
388
389     def _on_playlist_vscroll(self, widget):
390         if self._last_vscroll is not None:
391             self._last_vscroll = gobject.get_current_time()
392
393     def _on_playlist_view_row_activated(self, treeview, path, view_col):
394         row = path[0]
395         if not self._is_active:
396             self._row_activated_awaiting_active = row
397             self.emit('control_request_active')
398         else:
399             self._activate_row_cb(row)
400
401     def _activate_row_cb(self, row):
402         self._set_row_status()
403         self._audio_control.enqueue_source(absolute=row)
404         self._audio_control.play()
405
406     def _on_playlist_view_cursor_changed(self, treeview):
407         select_path = treeview.get_cursor()[0]
408         # Clear selection if the current selection is clicked
409         if (self._current_selection_path and
410             (select_path == self._current_selection_path)):
411             treeview.get_selection().unselect_path(select_path)
412             self._current_selection_path = None
413         else:
414             self._current_selection_path = select_path
415
416     def _on_playlist_view_button_press_event(self, treeview, event):
417         # Intercept right clicks for context menu
418         if event.button == 3:
419             # Calculate selected rows, if any
420             x, y = int(event.x), int(event.y)
421             path_info = treeview.get_path_at_pos(x, y)
422             selected_rows = []
423             if path_info is not None:
424                 path, col, cellx, celly = path_info
425                 selected_rows = self.get_selected_rows()
426                 if not selected_rows or (path[0] not in selected_rows):
427                     # No selections or click was outside selections, so select
428                     # row under mouse click
429                     treeview.grab_focus()
430                     treeview.set_cursor(path[0])
431                     selected_rows = [path[0]]
432
433             self._playlist_context_menu_popup(selected_rows, event)
434             return True
435         else:
436             # Let all other clicks pass through unhandled
437             return False
438
439     def _supports_properties(self):
440         '''A hook for subclasses to specify if playlist props are supported,
441         read-only is not enough'''
442         return True
443
444     def _process_tab_menu(self, menu_xml, menu):
445         if not self._supports_properties():
446             menu_xml.get_widget('properties_menuitem').hide()
447
448         # Tweak for read-only menu
449         if self._playlist.read_only:
450             menu_xml.get_widget('separator1').hide()
451             menu_xml.get_widget('delete_menuitem').hide()
452
453     def _playlist_tab_menu_popup(self, event):
454         tab_menu_xml = mesk.gtk_utils.get_glade('playlist_tab_menu',
455                                                 'playlist.glade')
456         tab_menu_xml.signal_autoconnect(self)
457         tab_menu = tab_menu_xml.get_widget('playlist_tab_menu')
458
459         if self.name == mesk.DEFAULT_PLAYLIST_NAME:
460             # The default playlist cannot be deleted
461             tab_menu_xml.get_widget('separator1').hide()
462             tab_menu_xml.get_widget('delete_menuitem').hide()
463
464         self._process_tab_menu(tab_menu_xml, tab_menu)
465         tab_menu.popup(None, None, None, event.button, event.time)
466
467     def _playlist_context_menu_popup(self, selected_rows, event):
468         menu_xml = mesk.gtk_utils.get_glade('playlist_context_menu',
469                                             'playlist.glade')
470         pl_menu = menu_xml.get_widget('playlist_context_menu')
471         menu_xml.signal_autoconnect(self)
472         pl_menu.show_all()
473
474         # Filter menuitems per the current playlist state
475         for menuitem in pl_menu.get_children():
476             menuitem_name = menuitem.get_name()
477
478             if menuitem_name in ['properties_menuitem']:
479                 if self._supports_properties():
480                     menuitem.show()
481                 else:
482                     menuitem.hide()
483             elif menuitem_name in ['add_menuitem']:
484                 if self._playlist.read_only:
485                     menuitem.hide()
486                 else:
487                     menuitem.show()
488             elif menuitem_name in ['copy_menuitem']:
489                 if selected_rows:
490                     menuitem.show()
491                 else:
492                     menuitem.hide()
493             elif menuitem_name in ['remove_menuitem', 'cut_menuitem']:
494                 # Hide these menuitems when there are no selections or
495                 # read-only
496                 if not self._playlist.read_only and selected_rows:
497                     menuitem.show()
498                 else:
499                     menuitem.hide()
500             elif menuitem_name == 'paste_menuitem':
501                 if self._playlist.read_only:
502                     menuitem.hide()
503                 else:
504                     available_targets = self._clipboard.wait_for_targets()
505                     # Show paste menu only if the clipboard contains data
506                     if (available_targets and
507                         (self._targets[self.URI_LIST_TARGET_ID][0] in
508                          self._clipboard.wait_for_targets())):
509                         menuitem.show()
510                     else:
511                         menuitem.hide()
512             elif menuitem_name == 'queue_menuitem':
513                 # Show/hide Queue menuitem
514                 if not len(self._playlist):
515                     menuitem.hide()
516                 else:
517                     menuitem.show()
518                     sub_menu = menuitem.get_submenu()
519                     sub_children = sub_menu.get_children()
520                     num_subs_hidden = 0
521                     num_sub_children = len(sub_children)
522                     # Process children of the queue menuitem
523                     for sub_item in sub_children:
524                         if sub_item.get_name() in ['queue_unqueue_menuitem',
525