source: trunk/StreamVision/StreamVision.py @ 631

Last change on this file since 631 was 631, checked in by Nicholas Riley, 9 years ago

StreamVision?.py: iTunes 10 takes a small step forward in accessibility. Block zoom in XQuartz too. Turn off stereo when switching back to local speakers.

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