source: trunk/StreamVision/StreamVision.py@ 663

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

StreamVision.py: Remove terminology workaround, no longer necessary
for current iTunes 10 or 11. Use new Apple Event exposed AirPlay
queries in iTunes 11.0.3 if available.

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