source: trunk/StreamVision/StreamVision.py@ 653

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

AudioDevicemodule.c: Determine if the current output device is
AirPlay.

setup.py: Compile AudioDevice module.

StreamVision.py: Don't turn off the stereo if the current output
device is AirPlay.

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