460b9539 |
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: |