chiark / gitweb /
leave a TODO relating to revno 78
[disorder] / python / tkdisorder
CommitLineData
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
23from Tkinter import *
24import tkFont
25import Queue
26import threading
27import disorder
28import time
29import string
30import re
31import getopt
32import 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
56class 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
79class 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.
126part_cache = {}
127def 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
135class 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
238class 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
343class 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
350class 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
359class 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
418def usage(s):
419 # display usage on S
420 s.write(
421 """Usage:
422
423 tkdisorder [OPTIONS]
424
425Options:
426
427 -h, --help Display this message
428 -V, --version Display version number
429
430tkdisorder is copyright (c) 2004, 2005 Richard Kettlewell.
431""")
432
433########################################################################
434
435try:
436 opts, rest = getopt.getopt(sys.argv[1:], "Vh", ["version", "help"])
437except getopt.GetoptError, e:
438 sys.stderr.write("ERROR: %s, try --help for help\n" % e.msg)
439 sys.exit(1)
440for 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
450client = disorder.client() # master thread's client
451
452root = Tk()
453root.title("DisOrder")
454
455tracklistFont = tkFont.Font(family='Helvetica', size=10)
456tracklistHFont = tracklistFont.copy()
457tracklistHFont.config(weight="bold")
458
459p = PlayingWidget(root)
460p.pack(fill=BOTH, expand=1)
461
462q = QueueWidget(root)
463q.pack(fill=BOTH, expand=1)
464
465intercom = Intercom(root) # only need a single intercom
466mst = MonitorStateThread([p, q], client)
467
468root.mainloop()
469
470# Local Variables:
471# py-indent-offset:2
472# comment-column:40
473# fill-column:79
474# End: