├── AirPlayActivator.plist ├── Makefile ├── README.md ├── Tweak.xm ├── autoairplaypreferences ├── AutoAirplayPreferences.mm ├── Makefile ├── Resources │ ├── AutoAirplayPreferences.plist │ ├── AutoAirplayPreferences@2x.png │ ├── AutoAirplayPreferences@3x.png │ └── Info.plist └── entry.plist └── control /AirPlayActivator.plist: -------------------------------------------------------------------------------- 1 | { Filter = { Bundles = ( "com.apple.springboard" ); }; } 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include theos/makefiles/common.mk 2 | 3 | TWEAK_NAME = AirPlayActivator 4 | AirPlayActivator_FILES = Tweak.xm 5 | AirPlayActivator_LIBRARIES = activator 6 | AirPlayActivator_FRAMEWORKS = MediaPlayer AVFoundation 7 | include $(THEOS_MAKE_PATH)/tweak.mk 8 | 9 | after-install:: 10 | install.exec "killall -9 SpringBoard" 11 | SUBPROJECTS += autoairplaypreferences 12 | include $(THEOS_MAKE_PATH)/aggregate.mk 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AutoAirPlay 2 | =============== 3 | 4 | Activator plugin to auto switch to first audio-only AirPlay device, 5 | if possible, or any other AirPlay device. 6 | 7 | 8 | Useful if you're using AirPlay in your car, the device would auto connect 9 | to the Wi-Fi network, then an activator trigger would switch to the AirPlay system. 10 | -------------------------------------------------------------------------------- /Tweak.xm: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import 4 | #import 5 | 6 | // The default "1" only does a shallow scan (Bluetooth or similar) 7 | #define DISCOVERY_MODE_THE_ONE_THAT_SHOWS_AIRPLAY_SPEAKERS 2 8 | 9 | // Not sure if this is actually default, but sounds like it. 10 | #define STOCK_UTTERANCE_SPEED 0.07 11 | 12 | @interface AirPlayActivator : NSObject 13 | @end 14 | 15 | @interface MPAVRoute : NSObject 16 | @property(readonly, nonatomic) NSString *routeName; 17 | @property(readonly, nonatomic) BOOL requiresPassword; 18 | - (NSDictionary*) avRouteDescription; 19 | @end 20 | 21 | @interface MPAVRoutingController 22 | - (void)fetchAvailableRoutesWithCompletionHandler:(id)block; 23 | - (BOOL)pickRoute:(MPAVRoute*)route withPassword:(NSString*)password; 24 | - (BOOL)pickRoute:(MPAVRoute*)route; 25 | @property(nonatomic) int discoveryMode; 26 | @property(readonly, copy, nonatomic) NSArray *availableRoutes; 27 | @end 28 | 29 | @interface SBMediaController 30 | @end 31 | 32 | static SBMediaController *mediaController = nil; 33 | static int retryCount = 0; 34 | 35 | static int maxRetryCount = 3; 36 | static int delay = 4; 37 | static BOOL audioOnlyFirst = YES; 38 | static NSString *preferredSpeakerRouteName = nil; 39 | static NSString *preferredSpeakerPassword = nil; 40 | static NSString *textToSpeak = nil; 41 | 42 | 43 | static const CFStringRef DOMAIN_NAME = CFSTR("com.mohammadag.airplayactivator"); 44 | 45 | static NSString * const KEY_DELAY = @"delay"; 46 | static NSString * const KEY_AUDIO_ONLY = @"audioOnlyFirst"; 47 | static NSString * const KEY_PREFERRED_SPEAKER = @"preferredSpeaker"; 48 | static NSString * const KEY_PREFERRED_SPEAKER_PASSWORD = @"preferredSpeakerPassword"; 49 | static NSString * const KEY_RETRY_COUNT = @"retryCount"; 50 | static NSString * const KEY_SPEAK_TEXT = @"speakWhenConnected"; 51 | static NSString * const KEY_TEXT_TO_SPEAK = @"textToSpeak"; 52 | 53 | static NSString * getStringPreference(NSDictionary *dictionary, NSString *key) { 54 | NSString *pref = [dictionary objectForKey:key]; 55 | if (pref) 56 | return [pref retain]; 57 | 58 | return nil; 59 | } 60 | 61 | static int getIntPreference(NSDictionary *dictionary, NSString *key, int defaultValue) { 62 | id value = [dictionary objectForKey:key]; 63 | if (value) 64 | return [value intValue]; 65 | 66 | return defaultValue; 67 | } 68 | 69 | static BOOL getBoolPreference(NSDictionary *dictionary, NSString *key, BOOL defaultValue) { 70 | id value = [dictionary objectForKey:key]; 71 | if (value) 72 | return [value boolValue]; 73 | 74 | return defaultValue; 75 | } 76 | 77 | static void notificationCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { 78 | 79 | CFPreferencesAppSynchronize(DOMAIN_NAME); 80 | 81 | CFArrayRef keyList = CFPreferencesCopyKeyList(DOMAIN_NAME, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); 82 | if (!keyList) { 83 | NSLog(@"There's been an error getting the key list!"); 84 | return; 85 | } 86 | NSDictionary* preferences = (NSDictionary *) CFPreferencesCopyMultiple(keyList, DOMAIN_NAME, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); 87 | if (!preferences) { 88 | NSLog(@"There's been an error getting the preferences dictionary!"); 89 | } 90 | 91 | delay = getIntPreference(preferences, KEY_DELAY, 4); 92 | maxRetryCount = getIntPreference(preferences, KEY_RETRY_COUNT, 3); 93 | audioOnlyFirst = getBoolPreference(preferences, KEY_AUDIO_ONLY, YES); 94 | preferredSpeakerRouteName = getStringPreference(preferences, KEY_PREFERRED_SPEAKER); 95 | preferredSpeakerPassword = getStringPreference(preferences, KEY_PREFERRED_SPEAKER_PASSWORD); 96 | BOOL speakWhenConnected = getBoolPreference(preferences, KEY_SPEAK_TEXT, NO); 97 | if (speakWhenConnected) { 98 | textToSpeak = getStringPreference(preferences, KEY_TEXT_TO_SPEAK); 99 | } else if (textToSpeak) { 100 | [textToSpeak release]; 101 | textToSpeak = nil; 102 | } 103 | 104 | NSLog(@"Delay: %i, Retries: %i, AudioOnly: %d, preferName: %@, pass: %@, speakConnected: %d, What? %@", 105 | delay, maxRetryCount, audioOnlyFirst, preferredSpeakerRouteName, preferredSpeakerPassword, speakWhenConnected, textToSpeak); 106 | 107 | CFRelease(keyList); 108 | 109 | [preferences release]; 110 | } 111 | 112 | @implementation AirPlayActivator 113 | 114 | -(void)onConnectedToAirPlaySpeaker { 115 | if (!textToSpeak) 116 | return; 117 | 118 | @autoreleasepool { 119 | AVSpeechUtterance *utterance = [AVSpeechUtterance 120 | speechUtteranceWithString:textToSpeak]; 121 | AVSpeechSynthesizer *synth = [[AVSpeechSynthesizer alloc] init]; 122 | utterance.rate = STOCK_UTTERANCE_SPEED; 123 | [synth speakUtterance:utterance]; 124 | } 125 | } 126 | 127 | -(MPAVRoute*)getPreferredRoute:(NSArray*)routes { 128 | for (MPAVRoute *route in routes) { 129 | if ([preferredSpeakerRouteName isEqualToString:route.routeName]) 130 | return route; 131 | } 132 | 133 | return nil; 134 | } 135 | 136 | -(BOOL)isAirPlayOutput:(MPAVRoute*)route { 137 | NSDictionary* routeDescription = [route avRouteDescription]; 138 | return [routeDescription[@"AVAudioRouteName"] isEqualToString:@"AirTunes"]; 139 | } 140 | 141 | -(BOOL)isRouteAudioOnly:(MPAVRoute*)route { 142 | NSDictionary* routeDescription = [route avRouteDescription]; 143 | return [self isAirPlayOutput:route] && ![routeDescription objectForKey:@"RouteSupportsAirPlayVideo"]; 144 | } 145 | 146 | -(void)performAction { 147 | if (!mediaController) 148 | return; 149 | 150 | MPAVRoutingController *routingController = MSHookIvar(mediaController, "_routingController"); 151 | 152 | if (!routingController) 153 | return; 154 | 155 | int oldDiscoveryMode = routingController.discoveryMode; 156 | routingController.discoveryMode = DISCOVERY_MODE_THE_ONE_THAT_SHOWS_AIRPLAY_SPEAKERS; 157 | 158 | [routingController fetchAvailableRoutesWithCompletionHandler:^{ 159 | NSArray *routes = routingController.availableRoutes; 160 | BOOL switched = NO; 161 | 162 | if (preferredSpeakerRouteName) { 163 | MPAVRoute *preferredSpeaker = [self getPreferredRoute:routes]; 164 | if (preferredSpeaker) { 165 | if (preferredSpeaker.requiresPassword && preferredSpeakerPassword) { 166 | if ([routingController pickRoute:preferredSpeaker withPassword:preferredSpeakerPassword]) 167 | return; 168 | } else { 169 | if ([routingController pickRoute:preferredSpeaker]) { 170 | [self onConnectedToAirPlaySpeaker]; 171 | return; 172 | } 173 | } 174 | } 175 | } 176 | 177 | for (MPAVRoute *route in routes) { 178 | if (route.requiresPassword) 179 | continue; 180 | 181 | BOOL isAudioOnly = [self isRouteAudioOnly:route]; 182 | if ((isAudioOnly && audioOnlyFirst) || !audioOnlyFirst) { 183 | if ([routingController pickRoute:route]) { 184 | if (isAudioOnly) 185 | NSLog(@"Succesfully switched to audio-only speaker"); 186 | else 187 | NSLog(@"Succesfully switched to speaker"); 188 | [self onConnectedToAirPlaySpeaker]; 189 | switched = YES; 190 | break; 191 | } else { 192 | NSLog(@"Failed, trying next speaker if available"); 193 | } 194 | } 195 | } 196 | 197 | if (!switched && audioOnlyFirst) { 198 | for (MPAVRoute *route in routes) { 199 | if (route.requiresPassword) 200 | continue; 201 | 202 | BOOL isAirPlay = [self isAirPlayOutput:route]; 203 | if (isAirPlay) { 204 | if ([routingController pickRoute:route]) { 205 | NSLog(@"Succesfully switched to speaker"); 206 | [self onConnectedToAirPlaySpeaker]; 207 | switched = YES; 208 | break; 209 | } else { 210 | NSLog(@"Failed, trying next AirPlay device"); 211 | } 212 | } 213 | } 214 | } 215 | 216 | routingController.discoveryMode = oldDiscoveryMode; 217 | 218 | if (!switched) { 219 | if (retryCount >= maxRetryCount) { 220 | retryCount = 0; 221 | return; 222 | } else { 223 | [self performSelector:@selector(performAction) withObject:nil afterDelay:1.0]; 224 | retryCount++; 225 | } 226 | } 227 | }]; 228 | } 229 | 230 | -(void)activator:(LAActivator *)activator receiveEvent:(LAEvent *)event { 231 | if (delay != 0 && [event.name rangeOfString:@"libactivator.network.joined-wifi" options:NSCaseInsensitiveSearch].location != NSNotFound) { 232 | [self performSelector:@selector(performAction) withObject:nil afterDelay:delay]; 233 | } else { 234 | [self performSelector:@selector(performAction)]; 235 | } 236 | } 237 | 238 | +(void)load { 239 | notificationCallback(NULL, NULL, NULL, NULL, NULL); 240 | @autoreleasepool { 241 | CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), NULL, notificationCallback, (CFStringRef) @"com.mohammadag.airplayactivator/preferences_changed", NULL, CFNotificationSuspensionBehaviorCoalesce); 242 | [[LAActivator sharedInstance] registerListener:[self new] forName:@"com.mohammadag.airplayactivator"]; 243 | } 244 | } 245 | 246 | - (NSString *)activator:(LAActivator *)activator requiresLocalizedTitleForListenerName:(NSString *)listenerName { 247 | return @"AirPlay Activator"; 248 | } 249 | - (NSString *)activator:(LAActivator *)activator requiresLocalizedDescriptionForListenerName:(NSString *)listenerName { 250 | return @"Enables the first possible AirPlay receiver"; 251 | } 252 | - (NSArray *)activator:(LAActivator *)activator requiresCompatibleEventModesForListenerWithName:(NSString *)listenerName { 253 | return [NSArray arrayWithObjects:@"springboard", @"lockscreen", @"application", nil]; 254 | } 255 | 256 | @end 257 | 258 | %hook SBMediaController 259 | 260 | -(void)init { 261 | %orig; 262 | mediaController = self; 263 | } 264 | 265 | %end -------------------------------------------------------------------------------- /autoairplaypreferences/AutoAirplayPreferences.mm: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface AutoAirplayPreferencesListController: PSListController { 4 | } 5 | @end 6 | 7 | @implementation AutoAirplayPreferencesListController 8 | - (id)specifiers { 9 | if(_specifiers == nil) { 10 | _specifiers = [[self loadSpecifiersFromPlistName:@"AutoAirplayPreferences" target:self] retain]; 11 | } 12 | return _specifiers; 13 | } 14 | 15 | - (void)sourceOnGithub { 16 | [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"https://github.com/MohammadAG/iOS-AutoAirPlay"]]; 17 | } 18 | @end 19 | 20 | // vim:ft=objc 21 | -------------------------------------------------------------------------------- /autoairplaypreferences/Makefile: -------------------------------------------------------------------------------- 1 | include theos/makefiles/common.mk 2 | 3 | BUNDLE_NAME = AutoAirplayPreferences 4 | AutoAirplayPreferences_FILES = AutoAirplayPreferences.mm 5 | AutoAirplayPreferences_INSTALL_PATH = /Library/PreferenceBundles 6 | AutoAirplayPreferences_FRAMEWORKS = UIKit 7 | AutoAirplayPreferences_PRIVATE_FRAMEWORKS = Preferences 8 | 9 | include $(THEOS_MAKE_PATH)/bundle.mk 10 | 11 | internal-stage:: 12 | $(ECHO_NOTHING)mkdir -p $(THEOS_STAGING_DIR)/Library/PreferenceLoader/Preferences$(ECHO_END) 13 | $(ECHO_NOTHING)cp entry.plist $(THEOS_STAGING_DIR)/Library/PreferenceLoader/Preferences/AutoAirplayPreferences.plist$(ECHO_END) 14 | -------------------------------------------------------------------------------- /autoairplaypreferences/Resources/AutoAirplayPreferences.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | items 6 | 7 | 8 | footerText 9 | Activator sends an event immediately after the device connects, there's a small delay needed before AirPlay devices can be detected. 10 | cell 11 | PSGroupCell 12 | label 13 | Delay when used with Wi-Fi activator event 14 | 15 | 16 | defaults 17 | com.mohammadag.airplayactivator 18 | isNumeric 19 | 20 | PostNotification 21 | com.mohammadag.airplayactivator/preferences_changed 22 | key 23 | delay 24 | default 25 | 4 26 | label 27 | Seconds (0 to disable) 28 | cell 29 | PSEditTextCell 30 | 31 | 32 | cell 33 | PSGroupCell 34 | label 35 | Number of retries before giving up 36 | 37 | 38 | defaults 39 | com.mohammadag.airplayactivator 40 | isNumeric 41 | 42 | PostNotification 43 | com.mohammadag.airplayactivator/preferences_changed 44 | key 45 | retryCount 46 | default 47 | 3 48 | label 49 | Retry count 50 | cell 51 | PSEditTextCell 52 | 53 | 54 | footerText 55 | Set the name of the preferred speaker, leave empty for none.
The password is only used if the AirPlay device needs a password.
Password stores unencrypted. 56 | cell 57 | PSGroupCell 58 | label 59 | Preferred speakers 60 | 61 | 62 | defaults 63 | com.mohammadag.airplayactivator 64 | PostNotification 65 | com.mohammadag.airplayactivator/preferences_changed 66 | key 67 | audioOnlyFirst 68 | default 69 | 70 | label 71 | Prefer audio-only speakers first 72 | cell 73 | PSSwitchCell 74 | 75 | 76 | default 77 | 78 | defaults 79 | com.mohammadag.airplayactivator 80 | PostNotification 81 | com.mohammadag.airplayactivator/preferences_changed 82 | key 83 | preferredSpeaker 84 | label 85 | Preferred speaker 86 | cell 87 | PSEditTextCell 88 | 89 | 90 | defaults 91 | com.mohammadag.airplayactivator 92 | PostNotification 93 | com.mohammadag.airplayactivator/preferences_changed 94 | key 95 | preferredSpeakerPassword 96 | label 97 | Preferred speaker password 98 | cell 99 | PSSecureEditTextCell 100 | 101 | 102 | cell 103 | PSGroupCell 104 | label 105 | When connected 106 | 107 | 108 | defaults 109 | com.mohammadag.airplayactivator 110 | PostNotification 111 | com.mohammadag.airplayactivator/preferences_changed 112 | key 113 | speakWhenConnected 114 | default 115 | 116 | label 117 | Speak text 118 | cell 119 | PSSwitchCell 120 | 121 | 122 | default 123 | 124 | defaults 125 | com.mohammadag.airplayactivator 126 | PostNotification 127 | com.mohammadag.airplayactivator/preferences_changed 128 | key 129 | textToSpeak 130 | label 131 | Text: 132 | cell 133 | PSEditTextCell 134 | 135 | 136 | footerText 137 | (C) 2015 Mohammad Abu-Garbeyyeh. Licensed under GPLv3 138 | cell 139 | PSGroupCell 140 | label 141 | 142 | 143 | 144 | action 145 | sourceOnGithub 146 | label 147 | Source on Github 148 | cell 149 | PSButtonCell 150 | 151 | 152 | title 153 | AutoAirplay 154 | 155 | 156 | -------------------------------------------------------------------------------- /autoairplaypreferences/Resources/AutoAirplayPreferences@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohammadAG/iOS-AutoAirPlay/d5ff8749c2bc7dd60c3ef39545197a5653deaeba/autoairplaypreferences/Resources/AutoAirplayPreferences@2x.png -------------------------------------------------------------------------------- /autoairplaypreferences/Resources/AutoAirplayPreferences@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MohammadAG/iOS-AutoAirPlay/d5ff8749c2bc7dd60c3ef39545197a5653deaeba/autoairplaypreferences/Resources/AutoAirplayPreferences@3x.png -------------------------------------------------------------------------------- /autoairplaypreferences/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleExecutable 8 | AutoAirplayPreferences 9 | CFBundleIdentifier 10 | com.mohammadag.airplayactivator 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundlePackageType 14 | BNDL 15 | CFBundleShortVersionString 16 | 1.0.0 17 | CFBundleSignature 18 | ???? 19 | CFBundleVersion 20 | 1.0 21 | DTPlatformName 22 | iphoneos 23 | MinimumOSVersion 24 | 3.0 25 | NSPrincipalClass 26 | AutoAirplayPreferencesListController 27 | 28 | 29 | -------------------------------------------------------------------------------- /autoairplaypreferences/entry.plist: -------------------------------------------------------------------------------- 1 | { 2 | entry = { 3 | bundle = AutoAirplayPreferences; 4 | cell = PSLinkCell; 5 | detail = AutoAirplayPreferencesListController; 6 | icon = AutoAirplayPreferences.png; 7 | isController = 1; 8 | label = AutoAirplay; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /control: -------------------------------------------------------------------------------- 1 | Package: com.mohammadag.airplayactivator 2 | Name: AutoAirPlay 3 | Depends: mobilesubstrate, libactivator 4 | Version: 1.1 5 | Architecture: iphoneos-arm 6 | Description: Enable the first possible audio-only AirPlay speaker, if none found, the first AirPlay device. 7 | Maintainer: Mohammad Abu-Garbeyyeh 8 | Author: Mohammad Abu-Garbeyyeh 9 | Section: Tweaks 10 | --------------------------------------------------------------------------------