Switch to GPL v3
[disorder] / python / tkdisorder
CommitLineData
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
27from Tkinter import *
28import tkFont
29import Queue
30import threading
31import disorder
32import time
33import string
34import re
35import getopt
36import 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
60class 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
83class 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.
130part_cache = {}
131def 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
139class 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
242class 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
347class 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
354class 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
363class 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
422def usage(s):
423 # display usage on S
424 s.write(
425 """Usage:
426
427 tkdisorder [OPTIONS]
428
429Options:
430
431 -h, --help Display this message
432 -V, --version Display version number
433
434tkdisorder is copyright (c) 2004, 2005 Richard Kettlewell.
435""")
436
437########################################################################
438
439try:
440 opts, rest = getopt.getopt(sys.argv[1:], "Vh", ["version", "help"])
441except getopt.GetoptError, e:
442 sys.stderr.write("ERROR: %s, try --help for help\n" % e.msg)
443 sys.exit(1)
444for 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
454client = disorder.client() # master thread's client
455
456root = Tk()
457root.title("DisOrder")
458
459tracklistFont = tkFont.Font(family='Helvetica', size=10)
460tracklistHFont = tracklistFont.copy()
461tracklistHFont.config(weight="bold")
462
463p = PlayingWidget(root)
464p.pack(fill=BOTH, expand=1)
465
466q = QueueWidget(root)
467q.pack(fill=BOTH, expand=1)
468
469intercom = Intercom(root) # only need a single intercom
470mst = MonitorStateThread([p, q], client)
471
472root.mainloop()
473
474# Local Variables:
475# py-indent-offset:2
476# comment-column:40
477# fill-column:79
478# End: