source: trunk/StreamVision/StreamVision.py @ 647

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

StreamVision?: Be resilient to iTunes quitting, finally.

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