chiark / gitweb /
Python bindings for playlist commands.
[disorder] / python / tkdisorder
1 #! /usr/bin/env python
2 #
3 # Copyright (C) 2004, 2005 Richard Kettlewell
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13 # General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
18 # USA
19 #
20
21 # THIS PROGRAM IS NO LONGER MAINTAINED.
22 #
23 # It worked last time I tried running it, but all client maintenance
24 # effort is now devoted to the web interface and the GTK+ client
25 # (Disobedience).
26
27 """Graphical user interface for DisOrder"""
28
29 from Tkinter import *
30 import tkFont
31 import Queue
32 import threading
33 import disorder
34 import time
35 import string
36 import re
37 import getopt
38 import sys
39
40 ########################################################################
41
42 # Architecture:
43 #
44 #  The main (initial) thread of the program runs all GUI code.  The GUI is only
45 #  directly modified from inside this thread.  We sometimes call this the
46 #  master thread.
47 #
48 #  We have a background thread, MonitorStateThread, which waits for changes to
49 #  the server's state which we care about.  Whenever such a change occurs it
50 #  notifies all the widgets which care about it (and possibly other widgets;
51 #  the current implementation is unsophisticated.)
52 #
53 #  Widget poll() methods usually, but NOT ALWAYS, called in the
54 #  MonitorStateThread.  Other widget methods are call in the master thread.
55 #
56 #  We have a separate disorder.client for each thread rather than locking a
57 #  single client.  MonitorStateThread also has a private disorder.client that
58 #  it uses to watch for server state changes.
59
60 ########################################################################
61
62 class Intercom:
63   # communication queue into thread containing Tk event loop
64   #
65   # Sets up a callback on the event loop (in this thread) which periodically
66   # checks the queue for elements; if any are found they are executed.
67   def __init__(self, master):
68     self.q = Queue.Queue();
69     self.master = master
70     self.poll()
71
72   def poll(self):
73     try:
74       item = self.q.get_nowait()
75       item()
76       self.master.after_idle(self.poll)
77     except Queue.Empty:
78       self.master.after(100, self.poll)
79
80   def put(self, item):
81     self.q.put(item)
82
83 ########################################################################
84
85 class ProgressBar(Canvas):
86   # progress bar widget
87   def __init__(self, master=None, **kw):
88     Canvas.__init__(self, master, highlightthickness=0, **kw)
89     self.outer = self.create_rectangle(0, 0, 0, 0,
90                                        outline="#000000",
91                                        width=1,
92                                        fill="#ffffff")
93     self.bar = self.create_rectangle(0, 0, 0, 0,
94                                      width=1,
95                                      fill="#ff0000",
96                                      outline='#ff0000')
97     self.current = None
98     self.total = None
99     self.bind("<Configure>", lambda e: self.redisplay())
100
101   def update(self, current, total):
102     self.current = current
103     if current > total:
104       current = total
105     elif current < 0:
106       current = 0
107     self.total = total
108     self.redisplay()
109
110   def clear(self):
111     self.current = None
112     self.total = None
113     self.redisplay()
114
115   def redisplay(self):
116     w, h = self.winfo_width(), self.winfo_height()
117     if w > 0 and h > 0:
118       self.coords(self.outer, 0, 0, w - 1, h - 1)
119       if self.total:
120         bw = int((w - 2) * self.current / self.total)
121         self.itemconfig(self.bar,
122                         fill="#ff0000",
123                         outline="#ff0000")
124         self.coords(self.bar, 1, 1, bw, h - 2)
125       else:
126         self.itemconfig(self.bar,
127                         fill="#909090",
128                         outline="#909090")
129         self.coords(self.bar, 1, 1, w - 2, h - 2)
130
131 # look up a track's name part, using client c.  Maintains a cache.
132 part_cache = {}
133 def part(c, track, context, part):
134   key = "%s-%s-%s" % (part, context, track)
135   now = time.time()
136   if not part_cache.has_key(key) or part_cache[key]['when'] < now - 3600:
137     part_cache[key] = {'when': now,
138                        'what': c.part(track, context, part)}
139   return part_cache[key]['what']
140
141 class PlayingWidget(Frame):
142   # widget that always displays information about what's
143   # playing
144   def __init__(self, master=None, **kw):
145     Frame.__init__(self, master, **kw)
146     # column 0 is descriptions, column 1 is the values
147     self.columnconfigure(0,weight=0)
148     self.columnconfigure(1,weight=1)
149     self.fields = {}
150     self.field(0, 0, "artist", "Artist")
151     self.field(1, 0, "album", "Album")
152     self.field(2, 0, "title", "Title")
153     # column 1 also has the progress bar in it
154     self.p = ProgressBar(self, height=20)
155     self.p.grid(row=3, column=1, sticky=E+W)
156     # column 2 has operation buttons
157     b = Button(self, text="Quit", command=self.quit)
158     b.grid(row=0, column=2, sticky=E+W)
159     b = Button(self, text="Scratch", command=self.scratch)
160     b.grid(row=1, column=2, sticky=E+W)
161     b = Button(self, text="Recent", command=self.recent)
162     b.grid(row=2, column=2, sticky=E+W)
163     self.length = 0
164     self.update_length()
165     self.last = None
166     self.recentw = None
167     
168   def field(self, row, column, name, label):
169     # create a field
170     Label(self, text=label).grid(row=row, column=column, sticky=E)
171     self.fields[name] = Text(self, height=1, state=DISABLED)
172     self.fields[name].grid(row=row, column=column + 1, sticky=W+E);
173
174   def set(self, name, value):
175     # set a field's value
176     f = self.fields[name]
177     f.config(state=NORMAL)
178     f.delete(1.0, END)
179     f.insert(END, value)
180     f.config(state=DISABLED)
181
182   def playing(self, p):
183     # called with new what's-playing information
184     values = {}
185     if p:
186       for tpart in ['artist', 'album', 'title']:
187         values[tpart] = part(client, p['track'], 'display', tpart)
188       try:
189         self.length = client.length(p['track'])
190       except disorder.operationError:
191         self.length = 0
192       self.started = int(p['played'])
193     else:
194       self.length = 0
195     for k in self.fields.keys():
196       if k in values:
197         self.set(k, values[k])
198       else:
199         self.set(k, "")
200     self.length_bar()
201
202   def length_bar(self):
203     if self.length and self.length > 0:
204       self.p.update(time.time() - self.started, self.length)
205     else:
206       self.p.clear()
207
208   def update_length(self):
209     self.length_bar()
210     self.after(1000, self.update_length)
211
212   def poll(self, c):
213     p = c.playing()
214     if p != self.last:
215       intercom.put(lambda: self.playing(p))
216       self.last = p
217
218   def quit(self):
219     sys.exit(0)
220
221   def scratch(self):
222     client.scratch()
223
224   def recent_close(self):
225     self.recentw.destroy()
226     self.recentw = None
227
228   def recent(self):
229     if self.recentw:
230       self.recentw.deiconify()
231       self.recentw.lift()
232     else:
233       w = 80*tracklistFont.measure('A')
234       h = 40*tracklistFont.metrics("linespace")
235       self.recentw = Toplevel()
236       self.recentw.protocol("WM_DELETE_WINDOW", self.recent_close)
237       self.recentw.title("Recently Played")
238       # XXX for some reason Toplevel(width=w,height=h) doesn't seem to work
239       self.recentw.geometry("%dx%d" % (w,h))
240       w = RecentWidget(self.recentw)
241       w.pack(fill=BOTH, expand=1)
242       mst.add(w);
243
244 class TrackListWidget(Frame):
245   def __init__(self, master=None, **kw):
246     Frame.__init__(self, master, **kw)
247     self.yscrollbar = Scrollbar(self)
248     self.xscrollbar = Scrollbar(self, orient=HORIZONTAL)
249     self.canvas = Canvas(self,
250                          xscrollcommand=self.xscrollbar.set,
251                          yscrollcommand=self.yscrollbar.set)
252     self.xscrollbar.config(command=self.canvas.xview)
253     self.yscrollbar.config(command=self.canvas.yview)
254     self.canvas.grid(row=0, column=0, sticky=N+S+E+W)
255     self.yscrollbar.grid(row=0, column=1, sticky=N+S)
256     self.xscrollbar.grid(row=1, column=0, sticky=E+W)
257     self.columnconfigure(0,weight=1)
258     self.rowconfigure(0,weight=1)
259     self.last = None
260     self.default_cursor = self['cursor']
261     self.configure(cursor="watch")
262
263   def queue(self, q, w_artists, w_albums, w_titles, artists, albums, titles):
264     # called with new queue state
265     # delete old contents
266     try:
267       for i in self.canvas.find_all():
268         self.canvas.delete(i)
269     except TclError:
270       # if the call was queued but not received before the window was deleted
271       # we might get an error from Tcl/Tk, which no longer knows the window,
272       # here
273       return
274     w = tracklistHFont.measure("Artist")
275     if w > w_artists:
276       w_artists = w
277     w = tracklistHFont.measure("Album")
278     if w > w_albums:
279       w_albums = w
280     w = tracklistHFont.measure("Title")
281     if w > w_titles:
282       w_titles = w
283     hheading = tracklistHFont.metrics("linespace")
284     h = tracklistFont.metrics('linespace')
285     x_artist = 8
286     x_album = x_artist + w_artists + 16
287     x_title = x_album + w_albums + 16
288     w = x_title + w_titles + 8
289     self.canvas['scrollregion'] = (0, 0, w, h * len(artists) + hheading)
290     self.canvas.create_text(x_artist, 0, text="Artist",
291                             font=tracklistHFont,
292                             anchor='nw')
293     self.canvas.create_text(x_album, 0, text="Album",
294                             font=tracklistHFont,
295                             anchor='nw')
296     self.canvas.create_text(x_title, 0, text="Title",
297                             font=tracklistHFont,
298                             anchor='nw')
299     y = hheading
300     for n in range(0,len(artists)):
301       artist = artists[n]
302       album = albums[n]
303       title = titles[n]
304       if artist != "":
305         self.canvas.create_text(x_artist, y, text=artist,
306                                 font=tracklistFont,
307                                 anchor='nw')
308       if album != "":
309         self.canvas.create_text(x_album, y, text=album,
310                                 font=tracklistFont,
311                                 anchor='nw')
312       if title != "":
313         self.canvas.create_text(x_title, y, text=title,
314                                 font=tracklistFont,
315                                 anchor='nw')
316       y += h
317     self.last = q
318     self.configure(cursor=self.default_cursor)
319
320   def poll(self, c):
321     q = self.getqueue(c)
322     if q != self.last:
323       # we do the track name calculation in the background thread so that
324       # the gui can still be responsive
325       artists = []
326       albums = []
327       titles = []
328       w_artists = w_albums = w_titles = 16
329       for t in q:
330         artist = part(c, t['track'], 'display', 'artist')
331         album = part(c, t['track'], 'display', 'album')
332         title = part(c, t['track'], 'display', 'title')
333         w = tracklistFont.measure(artist)
334         if w > w_artists:
335           w_artists = w
336         w = tracklistFont.measure(album)
337         if w > w_albums:
338           w_albums = w
339         w = tracklistFont.measure(title)
340         if w > w_titles:
341           w_titles = w
342         artists.append(artist)
343         albums.append(album)
344         titles.append(title)
345       intercom.put(lambda: self.queue(q, w_artists, w_albums, w_titles,
346                                       artists, albums, titles))
347       self.last = q
348
349 class QueueWidget(TrackListWidget):
350   def __init__(self, master=None, **kw):
351     TrackListWidget.__init__(self, master, **kw)
352
353   def getqueue(self, c):
354     return c.queue()
355
356 class RecentWidget(TrackListWidget):
357   def __init__(self, master=None, **kw):
358     TrackListWidget.__init__(self, master, **kw)
359
360   def getqueue(self, c):
361     l = c.recent()
362     l.reverse()
363     return l
364
365 class MonitorStateThread:
366   # thread to pick up current server state and publish it
367   #
368   # Creates a client and monitors it in a daemon thread for state changes.
369   # Whenever one occurs, call w.poll(c) for every member w of widgets with
370   # a client owned by the thread in which the call occurs.
371   def __init__(self, widgets, masterclient=None):
372     self.logclient = disorder.client()
373     self.client = disorder.client()
374     self.clientlock = threading.Lock()
375     if not masterclient:
376       masterclient = disorder.client()
377     self.masterclient = masterclient
378     self.widgets = widgets
379     self.lock = threading.Lock()
380     # the main thread
381     self.thread = threading.Thread(target=self.run)
382     self.thread.setDaemon(True)
383     self.thread.start()
384     # spare thread for processing additions
385     self.adderq = Queue.Queue()
386     self.adder = threading.Thread(target=self.runadder)
387     self.adder.setDaemon(True)
388     self.adder.start()
389
390   def notify(self, line):
391     self.lock.acquire()
392     widgets = self.widgets
393     self.lock.release()
394     for w in widgets:
395       self.clientlock.acquire()
396       w.poll(self.client)
397       self.clientlock.release()
398     return 1
399
400   def add(self, w):
401     self.lock.acquire()
402     self.widgets.append(w)
403     self.lock.release()
404     self.adderq.put(lambda client: w.poll(client))
405
406   def remove(self, what):
407     self.lock.acquire()
408     self.widgets.remove(what)    
409     self.lock.release()
410     
411   def run(self):
412     self.notify("")
413     self.logclient.log(lambda client, line: self.notify(line))
414
415   def runadder(self):
416     while True:
417       item = self.adderq.get()
418       self.clientlock.acquire()
419       item(self.client)
420       self.clientlock.release()
421
422 ########################################################################
423
424 def usage(s):
425   # display usage on S
426   s.write(
427     """Usage:
428
429   tkdisorder [OPTIONS]
430
431 Options:
432
433   -h, --help         Display this message
434   -V, --version      Display version number
435
436 tkdisorder is copyright (c) 2004, 2005 Richard Kettlewell.
437 """)
438
439 ########################################################################
440
441 try:
442   opts, rest = getopt.getopt(sys.argv[1:], "Vh", ["version", "help"])
443 except getopt.GetoptError, e:
444   sys.stderr.write("ERROR: %s, try --help for help\n" % e.msg)
445   sys.exit(1)
446 for o, v in opts:
447   if o in ('-V', '--version'):
448     print "%s" % disorder.version
449     sys.stdout.close()
450     sys.exit(0)
451   if o in ('h', '--help'):
452     usage(sys.stdout)
453     sys.stdout.close()
454     sys.exit(0)
455
456 client = disorder.client()              # master thread's client
457
458 root = Tk()
459 root.title("DisOrder")
460
461 tracklistFont = tkFont.Font(family='Helvetica', size=10)
462 tracklistHFont = tracklistFont.copy()
463 tracklistHFont.config(weight="bold")
464
465 p = PlayingWidget(root)
466 p.pack(fill=BOTH, expand=1)
467
468 q = QueueWidget(root)
469 q.pack(fill=BOTH, expand=1)
470
471 intercom = Intercom(root)               # only need a single intercom
472 mst = MonitorStateThread([p, q], client)
473
474 root.mainloop()
475
476 # Local Variables:
477 # py-indent-offset:2
478 # comment-column:40
479 # fill-column:79
480 # End: