source: trunk/StreamVision/StreamVision.py@ 647

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

StreamVision: Be resilient to iTunes quitting, finally.

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