source: trunk/StreamVision/StreamVision.py@ 672

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

StreamVision.py: Use notification userInfo for track information where possible.

Fixes several issues including StreamVision relaunching iTunes endlessly and no display of iTunes Store tracks.

Also (mostly) work around lack of track display when starting iTunes Radio station playback.

File size: 16.2 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
159def notifyTrackInfo(name, album=None, artist=None, rating=0, hasArtwork=False,
160 streamTitle=None, streamURL=None, playing=True, onChange=False):
161 if not playing:
162 growlNotify('iTunes is not playing.', name)
163 return
164 turnStereoOnOrOff()
165
166 if streamURL:
167 if amuaPlaying():
168 if onChange: # Amua displays it itself
169 AmuaApp().display_song_information()
170 return
171 kw = {}
172 if streamURL and streamURL.endswith('.jpg'):
173 try:
174 response, content = http.request(streamURL)
175 except Exception, e:
176 import sys
177 print >> sys.stderr, 'Request for album art failed:', e
178 else:
179 if response['content-type'].startswith('image/'):
180 kw['image'] = content
181 growlNotify(cleanStreamTitle(streamTitle),
182 cleanStreamTrackName(name), **kw)
183 return
184
185 if not name:
186 growlNotify('iTunes is playing.', '')
187 return
188
189 kw = {}
190 if hasArtwork:
191 try:
192 kw['image'] = iTunesApp().current_track.artworks[1].data_().data
193 except CommandError:
194 pass
195
196 growlNotify(name + ' ' + u'★' * (rating / 20),
197 (album or '') + '\n' + (artist or ''),
198 **kw)
199
200class OneFileCache(object):
201 __slots__ = ('key', 'cache')
202
203 def __init__(self, cache):
204 if not os.path.exists(cache):
205 os.makedirs(cache)
206 self.cache = os.path.join(cache, 'file')
207 self.key = None
208
209 def get(self, key):
210 if key == self.key:
211 return file(self.cache, 'r').read()
212
213 def set(self, key, value):
214 self.key = key
215 file(self.cache, 'w').write(value)
216
217 def delete(self, key):
218 if key == self.key:
219 self.key = None
220 os.remove(self.cache)
221
222class StreamVision(NSApplication):
223
224 hotKeyActions = {}
225 hotKeys = []
226
227 def playerInfoChanged(self, playerInfo):
228 infoDict = dict(playerInfo.userInfo())
229 trackName = infoDict.get('Name', '')
230 playerState = infoDict.get('Player State')
231 if playerState != 'Playing':
232 notifyTrackInfo(trackName, playing=False, onChange=True)
233 return
234 url = infoDict.get('Stream URL')
235 if url:
236 notifyTrackInfo(trackName, streamTitle=infoDict.get('Stream Title'),
237 streamURL=url, onChange=True)
238 return
239 artworkCount = int(infoDict.get('Artwork Count', 0))
240 # XXX When starting iTunes Radio station playback, we get 2 notifications,
241 # neither of which mention the track: the first has the station's artwork,
242 # the second doesn't. On the 2nd notification, wait 10 seconds and try again.
243 # By then, we should have artwork.
244 if not infoDict.has_key('Total Time') and artworkCount == 0:
245 self.performSelector_withObject_afterDelay_(self.displayTrackInfo, None, 10)
246 return
247 notifyTrackInfo(trackName, infoDict.get('Album'), infoDict.get('Artist'),
248 infoDict.get('Rating', 0), artworkCount > 0, onChange=True)
249
250 def displayTrackInfo(self):
251 iTunes = iTunesApp()
252
253 try:
254 trackClass = iTunes.current_track.class_()
255 except CommandError:
256 trackClass = k.property
257
258 trackName = ''
259 if trackClass != k.property:
260 trackName = iTunes.current_track.name()
261
262 try:
263 playerState = iTunes.player_state()
264 except CommandError:
265 playerState = None # probably iTunes quit
266 if playerState != k.playing:
267 notifyTrackInfo(trackName, playing=False)
268 return
269 if trackClass == k.URL_track:
270 url = iTunes.current_stream_URL()
271 if url == k.missing_value:
272 url = None
273 notifyTrackInfo(trackName, streamTitle=iTunes.current_stream_title(),
274 streamURL=url)
275 return
276 if trackClass == k.property:
277 notifyTrackInfo(None)
278 return
279 # XXX iTunes doesn't let you get artwork for shared tracks (still?)
280 notifyTrackInfo(trackName, iTunes.current_track.album(), iTunes.current_track.artist(),
281 iTunes.current_track.rating(), trackClass != k.shared_track)
282
283 def defaultOutputDeviceChanged(self):
284 turnStereoOnOrOff()
285 self.displayTrackInfo()
286
287 def goToSite(self):
288 iTunes = iTunesApp()
289 if iTunes.player_state() == k.playing:
290 if amuaPlaying():
291 AmuaApp().display_album_details()
292 return
293 url = iTunes.current_stream_URL()
294 if url != k.missing_value:
295 if 'radioparadise.com' in url and 'review' not in url:
296 url = radioParadiseURL()
297 NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
298 return
299 NSBeep()
300
301 def registerHotKey(self, func, keyCode, mods=0):
302 hotKeyRef = RegisterEventHotKey(keyCode, mods, (0, 0),
303 GetApplicationEventTarget(), 0)
304 self.hotKeys.append(hotKeyRef)
305 self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)] = func
306 return hotKeyRef
307
308 def unregisterHotKey(self, hotKeyRef):
309 self.hotKeys.remove(hotKeyRef)
310 del self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)]
311 hotKeyRef.UnregisterEventHotKey()
312
313 def incrementRatingBy(self, increment):
314 iTunes = iTunesApp()
315 if amuaPlaying():
316 if increment < 0:
317 AmuaApp().ban_song()
318 growlNotify('Banned song.', '', icon_of_application='Amua.app')
319 else:
320 AmuaApp().love_song()
321 growlNotify('Loved song.', '', icon_of_application='Amua.app')
322 return
323 rating = iTunes.current_track.rating()
324 rating += increment
325 if rating < 0:
326 rating = 0
327 NSBeep()
328 elif rating > 100:
329 rating = 100
330 NSBeep()
331 iTunes.current_track.rating.set(rating)
332
333 def playPause(self, useStereo=True):
334 global needsStereoPowerOn
335
336 iTunes = iTunesApp()
337 was_playing = (iTunes.player_state() == k.playing)
338 if not useStereo:
339 needsStereoPowerOn = False
340 if was_playing and amuaPlaying():
341 AmuaApp().stop()
342 else:
343 iTunes.playpause()
344 if not was_playing and iTunes.player_state() == k.stopped:
345 # most likely, we're focused on the iPod, so playing does nothing
346 iTunes.browser_windows[1].view.set(iTunes.user_playlists[its.name=='Stations'][1]())
347 iTunes.play()
348 if not useStereo:
349 return
350 if iTunes.player_state() == k.playing:
351 turnStereoOnOrOff()
352 else:
353 turnStereoOff()
354
355 def playPauseFront(self):
356 systemEvents = app(id='com.apple.systemEvents')
357 frontName = systemEvents.processes[its.frontmost == True][1].name()
358 if frontName == 'RealPlayer':
359 realPlayer = app(id='com.RealNetworks.RealPlayer')
360 if len(realPlayer.players()) > 0:
361 if realPlayer.players[1].state() == k.playing:
362 realPlayer.pause()
363 else:
364 realPlayer.play()
365 return
366 elif frontName == 'VLC':
367 app(id='org.videolan.vlc').play() # equivalent to playpause
368 else:
369 self.playPause(useStereo=False)
370
371 def nextTrack(self):
372 if amuaPlaying():
373 AmuaApp().skip_song()
374 return
375 iTunesApp().next_track()
376
377 def registerZoomWindowHotKey(self):
378 self.zoomWindowHotKey = self.registerHotKey(self.zoomWindow, 42, cmdKey | controlKey) # cmd-ctrl-\
379
380 def unregisterZoomWindowHotKey(self):
381 self.unregisterHotKey(self.zoomWindowHotKey)
382 self.zoomWindowHotKey = None
383
384 def zoomWindow(self):
385 # XXX detect if "enable access for assistive devices" needs to be enabled
386 systemEvents = app(id='com.apple.systemEvents')
387 frontName = systemEvents.processes[its.frontmost == True][1].name()
388 if frontName == 'iTunes':
389 systemEvents.processes['iTunes'].menu_bars[1]. \
390 menu_bar_items['Window'].menus.menu_items['Zoom'].click()
391 return
392 elif frontName in ('X11', 'XQuartz', 'Emacs'): # preserve C-M-\
393 self.unregisterZoomWindowHotKey()
394 systemEvents.key_code(42, using=[k.command_down, k.control_down])
395 self.registerZoomWindowHotKey()
396 return
397 frontPID = systemEvents.processes[its.frontmost == True][1].unix_id()
398 try:
399 zoomed = app(pid=frontPID).windows[1].zoomed
400 zoomed.set(not zoomed())
401 except (CommandError, RuntimeError):
402 systemEvents.processes[frontName].windows \
403 [its.subrole == 'AXStandardWindow'].windows[1]. \
404 buttons[its.subrole == 'AXZoomButton'].buttons[1].click()
405
406 def finishLaunching(self):
407 global http
408
409 super(StreamVision, self).finishLaunching()
410
411 caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
412 NSUserDomainMask, True)[0]
413 cache = os.path.join(caches, 'StreamVision')
414 http = httplib2.Http(OneFileCache(cache), 5)
415 self.imagePath = os.path.join(cache, 'image')
416
417 self.registerHotKey(self.displayTrackInfo, 100) # F8
418 self.registerHotKey(self.goToSite, 100, cmdKey) # cmd-F8
419 self.registerHotKey(self.playPause, 101) # F9
420 self.registerHotKey(lambda: iTunesApp().previous_track(), 109) # F10
421 self.registerHotKey(self.nextTrack, 103) # F11
422 self.registerHotKey(lambda: self.incrementRatingBy(-20), 109, shiftKey) # shift-F10
423 self.registerHotKey(lambda: self.incrementRatingBy(20), 103, shiftKey) # shift-F11
424 self.registerZoomWindowHotKey()
425
426 distributedNotificationCenter = NSDistributedNotificationCenter.defaultCenter()
427 distributedNotificationCenter.addObserver_selector_name_object_(self, self.playerInfoChanged, 'com.apple.iTunes.playerInfo', None)
428 distributedNotificationCenter.addObserver_selector_name_object_(self, self.terminate_, 'com.apple.logoutContinued', None)
429 try:
430 import HIDRemote
431 HIDRemote.connect()
432 except ImportError:
433 print "failed to import HIDRemote (XXX fix - on Intel)"
434 except OSError, e:
435 print "failed to connect to remote: ", e
436
437 set_default_output_device_changed_callback(
438 self.defaultOutputDeviceChanged)
439 turnStereoOnOrOff()
440
441 def sendEvent_(self, theEvent):
442 eventType = theEvent.type()
443 if eventType == NSSystemDefined and \
444 theEvent.subtype() == kEventHotKeyPressedSubtype:
445 self.hotKeyActions[theEvent.data1()]()
446 elif eventType == NSApplicationDefined:
447 key = theEvent.data1()
448 if key == kHIDUsage_Csmr_ScanNextTrack:
449 self.nextTrack()
450 elif key == kHIDUsage_Csmr_ScanPreviousTrack:
451 iTunesApp().previous_track()
452 elif key == kHIDUsage_Csmr_PlayOrPause:
453 self.playPauseFront()
454 super(StreamVision, self).sendEvent_(theEvent)
455
456if __name__ == "__main__":
457 growlRegister()
458 AppHelper.runEventLoop()
459 try:
460 HIDRemote.disconnect()
461 except:
462 pass
Note: See TracBrowser for help on using the repository browser.