Commit | Line | Data |
---|---|---|
460b9539 | 1 | #! /usr/bin/env python |
2 | # | |
3 | # Copyright (C) 2004, 2005 Richard Kettlewell | |
4 | # | |
e7eb3a27 | 5 | # This program is free software: you can redistribute it and/or modify |
460b9539 | 6 | # it under the terms of the GNU General Public License as published by |
e7eb3a27 | 7 | # the Free Software Foundation, either version 3 of the License, or |
460b9539 | 8 | # (at your option) any later version. |
e7eb3a27 RK |
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 | # | |
460b9539 | 15 | # You should have received a copy of the GNU General Public License |
e7eb3a27 | 16 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
460b9539 | 17 | # |
18 | ||
ffaf09ca RK |
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 | ||
460b9539 | 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: |