source: trunk/StreamVision/StreamVision.py @ 644

Last change on this file since 644 was 644, checked in by Nicholas Riley, 7 years ago

StreamVision?.py: Remove workaround for iTunes Store accessibility bug in older iTunes versions.

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