source: trunk/StreamVision/StreamVision.py@ 657

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

StreamVision.py: Display "(AirPlay)" in Growl notification if
outputting over AirPlay. Display notification when default output
device changes.

Useful to provide feedback from soundsource
(https://github.com/nriley/SoundSource), particularly given the issues
with my X10 interface not liking the winter.

File size: 13.9 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
[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:
[656]42 if usingStereo:
43 description += '\n(AirPlay)'
44
[340]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)
[195]54
[188]55def radioParadiseURL():
56 session = scrape.Session()
[645]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
[188]62
[194]63def cleanStreamTitle(title):
[316]64 if title == k.missing_value:
[194]65 return ''
66 title = title.split(' [')[0] # XXX move to description
[341]67 try: # incorrectly encoded?
68 title = title.encode('iso-8859-1').decode('utf-8')
[459]69 except (UnicodeDecodeError, UnicodeEncodeError):
[341]70 pass
[194]71 title = title.replace('`', u'’')
[323]72 return title
[195]73
[194]74def cleanStreamTrackName(name):
[188]75 name = name.split('. ')[0]
76 name = name.split(': ')[0]
[190]77 name = name.split(' - ')
78 if len(name) > 1:
79 name = ' - '.join(name[:-1])
80 else:
81 name = name[0]
[188]82 return name
[195]83
[649]84def iTunesApp(): return app(id='com.apple.iTunes', terms=tunes)
[199]85def XTensionApp(): return app(creator='SHEx')
[414]86def AmuaApp(): return app('Amua.app')
[188]87
[199]88HAVE_XTENSION = False
89try:
90 XTensionApp()
91 HAVE_XTENSION = True
92except:
93 pass
94
[414]95HAVE_AMUA = False
96try:
97 AmuaApp()
98 HAVE_AMUA = True
99except:
100 pass
101
[324]102needsStereoPowerOn = HAVE_XTENSION
[656]103usingStereo = False
[324]104
[580]105def mayUseStereo():
106 if not HAVE_XTENSION:
107 return False
108 systemEvents = app(id='com.apple.systemEvents')
[632]109 iTunesWindow = systemEvents.application_processes[u'iTunes'].windows[u'iTunes']
[646]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 True
[638]115 return remote_speakers and remote_speakers[0] not in (None, k.missing_value)
[580]116
[656]117def turnStereoOnOrOff():
118 global needsStereoPowerOn, usingStereo
119 usingStereo = False
[655]120 if not default_output_device_is_airplay() and not mayUseStereo():
[631]121 if HAVE_XTENSION and XTensionApp().status('Stereo'):
122 XTensionApp().turnoff('Stereo')
[580]123 return
124 if not XTensionApp().status('Stereo'):
125 XTensionApp().turnon('Stereo')
[656]126 usingStereo = True
[580]127 needsStereoPowerOn = False
128
129def turnStereoOff():
[656]130 global needsStereoPowerOn, usingStereo
131 usingStereo = False
[653]132 if default_output_device_is_airplay() or not mayUseStereo():
[580]133 return
134 if not needsStereoPowerOn and XTensionApp().status('Stereo'):
135 XTensionApp().turnoff('Stereo')
136 needsStereoPowerOn = True
137
[414]138def amuaPlaying():
139 if not HAVE_AMUA:
140 return False
141 return AmuaApp().is_playing()
142
[486]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
[188]165class StreamVision(NSApplication):
166
167 hotKeyActions = {}
168 hotKeys = []
169
[414]170 def displayTrackInfo(self, playerInfo=None):
[188]171 iTunes = iTunesApp()
[195]172
[497]173 try:
174 trackClass = iTunes.current_track.class_()
175 except CommandError:
176 trackClass = k.property
177
[211]178 trackName = ''
[315]179 if trackClass != k.property:
180 trackName = iTunes.current_track.name()
[211]181
[647]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)
[580]189 turnStereoOff()
[211]190 return
[656]191 turnStereoOnOrOff()
[211]192 if trackClass == k.URL_track:
[414]193 if amuaPlaying():
194 if playerInfo is None: # Amua displays it itself
195 AmuaApp().display_song_information()
196 return
[486]197 url = iTunes.current_stream_URL()
198 kw = {}
199 if url != k.missing_value and url.endswith('.jpg'):
[650]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
[315]209 growlNotify(cleanStreamTitle(iTunes.current_stream_title()),
[486]210 cleanStreamTrackName(trackName), **kw)
[211]211 return
[315]212 if trackClass == k.property:
[324]213 growlNotify('iTunes is playing.', '')
214 return
[211]215 kw = {}
216 # XXX iTunes doesn't let you get artwork for shared tracks
217 if trackClass != k.shared_track:
[315]218 artwork = iTunes.current_track.artworks()
[211]219 if artwork:
[487]220 try:
[636]221 kw['pictImage'] = artwork[0].data_()
[487]222 except CommandError:
223 pass
[211]224 growlNotify(trackName + ' ' +
[323]225 u'★' * (iTunes.current_track.rating() / 20),
226 iTunes.current_track.album() + '\n' +
[315]227 iTunes.current_track.artist(),
[211]228 **kw)
229
[656]230 def defaultOutputDeviceChanged(self):
231 turnStereoOnOrOff()
232 self.displayTrackInfo()
233
[188]234 def goToSite(self):
235 iTunes = iTunesApp()
[315]236 if iTunes.player_state() == k.playing:
[414]237 if amuaPlaying():
238 AmuaApp().display_album_details()
239 return
[315]240 url = iTunes.current_stream_URL()
[316]241 if url != k.missing_value:
[252]242 if 'radioparadise.com' in url and 'review' not in url:
[188]243 url = radioParadiseURL()
244 NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
245 return
246 NSBeep()
[195]247
[188]248 def registerHotKey(self, func, keyCode, mods=0):
249 hotKeyRef = RegisterEventHotKey(keyCode, mods, (0, 0),
250 GetApplicationEventTarget(), 0)
251 self.hotKeys.append(hotKeyRef)
[211]252 self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)] = func
[235]253 return hotKeyRef
[188]254
[235]255 def unregisterHotKey(self, hotKeyRef):
256 self.hotKeys.remove(hotKeyRef)
257 del self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)]
258 hotKeyRef.UnregisterEventHotKey()
259
[199]260 def incrementRatingBy(self, increment):
261 iTunes = iTunesApp()
[414]262 if amuaPlaying():
[415]263 if increment < 0:
[414]264 AmuaApp().ban_song()
[458]265 growlNotify('Banned song.', '', icon_of_application='Amua.app')
[414]266 else:
267 AmuaApp().love_song()
[458]268 growlNotify('Loved song.', '', icon_of_application='Amua.app')
[414]269 return
[315]270 rating = iTunes.current_track.rating()
[199]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
[300]280 def playPause(self, useStereo=True):
[324]281 global needsStereoPowerOn
282
[199]283 iTunes = iTunesApp()
[315]284 was_playing = (iTunes.player_state() == k.playing)
[324]285 if not useStereo:
286 needsStereoPowerOn = False
[414]287 if was_playing and amuaPlaying():
288 AmuaApp().stop()
289 else:
290 iTunes.playpause()
[315]291 if not was_playing and iTunes.player_state() == k.stopped:
[234]292 # most likely, we're focused on the iPod, so playing does nothing
[315]293 iTunes.browser_windows[1].view.set(iTunes.user_playlists[its.name=='Stations'][1]())
[234]294 iTunes.play()
[580]295 if not useStereo:
296 return
297 if iTunes.player_state() == k.playing:
[656]298 turnStereoOnOrOff()
[580]299 else:
300 turnStereoOff()
[235]301
[301]302 def playPauseFront(self):
303 systemEvents = app(id='com.apple.systemEvents')
[340]304 frontName = systemEvents.processes[its.frontmost == True][1].name()
[301]305 if frontName == 'RealPlayer':
306 realPlayer = app(id='com.RealNetworks.RealPlayer')
[315]307 if len(realPlayer.players()) > 0:
308 if realPlayer.players[1].state() == k.playing:
309 realPlayer.pause()
310 else:
311 realPlayer.play()
312 return
[301]313 elif frontName == 'VLC':
314 app(id='org.videolan.vlc').play() # equivalent to playpause
315 else:
[302]316 self.playPause(useStereo=False)
[301]317
[414]318 def nextTrack(self):
319 if amuaPlaying():
320 AmuaApp().skip_song()
321 return
322 iTunesApp().next_track()
323
[235]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
[232]331 def zoomWindow(self):
[313]332 # XXX detect if "enable access for assistive devices" needs to be enabled
[232]333 systemEvents = app(id='com.apple.systemEvents')
[340]334 frontName = systemEvents.processes[its.frontmost == True][1].name()
[233]335 if frontName == 'iTunes':
[232]336 systemEvents.processes['iTunes'].menu_bars[1]. \
337 menu_bar_items['Window'].menus.menu_items['Zoom'].click()
338 return
[631]339 elif frontName in ('X11', 'XQuartz', 'Emacs'): # preserve C-M-\
[235]340 self.unregisterZoomWindowHotKey()
341 systemEvents.key_code(42, using=[k.command_down, k.control_down])
342 self.registerZoomWindowHotKey()
343 return
[340]344 frontPID = systemEvents.processes[its.frontmost == True][1].unix_id()
[232]345 try:
[339]346 zoomed = app(pid=frontPID).windows[1].zoomed
[232]347 zoomed.set(not zoomed())
[235]348 except (CommandError, RuntimeError):
[313]349 systemEvents.processes[frontName].windows \
350 [its.subrole == 'AXStandardWindow'].windows[1]. \
351 buttons[its.subrole == 'AXZoomButton'].buttons[1].click()
[199]352
[188]353 def finishLaunching(self):
354 super(StreamVision, self).finishLaunching()
[486]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
[188]362 self.registerHotKey(self.displayTrackInfo, 100) # F8
363 self.registerHotKey(self.goToSite, 100, cmdKey) # cmd-F8
[199]364 self.registerHotKey(self.playPause, 101) # F9
[188]365 self.registerHotKey(lambda: iTunesApp().previous_track(), 109) # F10
[414]366 self.registerHotKey(self.nextTrack, 103) # F11
[211]367 self.registerHotKey(lambda: self.incrementRatingBy(-20), 109, shiftKey) # shift-F10
368 self.registerHotKey(lambda: self.incrementRatingBy(20), 103, shiftKey) # shift-F11
[235]369 self.registerZoomWindowHotKey()
[193]370 NSDistributedNotificationCenter.defaultCenter().addObserver_selector_name_object_(self, self.displayTrackInfo, 'com.apple.iTunes.playerInfo', None)
[300]371 try:
[302]372 import HIDRemote
[300]373 HIDRemote.connect()
[302]374 except ImportError:
375 print "failed to import HIDRemote (XXX fix - on Intel)"
[300]376 except OSError, e:
377 print "failed to connect to remote: ", e
[188]378
[656]379 set_default_output_device_changed_callback(
380 self.defaultOutputDeviceChanged)
381 turnStereoOnOrOff()
[654]382
[188]383 def sendEvent_(self, theEvent):
[300]384 eventType = theEvent.type()
385 if eventType == NSSystemDefined and \
[188]386 theEvent.subtype() == kEventHotKeyPressedSubtype:
387 self.hotKeyActions[theEvent.data1()]()
[300]388 elif eventType == NSApplicationDefined:
389 key = theEvent.data1()
390 if key == kHIDUsage_Csmr_ScanNextTrack:
[415]391 self.nextTrack()
[300]392 elif key == kHIDUsage_Csmr_ScanPreviousTrack:
393 iTunesApp().previous_track()
394 elif key == kHIDUsage_Csmr_PlayOrPause:
[301]395 self.playPauseFront()
[188]396 super(StreamVision, self).sendEvent_(theEvent)
397
398if __name__ == "__main__":
[340]399 growlRegister()
[195]400 AppHelper.runEventLoop()
[339]401 try:
402 HIDRemote.disconnect()
403 except:
404 pass
Note: See TracBrowser for help on using the repository browser.