source: trunk/StreamVision/StreamVision.py@ 663

Last change on this file since 663 was 663, checked in by Nicholas Riley, 11 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
RevLine 
[188]1#!/usr/bin/pythonw
2# -*- coding: utf-8 -*-
3
[232]4from appscript import app, k, its, CommandError
[300]5from AppKit import NSApplication, NSApplicationDefined, NSBeep, NSSystemDefined, NSURL, NSWorkspace
[486]6from Foundation import NSDistributedNotificationCenter, NSSearchPathForDirectoriesInDomains, NSCachesDirectory, NSUserDomainMask
[188]7from PyObjCTools import AppHelper
8from Carbon.CarbonEvt import RegisterEventHotKey, GetApplicationEventTarget
[232]9from Carbon.Events import cmdKey, shiftKey, controlKey
[654]10from AudioDevice import default_output_device_is_airplay, set_default_output_device_changed_callback
[486]11import httplib2
12import os
[188]13import struct
14import scrape
[211]15import HotKey
[188]16
17GROWL_APP_NAME = 'StreamVision'
18NOTIFICATION_TRACK_INFO = 'iTunes Track Info'
19NOTIFICATIONS_ALL = [NOTIFICATION_TRACK_INFO]
20
21kEventHotKeyPressedSubtype = 6
22kEventHotKeyReleasedSubtype = 9
23
[300]24kHIDUsage_Csmr_ScanNextTrack = 0xB5
25kHIDUsage_Csmr_ScanPreviousTrack = 0xB6
26kHIDUsage_Csmr_PlayOrPause = 0xCD
27
[340]28def growlRegister():
29 global growl
30 growl = app(id='com.Growl.GrowlHelperApp')
[188]31
[340]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
[188]38
39def growlNotify(title, description, **kw):
[340]40 try:
[656]41 if usingStereo:
42 description += '\n(AirPlay)'
43
[340]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)
[195]53
[188]54def radioParadiseURL():
55 session = scrape.Session()
[645]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
[188]61
[194]62def cleanStreamTitle(title):
[316]63 if title == k.missing_value:
[194]64 return ''
65 title = title.split(' [')[0] # XXX move to description
[341]66 try: # incorrectly encoded?
67 title = title.encode('iso-8859-1').decode('utf-8')
[459]68 except (UnicodeDecodeError, UnicodeEncodeError):
[341]69 pass
[194]70 title = title.replace('`', u'’')
[323]71 return title
[195]72
[194]73def cleanStreamTrackName(name):
[188]74 name = name.split('. ')[0]
75 name = name.split(': ')[0]
[190]76 name = name.split(' - ')
77 if len(name) > 1:
78 name = ' - '.join(name[:-1])
79 else:
80 name = name[0]
[188]81 return name
[195]82
[663]83def iTunesApp(): return app(id='com.apple.iTunes')
[199]84def XTensionApp(): return app(creator='SHEx')
[414]85def AmuaApp(): return app('Amua.app')
[188]86
[199]87HAVE_XTENSION = False
88try:
89 XTensionApp()
90 HAVE_XTENSION = True
91except:
92 pass
93
[414]94HAVE_AMUA = False
95try:
96 AmuaApp()
97 HAVE_AMUA = True
98except:
99 pass
100
[324]101needsStereoPowerOn = HAVE_XTENSION
[656]102usingStereo = False
[324]103
[580]104def mayUseStereo():
105 if not HAVE_XTENSION:
106 return False
[663]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
[580]115 systemEvents = app(id='com.apple.systemEvents')
[632]116 iTunesWindow = systemEvents.application_processes[u'iTunes'].windows[u'iTunes']
[646]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?
[658]121 return usingStereo
[638]122 return remote_speakers and remote_speakers[0] not in (None, k.missing_value)
[580]123
[656]124def turnStereoOnOrOff():
125 global needsStereoPowerOn, usingStereo
126 usingStereo = False
[655]127 if not default_output_device_is_airplay() and not mayUseStereo():
[631]128 if HAVE_XTENSION and XTensionApp().status('Stereo'):
129 XTensionApp().turnoff('Stereo')
[580]130 return
131 if not XTensionApp().status('Stereo'):
132 XTensionApp().turnon('Stereo')
[656]133 usingStereo = True
[580]134 needsStereoPowerOn = False
135
136def turnStereoOff():
[656]137 global needsStereoPowerOn, usingStereo
138 usingStereo = False
[653]139 if default_output_device_is_airplay() or not mayUseStereo():
[580]140 return
141 if not needsStereoPowerOn and XTensionApp().status('Stereo'):
142 XTensionApp().turnoff('Stereo')
143 needsStereoPowerOn = True
144
[414]145def amuaPlaying():
146 if not HAVE_AMUA:
147 return False
148 return AmuaApp().is_playing()
149
[486]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
[188]172class StreamVision(NSApplication):
173
174 hotKeyActions = {}
175 hotKeys = []
176
[414]177 def displayTrackInfo(self, playerInfo=None):
[188]178 iTunes = iTunesApp()
[195]179
[497]180 try:
181 trackClass = iTunes.current_track.class_()
182 except CommandError:
183 trackClass = k.property
184
[211]185 trackName = ''
[315]186 if trackClass != k.property:
187 trackName = iTunes.current_track.name()
[211]188
[647]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)
[580]196 turnStereoOff()
[211]197 return
[656]198 turnStereoOnOrOff()
[211]199 if trackClass == k.URL_track:
[414]200 if amuaPlaying():
201 if playerInfo is None: # Amua displays it itself
202 AmuaApp().display_song_information()
203 return
[486]204 url = iTunes.current_stream_URL()
205 kw = {}
206 if url != k.missing_value and url.endswith('.jpg'):
[650]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
[315]216 growlNotify(cleanStreamTitle(iTunes.current_stream_title()),
[486]217 cleanStreamTrackName(trackName), **kw)
[211]218 return
[315]219 if trackClass == k.property:
[324]220 growlNotify('iTunes is playing.', '')
221 return
[211]222 kw = {}
223 # XXX iTunes doesn't let you get artwork for shared tracks
224 if trackClass != k.shared_track:
[315]225 artwork = iTunes.current_track.artworks()
[211]226 if artwork:
[487]227 try:
[636]228 kw['pictImage'] = artwork[0].data_()
[487]229 except CommandError:
230 pass
[211]231 growlNotify(trackName + ' ' +
[323]232 u'★' * (iTunes.current_track.rating() / 20),
233 iTunes.current_track.album() + '\n' +
[315]234 iTunes.current_track.artist(),
[211]235 **kw)
236
[656]237 def defaultOutputDeviceChanged(self):
238 turnStereoOnOrOff()
239 self.displayTrackInfo()
240
[188]241 def goToSite(self):
242 iTunes = iTunesApp()
[315]243 if iTunes.player_state() == k.playing:
[414]244 if amuaPlaying():
245 AmuaApp().display_album_details()
246 return
[315]247 url = iTunes.current_stream_URL()
[316]248 if url != k.missing_value:
[252]249 if 'radioparadise.com' in url and 'review' not in url:
[188]250 url = radioParadiseURL()
251 NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
252 return
253 NSBeep()
[195]254
[188]255 def registerHotKey(self, func, keyCode, mods=0):
256 hotKeyRef = RegisterEventHotKey(keyCode, mods, (0, 0),
257 GetApplicationEventTarget(), 0)
258 self.hotKeys.append(hotKeyRef)
[211]259 self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)] = func
[235]260 return hotKeyRef
[188]261
[235]262 def unregisterHotKey(self, hotKeyRef):
263 self.hotKeys.remove(hotKeyRef)
264 del self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)]
265 hotKeyRef.UnregisterEventHotKey()
266
[199]267 def incrementRatingBy(self, increment):
268 iTunes = iTunesApp()
[414]269 if amuaPlaying():
[415]270 if increment < 0:
[414]271 AmuaApp().ban_song()
[458]272 growlNotify('Banned song.', '', icon_of_application='Amua.app')
[414]273 else:
274 AmuaApp().love_song()
[458]275 growlNotify('Loved song.', '', icon_of_application='Amua.app')
[414]276 return
[315]277 rating = iTunes.current_track.rating()
[199]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
[300]287 def playPause(self, useStereo=True):
[324]288 global needsStereoPowerOn
289
[199]290 iTunes = iTunesApp()
[315]291 was_playing = (iTunes.player_state() == k.playing)
[324]292 if not useStereo:
293 needsStereoPowerOn = False
[414]294 if was_playing and amuaPlaying():
295 AmuaApp().stop()
296 else:
297 iTunes.playpause()
[315]298 if not was_playing and iTunes.player_state() == k.stopped:
[234]299 # most likely, we're focused on the iPod, so playing does nothing
[315]300 iTunes.browser_windows[1].view.set(iTunes.user_playlists[its.name=='Stations'][1]())
[234]301 iTunes.play()
[580]302 if not useStereo:
303 return
304 if iTunes.player_state() == k.playing:
[656]305 turnStereoOnOrOff()
[580]306 else:
307 turnStereoOff()
[235]308
[301]309 def playPauseFront(self):
310 systemEvents = app(id='com.apple.systemEvents')
[340]311 frontName = systemEvents.processes[its.frontmost == True][1].name()
[301]312 if frontName == 'RealPlayer':
313 realPlayer = app(id='com.RealNetworks.RealPlayer')
[315]314 if len(realPlayer.players()) > 0:
315 if realPlayer.players[1].state() == k.playing:
316 realPlayer.pause()
317 else:
318 realPlayer.play()
319 return
[301]320 elif frontName == 'VLC':
321 app(id='org.videolan.vlc').play() # equivalent to playpause
322 else:
[302]323 self.playPause(useStereo=False)
[301]324
[414]325 def nextTrack(self):
326 if amuaPlaying():
327 AmuaApp().skip_song()
328 return
329 iTunesApp().next_track()
330
[235]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
[232]338 def zoomWindow(self):
[313]339 # XXX detect if "enable access for assistive devices" needs to be enabled
[232]340 systemEvents = app(id='com.apple.systemEvents')
[340]341 frontName = systemEvents.processes[its.frontmost == True][1].name()
[233]342 if frontName == 'iTunes':
[232]343 systemEvents.processes['iTunes'].menu_bars[1]. \
344 menu_bar_items['Window'].menus.menu_items['Zoom'].click()
345 return
[631]346 elif frontName in ('X11', 'XQuartz', 'Emacs'): # preserve C-M-\
[235]347 self.unregisterZoomWindowHotKey()
348 systemEvents.key_code(42, using=[k.command_down, k.control_down])
349 self.registerZoomWindowHotKey()
350 return
[340]351 frontPID = systemEvents.processes[its.frontmost == True][1].unix_id()
[232]352 try:
[339]353 zoomed = app(pid=frontPID).windows[1].zoomed
[232]354 zoomed.set(not zoomed())
[235]355 except (CommandError, RuntimeError):
[313]356 systemEvents.processes[frontName].windows \
357 [its.subrole == 'AXStandardWindow'].windows[1]. \
358 buttons[its.subrole == 'AXZoomButton'].buttons[1].click()
[199]359
[188]360 def finishLaunching(self):
361 super(StreamVision, self).finishLaunching()
[486]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
[188]369 self.registerHotKey(self.displayTrackInfo, 100) # F8
370 self.registerHotKey(self.goToSite, 100, cmdKey) # cmd-F8
[199]371 self.registerHotKey(self.playPause, 101) # F9
[188]372 self.registerHotKey(lambda: iTunesApp().previous_track(), 109) # F10
[414]373 self.registerHotKey(self.nextTrack, 103) # F11
[211]374 self.registerHotKey(lambda: self.incrementRatingBy(-20), 109, shiftKey) # shift-F10
375 self.registerHotKey(lambda: self.incrementRatingBy(20), 103, shiftKey) # shift-F11
[235]376 self.registerZoomWindowHotKey()
[193]377 NSDistributedNotificationCenter.defaultCenter().addObserver_selector_name_object_(self, self.displayTrackInfo, 'com.apple.iTunes.playerInfo', None)
[300]378 try:
[302]379 import HIDRemote
[300]380 HIDRemote.connect()
[302]381 except ImportError:
382 print "failed to import HIDRemote (XXX fix - on Intel)"
[300]383 except OSError, e:
384 print "failed to connect to remote: ", e
[188]385
[656]386 set_default_output_device_changed_callback(
387 self.defaultOutputDeviceChanged)
388 turnStereoOnOrOff()
[654]389
[188]390 def sendEvent_(self, theEvent):
[300]391 eventType = theEvent.type()
392 if eventType == NSSystemDefined and \
[188]393 theEvent.subtype() == kEventHotKeyPressedSubtype:
394 self.hotKeyActions[theEvent.data1()]()
[300]395 elif eventType == NSApplicationDefined:
396 key = theEvent.data1()
397 if key == kHIDUsage_Csmr_ScanNextTrack:
[415]398 self.nextTrack()
[300]399 elif key == kHIDUsage_Csmr_ScanPreviousTrack:
400 iTunesApp().previous_track()
401 elif key == kHIDUsage_Csmr_PlayOrPause:
[301]402 self.playPauseFront()
[188]403 super(StreamVision, self).sendEvent_(theEvent)
404
405if __name__ == "__main__":
[340]406 growlRegister()
[195]407 AppHelper.runEventLoop()
[339]408 try:
409 HIDRemote.disconnect()
410 except:
411 pass
Note: See TracBrowser for help on using the repository browser.