source: trunk/StreamVision/StreamVision.py@ 643

Last change on this file since 643 was 643, checked in by Nicholas Riley, 12 years ago

StreamVision.py: iTunes 10.6 AirPlay button description is of the form "AirPlay[, speakers]".

File size: 13.1 KB
Line 
1#!/usr/bin/pythonw
2# -*- coding: utf-8 -*-
3
4from appscript import app, k, its, CommandError
5from AppKit import NSApplication, NSApplicationDefined, NSBeep, NSSystemDefined, NSURL, NSWorkspace
6from Foundation import NSDistributedNotificationCenter, NSSearchPathForDirectoriesInDomains, NSCachesDirectory, NSUserDomainMask
7from PyObjCTools import AppHelper
8from Carbon.CarbonEvt import RegisterEventHotKey, GetApplicationEventTarget
9from Carbon.Events import cmdKey, shiftKey, controlKey
10import httplib2
11import os
12import struct
13import scrape
14import HotKey
15
16GROWL_APP_NAME = 'StreamVision'
17NOTIFICATION_TRACK_INFO = 'iTunes Track Info'
18NOTIFICATIONS_ALL = [NOTIFICATION_TRACK_INFO]
19
20kEventHotKeyPressedSubtype = 6
21kEventHotKeyReleasedSubtype = 9
22
23kHIDUsage_Csmr_ScanNextTrack = 0xB5
24kHIDUsage_Csmr_ScanPreviousTrack = 0xB6
25kHIDUsage_Csmr_PlayOrPause = 0xCD
26
27def growlRegister():
28 global growl
29 growl = app(id='com.Growl.GrowlHelperApp')
30
31 growl.register(
32 as_application=GROWL_APP_NAME,
33 all_notifications=NOTIFICATIONS_ALL,
34 default_notifications=NOTIFICATIONS_ALL,
35 icon_of_application='iTunes.app')
36 # if we leave off the .app, we can get Classic iTunes's icon
37
38def growlNotify(title, description, **kw):
39 try:
40 growl.notify(
41 with_name=NOTIFICATION_TRACK_INFO,
42 title=title,
43 description=description,
44 application_name=GROWL_APP_NAME,
45 **kw)
46 except CommandError:
47 growlRegister()
48 growlNotify(title, description, **kw)
49
50def radioParadiseURL():
51 # XXX better to use http://www2.radioparadise.com/playlist.xml ?
52 session = scrape.Session()
53 session.go('http://www2.radioparadise.com/nowplay_b.php')
54 return session.region.firsttag('a')['href']
55
56def cleanStreamTitle(title):
57 if title == k.missing_value:
58 return ''
59 title = title.split(' [')[0] # XXX move to description
60 try: # incorrectly encoded?
61 title = title.encode('iso-8859-1').decode('utf-8')
62 except (UnicodeDecodeError, UnicodeEncodeError):
63 pass
64 title = title.replace('`', u'’')
65 return title
66
67def cleanStreamTrackName(name):
68 name = name.split('. ')[0]
69 name = name.split(': ')[0]
70 name = name.split(' - ')
71 if len(name) > 1:
72 name = ' - '.join(name[:-1])
73 else:
74 name = name[0]
75 return name
76
77def iTunesApp(): return app(id='com.apple.iTunes')
78def XTensionApp(): return app(creator='SHEx')
79def AmuaApp(): return app('Amua.app')
80
81HAVE_XTENSION = False
82try:
83 XTensionApp()
84 HAVE_XTENSION = True
85except:
86 pass
87
88HAVE_AMUA = False
89try:
90 AmuaApp()
91 HAVE_AMUA = True
92except:
93 pass
94
95needsStereoPowerOn = HAVE_XTENSION
96
97def mayUseStereo():
98 if not HAVE_XTENSION:
99 return False
100 systemEvents = app(id='com.apple.systemEvents')
101 iTunesWindow = systemEvents.application_processes[u'iTunes'].windows[u'iTunes']
102 try:
103 remote_speakers = iTunesWindow.buttons[its.attributes['AXDescription'].value.beginswith(u'AirPlay')].title()
104 except CommandError:
105 # with iTunes Store visible, the query fails with errAENoSuchObject
106 for button in iTunesWindow.buttons():
107 try:
108 if button.attributes['AXDescription'].value().endswith('AirPlay'):
109 remote_speakers = [button.title()]
110 break
111 except CommandError:
112 pass
113 else:
114 return False # XXX shouldn't get here
115 return remote_speakers and remote_speakers[0] not in (None, k.missing_value)
116
117def turnStereoOn():
118 global needsStereoPowerOn
119 if not mayUseStereo():
120 if HAVE_XTENSION and XTensionApp().status('Stereo'):
121 XTensionApp().turnoff('Stereo')
122 return
123 if not XTensionApp().status('Stereo'):
124 XTensionApp().turnon('Stereo')
125 needsStereoPowerOn = False
126
127def turnStereoOff():
128 global needsStereoPowerOn
129 if not mayUseStereo():
130 return
131 if not needsStereoPowerOn and XTensionApp().status('Stereo'):
132 XTensionApp().turnoff('Stereo')
133 needsStereoPowerOn = True
134
135def amuaPlaying():
136 if not HAVE_AMUA:
137 return False
138 return AmuaApp().is_playing()
139
140class OneFileCache(object):
141 __slots__ = ('key', 'cache')
142
143 def __init__(self, cache):
144 if not os.path.exists(cache):
145 os.makedirs(cache)
146 self.cache = os.path.join(cache, 'file')
147 self.key = None
148
149 def get(self, key):
150 if key == self.key:
151 return file(self.cache, 'r').read()
152
153 def set(self, key, value):
154 self.key = key
155 file(self.cache, 'w').write(value)
156
157 def delete(self, key):
158 if key == self.key:
159 self.key = None
160 os.remove(cache)
161
162class StreamVision(NSApplication):
163
164 hotKeyActions = {}
165 hotKeys = []
166
167 def displayTrackInfo(self, playerInfo=None):
168 iTunes = iTunesApp()
169
170 try:
171 trackClass = iTunes.current_track.class_()
172 except CommandError:
173 trackClass = k.property
174
175 trackName = ''
176 if trackClass != k.property:
177 trackName = iTunes.current_track.name()
178
179 if iTunes.player_state() != k.playing:
180 growlNotify('iTunes is not playing.', trackName)
181 turnStereoOff()
182 return
183 turnStereoOn()
184 if trackClass == k.URL_track:
185 if amuaPlaying():
186 if playerInfo is None: # Amua displays it itself
187 AmuaApp().display_song_information()
188 return
189 url = iTunes.current_stream_URL()
190 kw = {}
191 if url != k.missing_value and url.endswith('.jpg'):
192 response, content = self.http.request(url)
193 if response['content-type'].startswith('image/'):
194 file(self.imagePath, 'w').write(content)
195 kw['image_from_location'] = self.imagePath
196 growlNotify(cleanStreamTitle(iTunes.current_stream_title()),
197 cleanStreamTrackName(trackName), **kw)
198 return
199 if trackClass == k.property:
200 growlNotify('iTunes is playing.', '')
201 return
202 kw = {}
203 # XXX iTunes doesn't let you get artwork for shared tracks
204 if trackClass != k.shared_track:
205 artwork = iTunes.current_track.artworks()
206 if artwork:
207 try:
208 kw['pictImage'] = artwork[0].data_()
209 except CommandError:
210 pass
211 growlNotify(trackName + ' ' +
212 u'★' * (iTunes.current_track.rating() / 20),
213 iTunes.current_track.album() + '\n' +
214 iTunes.current_track.artist(),
215 **kw)
216
217 def goToSite(self):
218 iTunes = iTunesApp()
219 if iTunes.player_state() == k.playing:
220 if amuaPlaying():
221 AmuaApp().display_album_details()
222 return
223 url = iTunes.current_stream_URL()
224 if url != k.missing_value:
225 if 'radioparadise.com' in url and 'review' not in url:
226 url = radioParadiseURL()
227 NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
228 return
229 NSBeep()
230
231 def registerHotKey(self, func, keyCode, mods=0):
232 hotKeyRef = RegisterEventHotKey(keyCode, mods, (0, 0),
233 GetApplicationEventTarget(), 0)
234 self.hotKeys.append(hotKeyRef)
235 self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)] = func
236 return hotKeyRef
237
238 def unregisterHotKey(self, hotKeyRef):
239 self.hotKeys.remove(hotKeyRef)
240 del self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)]
241 hotKeyRef.UnregisterEventHotKey()
242
243 def incrementRatingBy(self, increment):
244 iTunes = iTunesApp()
245 if amuaPlaying():
246 if increment < 0:
247 AmuaApp().ban_song()
248 growlNotify('Banned song.', '', icon_of_application='Amua.app')
249 else:
250 AmuaApp().love_song()
251 growlNotify('Loved song.', '', icon_of_application='Amua.app')
252 return
253 rating = iTunes.current_track.rating()
254 rating += increment
255 if rating < 0:
256 rating = 0
257 NSBeep()
258 elif rating > 100:
259 rating = 100
260 NSBeep()
261 iTunes.current_track.rating.set(rating)
262
263 def playPause(self, useStereo=True):
264 global needsStereoPowerOn
265
266 iTunes = iTunesApp()
267 was_playing = (iTunes.player_state() == k.playing)
268 if not useStereo:
269 needsStereoPowerOn = False
270 if was_playing and amuaPlaying():
271 AmuaApp().stop()
272 else:
273 iTunes.playpause()
274 if not was_playing and iTunes.player_state() == k.stopped:
275 # most likely, we're focused on the iPod, so playing does nothing
276 iTunes.browser_windows[1].view.set(iTunes.user_playlists[its.name=='Stations'][1]())
277 iTunes.play()
278 if not useStereo:
279 return
280 if iTunes.player_state() == k.playing:
281 turnStereoOn()
282 else:
283 turnStereoOff()
284
285 def playPauseFront(self):
286 systemEvents = app(id='com.apple.systemEvents')
287 frontName = systemEvents.processes[its.frontmost == True][1].name()
288 if frontName == 'RealPlayer':
289 realPlayer = app(id='com.RealNetworks.RealPlayer')
290 if len(realPlayer.players()) > 0:
291 if realPlayer.players[1].state() == k.playing:
292 realPlayer.pause()
293 else:
294 realPlayer.play()
295 return
296 elif frontName == 'VLC':
297 app(id='org.videolan.vlc').play() # equivalent to playpause
298 else:
299 self.playPause(useStereo=False)
300
301 def nextTrack(self):
302 if amuaPlaying():
303 AmuaApp().skip_song()
304 return
305 iTunesApp().next_track()
306
307 def registerZoomWindowHotKey(self):
308 self.zoomWindowHotKey = self.registerHotKey(self.zoomWindow, 42, cmdKey | controlKey) # cmd-ctrl-\
309
310 def unregisterZoomWindowHotKey(self):
311 self.unregisterHotKey(self.zoomWindowHotKey)
312 self.zoomWindowHotKey = None
313
314 def zoomWindow(self):
315 # XXX detect if "enable access for assistive devices" needs to be enabled
316 systemEvents = app(id='com.apple.systemEvents')
317 frontName = systemEvents.processes[its.frontmost == True][1].name()
318 if frontName == 'iTunes':
319 systemEvents.processes['iTunes'].menu_bars[1]. \
320 menu_bar_items['Window'].menus.menu_items['Zoom'].click()
321 return
322 elif frontName in ('X11', 'XQuartz', 'Emacs'): # preserve C-M-\
323 self.unregisterZoomWindowHotKey()
324 systemEvents.key_code(42, using=[k.command_down, k.control_down])
325 self.registerZoomWindowHotKey()
326 return
327 frontPID = systemEvents.processes[its.frontmost == True][1].unix_id()
328 try:
329 zoomed = app(pid=frontPID).windows[1].zoomed
330 zoomed.set(not zoomed())
331 except (CommandError, RuntimeError):
332 systemEvents.processes[frontName].windows \
333 [its.subrole == 'AXStandardWindow'].windows[1]. \
334 buttons[its.subrole == 'AXZoomButton'].buttons[1].click()
335
336 def finishLaunching(self):
337 super(StreamVision, self).finishLaunching()
338
339 caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
340 NSUserDomainMask, True)[0]
341 cache = os.path.join(caches, 'StreamVision')
342 self.http = httplib2.Http(OneFileCache(cache), 5)
343 self.imagePath = os.path.join(cache, 'image')
344
345 self.registerHotKey(self.displayTrackInfo, 100) # F8
346 self.registerHotKey(self.goToSite, 100, cmdKey) # cmd-F8
347 self.registerHotKey(self.playPause, 101) # F9
348 self.registerHotKey(lambda: iTunesApp().previous_track(), 109) # F10
349 self.registerHotKey(self.nextTrack, 103) # F11
350 self.registerHotKey(lambda: self.incrementRatingBy(-20), 109, shiftKey) # shift-F10
351 self.registerHotKey(lambda: self.incrementRatingBy(20), 103, shiftKey) # shift-F11
352 self.registerZoomWindowHotKey()
353 NSDistributedNotificationCenter.defaultCenter().addObserver_selector_name_object_(self, self.displayTrackInfo, 'com.apple.iTunes.playerInfo', None)
354 try:
355 import HIDRemote
356 HIDRemote.connect()
357 except ImportError:
358 print "failed to import HIDRemote (XXX fix - on Intel)"
359 except OSError, e:
360 print "failed to connect to remote: ", e
361
362 def sendEvent_(self, theEvent):
363 eventType = theEvent.type()
364 if eventType == NSSystemDefined and \
365 theEvent.subtype() == kEventHotKeyPressedSubtype:
366 self.hotKeyActions[theEvent.data1()]()
367 elif eventType == NSApplicationDefined:
368 key = theEvent.data1()
369 if key == kHIDUsage_Csmr_ScanNextTrack:
370 self.nextTrack()
371 elif key == kHIDUsage_Csmr_ScanPreviousTrack:
372 iTunesApp().previous_track()
373 elif key == kHIDUsage_Csmr_PlayOrPause:
374 self.playPauseFront()
375 super(StreamVision, self).sendEvent_(theEvent)
376
377if __name__ == "__main__":
378 growlRegister()
379 AppHelper.runEventLoop()
380 try:
381 HIDRemote.disconnect()
382 except:
383 pass
Note: See TracBrowser for help on using the repository browser.