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