source: trunk/StreamVision/StreamVision.py @ 656

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