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