source: trunk/StreamVision/StreamVision.py@ 670

Last change on this file since 670 was 670, checked in by Nicholas Riley, 10 years ago

StreamVision.py: Remove iTunes 11.1 AirPlay scripting bug workaround.

It's fixed as of iTunes 11.1.2.

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