source: trunk/StreamVision/StreamVision.py @ 653

Last change on this file since 653 was 653, checked in by Nicholas Riley, 8 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
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
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        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)
51
52def radioParadiseURL():
53    session = scrape.Session()
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
59
60def cleanStreamTitle(title):
61    if title == k.missing_value:
62        return ''
63    title = title.split(' [')[0] # XXX move to description
64    try: # incorrectly encoded?
65        title = title.encode('iso-8859-1').decode('utf-8')
66    except (UnicodeDecodeError, UnicodeEncodeError):
67        pass
68    title = title.replace('`', u'’')
69    return title
70
71def cleanStreamTrackName(name):
72    name = name.split('. ')[0]
73    name = name.split(': ')[0]
74    name = name.split(' - ')
75    if len(name) > 1:
76        name = ' - '.join(name[:-1])
77    else:
78        name = name[0]
79    return name
80
81def iTunesApp(): return app(id='com.apple.iTunes', terms=tunes)
82def XTensionApp(): return app(creator='SHEx')
83def AmuaApp(): return app('Amua.app')
84
85HAVE_XTENSION = False
86try:
87    XTensionApp()
88    HAVE_XTENSION = True
89except:
90    pass
91
92HAVE_AMUA = False
93try:
94    AmuaApp()
95    HAVE_AMUA = True
96except:
97    pass
98
99needsStereoPowerOn = HAVE_XTENSION
100
101def mayUseStereo():
102    if not HAVE_XTENSION:
103        return False
104    systemEvents = app(id='com.apple.systemEvents')
105    iTunesWindow = systemEvents.application_processes[u'iTunes'].windows[u'iTunes']
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
111    return remote_speakers and remote_speakers[0] not in (None, k.missing_value)
112
113def turnStereoOn():
114    global needsStereoPowerOn
115    if not default_output_device_is_airplay() or not mayUseStereo():
116        if HAVE_XTENSION and XTensionApp().status('Stereo'):
117            XTensionApp().turnoff('Stereo')
118        return
119    if not XTensionApp().status('Stereo'):
120        XTensionApp().turnon('Stereo')
121    needsStereoPowerOn = False
122
123def turnStereoOff():
124    global needsStereoPowerOn
125    if default_output_device_is_airplay() or not mayUseStereo():
126        return
127    if not needsStereoPowerOn and XTensionApp().status('Stereo'):
128        XTensionApp().turnoff('Stereo')
129    needsStereoPowerOn = True
130
131def amuaPlaying():
132    if not HAVE_AMUA:
133        return False
134    return AmuaApp().is_playing()
135
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
158class StreamVision(NSApplication):
159
160    hotKeyActions = {}
161    hotKeys = []
162
163    def displayTrackInfo(self, playerInfo=None):
164        iTunes = iTunesApp()
165
166        try:
167            trackClass = iTunes.current_track.class_()
168        except CommandError:
169            trackClass = k.property
170
171        trackName = ''
172        if trackClass != k.property:
173            trackName = iTunes.current_track.name()
174
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)
182            turnStereoOff()
183            return
184        turnStereoOn()
185        if trackClass == k.URL_track:
186            if amuaPlaying():
187                if playerInfo is None: # Amua displays it itself
188                    AmuaApp().display_song_information()
189                return
190            url = iTunes.current_stream_URL()
191            kw = {}
192            if url != k.missing_value and url.endswith('.jpg'):
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
202            growlNotify(cleanStreamTitle(iTunes.current_stream_title()),
203                        cleanStreamTrackName(trackName), **kw)
204            return
205        if trackClass == k.property:
206            growlNotify('iTunes is playing.', '')
207            return
208        kw = {}
209        # XXX iTunes doesn't let you get artwork for shared tracks
210        if trackClass != k.shared_track:
211            artwork = iTunes.current_track.artworks()
212            if artwork:
213                try:
214                    kw['pictImage'] = artwork[0].data_()
215                except CommandError:
216                    pass
217        growlNotify(trackName + '  ' +
218                    u'★' * (iTunes.current_track.rating() / 20),
219                    iTunes.current_track.album() + '\n' +
220                    iTunes.current_track.artist(),
221                    **kw)
222
223    def goToSite(self):
224        iTunes = iTunesApp()
225        if iTunes.player_state() == k.playing:
226            if amuaPlaying():
227                AmuaApp().display_album_details()
228                return
229            url = iTunes.current_stream_URL()
230            if url != k.missing_value:
231                if 'radioparadise.com' in url and 'review' not in url:
232                    url = radioParadiseURL()
233                NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
234                return
235        NSBeep()
236
237    def registerHotKey(self, func, keyCode, mods=0):
238        hotKeyRef = RegisterEventHotKey(keyCode, mods, (0, 0),
239                                        GetApplicationEventTarget(), 0)
240        self.hotKeys.append(hotKeyRef)
241        self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)] = func
242        return hotKeyRef
243
244    def unregisterHotKey(self, hotKeyRef):
245        self.hotKeys.remove(hotKeyRef)
246        del self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)]
247        hotKeyRef.UnregisterEventHotKey()
248
249    def incrementRatingBy(self, increment):
250        iTunes = iTunesApp()
251        if amuaPlaying():
252            if increment < 0:
253                AmuaApp().ban_song()
254                growlNotify('Banned song.', '', icon_of_application='Amua.app')
255            else:
256                AmuaApp().love_song()
257                growlNotify('Loved song.', '', icon_of_application='Amua.app')
258            return
259        rating = iTunes.current_track.rating()
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
269    def playPause(self, useStereo=True):
270        global needsStereoPowerOn
271
272        iTunes = iTunesApp()
273        was_playing = (iTunes.player_state() == k.playing)
274        if not useStereo:
275            needsStereoPowerOn = False
276        if was_playing and amuaPlaying():
277            AmuaApp().stop()
278        else:
279            iTunes.playpause()
280        if not was_playing and iTunes.player_state() == k.stopped:
281            # most likely, we're focused on the iPod, so playing does nothing
282            iTunes.browser_windows[1].view.set(iTunes.user_playlists[its.name=='Stations'][1]())
283            iTunes.play()
284        if not useStereo:
285            return
286        if iTunes.player_state() == k.playing:
287            turnStereoOn()
288        else:
289            turnStereoOff()
290
291    def playPauseFront(self):
292        systemEvents = app(id='com.apple.systemEvents')
293        frontName = systemEvents.processes[its.frontmost == True][1].name()
294        if frontName == 'RealPlayer':
295            realPlayer = app(id='com.RealNetworks.RealPlayer')
296            if len(realPlayer.players()) > 0:
297                if realPlayer.players[1].state() == k.playing:
298                    realPlayer.pause()
299                else:
300                    realPlayer.play()
301                return
302        elif frontName == 'VLC':
303            app(id='org.videolan.vlc').play() # equivalent to playpause
304        else:
305            self.playPause(useStereo=False)
306
307    def nextTrack(self):
308        if amuaPlaying():
309            AmuaApp().skip_song()
310            return
311        iTunesApp().next_track()
312
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
320    def zoomWindow(self):
321        # XXX detect if "enable access for assistive devices" needs to be enabled
322        systemEvents = app(id='com.apple.systemEvents')
323        frontName = systemEvents.processes[its.frontmost == True][1].name()
324        if frontName == 'iTunes':
325            systemEvents.processes['iTunes'].menu_bars[1]. \
326                menu_bar_items['Window'].menus.menu_items['Zoom'].click()
327            return
328        elif frontName in ('X11', 'XQuartz', 'Emacs'): # preserve C-M-\
329            self.unregisterZoomWindowHotKey()
330            systemEvents.key_code(42, using=[k.command_down, k.control_down])
331            self.registerZoomWindowHotKey()
332            return
333        frontPID = systemEvents.processes[its.frontmost == True][1].unix_id()
334        try:
335            zoomed = app(pid=frontPID).windows[1].zoomed
336            zoomed.set(not zoomed())
337        except (CommandError, RuntimeError):
338            systemEvents.processes[frontName].windows \
339                [its.subrole == 'AXStandardWindow'].windows[1]. \
340                buttons[its.subrole == 'AXZoomButton'].buttons[1].click()
341
342    def finishLaunching(self):
343        super(StreamVision, self).finishLaunching()
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
351        self.registerHotKey(self.displayTrackInfo, 100) # F8
352        self.registerHotKey(self.goToSite, 100, cmdKey) # cmd-F8
353        self.registerHotKey(self.playPause, 101) # F9
354        self.registerHotKey(lambda: iTunesApp().previous_track(), 109) # F10
355        self.registerHotKey(self.nextTrack, 103) # F11
356        self.registerHotKey(lambda: self.incrementRatingBy(-20), 109, shiftKey) # shift-F10
357        self.registerHotKey(lambda: self.incrementRatingBy(20), 103, shiftKey) # shift-F11
358        self.registerZoomWindowHotKey()
359        NSDistributedNotificationCenter.defaultCenter().addObserver_selector_name_object_(self, self.displayTrackInfo, 'com.apple.iTunes.playerInfo', None)
360        try:
361            import HIDRemote
362            HIDRemote.connect()
363        except ImportError:
364            print "failed to import HIDRemote (XXX fix - on Intel)"
365        except OSError, e:
366            print "failed to connect to remote: ", e
367
368    def sendEvent_(self, theEvent):
369        eventType = theEvent.type()
370        if eventType == NSSystemDefined and \
371               theEvent.subtype() == kEventHotKeyPressedSubtype:
372            self.hotKeyActions[theEvent.data1()]()
373        elif eventType == NSApplicationDefined:
374            key = theEvent.data1()
375            if key == kHIDUsage_Csmr_ScanNextTrack:
376                self.nextTrack()
377            elif key == kHIDUsage_Csmr_ScanPreviousTrack:
378                iTunesApp().previous_track()
379            elif key == kHIDUsage_Csmr_PlayOrPause:
380                self.playPauseFront()
381        super(StreamVision, self).sendEvent_(theEvent)
382
383if __name__ == "__main__":
384    growlRegister()
385    AppHelper.runEventLoop()
386    try:
387        HIDRemote.disconnect()
388    except:
389        pass
Note: See TracBrowser for help on using the repository browser.