source: trunk/StreamVision/StreamVision.py@ 658

Last change on this file since 658 was 658, checked in by Nicholas Riley, 11 years ago

Persist usingStereo if we can't read status from iTunes.

Avoids switching on stereo when you're in another Space (or in
10.7+'s fullscreen).

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