├── .gitignore ├── Cartfile ├── Cartfile.resolved ├── Crashlytics.framework ├── Crashlytics ├── Headers ├── Modules ├── Resources ├── Versions │ ├── A │ │ ├── Crashlytics │ │ ├── Headers │ │ │ ├── ANSCompatibility.h │ │ │ ├── Answers.h │ │ │ ├── CLSAttributes.h │ │ │ ├── CLSLogging.h │ │ │ ├── CLSReport.h │ │ │ ├── CLSStackFrame.h │ │ │ └── Crashlytics.h │ │ ├── Modules │ │ │ └── module.modulemap │ │ └── Resources │ │ │ └── Info.plist │ └── Current ├── run ├── submit └── uploadDSYM ├── Fabric.framework ├── Fabric ├── Headers ├── Modules ├── Resources ├── Versions │ ├── A │ │ ├── Fabric │ │ ├── Headers │ │ │ ├── FABAttributes.h │ │ │ └── Fabric.h │ │ ├── Modules │ │ │ └── module.modulemap │ │ └── Resources │ │ │ └── Info.plist │ └── Current ├── run └── uploadDSYM ├── LICENSE ├── PodcastMenu.xcodeproj └── project.pbxproj ├── PodcastMenu ├── Adapter.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ ├── Contents.json │ ├── controlStripIcon.imageset │ │ ├── Contents.json │ │ └── controlStripIcon@2x.png │ ├── pause_touchbar.imageset │ │ ├── Contents.json │ │ └── pause_touchbar.png │ ├── play_touchbar.imageset │ │ ├── Contents.json │ │ └── play_touchbar.png │ └── podcast.imageset │ │ ├── Contents.json │ │ ├── podcast.png │ │ ├── podcast@2x.png │ │ └── podcast@3x.png ├── Base.lproj │ └── MainMenu.xib ├── Constants.swift ├── Episode.swift ├── EpisodeAdapter.swift ├── EpisodesParser.js ├── ErrorViewController.swift ├── ImageCache.swift ├── Info.plist ├── MediaKeysCoordinator.swift ├── MediaKeysHandler.swift ├── MediaKeysUsers.plist ├── Metrics.swift ├── NSImage+CGImage.h ├── NSImage+CGImage.m ├── OvercastController.swift ├── OvercastModel.swift ├── PMEventTap.h ├── PMEventTap.m ├── PMFreestandingButton.swift ├── PMWebView.swift ├── PlaybackInfo.swift ├── PlaybackInfoAdapter.swift ├── PlaybackInfoParser.js ├── Podcast.swift ├── PodcastAdapter.swift ├── PodcastMenu-Bridging-Header.h ├── PodcastWebAppViewController.swift ├── PodcastsParser.js ├── Poster.swift ├── Preferences.swift ├── ProgressBar.swift ├── Result.swift ├── ScrubberRemoteImageItemView.swift ├── StatusPopoverController.swift ├── Theme.swift ├── TitleParser.js ├── TouchBarController.swift ├── TouchBarMiniPlayer.swift ├── TouchBarMiniPlayer.xib ├── TouchBarNowPlayingController.swift ├── TouchBarPrivate.h ├── TouchBarScrubberViewController.swift ├── VUController.swift ├── look.js ├── overcast.js └── test-artwork.jpg ├── PodcastMenuTests ├── Data │ ├── Episodes.json │ └── Podcasts.json ├── Info.plist └── PodcastMenuTests.swift ├── Readme.md ├── Releases ├── PodcastMenu_v1.0.zip ├── PodcastMenu_v1.1.2.zip ├── PodcastMenu_v1.1.zip ├── PodcastMenu_v1.2.1.zip ├── PodcastMenu_v1.2.zip ├── PodcastMenu_v1.3.zip └── appcast.xml ├── Resources ├── PodcastMenu Icon.sketch ├── PodcastMenu.sketch ├── Touch Bar Stuff.sketch └── exported │ ├── podcast.png │ ├── podcast@2x.png │ └── podcast@3x.png ├── bootstrap.sh ├── screenshot2.png └── touchbar.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __MACOSX 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | *.xcworkspace 12 | !default.xcworkspace 13 | xcuserdata 14 | profile 15 | *.moved-aside 16 | DerivedData 17 | .idea/ 18 | Carthage -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | github "SwiftyJSON/SwiftyJSON" ~> 3.1 2 | 3 | github "Instagram/IGListKit" ~> 2.0.0 4 | 5 | github "sparkle-project/Sparkle" ~> 1.17 -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "Instagram/IGListKit" "2.1.0" 2 | github "SwiftyJSON/SwiftyJSON" "3.1.4" 3 | github "sparkle-project/Sparkle" "1.18.1" 4 | -------------------------------------------------------------------------------- /Crashlytics.framework/Crashlytics: -------------------------------------------------------------------------------- 1 | Versions/Current/Crashlytics -------------------------------------------------------------------------------- /Crashlytics.framework/Headers: -------------------------------------------------------------------------------- 1 | Versions/Current/Headers -------------------------------------------------------------------------------- /Crashlytics.framework/Modules: -------------------------------------------------------------------------------- 1 | Versions/Current/Modules -------------------------------------------------------------------------------- /Crashlytics.framework/Resources: -------------------------------------------------------------------------------- 1 | Versions/Current/Resources -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Crashlytics: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Crashlytics.framework/Versions/A/Crashlytics -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Headers/ANSCompatibility.h: -------------------------------------------------------------------------------- 1 | // 2 | // ANSCompatibility.h 3 | // AnswersKit 4 | // 5 | // Copyright (c) 2015 Crashlytics, Inc. All rights reserved. 6 | // 7 | 8 | #pragma once 9 | 10 | #if !__has_feature(nullability) 11 | #define nonnull 12 | #define nullable 13 | #define _Nullable 14 | #define _Nonnull 15 | #endif 16 | 17 | #ifndef NS_ASSUME_NONNULL_BEGIN 18 | #define NS_ASSUME_NONNULL_BEGIN 19 | #endif 20 | 21 | #ifndef NS_ASSUME_NONNULL_END 22 | #define NS_ASSUME_NONNULL_END 23 | #endif 24 | 25 | #if __has_feature(objc_generics) 26 | #define ANS_GENERIC_NSARRAY(type) NSArray 27 | #define ANS_GENERIC_NSDICTIONARY(key_type,object_key) NSDictionary 28 | #else 29 | #define ANS_GENERIC_NSARRAY(type) NSArray 30 | #define ANS_GENERIC_NSDICTIONARY(key_type,object_key) NSDictionary 31 | #endif 32 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Headers/Answers.h: -------------------------------------------------------------------------------- 1 | // 2 | // Answers.h 3 | // Crashlytics 4 | // 5 | // Copyright (c) 2015 Crashlytics, Inc. All rights reserved. 6 | // 7 | 8 | #import 9 | #import "ANSCompatibility.h" 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | /** 14 | * This class exposes the Answers Events API, allowing you to track key 15 | * user user actions and metrics in your app. 16 | */ 17 | @interface Answers : NSObject 18 | 19 | /** 20 | * Log a Sign Up event to see users signing up for your app in real-time, understand how 21 | * many users are signing up with different methods and their success rate signing up. 22 | * 23 | * @param signUpMethodOrNil The method by which a user logged in, e.g. Twitter or Digits. 24 | * @param signUpSucceededOrNil The ultimate success or failure of the login 25 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 26 | */ 27 | + (void)logSignUpWithMethod:(nullable NSString *)signUpMethodOrNil 28 | success:(nullable NSNumber *)signUpSucceededOrNil 29 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 30 | 31 | /** 32 | * Log an Log In event to see users logging into your app in real-time, understand how many 33 | * users are logging in with different methods and their success rate logging into your app. 34 | * 35 | * @param loginMethodOrNil The method by which a user logged in, e.g. email, Twitter or Digits. 36 | * @param loginSucceededOrNil The ultimate success or failure of the login 37 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 38 | */ 39 | + (void)logLoginWithMethod:(nullable NSString *)loginMethodOrNil 40 | success:(nullable NSNumber *)loginSucceededOrNil 41 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 42 | 43 | /** 44 | * Log a Share event to see users sharing from your app in real-time, letting you 45 | * understand what content they're sharing from the type or genre down to the specific id. 46 | * 47 | * @param shareMethodOrNil The method by which a user shared, e.g. email, Twitter, SMS. 48 | * @param contentNameOrNil The human readable name for this piece of content. 49 | * @param contentTypeOrNil The type of content shared. 50 | * @param contentIdOrNil The unique identifier for this piece of content. Useful for finding the top shared item. 51 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 52 | */ 53 | + (void)logShareWithMethod:(nullable NSString *)shareMethodOrNil 54 | contentName:(nullable NSString *)contentNameOrNil 55 | contentType:(nullable NSString *)contentTypeOrNil 56 | contentId:(nullable NSString *)contentIdOrNil 57 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 58 | 59 | /** 60 | * Log an Invite Event to track how users are inviting other users into 61 | * your application. 62 | * 63 | * @param inviteMethodOrNil The method of invitation, e.g. GameCenter, Twitter, email. 64 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 65 | */ 66 | + (void)logInviteWithMethod:(nullable NSString *)inviteMethodOrNil 67 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 68 | 69 | /** 70 | * Log a Purchase event to see your revenue in real-time, understand how many users are making purchases, see which 71 | * items are most popular, and track plenty of other important purchase-related metrics. 72 | * 73 | * @param itemPriceOrNil The purchased item's price. 74 | * @param currencyOrNil The ISO4217 currency code. Example: USD 75 | * @param purchaseSucceededOrNil Was the purchase succesful or unsuccesful 76 | * @param itemNameOrNil The human-readable form of the item's name. Example: 77 | * @param itemTypeOrNil The type, or genre of the item. Example: Song 78 | * @param itemIdOrNil The machine-readable, unique item identifier Example: SKU 79 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this purchase. 80 | */ 81 | + (void)logPurchaseWithPrice:(nullable NSDecimalNumber *)itemPriceOrNil 82 | currency:(nullable NSString *)currencyOrNil 83 | success:(nullable NSNumber *)purchaseSucceededOrNil 84 | itemName:(nullable NSString *)itemNameOrNil 85 | itemType:(nullable NSString *)itemTypeOrNil 86 | itemId:(nullable NSString *)itemIdOrNil 87 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 88 | 89 | /** 90 | * Log a Level Start Event to track where users are in your game. 91 | * 92 | * @param levelNameOrNil The level name 93 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this level start event. 94 | */ 95 | + (void)logLevelStart:(nullable NSString *)levelNameOrNil 96 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 97 | 98 | /** 99 | * Log a Level End event to track how users are completing levels in your game. 100 | * 101 | * @param levelNameOrNil The name of the level completed, E.G. "1" or "Training" 102 | * @param scoreOrNil The score the user completed the level with. 103 | * @param levelCompletedSuccesfullyOrNil A boolean representing whether or not the level was completed succesfully. 104 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 105 | */ 106 | + (void)logLevelEnd:(nullable NSString *)levelNameOrNil 107 | score:(nullable NSNumber *)scoreOrNil 108 | success:(nullable NSNumber *)levelCompletedSuccesfullyOrNil 109 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 110 | 111 | /** 112 | * Log an Add to Cart event to see users adding items to a shopping cart in real-time, understand how 113 | * many users start the purchase flow, see which items are most popular, and track plenty of other important 114 | * purchase-related metrics. 115 | * 116 | * @param itemPriceOrNil The purchased item's price. 117 | * @param currencyOrNil The ISO4217 currency code. Example: USD 118 | * @param itemNameOrNil The human-readable form of the item's name. Example: 119 | * @param itemTypeOrNil The type, or genre of the item. Example: Song 120 | * @param itemIdOrNil The machine-readable, unique item identifier Example: SKU 121 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 122 | */ 123 | + (void)logAddToCartWithPrice:(nullable NSDecimalNumber *)itemPriceOrNil 124 | currency:(nullable NSString *)currencyOrNil 125 | itemName:(nullable NSString *)itemNameOrNil 126 | itemType:(nullable NSString *)itemTypeOrNil 127 | itemId:(nullable NSString *)itemIdOrNil 128 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 129 | 130 | /** 131 | * Log a Start Checkout event to see users moving through the purchase funnel in real-time, understand how many 132 | * users are doing this and how much they're spending per checkout, and see how it related to other important 133 | * purchase-related metrics. 134 | * 135 | * @param totalPriceOrNil The total price of the cart. 136 | * @param currencyOrNil The ISO4217 currency code. Example: USD 137 | * @param itemCountOrNil The number of items in the cart. 138 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 139 | */ 140 | + (void)logStartCheckoutWithPrice:(nullable NSDecimalNumber *)totalPriceOrNil 141 | currency:(nullable NSString *)currencyOrNil 142 | itemCount:(nullable NSNumber *)itemCountOrNil 143 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 144 | 145 | /** 146 | * Log a Rating event to see users rating content within your app in real-time and understand what 147 | * content is most engaging, from the type or genre down to the specific id. 148 | * 149 | * @param ratingOrNil The integer rating given by the user. 150 | * @param contentNameOrNil The human readable name for this piece of content. 151 | * @param contentTypeOrNil The type of content shared. 152 | * @param contentIdOrNil The unique identifier for this piece of content. Useful for finding the top shared item. 153 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 154 | */ 155 | + (void)logRating:(nullable NSNumber *)ratingOrNil 156 | contentName:(nullable NSString *)contentNameOrNil 157 | contentType:(nullable NSString *)contentTypeOrNil 158 | contentId:(nullable NSString *)contentIdOrNil 159 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 160 | 161 | /** 162 | * Log a Content View event to see users viewing content within your app in real-time and 163 | * understand what content is most engaging, from the type or genre down to the specific id. 164 | * 165 | * @param contentNameOrNil The human readable name for this piece of content. 166 | * @param contentTypeOrNil The type of content shared. 167 | * @param contentIdOrNil The unique identifier for this piece of content. Useful for finding the top shared item. 168 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 169 | */ 170 | + (void)logContentViewWithName:(nullable NSString *)contentNameOrNil 171 | contentType:(nullable NSString *)contentTypeOrNil 172 | contentId:(nullable NSString *)contentIdOrNil 173 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 174 | 175 | /** 176 | * Log a Search event allows you to see users searching within your app in real-time and understand 177 | * exactly what they're searching for. 178 | * 179 | * @param queryOrNil The user's query. 180 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. 181 | */ 182 | + (void)logSearchWithQuery:(nullable NSString *)queryOrNil 183 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 184 | 185 | /** 186 | * Log a Custom Event to see user actions that are uniquely important for your app in real-time, to see how often 187 | * they're performing these actions with breakdowns by different categories you add. Use a human-readable name for 188 | * the name of the event, since this is how the event will appear in Answers. 189 | * 190 | * @param eventName The human-readable name for the event. 191 | * @param customAttributesOrNil A dictionary of custom attributes to associate with this event. Attribute keys 192 | * must be NSString and and values must be NSNumber or NSString. 193 | * @discussion How we treat NSNumbers: 194 | * We will provide information about the distribution of values over time. 195 | * 196 | * How we treat NSStrings: 197 | * NSStrings are used as categorical data, allowing comparison across different category values. 198 | * Strings are limited to a maximum length of 100 characters, attributes over this length will be 199 | * truncated. 200 | * 201 | * When tracking the Tweet views to better understand user engagement, sending the tweet's length 202 | * and the type of media present in the tweet allows you to track how tweet length and the type of media influence 203 | * engagement. 204 | */ 205 | + (void)logCustomEventWithName:(NSString *)eventName 206 | customAttributes:(nullable ANS_GENERIC_NSDICTIONARY(NSString *, id) *)customAttributesOrNil; 207 | 208 | @end 209 | 210 | NS_ASSUME_NONNULL_END 211 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Headers/CLSAttributes.h: -------------------------------------------------------------------------------- 1 | // 2 | // CLSAttributes.h 3 | // Crashlytics 4 | // 5 | // Copyright (c) 2015 Crashlytics, Inc. All rights reserved. 6 | // 7 | 8 | #pragma once 9 | 10 | #define CLS_DEPRECATED(x) __attribute__ ((deprecated(x))) 11 | 12 | #if !__has_feature(nullability) 13 | #define nonnull 14 | #define nullable 15 | #define _Nullable 16 | #define _Nonnull 17 | #endif 18 | 19 | #ifndef NS_ASSUME_NONNULL_BEGIN 20 | #define NS_ASSUME_NONNULL_BEGIN 21 | #endif 22 | 23 | #ifndef NS_ASSUME_NONNULL_END 24 | #define NS_ASSUME_NONNULL_END 25 | #endif 26 | 27 | #if __has_feature(objc_generics) 28 | #define CLS_GENERIC_NSARRAY(type) NSArray 29 | #define CLS_GENERIC_NSDICTIONARY(key_type,object_key) NSDictionary 30 | #else 31 | #define CLS_GENERIC_NSARRAY(type) NSArray 32 | #define CLS_GENERIC_NSDICTIONARY(key_type,object_key) NSDictionary 33 | #endif 34 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Headers/CLSLogging.h: -------------------------------------------------------------------------------- 1 | // 2 | // CLSLogging.h 3 | // Crashlytics 4 | // 5 | // Copyright (c) 2015 Crashlytics, Inc. All rights reserved. 6 | // 7 | #ifdef __OBJC__ 8 | #import "CLSAttributes.h" 9 | #import 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | #endif 13 | 14 | 15 | 16 | /** 17 | * 18 | * The CLS_LOG macro provides as easy way to gather more information in your log messages that are 19 | * sent with your crash data. CLS_LOG prepends your custom log message with the function name and 20 | * line number where the macro was used. If your app was built with the DEBUG preprocessor macro 21 | * defined CLS_LOG uses the CLSNSLog function which forwards your log message to NSLog and CLSLog. 22 | * If the DEBUG preprocessor macro is not defined CLS_LOG uses CLSLog only. 23 | * 24 | * Example output: 25 | * -[AppDelegate login:] line 134 $ login start 26 | * 27 | * If you would like to change this macro, create a new header file, unset our define and then define 28 | * your own version. Make sure this new header file is imported after the Crashlytics header file. 29 | * 30 | * #undef CLS_LOG 31 | * #define CLS_LOG(__FORMAT__, ...) CLSNSLog... 32 | * 33 | **/ 34 | #ifdef __OBJC__ 35 | #ifdef DEBUG 36 | #define CLS_LOG(__FORMAT__, ...) CLSNSLog((@"%s line %d $ " __FORMAT__), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__) 37 | #else 38 | #define CLS_LOG(__FORMAT__, ...) CLSLog((@"%s line %d $ " __FORMAT__), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__) 39 | #endif 40 | #endif 41 | 42 | /** 43 | * 44 | * Add logging that will be sent with your crash data. This logging will not show up in the system.log 45 | * and will only be visible in your Crashlytics dashboard. 46 | * 47 | **/ 48 | 49 | #ifdef __OBJC__ 50 | OBJC_EXTERN void CLSLog(NSString *format, ...) NS_FORMAT_FUNCTION(1,2); 51 | OBJC_EXTERN void CLSLogv(NSString *format, va_list ap) NS_FORMAT_FUNCTION(1,0); 52 | 53 | /** 54 | * 55 | * Add logging that will be sent with your crash data. This logging will show up in the system.log 56 | * and your Crashlytics dashboard. It is not recommended for Release builds. 57 | * 58 | **/ 59 | OBJC_EXTERN void CLSNSLog(NSString *format, ...) NS_FORMAT_FUNCTION(1,2); 60 | OBJC_EXTERN void CLSNSLogv(NSString *format, va_list ap) NS_FORMAT_FUNCTION(1,0); 61 | 62 | 63 | NS_ASSUME_NONNULL_END 64 | #endif 65 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Headers/CLSReport.h: -------------------------------------------------------------------------------- 1 | // 2 | // CLSReport.h 3 | // Crashlytics 4 | // 5 | // Copyright (c) 2015 Crashlytics, Inc. All rights reserved. 6 | // 7 | 8 | #import 9 | #import "CLSAttributes.h" 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | /** 14 | * The CLSCrashReport protocol is deprecated. See the CLSReport class and the CrashyticsDelegate changes for details. 15 | **/ 16 | @protocol CLSCrashReport 17 | 18 | @property (nonatomic, copy, readonly) NSString *identifier; 19 | @property (nonatomic, copy, readonly) NSDictionary *customKeys; 20 | @property (nonatomic, copy, readonly) NSString *bundleVersion; 21 | @property (nonatomic, copy, readonly) NSString *bundleShortVersionString; 22 | @property (nonatomic, copy, readonly) NSDate *crashedOnDate; 23 | @property (nonatomic, copy, readonly) NSString *OSVersion; 24 | @property (nonatomic, copy, readonly) NSString *OSBuildVersion; 25 | 26 | @end 27 | 28 | /** 29 | * The CLSReport exposes an interface to the phsyical report that Crashlytics has created. You can 30 | * use this class to get information about the event, and can also set some values after the 31 | * event has occured. 32 | **/ 33 | @interface CLSReport : NSObject 34 | 35 | - (instancetype)init NS_UNAVAILABLE; 36 | + (instancetype)new NS_UNAVAILABLE; 37 | 38 | /** 39 | * Returns the session identifier for the report. 40 | **/ 41 | @property (nonatomic, copy, readonly) NSString *identifier; 42 | 43 | /** 44 | * Returns the custom key value data for the report. 45 | **/ 46 | @property (nonatomic, copy, readonly) NSDictionary *customKeys; 47 | 48 | /** 49 | * Returns the CFBundleVersion of the application that generated the report. 50 | **/ 51 | @property (nonatomic, copy, readonly) NSString *bundleVersion; 52 | 53 | /** 54 | * Returns the CFBundleShortVersionString of the application that generated the report. 55 | **/ 56 | @property (nonatomic, copy, readonly) NSString *bundleShortVersionString; 57 | 58 | /** 59 | * Returns the date that the report was created. 60 | **/ 61 | @property (nonatomic, copy, readonly) NSDate *dateCreated; 62 | 63 | /** 64 | * Returns the os version that the application crashed on. 65 | **/ 66 | @property (nonatomic, copy, readonly) NSString *OSVersion; 67 | 68 | /** 69 | * Returns the os build version that the application crashed on. 70 | **/ 71 | @property (nonatomic, copy, readonly) NSString *OSBuildVersion; 72 | 73 | /** 74 | * Returns YES if the report contains any crash information, otherwise returns NO. 75 | **/ 76 | @property (nonatomic, assign, readonly) BOOL isCrash; 77 | 78 | /** 79 | * You can use this method to set, after the event, additional custom keys. The rules 80 | * and semantics for this method are the same as those documented in Crashlytics.h. Be aware 81 | * that the maximum size and count of custom keys is still enforced, and you can overwrite keys 82 | * and/or cause excess keys to be deleted by using this method. 83 | **/ 84 | - (void)setObjectValue:(nullable id)value forKey:(NSString *)key; 85 | 86 | /** 87 | * Record an application-specific user identifier. See Crashlytics.h for details. 88 | **/ 89 | @property (nonatomic, copy, nullable) NSString * userIdentifier; 90 | 91 | /** 92 | * Record a user name. See Crashlytics.h for details. 93 | **/ 94 | @property (nonatomic, copy, nullable) NSString * userName; 95 | 96 | /** 97 | * Record a user email. See Crashlytics.h for details. 98 | **/ 99 | @property (nonatomic, copy, nullable) NSString * userEmail; 100 | 101 | @end 102 | 103 | NS_ASSUME_NONNULL_END 104 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Headers/CLSStackFrame.h: -------------------------------------------------------------------------------- 1 | // 2 | // CLSStackFrame.h 3 | // Crashlytics 4 | // 5 | // Copyright 2015 Crashlytics, Inc. All rights reserved. 6 | // 7 | 8 | #import 9 | #import "CLSAttributes.h" 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | /** 14 | * 15 | * This class is used in conjunction with -[Crashlytics recordCustomExceptionName:reason:frameArray:] to 16 | * record information about non-ObjC/C++ exceptions. All information included here will be displayed 17 | * in the Crashlytics UI, and can influence crash grouping. Be particularly careful with the use of the 18 | * address property. If set, Crashlytics will attempt symbolication and could overwrite other properities 19 | * in the process. 20 | * 21 | **/ 22 | @interface CLSStackFrame : NSObject 23 | 24 | + (instancetype)stackFrame; 25 | + (instancetype)stackFrameWithAddress:(NSUInteger)address; 26 | + (instancetype)stackFrameWithSymbol:(NSString *)symbol; 27 | 28 | @property (nonatomic, copy, nullable) NSString *symbol; 29 | @property (nonatomic, copy, nullable) NSString *rawSymbol; 30 | @property (nonatomic, copy, nullable) NSString *library; 31 | @property (nonatomic, copy, nullable) NSString *fileName; 32 | @property (nonatomic, assign) uint32_t lineNumber; 33 | @property (nonatomic, assign) uint64_t offset; 34 | @property (nonatomic, assign) uint64_t address; 35 | 36 | @end 37 | 38 | NS_ASSUME_NONNULL_END 39 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Headers/Crashlytics.h: -------------------------------------------------------------------------------- 1 | // 2 | // Crashlytics.h 3 | // Crashlytics 4 | // 5 | // Copyright (c) 2015 Crashlytics, Inc. All rights reserved. 6 | // 7 | 8 | #import 9 | 10 | #import "CLSAttributes.h" 11 | #import "CLSLogging.h" 12 | #import "CLSReport.h" 13 | #import "CLSStackFrame.h" 14 | #import "Answers.h" 15 | 16 | NS_ASSUME_NONNULL_BEGIN 17 | 18 | @protocol CrashlyticsDelegate; 19 | 20 | /** 21 | * Crashlytics. Handles configuration and initialization of Crashlytics. 22 | * 23 | * Note: The Crashlytics class cannot be subclassed. If this is causing you pain for 24 | * testing, we suggest using either a wrapper class or a protocol extension. 25 | */ 26 | @interface Crashlytics : NSObject 27 | 28 | @property (nonatomic, readonly, copy) NSString *APIKey; 29 | @property (nonatomic, readonly, copy) NSString *version; 30 | @property (nonatomic, assign) BOOL debugMode; 31 | 32 | /** 33 | * 34 | * The delegate can be used to influence decisions on reporting and behavior, as well as reacting 35 | * to previous crashes. 36 | * 37 | * Make certain that the delegate is setup before starting Crashlytics with startWithAPIKey:... or 38 | * via +[Fabric with:...]. Failure to do will result in missing any delegate callbacks that occur 39 | * synchronously during start. 40 | * 41 | **/ 42 | @property (nonatomic, assign, nullable) id delegate; 43 | 44 | /** 45 | * The recommended way to install Crashlytics into your application is to place a call to +startWithAPIKey: 46 | * in your -application:didFinishLaunchingWithOptions: or -applicationDidFinishLaunching: 47 | * method. 48 | * 49 | * Note: Starting with 3.0, the submission process has been significantly improved. The delay parameter 50 | * is no longer required to throttle submissions on launch, performance will be great without it. 51 | * 52 | * @param apiKey The Crashlytics API Key for this app 53 | * 54 | * @return The singleton Crashlytics instance 55 | */ 56 | + (Crashlytics *)startWithAPIKey:(NSString *)apiKey; 57 | + (Crashlytics *)startWithAPIKey:(NSString *)apiKey afterDelay:(NSTimeInterval)delay CLS_DEPRECATED("Crashlytics no longer needs or uses the delay parameter. Please use +startWithAPIKey: instead."); 58 | 59 | /** 60 | * If you need the functionality provided by the CrashlyticsDelegate protocol, you can use 61 | * these convenience methods to activate the framework and set the delegate in one call. 62 | * 63 | * @param apiKey The Crashlytics API Key for this app 64 | * @param delegate A delegate object which conforms to CrashlyticsDelegate. 65 | * 66 | * @return The singleton Crashlytics instance 67 | */ 68 | + (Crashlytics *)startWithAPIKey:(NSString *)apiKey delegate:(nullable id)delegate; 69 | + (Crashlytics *)startWithAPIKey:(NSString *)apiKey delegate:(nullable id)delegate afterDelay:(NSTimeInterval)delay CLS_DEPRECATED("Crashlytics no longer needs or uses the delay parameter. Please use +startWithAPIKey:delegate: instead."); 70 | 71 | /** 72 | * Access the singleton Crashlytics instance. 73 | * 74 | * @return The singleton Crashlytics instance 75 | */ 76 | + (Crashlytics *)sharedInstance; 77 | 78 | /** 79 | * The easiest way to cause a crash - great for testing! 80 | */ 81 | - (void)crash; 82 | 83 | /** 84 | * The easiest way to cause a crash with an exception - great for testing. 85 | */ 86 | - (void)throwException; 87 | 88 | /** 89 | * Specify a user identifier which will be visible in the Crashlytics UI. 90 | * 91 | * Many of our customers have requested the ability to tie crashes to specific end-users of their 92 | * application in order to facilitate responses to support requests or permit the ability to reach 93 | * out for more information. We allow you to specify up to three separate values for display within 94 | * the Crashlytics UI - but please be mindful of your end-user's privacy. 95 | * 96 | * We recommend specifying a user identifier - an arbitrary string that ties an end-user to a record 97 | * in your system. This could be a database id, hash, or other value that is meaningless to a 98 | * third-party observer but can be indexed and queried by you. 99 | * 100 | * Optionally, you may also specify the end-user's name or username, as well as email address if you 101 | * do not have a system that works well with obscured identifiers. 102 | * 103 | * Pursuant to our EULA, this data is transferred securely throughout our system and we will not 104 | * disseminate end-user data unless required to by law. That said, if you choose to provide end-user 105 | * contact information, we strongly recommend that you disclose this in your application's privacy 106 | * policy. Data privacy is of our utmost concern. 107 | * 108 | * @param identifier An arbitrary user identifier string which ties an end-user to a record in your system. 109 | */ 110 | - (void)setUserIdentifier:(nullable NSString *)identifier; 111 | 112 | /** 113 | * Specify a user name which will be visible in the Crashlytics UI. 114 | * Please be mindful of your end-user's privacy and see if setUserIdentifier: can fulfil your needs. 115 | * @see setUserIdentifier: 116 | * 117 | * @param name An end user's name. 118 | */ 119 | - (void)setUserName:(nullable NSString *)name; 120 | 121 | /** 122 | * Specify a user email which will be visible in the Crashlytics UI. 123 | * Please be mindful of your end-user's privacy and see if setUserIdentifier: can fulfil your needs. 124 | * 125 | * @see setUserIdentifier: 126 | * 127 | * @param email An end user's email address. 128 | */ 129 | - (void)setUserEmail:(nullable NSString *)email; 130 | 131 | + (void)setUserIdentifier:(nullable NSString *)identifier CLS_DEPRECATED("Please access this method via +sharedInstance"); 132 | + (void)setUserName:(nullable NSString *)name CLS_DEPRECATED("Please access this method via +sharedInstance"); 133 | + (void)setUserEmail:(nullable NSString *)email CLS_DEPRECATED("Please access this method via +sharedInstance"); 134 | 135 | /** 136 | * Set a value for a for a key to be associated with your crash data which will be visible in the Crashlytics UI. 137 | * When setting an object value, the object is converted to a string. This is typically done by calling 138 | * -[NSObject description]. 139 | * 140 | * @param value The object to be associated with the key 141 | * @param key The key with which to associate the value 142 | */ 143 | - (void)setObjectValue:(nullable id)value forKey:(NSString *)key; 144 | 145 | /** 146 | * Set an int value for a key to be associated with your crash data which will be visible in the Crashlytics UI. 147 | * 148 | * @param value The integer value to be set 149 | * @param key The key with which to associate the value 150 | */ 151 | - (void)setIntValue:(int)value forKey:(NSString *)key; 152 | 153 | /** 154 | * Set an BOOL value for a key to be associated with your crash data which will be visible in the Crashlytics UI. 155 | * 156 | * @param value The BOOL value to be set 157 | * @param key The key with which to associate the value 158 | */ 159 | - (void)setBoolValue:(BOOL)value forKey:(NSString *)key; 160 | 161 | /** 162 | * Set an float value for a key to be associated with your crash data which will be visible in the Crashlytics UI. 163 | * 164 | * @param value The float value to be set 165 | * @param key The key with which to associate the value 166 | */ 167 | - (void)setFloatValue:(float)value forKey:(NSString *)key; 168 | 169 | + (void)setObjectValue:(nullable id)value forKey:(NSString *)key CLS_DEPRECATED("Please access this method via +sharedInstance"); 170 | + (void)setIntValue:(int)value forKey:(NSString *)key CLS_DEPRECATED("Please access this method via +sharedInstance"); 171 | + (void)setBoolValue:(BOOL)value forKey:(NSString *)key CLS_DEPRECATED("Please access this method via +sharedInstance"); 172 | + (void)setFloatValue:(float)value forKey:(NSString *)key CLS_DEPRECATED("Please access this method via +sharedInstance"); 173 | 174 | /** 175 | * This method can be used to record a single exception structure in a report. This is particularly useful 176 | * when your code interacts with non-native languages like Lua, C#, or Javascript. This call can be 177 | * expensive and should only be used shortly before process termination. This API is not intended be to used 178 | * to log NSException objects. All safely-reportable NSExceptions are automatically captured by 179 | * Crashlytics. 180 | * 181 | * @param name The name of the custom exception 182 | * @param reason The reason this exception occured 183 | * @param frameArray An array of CLSStackFrame objects 184 | */ 185 | - (void)recordCustomExceptionName:(NSString *)name reason:(nullable NSString *)reason frameArray:(CLS_GENERIC_NSARRAY(CLSStackFrame *) *)frameArray; 186 | 187 | /** 188 | * 189 | * This allows you to record a non-fatal event, described by an NSError object. These events will be grouped and 190 | * displayed similarly to crashes. Keep in mind that this method can be expensive. Also, the total number of 191 | * NSErrors that can be recorded during your app's life-cycle is limited by a fixed-size circular buffer. If the 192 | * buffer is overrun, the oldest data is dropped. Errors are relayed to Crashlytics on a subsequent launch 193 | * of your application. 194 | * 195 | * You can also use the -recordError:withAdditionalUserInfo: to include additional context not represented 196 | * by the NSError instance itself. 197 | * 198 | **/ 199 | - (void)recordError:(NSError *)error; 200 | - (void)recordError:(NSError *)error withAdditionalUserInfo:(nullable CLS_GENERIC_NSDICTIONARY(NSString *, id) *)userInfo; 201 | 202 | - (void)logEvent:(NSString *)eventName CLS_DEPRECATED("Please refer to Answers +logCustomEventWithName:"); 203 | - (void)logEvent:(NSString *)eventName attributes:(nullable NSDictionary *) attributes CLS_DEPRECATED("Please refer to Answers +logCustomEventWithName:"); 204 | + (void)logEvent:(NSString *)eventName CLS_DEPRECATED("Please refer to Answers +logCustomEventWithName:"); 205 | + (void)logEvent:(NSString *)eventName attributes:(nullable NSDictionary *) attributes CLS_DEPRECATED("Please refer to Answers +logCustomEventWithName:"); 206 | 207 | @end 208 | 209 | /** 210 | * 211 | * The CrashlyticsDelegate protocol provides a mechanism for your application to take 212 | * action on events that occur in the Crashlytics crash reporting system. You can make 213 | * use of these calls by assigning an object to the Crashlytics' delegate property directly, 214 | * or through the convenience +startWithAPIKey:delegate: method. 215 | * 216 | */ 217 | @protocol CrashlyticsDelegate 218 | @optional 219 | 220 | 221 | - (void)crashlyticsDidDetectCrashDuringPreviousExecution:(Crashlytics *)crashlytics CLS_DEPRECATED("Please refer to -crashlyticsDidDetectReportForLastExecution:"); 222 | - (void)crashlytics:(Crashlytics *)crashlytics didDetectCrashDuringPreviousExecution:(id )crash CLS_DEPRECATED("Please refer to -crashlyticsDidDetectReportForLastExecution:"); 223 | 224 | /** 225 | * 226 | * Called when a Crashlytics instance has determined that the last execution of the 227 | * application ended in a crash. This is called synchronously on Crashlytics 228 | * initialization. Your delegate must invoke the completionHandler, but does not need to do so 229 | * synchronously, or even on the main thread. Invoking completionHandler with NO will cause the 230 | * detected report to be deleted and not submitted to Crashlytics. This is useful for 231 | * implementing permission prompts, or other more-complex forms of logic around submitting crashes. 232 | * 233 | * @warning Failure to invoke the completionHandler will prevent submissions from being reported. Watch out. 234 | * 235 | * @warning Just implementing this delegate method will disable all forms of synchronous report submission. This can 236 | * impact the reliability of reporting crashes very early in application launch. 237 | * 238 | * @param report The CLSReport object representing the last detected crash 239 | * @param completionHandler The completion handler to call when your logic has completed. 240 | * 241 | */ 242 | - (void)crashlyticsDidDetectReportForLastExecution:(CLSReport *)report completionHandler:(void (^)(BOOL submit))completionHandler; 243 | 244 | /** 245 | * If your app is running on an OS that supports it (OS X 10.9+, iOS 7.0+), Crashlytics will submit 246 | * most reports using out-of-process background networking operations. This results in a significant 247 | * improvement in reliability of reporting, as well as power and performance wins for your users. 248 | * If you don't want this functionality, you can disable by returning NO from this method. 249 | * 250 | * @warning Background submission is not supported for extensions on iOS or OS X. 251 | * 252 | * @param crashlytics The Crashlytics singleton instance 253 | * 254 | * @return Return NO if you don't want out-of-process background network operations. 255 | * 256 | */ 257 | - (BOOL)crashlyticsCanUseBackgroundSessions:(Crashlytics *)crashlytics; 258 | 259 | @end 260 | 261 | /** 262 | * `CrashlyticsKit` can be used as a parameter to `[Fabric with:@[CrashlyticsKit]];` in Objective-C. In Swift, use Crashlytics.sharedInstance() 263 | */ 264 | #define CrashlyticsKit [Crashlytics sharedInstance] 265 | 266 | NS_ASSUME_NONNULL_END 267 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Modules/module.modulemap: -------------------------------------------------------------------------------- 1 | framework module Crashlytics { 2 | header "Crashlytics.h" 3 | header "Answers.h" 4 | header "ANSCompatibility.h" 5 | header "CLSLogging.h" 6 | header "CLSReport.h" 7 | header "CLSStackFrame.h" 8 | header "CLSAttributes.h" 9 | 10 | export * 11 | 12 | link "z" 13 | link "c++" 14 | } 15 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/A/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 15G31 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleExecutable 10 | Crashlytics 11 | CFBundleIdentifier 12 | com.twitter.crashlytics.mac 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | Crashlytics 17 | CFBundlePackageType 18 | FMWK 19 | CFBundleShortVersionString 20 | 3.8.3 21 | CFBundleSignature 22 | ???? 23 | CFBundleSupportedPlatforms 24 | 25 | MacOSX 26 | 27 | CFBundleVersion 28 | 120 29 | DTCompiler 30 | com.apple.compilers.llvm.clang.1_0 31 | DTPlatformBuild 32 | 7D1014 33 | DTPlatformVersion 34 | GM 35 | DTSDKBuild 36 | 15E60 37 | DTSDKName 38 | macosx10.11 39 | DTXcode 40 | 0731 41 | DTXcodeBuild 42 | 7D1014 43 | NSHumanReadableCopyright 44 | Copyright © 2016 Crashlytics, Inc. All rights reserved. 45 | UIDeviceFamily 46 | 47 | 3 48 | 2 49 | 1 50 | 4 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /Crashlytics.framework/Versions/Current: -------------------------------------------------------------------------------- 1 | A -------------------------------------------------------------------------------- /Crashlytics.framework/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # run 4 | # 5 | # Copyright (c) 2015 Crashlytics. All rights reserved. 6 | 7 | # Figure out where we're being called from 8 | DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 9 | 10 | # Quote path in case of spaces or special chars 11 | DIR="\"${DIR}" 12 | 13 | PATH_SEP="/" 14 | VALIDATE_COMMAND="uploadDSYM\" $@ validate run-script" 15 | UPLOAD_COMMAND="uploadDSYM\" $@ run-script" 16 | 17 | # Ensure params are as expected, run in sync mode to validate 18 | eval $DIR$PATH_SEP$VALIDATE_COMMAND 19 | return_code=$? 20 | 21 | if [[ $return_code != 0 ]]; then 22 | exit $return_code 23 | fi 24 | 25 | # Verification passed, upload dSYM in background to prevent Xcode from waiting 26 | # Note: Validation is performed again before upload. 27 | # Output can still be found in Console.app 28 | eval $DIR$PATH_SEP$UPLOAD_COMMAND > /dev/null 2>&1 & 29 | -------------------------------------------------------------------------------- /Crashlytics.framework/submit: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Crashlytics.framework/submit -------------------------------------------------------------------------------- /Crashlytics.framework/uploadDSYM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Crashlytics.framework/uploadDSYM -------------------------------------------------------------------------------- /Fabric.framework/Fabric: -------------------------------------------------------------------------------- 1 | Versions/Current/Fabric -------------------------------------------------------------------------------- /Fabric.framework/Headers: -------------------------------------------------------------------------------- 1 | Versions/Current/Headers -------------------------------------------------------------------------------- /Fabric.framework/Modules: -------------------------------------------------------------------------------- 1 | Versions/Current/Modules -------------------------------------------------------------------------------- /Fabric.framework/Resources: -------------------------------------------------------------------------------- 1 | Versions/Current/Resources -------------------------------------------------------------------------------- /Fabric.framework/Versions/A/Fabric: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Fabric.framework/Versions/A/Fabric -------------------------------------------------------------------------------- /Fabric.framework/Versions/A/Headers/FABAttributes.h: -------------------------------------------------------------------------------- 1 | // 2 | // FABAttributes.h 3 | // Fabric 4 | // 5 | // Copyright (C) 2015 Twitter, Inc. 6 | // 7 | // Licensed under the Apache License, Version 2.0 (the "License"); 8 | // you may not use this file except in compliance with the License. 9 | // You may obtain a copy of the License at 10 | // 11 | // http://www.apache.org/licenses/LICENSE-2.0 12 | // 13 | // Unless required by applicable law or agreed to in writing, software 14 | // distributed under the License is distributed on an "AS IS" BASIS, 15 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | // See the License for the specific language governing permissions and 17 | // limitations under the License. 18 | // 19 | 20 | #pragma once 21 | 22 | #define FAB_UNAVAILABLE(x) __attribute__((unavailable(x))) 23 | 24 | #if !__has_feature(nullability) 25 | #define nonnull 26 | #define nullable 27 | #define _Nullable 28 | #define _Nonnull 29 | #endif 30 | 31 | #ifndef NS_ASSUME_NONNULL_BEGIN 32 | #define NS_ASSUME_NONNULL_BEGIN 33 | #endif 34 | 35 | #ifndef NS_ASSUME_NONNULL_END 36 | #define NS_ASSUME_NONNULL_END 37 | #endif 38 | 39 | 40 | /** 41 | * The following macros are defined here to provide 42 | * backwards compatability. If you are still using 43 | * them you should migrate to the native nullability 44 | * macros. 45 | */ 46 | #define fab_nullable nullable 47 | #define fab_nonnull nonnull 48 | #define FAB_NONNULL __fab_nonnull 49 | #define FAB_NULLABLE __fab_nullable 50 | #define FAB_START_NONNULL NS_ASSUME_NONNULL_BEGIN 51 | #define FAB_END_NONNULL NS_ASSUME_NONNULL_END 52 | -------------------------------------------------------------------------------- /Fabric.framework/Versions/A/Headers/Fabric.h: -------------------------------------------------------------------------------- 1 | // 2 | // Fabric.h 3 | // Fabric 4 | // 5 | // Copyright (C) 2015 Twitter, Inc. 6 | // 7 | // Licensed under the Apache License, Version 2.0 (the "License"); 8 | // you may not use this file except in compliance with the License. 9 | // You may obtain a copy of the License at 10 | // 11 | // http://www.apache.org/licenses/LICENSE-2.0 12 | // 13 | // Unless required by applicable law or agreed to in writing, software 14 | // distributed under the License is distributed on an "AS IS" BASIS, 15 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | // See the License for the specific language governing permissions and 17 | // limitations under the License. 18 | // 19 | 20 | #import 21 | #import "FABAttributes.h" 22 | 23 | NS_ASSUME_NONNULL_BEGIN 24 | 25 | #if TARGET_OS_IPHONE 26 | #if __IPHONE_OS_VERSION_MIN_REQUIRED < 60000 27 | #error "Fabric's minimum iOS version is 6.0" 28 | #endif 29 | #else 30 | #if __MAC_OS_X_VERSION_MIN_REQUIRED < 1070 31 | #error "Fabric's minimum OS X version is 10.7" 32 | #endif 33 | #endif 34 | 35 | /** 36 | * Fabric Base. Coordinates configuration and starts all provided kits. 37 | */ 38 | @interface Fabric : NSObject 39 | 40 | /** 41 | * Initialize Fabric and all provided kits. Call this method within your App Delegate's `application:didFinishLaunchingWithOptions:` and provide the kits you wish to use. 42 | * 43 | * For example, in Objective-C: 44 | * 45 | * `[Fabric with:@[[Crashlytics class], [Twitter class], [Digits class], [MoPub class]]];` 46 | * 47 | * Swift: 48 | * 49 | * `Fabric.with([Crashlytics.self(), Twitter.self(), Digits.self(), MoPub.self()])` 50 | * 51 | * Only the first call to this method is honored. Subsequent calls are no-ops. 52 | * 53 | * @param kitClasses An array of kit Class objects 54 | * 55 | * @return Returns the shared Fabric instance. In most cases this can be ignored. 56 | */ 57 | + (instancetype)with:(NSArray *)kitClasses; 58 | 59 | /** 60 | * Returns the Fabric singleton object. 61 | */ 62 | + (instancetype)sharedSDK; 63 | 64 | /** 65 | * This BOOL enables or disables debug logging, such as kit version information. The default value is NO. 66 | */ 67 | @property (nonatomic, assign) BOOL debug; 68 | 69 | /** 70 | * Unavailable. Use `+sharedSDK` to retrieve the shared Fabric instance. 71 | */ 72 | - (id)init FAB_UNAVAILABLE("Use +sharedSDK to retrieve the shared Fabric instance."); 73 | 74 | /** 75 | * Unavailable. Use `+sharedSDK` to retrieve the shared Fabric instance. 76 | */ 77 | + (instancetype)new FAB_UNAVAILABLE("Use +sharedSDK to retrieve the shared Fabric instance."); 78 | 79 | @end 80 | 81 | NS_ASSUME_NONNULL_END 82 | 83 | -------------------------------------------------------------------------------- /Fabric.framework/Versions/A/Modules/module.modulemap: -------------------------------------------------------------------------------- 1 | framework module Fabric { 2 | umbrella header "Fabric.h" 3 | 4 | export * 5 | module * { export * } 6 | } -------------------------------------------------------------------------------- /Fabric.framework/Versions/A/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 15G31 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | Fabric 11 | CFBundleIdentifier 12 | io.fabric.sdk.mac 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | Fabric 17 | CFBundlePackageType 18 | FMWK 19 | CFBundleShortVersionString 20 | 1.6.11 21 | CFBundleSignature 22 | ???? 23 | CFBundleSupportedPlatforms 24 | 25 | MacOSX 26 | 27 | CFBundleVersion 28 | 60 29 | DTCompiler 30 | com.apple.compilers.llvm.clang.1_0 31 | DTPlatformBuild 32 | 7D1014 33 | DTPlatformVersion 34 | GM 35 | DTSDKBuild 36 | 15E60 37 | DTSDKName 38 | macosx10.11 39 | DTXcode 40 | 0731 41 | DTXcodeBuild 42 | 7D1014 43 | NSHumanReadableCopyright 44 | Copyright © 2015 Twitter. All rights reserved. 45 | UIDeviceFamily 46 | 47 | 3 48 | 2 49 | 1 50 | 4 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /Fabric.framework/Versions/Current: -------------------------------------------------------------------------------- 1 | A -------------------------------------------------------------------------------- /Fabric.framework/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # run 4 | # 5 | # Copyright (c) 2015 Crashlytics. All rights reserved. 6 | 7 | # Figure out where we're being called from 8 | DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 9 | 10 | # Quote path in case of spaces or special chars 11 | DIR="\"${DIR}" 12 | 13 | PATH_SEP="/" 14 | VALIDATE_COMMAND="uploadDSYM\" $@ validate run-script" 15 | UPLOAD_COMMAND="uploadDSYM\" $@ run-script" 16 | 17 | # Ensure params are as expected, run in sync mode to validate 18 | eval $DIR$PATH_SEP$VALIDATE_COMMAND 19 | return_code=$? 20 | 21 | if [[ $return_code != 0 ]]; then 22 | exit $return_code 23 | fi 24 | 25 | # Verification passed, upload dSYM in background to prevent Xcode from waiting 26 | # Note: Validation is performed again before upload. 27 | # Output can still be found in Console.app 28 | eval $DIR$PATH_SEP$UPLOAD_COMMAND > /dev/null 2>&1 & 29 | -------------------------------------------------------------------------------- /Fabric.framework/uploadDSYM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Fabric.framework/uploadDSYM -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Guilherme Rambo 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | - Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | - Redistributions in any kind of AppStore (whether for free or paid) are not permitted. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /PodcastMenu/Adapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Adapter.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum AdapterError: Error { 12 | case notImplemented 13 | case missingRequiredFields 14 | } 15 | 16 | class Adapter { 17 | 18 | var input: I 19 | 20 | required init(input: I) { 21 | self.input = input 22 | } 23 | 24 | func adapt() -> Result { 25 | return Result.error(.notImplemented) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /PodcastMenu/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 10/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | import Sparkle 12 | import Fabric 13 | import Crashlytics 14 | 15 | @NSApplicationMain 16 | class AppDelegate: NSObject, NSApplicationDelegate { 17 | 18 | fileprivate var updater = SUUpdater() 19 | 20 | fileprivate var statusItem: NSStatusItem! 21 | fileprivate lazy var popoverController = StatusPopoverController() 22 | fileprivate var vuController: VUController! 23 | 24 | func applicationWillFinishLaunching(_ notification: Notification) { 25 | UserDefaults.standard.register(defaults: ["NSApplicationCrashOnExceptions": true]) 26 | 27 | registerURLHandler() 28 | } 29 | 30 | func applicationDidFinishLaunching(_ aNotification: Notification) { 31 | Fabric.with([Crashlytics.self]) 32 | 33 | statusItem = NSStatusBar.system().statusItem(withLength: NSVariableStatusItemLength) 34 | statusItem.image = NSImage(named: "podcast")! 35 | statusItem.target = self 36 | statusItem.action = #selector(statusItemAction(_:)) 37 | statusItem.highlightMode = true 38 | 39 | vuController = VUController(statusItem: statusItem) 40 | popoverController.webAppController.loudnessDelegate = vuController 41 | 42 | perform(#selector(statusItemAction(_:)), with: statusItem.button, afterDelay: 0.5) 43 | 44 | NSUserNotificationCenter.default.delegate = self 45 | } 46 | 47 | @objc fileprivate func statusItemAction(_ sender: NSStatusBarButton) { 48 | popoverController.showPopoverFromStatusItemButton(sender) 49 | } 50 | 51 | fileprivate func registerURLHandler() { 52 | NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(handleURLEvent(_:replyEvent:)), forEventClass: UInt32(kInternetEventClass), andEventID: UInt32(kAEGetURL)) 53 | } 54 | 55 | @objc fileprivate func handleURLEvent(_ event: NSAppleEventDescriptor!, replyEvent: NSAppleEventDescriptor!) { 56 | guard let urlString = event.paramDescriptor(forKeyword: UInt32(keyDirectObject))?.stringValue else { return } 57 | guard let URL = URL(string: urlString) else { return } 58 | guard statusItem?.button != nil else { return } 59 | 60 | statusItemAction(statusItem.button!) 61 | 62 | popoverController.webAppController.openURL(URL) 63 | } 64 | 65 | } 66 | 67 | extension AppDelegate: NSUserNotificationCenterDelegate { 68 | 69 | func userNotificationCenter(_ center: NSUserNotificationCenter, shouldPresent notification: NSUserNotification) -> Bool { 70 | return true 71 | } 72 | 73 | func userNotificationCenter(_ center: NSUserNotificationCenter, didActivate notification: NSUserNotification) { 74 | guard statusItem?.button != nil else { return } 75 | popoverController.showPopoverFromStatusItemButton(statusItem.button!) 76 | center.removeDeliveredNotification(notification) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "icon_16x16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "icon_16x16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "icon_32x32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "icon_32x32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "icon_128x128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "icon_128x128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "icon_256x256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "icon_256x256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "icon_512x512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "icon_512x512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/controlStripIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "controlStripIcon@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/controlStripIcon.imageset/controlStripIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/controlStripIcon.imageset/controlStripIcon@2x.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/pause_touchbar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "pause_touchbar.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/pause_touchbar.imageset/pause_touchbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/pause_touchbar.imageset/pause_touchbar.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/play_touchbar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "play_touchbar.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/play_touchbar.imageset/play_touchbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/play_touchbar.imageset/play_touchbar.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/podcast.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "podcast.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "podcast@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "podcast@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/podcast.imageset/podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/podcast.imageset/podcast.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/podcast.imageset/podcast@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/podcast.imageset/podcast@2x.png -------------------------------------------------------------------------------- /PodcastMenu/Assets.xcassets/podcast.imageset/podcast@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/Assets.xcassets/podcast.imageset/podcast@3x.png -------------------------------------------------------------------------------- /PodcastMenu/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 10/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Constants { 12 | static let allowedHosts = ["overcast.fm","www.overcast.fm"] 13 | static let webAppURL = URL(string: "https://overcast.fm/podcasts")! 14 | static let javascriptBridgeName = "PodcastMenuApp" 15 | static let maxLoudness = 128.0 16 | static let retryIntervalAfterError = 10.0 17 | static let homeTitle = "Overcast" 18 | static let homePath = "/podcasts" 19 | static let logOutURL = URL(string: "https://overcast.fm/logout")! 20 | static let mainStyleName = "main.css" 21 | static let automaticRefreshInterval = 900.0 22 | } 23 | -------------------------------------------------------------------------------- /PodcastMenu/Episode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Episode.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Episode: Equatable { 12 | 13 | enum Time { 14 | case duration(String) 15 | case remaining(String) 16 | } 17 | 18 | let podcast: Podcast 19 | let title: String 20 | let poster: URL 21 | let date: Date 22 | let time: Time 23 | let link: URL? 24 | 25 | static func == (lhs: Episode, rhs: Episode) -> Bool { 26 | return lhs.podcast == rhs.podcast && lhs.title == rhs.title && lhs.date == rhs.date 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /PodcastMenu/EpisodeAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EpisodeAdapter.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | 12 | final class EpisodeAdapter: Adapter { 13 | 14 | private lazy var dateFormatter: DateFormatter = { 15 | let formatter = DateFormatter() 16 | 17 | formatter.locale = Locale(identifier: "en-US") 18 | formatter.dateFormat = "MMM dd, yyyy" 19 | 20 | return formatter 21 | }() 22 | 23 | override func adapt() -> Result { 24 | let podcastResult = PodcastAdapter(input: input["podcast"]).adapt() 25 | 26 | var podcast: Podcast! 27 | 28 | switch podcastResult { 29 | case .error(let error): return .error(error) 30 | case .success(let pod): podcast = pod 31 | } 32 | 33 | guard let title = input["title"].string else { 34 | return .error(.missingRequiredFields) 35 | } 36 | 37 | guard let poster = input["poster"].string?.overcastPoster else { 38 | return .error(.missingRequiredFields) 39 | } 40 | 41 | guard let link = input["link"].string else { 42 | return .error(.missingRequiredFields) 43 | } 44 | 45 | guard let timeType = input["time"]["type"].string else { 46 | return .error(.missingRequiredFields) 47 | } 48 | 49 | guard let timeValue = input["time"]["value"].string else { 50 | return .error(.missingRequiredFields) 51 | } 52 | 53 | guard let dateStr = input["date"].string else { 54 | return .error(.missingRequiredFields) 55 | } 56 | 57 | guard let date = dateFormatter.date(from: dateStr) else { 58 | return .error(.missingRequiredFields) 59 | } 60 | 61 | var time: Episode.Time 62 | 63 | switch timeType { 64 | case "remaining": time = Episode.Time.remaining(timeValue) 65 | default: time = Episode.Time.duration(timeValue) 66 | } 67 | 68 | let episode = Episode( 69 | podcast: podcast, 70 | title: title, 71 | poster: poster, 72 | date: date, 73 | time: time, 74 | link: URL(string: link)! 75 | ) 76 | 77 | return .success(episode) 78 | } 79 | 80 | } 81 | 82 | final class EpisodesAdapter: Adapter { 83 | 84 | override func adapt() -> Result<[Episode], AdapterError> { 85 | guard let jsonEpisodes = input.array else { 86 | return .error(.missingRequiredFields) 87 | } 88 | 89 | let episodes: [Episode] = jsonEpisodes.flatMap { json -> Episode? in 90 | let result = EpisodeAdapter(input: json).adapt() 91 | switch result { 92 | case .error(_): return nil 93 | case .success(let episode): return episode 94 | } 95 | } 96 | 97 | return .success(episodes) 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /PodcastMenu/EpisodesParser.js: -------------------------------------------------------------------------------- 1 | var episodeCells = document.querySelectorAll('.episodecell'); 2 | 3 | var year = (new Date()).getFullYear(); 4 | 5 | var episodeCellsArray = []; 6 | var len = episodeCells.length; 7 | for (var i = 0; i < len; i++) { 8 | episodeCellsArray.push(episodeCells[i]); 9 | } 10 | 11 | var episodes = episodeCellsArray.map(function(cell){ 12 | var link = cell.href; 13 | if (!link) return; 14 | 15 | var poster = cell.querySelector('img.art').src; 16 | if (!poster) return; 17 | 18 | var dataDivs = cell.querySelectorAll('.cellcontent .titlestack div'); 19 | if (dataDivs.length < 3) return; 20 | 21 | var showName = dataDivs[0].innerText; 22 | var title = dataDivs[1].innerText; 23 | var info = dataDivs[2].innerText; 24 | 25 | var infoComponents = info.split(" • "); 26 | if (infoComponents.length < 2) return; 27 | 28 | var date = infoComponents[0]; 29 | if (date.indexOf(",") == -1) date += ", " + year; 30 | 31 | var timeComponents = infoComponents[1].split(" "); 32 | var time = timeComponents[0]; 33 | var timeType = (infoComponents[1].indexOf("remaining") != -1) ? "remaining" : "duration"; 34 | 35 | return { 36 | "podcast": { 37 | "name": showName, 38 | "poster": poster 39 | }, 40 | "title": title, 41 | "poster": poster, 42 | "date": date, 43 | "time": { 44 | "type": timeType, 45 | "value": time 46 | }, 47 | "link": link 48 | }; 49 | }); 50 | 51 | JSON.stringify(episodes); -------------------------------------------------------------------------------- /PodcastMenu/ErrorViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorViewController.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 28/06/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class ErrorViewController: NSViewController { 12 | 13 | fileprivate let error: NSError 14 | 15 | var reloadHandler = {} 16 | 17 | init(error: NSError) { 18 | self.error = error 19 | 20 | super.init(nibName: nil, bundle: nil)! 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | fileprivate let errorLabel: NSTextField = { 28 | let f = NSTextField(frame: NSZeroRect) 29 | 30 | f.isEditable = false 31 | f.isBezeled = false 32 | f.isBordered = false 33 | f.isSelectable = false 34 | f.backgroundColor = Theme.Colors.tint 35 | f.textColor = NSColor(calibratedWhite: 0, alpha: 0.7) 36 | f.translatesAutoresizingMaskIntoConstraints = false 37 | 38 | return f 39 | }() 40 | 41 | fileprivate let reloadButton: NSButton = { 42 | let b = NSButton(frame: NSZeroRect) 43 | 44 | b.image = NSImage(named: NSImageNameRefreshFreestandingTemplate) 45 | b.setButtonType(.momentaryPushIn) 46 | b.isBordered = false 47 | b.bezelStyle = NSBezelStyle.shadowlessSquare 48 | b.sizeToFit() 49 | b.translatesAutoresizingMaskIntoConstraints = false 50 | b.appearance = NSAppearance(named: NSAppearanceNameAqua) 51 | 52 | return b 53 | }() 54 | 55 | override func loadView() { 56 | view = NSView(frame: NSZeroRect) 57 | view.wantsLayer = true 58 | view.layer?.backgroundColor = Theme.Colors.tint.cgColor 59 | 60 | view.addSubview(errorLabel) 61 | errorLabel.setContentCompressionResistancePriority(NSLayoutPriorityDefaultLow, for: .horizontal) 62 | errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true 63 | errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.errorBarMargin).isActive = true 64 | 65 | view.addSubview(reloadButton) 66 | reloadButton.leadingAnchor.constraint(equalTo: errorLabel.trailingAnchor, constant: 8.0).isActive = true 67 | reloadButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.errorBarMargin).isActive = true 68 | reloadButton.centerYAnchor.constraint(equalTo: errorLabel.centerYAnchor).isActive = true 69 | 70 | reloadButton.target = self 71 | reloadButton.action = #selector(reload) 72 | } 73 | 74 | @objc fileprivate func reload() { 75 | reloadHandler() 76 | } 77 | 78 | override func viewDidLoad() { 79 | super.viewDidLoad() 80 | 81 | errorLabel.stringValue = error.localizedDescription 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /PodcastMenu/ImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCache.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 12/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | private extension String { 12 | 13 | var base64encoded: String? { 14 | return self.data(using: .utf8)?.base64EncodedString() 15 | } 16 | 17 | } 18 | 19 | final class ImageCache { 20 | 21 | static let shared: ImageCache = ImageCache() 22 | 23 | typealias CancellationHandler = () -> () 24 | 25 | class func cacheUrl(for imageUrl: URL) -> URL { 26 | let filebase = imageUrl.absoluteString.data(using: .utf8)?.base64EncodedString() ?? UUID().uuidString 27 | let limitIndex = filebase.index(filebase.endIndex, offsetBy: -filebase.characters.count/2) 28 | let finalBase = filebase.substring(from: limitIndex).replacingOccurrences(of: "==", with: "") 29 | let filename = finalBase + "-" + imageUrl.lastPathComponent 30 | 31 | let path = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first! + "/" + Bundle.main.bundleIdentifier! + "/ImageCache/" + filename + "-" + imageUrl.lastPathComponent 32 | 33 | return URL(fileURLWithPath: path) 34 | } 35 | 36 | func fetchImage(at imageUrl: URL, completion: @escaping (URL, NSImage?) -> ()) -> CancellationHandler { 37 | let cacheUrl = ImageCache.cacheUrl(for: imageUrl) 38 | 39 | guard !FileManager.default.fileExists(atPath: cacheUrl.path) else { 40 | completion(imageUrl, NSImage(contentsOfFile: cacheUrl.path)) 41 | return { } 42 | } 43 | 44 | let task = URLSession.shared.dataTask(with: imageUrl) { data, _, error in 45 | guard let data = data, error == nil else { 46 | DispatchQueue.main.async { 47 | completion(imageUrl, nil) 48 | } 49 | return 50 | } 51 | 52 | guard let cachedImage = self.cache(data: data, cacheURL: cacheUrl) else { 53 | DispatchQueue.main.async { 54 | completion(imageUrl, nil) 55 | } 56 | return 57 | } 58 | 59 | DispatchQueue.main.async { 60 | completion(imageUrl, cachedImage) 61 | } 62 | } 63 | task.resume() 64 | 65 | return { task.cancel() } 66 | } 67 | 68 | private func cache(data: Data, cacheURL: URL) -> NSImage? { 69 | guard let inputImage = NSImage(data: data) else { 70 | return nil 71 | } 72 | 73 | let cacheDir = cacheURL.deletingLastPathComponent() 74 | if !FileManager.default.fileExists(atPath: cacheDir.path) { 75 | do { 76 | try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true, attributes: nil) 77 | } catch { 78 | NSLog("Error creating cache directory: \(error)") 79 | return nil 80 | } 81 | } 82 | 83 | let outputImage = NSImage(size: Metrics.thumbnailSize) 84 | 85 | outputImage.lockFocus() 86 | inputImage.draw(in: NSRect(origin: .zero, size: Metrics.thumbnailSize)) 87 | outputImage.unlockFocus() 88 | 89 | do { 90 | try outputImage.tiffRepresentation?.write(to: cacheURL) 91 | } catch { 92 | NSLog("Error saving image to cache: \(error)") 93 | } 94 | 95 | return outputImage 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /PodcastMenu/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.3 21 | CFBundleSignature 22 | ???? 23 | CFBundleURLTypes 24 | 25 | 26 | CFBundleURLName 27 | http URL 28 | CFBundleURLSchemes 29 | 30 | http 31 | 32 | 33 | 34 | CFBundleURLName 35 | Secure http URL 36 | CFBundleURLSchemes 37 | 38 | https 39 | 40 | 41 | 42 | CFBundleVersion 43 | 10 44 | Fabric 45 | 46 | APIKey 47 | 69b44b9b0e1f177a7fb1b6199e9a040897e9dfc0 48 | Kits 49 | 50 | 51 | KitInfo 52 | 53 | KitName 54 | Crashlytics 55 | 56 | 57 | 58 | LSMinimumSystemVersion 59 | $(MACOSX_DEPLOYMENT_TARGET) 60 | LSUIElement 61 | 62 | NSAppTransportSecurity 63 | 64 | NSAllowsArbitraryLoads 65 | 66 | 67 | NSHumanReadableCopyright 68 | Copyright © 2016 Guilherme Rambo. All rights reserved. 69 | NSMainNibFile 70 | MainMenu 71 | NSPrincipalClass 72 | NSApplication 73 | SUEnableAutomaticChecks 74 | 75 | SUFeedURL 76 | https://github.com/insidegui/PodcastMenu/raw/master/Releases/appcast.xml 77 | SUScheduledCheckInterval 78 | 86400 79 | 80 | 81 | -------------------------------------------------------------------------------- /PodcastMenu/MediaKeysCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaKeysCoordinator.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 15/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class MediaKeysCoordinator: NSObject { 12 | 13 | fileprivate let mediaKeysUsers: [String] 14 | 15 | override init() { 16 | let data = try! Data(contentsOf: Bundle.main.url(forResource: "MediaKeysUsers", withExtension: "plist")!) 17 | self.mediaKeysUsers = try! PropertyListSerialization.propertyList(from: data, options: PropertyListSerialization.MutabilityOptions(), format: nil) as! [String] 18 | 19 | super.init() 20 | 21 | 22 | NSWorkspace.shared().addObserver(self, forKeyPath: "frontmostApplication", options: [.initial, .new], context: nil) 23 | NSWorkspace.shared().addObserver(self, forKeyPath: "runningApplications", options: [.initial, .new], context: nil) 24 | } 25 | 26 | fileprivate var keysOwnedByAnotherApplication = false 27 | 28 | func shouldInterceptMediaKeys() -> Bool { 29 | return keysOwnedByAnotherApplication == false || Preferences.mediaKeysPassthroughEnabled 30 | } 31 | 32 | func shouldPassthroughMediaKeysEvents() -> Bool { 33 | return Preferences.mediaKeysPassthroughEnabled 34 | } 35 | 36 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { 37 | if keyPath == "frontmostApplication" { 38 | // if frontmost application is one of the apps listed in mediaKeysUsers, disable media keys handling, if It's our app, reenable if disabled 39 | if let identifier = NSWorkspace.shared().frontmostApplication?.bundleIdentifier { 40 | if !keysOwnedByAnotherApplication { 41 | if (mediaKeysUsers.contains(identifier)) { 42 | keysOwnedByAnotherApplication = true 43 | #if DEBUG 44 | NSLog("[MediaKeysCoordinator] Media keys now owned by \(identifier)") 45 | #endif 46 | } 47 | } else { 48 | if identifier == Bundle.main.bundleIdentifier { 49 | keysOwnedByAnotherApplication = false 50 | #if DEBUG 51 | NSLog("[MediaKeysCoordinator] Media keys now owned by PodcastMenu") 52 | #endif 53 | } 54 | } 55 | } 56 | } else if keyPath == "runningApplications" { 57 | if !NSWorkspace.shared().runningApplications.reduce(false, { $0 ? $0 : mediaKeysUsers.contains($1.bundleIdentifier ?? "") }) { 58 | // all media keys users have quit, reclaim media keys 59 | keysOwnedByAnotherApplication = false 60 | #if DEBUG 61 | NSLog("[MediaKeysCoordinator] Media keys now owned by PodcastMenu because no other media apps are running") 62 | #endif 63 | } 64 | } else { 65 | super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /PodcastMenu/MediaKeysHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaKeysHandler.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 10/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class MediaKeysHandler: NSObject { 12 | 13 | var playPauseHandler = {} 14 | var forwardHandler = {} 15 | var backwardHandler = {} 16 | 17 | override init() { 18 | super.init() 19 | 20 | startEventTap() 21 | } 22 | 23 | @objc fileprivate func playPressed() { 24 | DispatchQueue.main.async(execute: playPauseHandler) 25 | } 26 | 27 | @objc fileprivate func forwardPressed() { 28 | DispatchQueue.main.async(execute: forwardHandler) 29 | } 30 | 31 | @objc fileprivate func backwardPressed() { 32 | DispatchQueue.main.async(execute: backwardHandler) 33 | } 34 | 35 | // MARK: - Media Keys Events 36 | 37 | fileprivate func mediaKeyEvent(_ key: Int32, down: Bool) { 38 | guard down else { return } 39 | 40 | switch(key) { 41 | case NX_KEYTYPE_PLAY: playPressed() 42 | case NX_KEYTYPE_FAST: forwardPressed() 43 | case NX_KEYTYPE_REWIND: backwardPressed() 44 | default: break 45 | } 46 | } 47 | 48 | // MARK: Event tap 49 | 50 | fileprivate var eventTap: PMEventTap! 51 | 52 | fileprivate func startEventTap() { 53 | eventTap = PMEventTap(mediaKeyEventHandler: mediaKeyEvent) 54 | eventTap.start() 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /PodcastMenu/MediaKeysUsers.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.spotify.client 6 | com.apple.iTunes 7 | com.apple.iTunesX 8 | com.apple.QuickTimePlayerX 9 | com.apple.quicktimeplayer 10 | com.apple.iWork.Keynote 11 | com.apple.iPhoto 12 | org.videolan.vlc 13 | com.apple.Aperture 14 | com.plexsquared.Plex 15 | com.soundcloud.desktop 16 | org.niltsh.MPlayerX 17 | com.ilabs.PandorasHelper 18 | com.mahasoftware.pandabar 19 | com.bitcartel.pandorajam 20 | org.clementine-player.clementine 21 | fm.last.Last.fm 22 | fm.last.Scrobbler 23 | com.beatport.BeatportPro 24 | com.Timenut.SongKey 25 | com.macromedia.fireworks 26 | at.justp.Theremin 27 | ru.ya.themblsha.YandexMusic 28 | com.jriver.MediaCenter18 29 | com.jriver.MediaCenter19 30 | com.jriver.MediaCenter20 31 | co.rackit.mate 32 | com.ttitt.b-music 33 | com.beardedspice.BeardedSpice 34 | com.plug.Plug 35 | com.plug.Plug2 36 | com.netease.163music 37 | org.quodlibet.quodlibet 38 | com.blackmagic-design.desktopvideoutility.setup 39 | br.com.guilhermerambo.Event-Player 40 | br.com.guilhermerambo.WWDC 41 | com.apple.Photos 42 | 43 | 44 | -------------------------------------------------------------------------------- /PodcastMenu/Metrics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Metrics.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 10/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Metrics { 12 | static let viewportWidth = CGFloat(320.0) 13 | static let viewportHeight = CGFloat(430.0) 14 | static let webViewMargin = CGFloat(0.0) 15 | static let progressBarThickness = CGFloat(2.0) 16 | static let errorBarHeight = CGFloat(48.0) 17 | static let errorBarMargin = CGFloat(8.0) 18 | static let controlToWindowMargin = CGFloat(8.0) 19 | static let thumbnailSize = NSSize(width: 100, height: 100) 20 | } 21 | -------------------------------------------------------------------------------- /PodcastMenu/NSImage+CGImage.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage+CGImage.h 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 21/09/15. 6 | // Copyright © 2015 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface NSImage (CGImage) 12 | 13 | @property (nonatomic, readonly) CGImageRef CGImage; 14 | @property (nonatomic, readonly) CGImageRef CGImageForCurrentScale; 15 | 16 | - (CGImageRef)imageRefAtIndex:(int)index; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /PodcastMenu/NSImage+CGImage.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage+CGImage.m 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 21/09/15. 6 | // Copyright © 2015 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | #import "NSImage+CGImage.h" 10 | 11 | @implementation NSImage (CGImage) 12 | 13 | - (CGImageRef)imageRefAtIndex:(int)index 14 | { 15 | CGImageSourceRef source = CGImageSourceCreateWithData((CFDataRef)[self TIFFRepresentation], NULL); 16 | 17 | return CGImageSourceCreateImageAtIndex(source, index, NULL); 18 | } 19 | 20 | - (CGImageRef)CGImage 21 | { 22 | return [self imageRefAtIndex:0]; 23 | } 24 | 25 | - (CGImageRef)CGImageForCurrentScale 26 | { 27 | int idx = ([NSScreen mainScreen].backingScaleFactor > 1) ? 1 : 0; 28 | 29 | return [self imageRefAtIndex:idx]; 30 | } 31 | 32 | @end 33 | -------------------------------------------------------------------------------- /PodcastMenu/OvercastController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OvercastController.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 10/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import WebKit 11 | 12 | protocol OvercastLoudnessDelegate { 13 | func loudnessDidChange(_ value: Double) 14 | } 15 | 16 | extension Notification.Name { 17 | static let OvercastDidPlay = Notification.Name(rawValue: "OvercastDidPlay") 18 | static let OvercastDidPause = Notification.Name(rawValue: "OvercastDidPause") 19 | static let OvercastShouldUpdatePlaybackInfo = Notification.Name(rawValue: "OvercastShouldUpdatePlaybackInfo") 20 | static let OvercastIsNotOnEpisodePage = Notification.Name(rawValue: "OvercastIsNotOnEpisodePage") 21 | static let OvercastCommandTogglePlaying = Notification.Name(rawValue: "OvercastCommandTogglePlaying") 22 | } 23 | 24 | class OvercastController: NSObject, WKNavigationDelegate { 25 | 26 | var loudnessDelegate: OvercastLoudnessDelegate? 27 | 28 | fileprivate let webView: WKWebView 29 | fileprivate let bridge: OvercastJavascriptBridge 30 | 31 | fileprivate var mediaKeysHandler = MediaKeysHandler() 32 | 33 | fileprivate lazy var userScript: WKUserScript = { 34 | let source = try! String(contentsOf: Bundle.main.url(forResource: "overcast", withExtension: "js")!) 35 | 36 | return WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true) 37 | }() 38 | 39 | fileprivate lazy var lookUserScript: WKUserScript = { 40 | let source = try! String(contentsOf: Bundle.main.url(forResource: "look", withExtension: "js")!) 41 | 42 | return WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false) 43 | }() 44 | 45 | fileprivate func startAutomaticRefresh() { 46 | Timer.scheduledTimer(timeInterval: TimeInterval(Constants.automaticRefreshInterval), target: self, selector: #selector(self.refresh(timer:)) , userInfo: nil, repeats: true) 47 | 48 | NSWorkspace.shared().notificationCenter.addObserver(forName: Notification.Name.NSWorkspaceDidWake, object: NSWorkspace.shared(), queue: nil) { [weak self] _ in 49 | self?.refreshPodcastsIfNeeded() 50 | } 51 | } 52 | 53 | @objc fileprivate func refresh(timer: Timer) { 54 | refreshPodcastsIfNeeded() 55 | } 56 | 57 | fileprivate func refreshPodcastsIfNeeded() { 58 | guard (activity == nil) else { return } 59 | guard (self.webView.url != nil) else { return } 60 | guard self.webView.url?.path == Constants.homePath else { return } 61 | guard !self.webView.isLoading else { return } 62 | guard (self.webView.window?.isVisible == false) else { return } 63 | #if DEBUG 64 | NSLog("[OvercastController] automatically refreshing podcasts") 65 | #endif 66 | self.webView.reload() 67 | } 68 | 69 | init(webView: WKWebView) { 70 | self.webView = webView 71 | self.bridge = OvercastJavascriptBridge(webView: webView) 72 | 73 | super.init() 74 | 75 | startAutomaticRefresh() 76 | self.bridge.callback = callLoudnessDelegate 77 | 78 | webView.navigationDelegate = self 79 | 80 | mediaKeysHandler.playPauseHandler = handlePlayPauseButton 81 | mediaKeysHandler.forwardHandler = handleForwardButton 82 | mediaKeysHandler.backwardHandler = handleBackwardButton 83 | 84 | webView.configuration.userContentController.addUserScript(lookUserScript) 85 | webView.configuration.userContentController.addUserScript(userScript) 86 | 87 | NotificationCenter.default.addObserver(forName: Notification.Name.OvercastDidPlay, object: nil, queue: nil) { [weak self] _ in 88 | self?.startActivityIfNeeded() 89 | } 90 | NotificationCenter.default.addObserver(forName: Notification.Name.OvercastDidPause, object: nil, queue: nil) { [weak self] _ in 91 | self?.stopActivity() 92 | } 93 | NotificationCenter.default.addObserver(forName: Notification.Name.OvercastCommandTogglePlaying, object: nil, queue: nil) { [weak self] _ in 94 | self?.handlePlayPauseButton() 95 | } 96 | } 97 | 98 | func isValidOvercastURL(_ URL: Foundation.URL) -> Bool { 99 | guard let host = URL.host else { return false } 100 | 101 | return Constants.allowedHosts.contains(host) 102 | } 103 | 104 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 105 | // the default is to allow the navigation 106 | var decision = WKNavigationActionPolicy.allow 107 | 108 | defer { decisionHandler(decision) } 109 | 110 | guard navigationAction.navigationType == .linkActivated else { return } 111 | 112 | guard let URL = navigationAction.request.url else { return } 113 | 114 | // if the user clicked a link to another website, open with the default browser instead of navigating inside the app 115 | guard isValidOvercastURL(URL) else { 116 | decision = .cancel 117 | NSWorkspace.shared().open(URL) 118 | return 119 | } 120 | } 121 | 122 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 123 | DispatchQueue.main.async { 124 | if webView.url?.path != Constants.homePath { 125 | self.startPlaybackInfoTimer() 126 | } else { 127 | NotificationCenter.default.post(name: .OvercastIsNotOnEpisodePage, object: nil) 128 | self.stopPlaybackInfoTimer() 129 | } 130 | } 131 | } 132 | 133 | // MARK: - Playback Info 134 | 135 | private var playbackInfoTimer: Timer? 136 | 137 | fileprivate func startPlaybackInfoTimer() { 138 | playbackInfoTimer?.invalidate() 139 | 140 | playbackInfoTimer = Timer.scheduledTimer(timeInterval: 1, 141 | target: self, 142 | selector: #selector(updatePlaybackInfo(_:)), 143 | userInfo: nil, 144 | repeats: true) 145 | } 146 | 147 | fileprivate func stopPlaybackInfoTimer() { 148 | playbackInfoTimer?.invalidate() 149 | playbackInfoTimer = nil 150 | } 151 | 152 | @objc fileprivate func updatePlaybackInfo(_ sender: Timer) { 153 | guard !isPaused else { return } 154 | 155 | NotificationCenter.default.post(name: .OvercastShouldUpdatePlaybackInfo, object: nil) 156 | } 157 | 158 | fileprivate var isPaused = false 159 | 160 | // WKWebView has a bug where javascript will not be evaluated in some circumstances (Radar #26290876) 161 | fileprivate func fakeOrderFrontToWorkaround26290876() { 162 | guard !NSApp.isActive else { return } 163 | 164 | NSAnimationContext.beginGrouping() 165 | NSAnimationContext.current().duration = 0.0 166 | webView.window?.orderFrontRegardless() 167 | webView.window?.alphaValue = 0.0 168 | NSAnimationContext.endGrouping() 169 | } 170 | 171 | fileprivate func undoFakeOrderFront() { 172 | guard !NSApp.isActive else { return } 173 | 174 | NSAnimationContext.beginGrouping() 175 | NSAnimationContext.current().duration = 0.0 176 | webView.window?.orderOut(nil) 177 | NSAnimationContext.endGrouping() 178 | } 179 | 180 | fileprivate func handlePlayPauseButton() { 181 | fakeOrderFrontToWorkaround26290876() 182 | 183 | if isPaused { 184 | webView.evaluateJavaScript("document.querySelector('audio').play()") { [unowned self] _, _ in 185 | DispatchQueue.main.async(execute: self.undoFakeOrderFront); 186 | } 187 | } else { 188 | webView.evaluateJavaScript("document.querySelector('audio').pause()") { [unowned self] _, _ in 189 | DispatchQueue.main.async(execute: self.undoFakeOrderFront); 190 | } 191 | } 192 | 193 | isPaused = !isPaused 194 | } 195 | 196 | fileprivate func handleForwardButton() { 197 | fakeOrderFrontToWorkaround26290876() 198 | 199 | webView.evaluateJavaScript("document.querySelector('#seekforwardbutton').click()") { [unowned self] _, _ in 200 | DispatchQueue.main.async(execute: self.undoFakeOrderFront); 201 | } 202 | } 203 | 204 | fileprivate func handleBackwardButton() { 205 | fakeOrderFrontToWorkaround26290876() 206 | 207 | webView.evaluateJavaScript("document.querySelector('#seekbackbutton').click()") { [unowned self] _, _ in 208 | DispatchQueue.main.async(execute: self.undoFakeOrderFront); 209 | } 210 | } 211 | 212 | fileprivate func callLoudnessDelegate(_ value: Double) { 213 | loudnessDelegate?.loudnessDidChange(value) 214 | } 215 | 216 | // MARK: - Activity 217 | 218 | fileprivate var activity: NSObjectProtocol? 219 | 220 | fileprivate func startActivityIfNeeded() { 221 | guard activity == nil else { return } 222 | 223 | activity = ProcessInfo.processInfo.beginActivity(options: [.userInitiated, .automaticTerminationDisabled, .suddenTerminationDisabled, .idleSystemSleepDisabled], reason: "PodcastMenu") 224 | 225 | #if DEBUG 226 | NSLog("[OvercastController] Started activity \(String(describing: activity))") 227 | #endif 228 | } 229 | fileprivate func stopActivity() { 230 | guard let activity = activity else { return } 231 | 232 | #if DEBUG 233 | NSLog("[OvercastController] Stopping activity \(activity)") 234 | #endif 235 | 236 | ProcessInfo.processInfo.endActivity(activity) 237 | 238 | self.activity = nil 239 | } 240 | 241 | // MARK: - Error handling 242 | 243 | fileprivate var errorViewController: ErrorViewController! 244 | 245 | @objc fileprivate func reload() { 246 | let currentURL = webView.url ?? Constants.webAppURL as URL 247 | webView.load(URLRequest(url: currentURL)) 248 | } 249 | 250 | func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { 251 | DispatchQueue.main.async { 252 | self.hideErrorViewController() 253 | } 254 | } 255 | 256 | func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 257 | let delayTime = DispatchTime.now() + Double(Int64(Constants.retryIntervalAfterError * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) 258 | DispatchQueue.main.asyncAfter(deadline: delayTime) { [weak self] in 259 | self?.reload() 260 | } 261 | 262 | DispatchQueue.main.async { 263 | self.showErrorViewControllerWithError(error as NSError) 264 | } 265 | } 266 | 267 | fileprivate func showErrorViewControllerWithError(_ error: NSError) { 268 | if errorViewController == nil { 269 | errorViewController = ErrorViewController(error: error) 270 | 271 | if let superview = webView.superview { 272 | errorViewController.view.frame = NSRect(x: 0.0, y: superview.bounds.height - Metrics.errorBarHeight, width: superview.bounds.width, height: Metrics.errorBarHeight) 273 | errorViewController.view.alphaValue = 0.0 274 | errorViewController.view.autoresizingMask = [.viewWidthSizable, .viewMinYMargin] 275 | superview.addSubview(errorViewController.view) 276 | } 277 | 278 | errorViewController.reloadHandler = { [weak self] in 279 | self?.reload() 280 | } 281 | } 282 | 283 | errorViewController.view.isHidden = false 284 | 285 | NSAnimationContext.runAnimationGroup({ ctx in 286 | ctx.duration = 0.4 287 | self.errorViewController.view.animator().alphaValue = 1.0 288 | }, completionHandler: nil) 289 | } 290 | 291 | fileprivate func hideErrorViewController() { 292 | guard errorViewController != nil else { return } 293 | 294 | NSAnimationContext.runAnimationGroup({ ctx in 295 | ctx.duration = 0.4 296 | self.errorViewController.view.animator().alphaValue = 0.0 297 | }, completionHandler: { self.errorViewController.view.isHidden = true }) 298 | } 299 | 300 | } 301 | 302 | private class OvercastJavascriptBridge: NSObject, WKScriptMessageHandler { 303 | 304 | var callback: (Double) -> () = { _ in } 305 | 306 | fileprivate var fakeGenerator: FakeLoudnessDataGenerator! 307 | 308 | init(webView: WKWebView) { 309 | super.init() 310 | 311 | webView.configuration.userContentController.add(self, name: Constants.javascriptBridgeName) 312 | } 313 | 314 | @objc fileprivate func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 315 | guard let msg = message.body as? String else { return } 316 | 317 | DispatchQueue.main.async { 318 | switch msg { 319 | case "pause": self.didPause() 320 | case "play": self.didPlay() 321 | default: break; 322 | } 323 | } 324 | 325 | /* JS-based VU disabled because of webkit bug (issue #3) 326 | guard let value = message.body as? Double else { return } 327 | 328 | dispatch_async(dispatch_get_main_queue()) { 329 | self.callback(value) 330 | } 331 | */ 332 | } 333 | 334 | fileprivate func didPause() { 335 | guard fakeGenerator != nil else { return } 336 | 337 | NotificationCenter.default.post(name: Notification.Name.OvercastDidPause, object: self) 338 | 339 | DispatchQueue.main.async { 340 | self.fakeGenerator.suspend() 341 | } 342 | } 343 | 344 | fileprivate func didPlay() { 345 | if fakeGenerator == nil { fakeGenerator = FakeLoudnessDataGenerator(callback: callback) } 346 | 347 | NotificationCenter.default.post(name: Notification.Name.OvercastDidPlay, object: self) 348 | 349 | DispatchQueue.main.async { 350 | self.fakeGenerator.resume() 351 | } 352 | } 353 | 354 | } 355 | 356 | private class FakeLoudnessDataGenerator { 357 | 358 | fileprivate let callback: (Double) -> () 359 | fileprivate var timer: Timer! 360 | 361 | init(callback: @escaping (Double) -> ()) { 362 | self.callback = callback 363 | } 364 | 365 | func resume() { 366 | guard timer == nil else { return } 367 | 368 | timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(generate), userInfo: nil, repeats: true) 369 | } 370 | 371 | func suspend() { 372 | guard timer != nil else { return } 373 | 374 | timer.invalidate() 375 | timer = nil 376 | } 377 | 378 | fileprivate var minValue = 22.0 379 | fileprivate var maxValue = 100.0 380 | fileprivate var stepValue = 4.0 381 | fileprivate var currentValue = 0.0 382 | fileprivate var direction = 1 383 | 384 | @objc fileprivate func generate() { 385 | let step = (stepValue + stepValue * drand48()) * Double(direction) 386 | currentValue += step 387 | 388 | if (currentValue >= maxValue) { 389 | direction = -1 390 | } else if (currentValue <= minValue) { 391 | direction = 1 392 | } 393 | 394 | callback(currentValue) 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /PodcastMenu/OvercastModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OvercastModel.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 09/04/17. 6 | // Copyright © 2017 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol OvercastModel { 12 | 13 | var title: String { get } 14 | var link: URL? { get } 15 | var poster: URL { get } 16 | 17 | } 18 | 19 | extension Podcast: OvercastModel { 20 | 21 | var title: String { 22 | return name 23 | } 24 | 25 | } 26 | 27 | extension Episode: OvercastModel { } 28 | 29 | extension OvercastModel { 30 | 31 | func compare(to other: OvercastModel) -> Bool { 32 | return self.title == other.title && self.link == other.link && self.poster == other.poster 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /PodcastMenu/PMEventTap.h: -------------------------------------------------------------------------------- 1 | // 2 | // PMEventTap.h 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 15/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | @import Cocoa; 10 | 11 | @interface PMEventTap : NSObject 12 | 13 | - (_Nonnull instancetype)initWithMediaKeyEventHandler:(void (^_Nonnull)(int32_t key, BOOL down))eventHandler; 14 | 15 | - (void)start; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /PodcastMenu/PMEventTap.m: -------------------------------------------------------------------------------- 1 | // 2 | // PMEventTap.m 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 15/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | #import "PMEventTap.h" 10 | #import "PodcastMenu-Swift.h" 11 | 12 | @interface PMEventTap () 13 | 14 | @property (nonatomic, strong) MediaKeysCoordinator *coordinator; 15 | @property (nonatomic, copy) void (^mediaKeyEventHandler)(int32_t key, BOOL down); 16 | @property (nonatomic, assign) CFMachPortRef eventPort; 17 | 18 | @end 19 | 20 | #define CGEventTypeSystemDefined NSSystemDefined 21 | 22 | CGEventRef eventTapCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void* context) 23 | { 24 | PMEventTap *eventTap = (__bridge PMEventTap *)context; 25 | 26 | if (![eventTap.coordinator shouldInterceptMediaKeys]) return event; 27 | 28 | if (type == kCGEventTapDisabledByTimeout) { 29 | #ifdef DEBUG 30 | NSLog(@"[PMEventTap] Tap disabled by timeout, reenabling."); 31 | #endif 32 | 33 | CGEventTapEnable(eventTap.eventPort, true); 34 | 35 | return event; 36 | } 37 | 38 | if ((NSEventType)type != CGEventTypeSystemDefined) return event; 39 | 40 | NSEvent *theEvent; 41 | 42 | @try { 43 | theEvent = [NSEvent eventWithCGEvent:event]; 44 | } @catch (NSException *e) { 45 | #ifdef DEBUG 46 | NSLog(@"[PMEventTap] Received an unknown event. Exception: %@", e); 47 | #endif 48 | return event; 49 | } 50 | 51 | if (theEvent.type == NSSystemDefined && theEvent.subtype == 8) { 52 | NSInteger keyCode = ((theEvent.data1 & 0xFFFF0000) >> 16); 53 | 54 | // only fast, play and rewind keys are supported 55 | if (keyCode != NX_KEYTYPE_FAST && keyCode != NX_KEYTYPE_PLAY && keyCode != NX_KEYTYPE_REWIND) { 56 | return event; 57 | } 58 | 59 | NSInteger keyFlags = (theEvent.data1 & 0x0000FFFF); 60 | 61 | BOOL keyState = (((keyFlags & 0xFF00) >> 8)) == 0xA; 62 | 63 | eventTap.mediaKeyEventHandler((int32_t)keyCode, keyState); 64 | 65 | if ([eventTap.coordinator shouldPassthroughMediaKeysEvents]) { 66 | return event; 67 | } else { 68 | return NULL; 69 | } 70 | return NULL; 71 | } else { 72 | return event; 73 | } 74 | } 75 | 76 | @implementation PMEventTap 77 | 78 | - (instancetype)initWithMediaKeyEventHandler:(void (^)(int32_t, BOOL))eventHandler 79 | { 80 | self = [super init]; 81 | 82 | self.coordinator = [[MediaKeysCoordinator alloc] init]; 83 | self.mediaKeyEventHandler = eventHandler; 84 | 85 | return self; 86 | } 87 | 88 | - (void)start 89 | { 90 | dispatch_queue_t eventQueue = dispatch_queue_create("br.com.guilhermerambo.EventTap", NULL); 91 | dispatch_async(eventQueue, ^{ 92 | CGEventMask mask = CGEventMaskBit(NSSystemDefined); 93 | 94 | self.eventPort = CGEventTapCreate(kCGHIDEventTap, kCGHeadInsertEventTap, 0, mask, eventTapCallback, (__bridge void *)self); 95 | CFRunLoopSourceRef runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, self.eventPort, 0); 96 | CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, kCFRunLoopCommonModes); 97 | CGEventTapEnable(self.eventPort, true); 98 | CFRunLoopRun(); 99 | }); 100 | } 101 | 102 | @end 103 | -------------------------------------------------------------------------------- /PodcastMenu/PMFreestandingButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PMFreestandingButton.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 09/04/17. 6 | // Copyright © 2017 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PMFreestandingButton: NSButton { 12 | 13 | override init(frame frameRect: NSRect) { 14 | super.init(frame: frameRect) 15 | 16 | wantsLayer = true 17 | } 18 | 19 | override var isOpaque: Bool { 20 | return false 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | override var allowsVibrancy: Bool { 28 | return false 29 | } 30 | 31 | override class func cellClass() -> AnyClass { 32 | return PMFreestandingButtonCell.self 33 | } 34 | 35 | override var isHighlighted: Bool { 36 | didSet { 37 | setNeedsDisplay() 38 | } 39 | } 40 | 41 | } 42 | 43 | final class PMFreestandingButtonCell: NSButtonCell { 44 | 45 | override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { 46 | guard let image = image else { return } 47 | 48 | guard let ctx = NSGraphicsContext.current()?.cgContext else { return } 49 | ctx.saveGState() 50 | 51 | let constrainedWidth: CGFloat 52 | let constrainedHeight: CGFloat 53 | 54 | let rw = image.size.width / cellFrame.width 55 | let rh = image.size.height / cellFrame.height 56 | 57 | if (rw > rh) { 58 | constrainedHeight = round(image.size.height / rw) 59 | constrainedWidth = cellFrame.width 60 | } else { 61 | constrainedWidth = round(image.size.width / rh) 62 | constrainedHeight = cellFrame.height 63 | } 64 | 65 | let maskRect = NSRect( 66 | x: (cellFrame.width / 2.0 - constrainedWidth / 2.0), 67 | y: (cellFrame.height / 2.0 - constrainedHeight / 2.0), 68 | width: constrainedWidth, 69 | height: constrainedHeight 70 | ) 71 | 72 | ctx.translateBy(x: 0, y: constrainedHeight) 73 | ctx.scaleBy(x: 1, y: -1) 74 | 75 | ctx.clip(to: maskRect, mask: image.cgImageForCurrentScale) 76 | ctx.setFillColor(Theme.Colors.tint.cgColor) 77 | ctx.fill(cellFrame) 78 | 79 | ctx.restoreGState() 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /PodcastMenu/PMWebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PMWebView.swift 3 | // WebView Tests 4 | // 5 | // Created by Guilherme Rambo on 13/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import WebKit 11 | 12 | class PMWebView: WKWebView { 13 | 14 | fileprivate var scrollTimer: Timer! 15 | fileprivate lazy var scrollCaptureView = PMScrollCaptureView(frame: NSZeroRect) 16 | 17 | override func viewDidMoveToWindow() { 18 | super.viewDidMoveToWindow() 19 | 20 | guard scrollCaptureView.superview == nil else { return } 21 | guard subviews.count > 0 else { return } 22 | 23 | scrollCaptureView.webView = self 24 | scrollCaptureView.frame = bounds 25 | scrollCaptureView.autoresizingMask = [.viewWidthSizable, .viewHeightSizable] 26 | subviews[0].addSubview(scrollCaptureView) 27 | } 28 | 29 | fileprivate func didScroll() { 30 | showScrollbar() 31 | resetScrollTimer() 32 | } 33 | 34 | fileprivate func resetScrollTimer() { 35 | if scrollTimer != nil { 36 | scrollTimer.invalidate() 37 | scrollTimer = nil 38 | } 39 | 40 | scrollTimer = Timer.scheduledTimer(timeInterval: 0.4, target: self, selector: #selector(hideScrollbar), userInfo: nil, repeats: false) 41 | } 42 | 43 | @objc fileprivate func hideScrollbar() { 44 | evaluateJavaScript("PodcastMenuLook.hideScroll()", completionHandler: nil) 45 | } 46 | 47 | fileprivate func showScrollbar() { 48 | evaluateJavaScript("PodcastMenuLook.showScroll()", completionHandler: nil) 49 | } 50 | 51 | } 52 | 53 | private class PMScrollCaptureView: NSView { 54 | 55 | fileprivate var webView: PMWebView! 56 | 57 | fileprivate override func scrollWheel(with theEvent: NSEvent) { 58 | // cancel horizontal scrolling 59 | guard fabs(theEvent.scrollingDeltaX) < fabs(theEvent.scrollingDeltaY) else { return } 60 | 61 | webView.didScroll() 62 | 63 | superview?.scrollWheel(with: theEvent) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /PodcastMenu/PlaybackInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaybackInfo.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 09/04/17. 6 | // Copyright © 2017 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct PlaybackInfo { 12 | 13 | let title: String 14 | let timeElapsed: String 15 | let timeRemaining: String 16 | let audioURL: URL 17 | let shareURL: URL 18 | let shareWithTimeURL: URL 19 | let artworkURL: URL 20 | let isPlaying: Bool 21 | 22 | } 23 | 24 | extension PlaybackInfo: Equatable { 25 | 26 | static func ==(lhs: PlaybackInfo, rhs: PlaybackInfo) -> Bool { 27 | return lhs.title == rhs.title 28 | && lhs.timeElapsed == rhs.timeElapsed 29 | && lhs.timeRemaining == rhs.timeRemaining 30 | && lhs.audioURL == rhs.audioURL 31 | && lhs.shareURL == rhs.shareURL 32 | && lhs.shareWithTimeURL == rhs.shareWithTimeURL 33 | && lhs.artworkURL == rhs.artworkURL 34 | && lhs.isPlaying == rhs.isPlaying 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /PodcastMenu/PlaybackInfoAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaybackInfoAdapter.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 09/04/17. 6 | // Copyright © 2017 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | 12 | final class PlaybackInfoAdapter: Adapter { 13 | 14 | override func adapt() -> Result { 15 | guard let title = input["title"].string else { 16 | return .error(.missingRequiredFields) 17 | } 18 | 19 | guard let timeElapsed = input["time_elapsed"].string else { 20 | return .error(.missingRequiredFields) 21 | } 22 | 23 | guard let timeRemaining = input["time_remaining"].string else { 24 | return .error(.missingRequiredFields) 25 | } 26 | 27 | guard let shareLink = input["share_link"].string, 28 | let shareURL = URL(string: shareLink) else { 29 | return .error(.missingRequiredFields) 30 | } 31 | 32 | guard let shareLinkWithTimestamp = input["share_link_timestamp"].string, 33 | let shareWithTimeURL = URL(string: shareLinkWithTimestamp) else { 34 | return .error(.missingRequiredFields) 35 | } 36 | 37 | guard let audioSource = input["audio_source"].string, 38 | let audioURL = URL(string: audioSource) else { 39 | return .error(.missingRequiredFields) 40 | } 41 | 42 | guard let artworkSource = input["artwork_url"].string, 43 | let artworkURL = URL(string: artworkSource) else { 44 | return .error(.missingRequiredFields) 45 | } 46 | 47 | let playing = input["is_playing"].boolValue 48 | 49 | let info = PlaybackInfo(title: title, 50 | timeElapsed: timeElapsed, 51 | timeRemaining: timeRemaining, 52 | audioURL: audioURL, 53 | shareURL: shareURL, 54 | shareWithTimeURL: shareWithTimeURL, 55 | artworkURL: artworkURL, 56 | isPlaying: playing) 57 | 58 | return .success(info) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /PodcastMenu/PlaybackInfoParser.js: -------------------------------------------------------------------------------- 1 | var timeElapsedElement = document.querySelector('#timeelapsed'); 2 | var timeRemainingElement = document.querySelector('#timeremaining'); 3 | var audioElement = document.querySelector('audio'); 4 | var titleElement = document.querySelector('div.titlestack div.title'); 5 | var shareContainer = document.querySelector('div.centertext.lighttext.margintop1'); 6 | var shareLinks = shareContainer.querySelectorAll('a'); 7 | var shareLinkElement = shareLinks[1]; 8 | var shareLinkWithTimestampElement = shareLinks[2]; 9 | var artworkElement = document.querySelector(".fullart_container img.art.fullart"); 10 | 11 | var playbackInfo = { 12 | "title": titleElement.innerText, 13 | "time_elapsed": timeElapsedElement.innerText, 14 | "time_remaining": timeRemainingElement.innerText, 15 | "share_link": shareLinkElement.getAttribute('href'), 16 | "share_link_timestamp": shareLinkWithTimestampElement.getAttribute('href'), 17 | "audio_source": audioElement.currentSrc, 18 | "artwork_url": artworkElement.getAttribute('src'), 19 | "is_playing": document.querySelector('audio').playbackRate >= 1 20 | }; 21 | 22 | JSON.stringify(playbackInfo); 23 | -------------------------------------------------------------------------------- /PodcastMenu/Podcast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Podcast.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Podcast: Equatable { 12 | 13 | let name: String 14 | let poster: URL 15 | let link: URL? 16 | 17 | static func == (lhs: Podcast, rhs: Podcast) -> Bool { 18 | return lhs.name == rhs.name && lhs.poster == rhs.poster && lhs.link == rhs.link 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PodcastMenu/PodcastAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PodcastAdapter.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | 12 | final class PodcastAdapter: Adapter { 13 | 14 | override func adapt() -> Result { 15 | guard let name = input["name"].string else { 16 | return .error(.missingRequiredFields) 17 | } 18 | 19 | guard let poster = input["poster"].string?.overcastPoster else { 20 | return .error(.missingRequiredFields) 21 | } 22 | 23 | let link = input["link"].stringValue 24 | 25 | let podcast = Podcast(name: name, poster: poster, link: URL(string: link)) 26 | 27 | return .success(podcast) 28 | } 29 | 30 | } 31 | 32 | 33 | final class PodcastsAdapter: Adapter { 34 | 35 | override func adapt() -> Result<[Podcast], AdapterError> { 36 | guard let jsonPodcasts = input.array else { 37 | return .error(.missingRequiredFields) 38 | } 39 | 40 | let podcasts: [Podcast] = jsonPodcasts.flatMap { json -> Podcast? in 41 | let result = PodcastAdapter(input: json).adapt() 42 | switch result { 43 | case .error(_): return nil 44 | case .success(let podcast): return podcast 45 | } 46 | } 47 | 48 | return .success(podcasts) 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /PodcastMenu/PodcastMenu-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "NSImage+CGImage.h" 6 | #import "PMEventTap.h" 7 | #import "TouchBarPrivate.h" 8 | -------------------------------------------------------------------------------- /PodcastMenu/PodcastsParser.js: -------------------------------------------------------------------------------- 1 | var podcastCells = document.querySelectorAll('.feedcell'); 2 | 3 | var podcastCellsArray = []; 4 | var len = podcastCells.length; 5 | for (var i = 0; i < len; i++) { 6 | podcastCellsArray.push(podcastCells[i]); 7 | } 8 | 9 | var podcasts = podcastCellsArray.map(function(cell){ 10 | var link = cell.href; 11 | if (!link) return; 12 | 13 | var poster = cell.querySelector('img.art').src; 14 | if (!poster) return; 15 | 16 | var dataDivs = cell.querySelectorAll('.cellcontent .titlestack div'); 17 | if (!dataDivs.length) return; 18 | 19 | var showName = dataDivs[0].innerText; 20 | 21 | return { 22 | "name": showName, 23 | "poster": poster, 24 | "link": link 25 | }; 26 | }); 27 | 28 | JSON.stringify(podcasts); -------------------------------------------------------------------------------- /PodcastMenu/Poster.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Poster.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 12/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | 13 | var overcastPoster: URL? { 14 | guard let posterComponents = URLComponents(string: self) else { 15 | return nil 16 | } 17 | 18 | guard let posterUrlString = posterComponents.queryItems?.first(where: { $0.name == "u" })?.value else { 19 | return nil 20 | } 21 | 22 | return URL(string: posterUrlString) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /PodcastMenu/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class Preferences { 12 | 13 | fileprivate class var defaults: UserDefaults { 14 | return UserDefaults.standard 15 | } 16 | 17 | class var enableVU: Bool { 18 | set { 19 | #if DEBUG 20 | NSLog("enableVU = \(!newValue)") 21 | #endif 22 | defaults.set(!newValue, forKey: "vudisabled") 23 | defaults.synchronize() 24 | } 25 | get { 26 | return !defaults.bool(forKey: "vudisabled") 27 | } 28 | } 29 | 30 | class var mediaKeysPassthroughEnabled: Bool { 31 | set { 32 | defaults.set(newValue, forKey: "mediakeyspassthrough") 33 | defaults.synchronize() 34 | } 35 | get { 36 | return defaults.bool(forKey: "mediakeyspassthrough") 37 | } 38 | } 39 | 40 | class var notificationsEnabled: Bool { 41 | set { 42 | defaults.set(newValue, forKey: "enableNotifications") 43 | defaults.synchronize() 44 | } 45 | get { 46 | 47 | return defaults.bool(forKey: "enableNotifications") 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /PodcastMenu/ProgressBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressBar.swift 3 | // Asset Catalog Tinkerer 4 | // 5 | // Created by Guilherme Rambo on 27/03/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | open class ProgressBar: NSView { 12 | 13 | override public init(frame frameRect: NSRect) { 14 | super.init(frame: frameRect) 15 | 16 | commonInit() 17 | } 18 | 19 | required public init?(coder: NSCoder) { 20 | super.init(coder: coder) 21 | 22 | commonInit() 23 | } 24 | 25 | override open func awakeFromNib() { 26 | super.awakeFromNib() 27 | 28 | commonInit() 29 | } 30 | 31 | open var tintColor: NSColor? { 32 | didSet { 33 | CATransaction.begin() 34 | CATransaction.setAnimationDuration(0.0) 35 | progressLayer.backgroundColor = tintColor?.cgColor 36 | CATransaction.commit() 37 | } 38 | } 39 | 40 | open var progress: Double = 0.0 { 41 | didSet { 42 | let animated = oldValue < progress 43 | 44 | DispatchQueue.main.async { self.updateProgressLayer(animated) } 45 | } 46 | } 47 | 48 | open var completedThreshold = 0.99 49 | 50 | fileprivate var progressLayer: CALayer! 51 | 52 | fileprivate func commonInit() { 53 | guard progressLayer == nil else { return } 54 | 55 | wantsLayer = true 56 | layer = CALayer() 57 | 58 | progressLayer = CALayer() 59 | progressLayer.backgroundColor = tintColor?.cgColor 60 | progressLayer.frame = NSRect(x: 0.0, y: 0.0, width: 0.0, height: bounds.height) 61 | layer!.addSublayer(progressLayer) 62 | progressLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] 63 | 64 | updateProgressLayer() 65 | } 66 | 67 | fileprivate var widthForProgressLayer: CGFloat { 68 | return bounds.width * CGFloat(progress) 69 | } 70 | 71 | fileprivate func updateProgressLayer(_ animated: Bool = true) { 72 | CATransaction.begin() 73 | CATransaction.setAnimationDuration(animated ? 0.4 : 0.0) 74 | var frame = progressLayer.frame 75 | frame.size.width = widthForProgressLayer 76 | progressLayer.frame = frame 77 | 78 | if progress >= completedThreshold { 79 | progressLayer.opacity = 0.0 80 | } else { 81 | progressLayer.opacity = 1.0 82 | } 83 | 84 | CATransaction.commit() 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /PodcastMenu/Result.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Result.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum Result { 12 | case error(E) 13 | case success(T) 14 | } 15 | -------------------------------------------------------------------------------- /PodcastMenu/ScrubberRemoteImageItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrubberRemoteImageItemView.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 12/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | @available(OSX 10.12.2, *) 12 | class ScrubberRemoteImageItemView: NSScrubberImageItemView { 13 | 14 | var indexInScrubber: Int = -1 15 | 16 | var imageUrl: URL? { 17 | didSet { 18 | guard imageUrl != nil, superview != nil else { return } 19 | 20 | displayImage() 21 | } 22 | } 23 | 24 | override func viewDidMoveToSuperview() { 25 | super.viewDidMoveToSuperview() 26 | 27 | displayImage() 28 | } 29 | 30 | private var cancelDownload: ImageCache.CancellationHandler? 31 | 32 | override func prepareForReuse() { 33 | super.prepareForReuse() 34 | 35 | cancelDownload?() 36 | } 37 | 38 | private func displayImage() { 39 | guard let imageUrl = imageUrl else { return } 40 | 41 | let imageUrlWhenDownloadStarted = imageUrl 42 | 43 | cancelDownload = ImageCache.shared.fetchImage(at: imageUrl) { [weak self] _, image in 44 | guard let welf = self else { return } 45 | 46 | guard imageUrlWhenDownloadStarted == welf.imageUrl else { 47 | #if DEBUG 48 | NSLog("Skipped setting scrubber item image because the URL changed \(imageUrlWhenDownloadStarted)") 49 | #endif 50 | return 51 | } 52 | guard let image = image else { return } 53 | 54 | welf.image = image 55 | } 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /PodcastMenu/StatusPopoverController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusPopoverController.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 10/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class StatusPopoverController: NSObject { 12 | 13 | fileprivate var theme = Theme() 14 | fileprivate var popover: NSPopover? 15 | lazy var webAppController = PodcastWebAppViewController() 16 | fileprivate var themeObserver: NSObjectProtocol? 17 | 18 | override init() { 19 | super.init() 20 | 21 | themeObserver = NotificationCenter.default.addObserver(forName: Notification.Name.SystemAppearanceDidChange, object: nil, queue: nil) { [weak self] _ in 22 | self?.updatePopoverAppearance() 23 | } 24 | 25 | installApplicationTerminationListener() 26 | } 27 | 28 | func showPopoverFromStatusItemButton(_ statusItemButton: NSStatusBarButton) { 29 | if popover == nil { 30 | popover = NSPopover() 31 | popover!.contentViewController = webAppController 32 | popover!.behavior = .transient 33 | } 34 | 35 | updatePopoverAppearance() 36 | 37 | popover!.show(relativeTo: NSZeroRect, of: statusItemButton, preferredEdge: .maxY) 38 | 39 | NSApp.activate(ignoringOtherApps: true) 40 | } 41 | 42 | fileprivate func installApplicationTerminationListener() { 43 | let delayTime = DispatchTime.now() + Double(Int64(2 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) 44 | DispatchQueue.main.asyncAfter(deadline: delayTime) { 45 | NotificationCenter.default.addObserver(self, selector: #selector(StatusPopoverController.closePopover), name: NSNotification.Name.NSApplicationDidResignActive, object: nil) 46 | } 47 | } 48 | 49 | fileprivate func updatePopoverAppearance() { 50 | guard let popover = popover else { return } 51 | 52 | popover.appearance = Theme.isDark ? NSAppearance(named: NSAppearanceNameVibrantDark) : nil 53 | } 54 | 55 | @objc fileprivate func closePopover() { 56 | guard let popover = popover else { return } 57 | 58 | popover.performClose(nil) 59 | } 60 | 61 | deinit { 62 | if let themeObserver = themeObserver { 63 | NotificationCenter.default.removeObserver(themeObserver) 64 | } 65 | 66 | NotificationCenter.default.removeObserver(self) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /PodcastMenu/Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 10/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | extension Notification.Name { 12 | static let SystemAppearanceDidChange = Notification.Name(rawValue: "SystemAppearanceDidChange") 13 | } 14 | 15 | class Theme: NSObject { 16 | 17 | struct Colors { 18 | static var tint: NSColor { 19 | return Theme.isDark ? NSColor(calibratedRed:0.29, green:0.71, blue:0.75, alpha:1.00) : NSColor(calibratedRed:0.989, green:0.496, blue:0.059, alpha:1) 20 | } 21 | 22 | static var background: NSColor { 23 | return Theme.isDark ? NSColor(calibratedRed:0.19, green:0.20, blue:0.20, alpha:1.00) : .white 24 | } 25 | 26 | static var iconFill: NSColor { 27 | return Theme.isDark ? NSColor.white : NSColor.black 28 | } 29 | } 30 | 31 | static var isDark: Bool { 32 | return UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" 33 | } 34 | 35 | static var appearance: NSAppearance? { 36 | return isDark ? NSAppearance(named: NSAppearanceNameVibrantDark) : NSAppearance(named: NSAppearanceNameAqua) 37 | } 38 | 39 | override init() { 40 | super.init() 41 | 42 | DistributedNotificationCenter.default().addObserver(self, selector: #selector(systemAppearanceChanged), name: NSNotification.Name(rawValue: "AppleInterfaceThemeChangedNotification"), object: nil) 43 | } 44 | 45 | @objc fileprivate func systemAppearanceChanged() { 46 | NotificationCenter.default.post(name: .SystemAppearanceDidChange, object: nil) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /PodcastMenu/TitleParser.js: -------------------------------------------------------------------------------- 1 | function parseTitle() { 2 | var titleComponents = document.title.split(" — "); 3 | if (titleComponents.count < 2) return ""; 4 | 5 | return titleComponents[0]; 6 | } 7 | 8 | parseTitle(); 9 | -------------------------------------------------------------------------------- /PodcastMenu/TouchBarController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchBarController.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 12/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import WebKit 11 | 12 | class TouchBarController: NSObject { 13 | 14 | let webView: WKWebView 15 | 16 | init(webView: WKWebView) { 17 | self.webView = webView 18 | 19 | super.init() 20 | 21 | self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack), options: [.initial, .new], context: nil) 22 | self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward), options: [.initial, .new], context: nil) 23 | } 24 | 25 | @available(OSX 10.12.2, *) 26 | lazy var scrubberController = TouchBarScrubberViewController() 27 | 28 | var currentEpisodeTitle: String? = nil { 29 | didSet { 30 | if #available(OSX 10.12.2, *) { 31 | scrubberController.currentEpisodeTitle = currentEpisodeTitle 32 | } 33 | } 34 | } 35 | 36 | var episodes: [Episode] = [] { 37 | didSet { 38 | if #available(OSX 10.12.2, *) { 39 | scrubberController.episodes = episodes 40 | } 41 | } 42 | } 43 | 44 | var podcasts: [Podcast] = [] { 45 | didSet { 46 | if #available(OSX 10.12.2, *) { 47 | scrubberController.podcasts = podcasts 48 | } 49 | } 50 | } 51 | 52 | var playbackInfo: PlaybackInfo? { 53 | didSet { 54 | if #available(OSX 10.12.2, *) { 55 | miniPlayer.updateUI(oldInfo: oldValue, newInfo: playbackInfo) 56 | } 57 | } 58 | } 59 | 60 | @available(OSX 10.12.2, *) 61 | lazy var backButton: NSButton = { 62 | return NSButton(title: "", image: NSImage(named: NSImageNameTouchBarGoBackTemplate)!, target: nil, action: #selector(WKWebView.goBack(_:))) 63 | }() 64 | 65 | @available(OSX 10.12.2, *) 66 | lazy var forwardButton: NSButton = { 67 | return NSButton(title: "", image: NSImage(named: NSImageNameTouchBarGoForwardTemplate)!, target: nil, action: #selector(WKWebView.goForward(_:))) 68 | }() 69 | 70 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { 71 | if keyPath == #keyPath(WKWebView.canGoBack) { 72 | if #available(OSX 10.12.2, *) { 73 | backButton.isEnabled = webView.canGoBack; 74 | } 75 | } else if keyPath == #keyPath(WKWebView.canGoForward) { 76 | if #available(OSX 10.12.2, *) { 77 | forwardButton.isEnabled = webView.canGoForward; 78 | } 79 | } else { 80 | super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) 81 | } 82 | } 83 | 84 | @available(OSX 10.12.2, *) 85 | fileprivate lazy var nowPlayingTouchBar: NSTouchBar = { 86 | let bar = NSTouchBar() 87 | 88 | bar.delegate = self 89 | 90 | bar.customizationAllowedItemIdentifiers = [ 91 | .backButton, 92 | .forwardButton, 93 | ] 94 | 95 | bar.defaultItemIdentifiers = [.miniPlayer, .scrubber, .otherItemsProxy] 96 | 97 | return bar 98 | }() 99 | 100 | @available(OSX 10.12.2, *) 101 | func installControlStripNowPlayingItem() { 102 | let nowPlayingItem = NSCustomTouchBarItem(identifier: .nowPlayingControlStrip) 103 | nowPlayingItem.view = NSButton(image: #imageLiteral(resourceName: "controlStripIcon"), target: self, action: #selector(nowPlayingItemActivated)) 104 | NSTouchBarItem.addSystemTrayItem(nowPlayingItem) 105 | 106 | DFRElementSetControlStripPresenceForIdentifier(NSTouchBarItemIdentifier.nowPlayingControlStrip.rawValue, true); 107 | } 108 | 109 | @available(OSX 10.12.2, *) 110 | @objc private func nowPlayingItemActivated(_ sender: Any) { 111 | showTouchBar() 112 | } 113 | 114 | @available(OSX 10.12.2, *) 115 | func showTouchBar() { 116 | NSTouchBar.presentSystemModalFunctionBar(nowPlayingTouchBar, placement: 0, systemTrayItemIdentifier: "otherTouchBar") 117 | } 118 | 119 | @available(OSX 10.12.2, *) 120 | func hideTouchBar() { 121 | NSTouchBar.dismissSystemModalFunctionBar(nowPlayingTouchBar) 122 | } 123 | 124 | @available(OSX 10.12.2, *) 125 | fileprivate lazy var miniPlayer: TouchBarMiniPlayer = { 126 | return TouchBarMiniPlayer.instantiate() 127 | }() 128 | 129 | deinit { 130 | webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack)) 131 | webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward)) 132 | } 133 | 134 | } 135 | 136 | @available(OSX 10.12.2, *) 137 | extension NSTouchBarItemIdentifier { 138 | static let backButton = NSTouchBarItemIdentifier("br.com.guilhermerambo.podcastmenu.back") 139 | static let forwardButton = NSTouchBarItemIdentifier("br.com.guilhermerambo.podcastmenu.forward") 140 | static let scrubber = NSTouchBarItemIdentifier("br.com.guilhermerambo.podcastmenu.scrubber") 141 | static let nowPlayingControlStrip = NSTouchBarItemIdentifier("br.com.guilhermerambo.podcastmenu.nowPlaying") 142 | static let miniPlayer = NSTouchBarItemIdentifier("br.com.guilhermerambo.podcastmenu.miniPlayer") 143 | } 144 | 145 | @available(OSX 10.12.2, *) 146 | extension TouchBarController: NSTouchBarDelegate { 147 | 148 | func touchBar(_ touchBar: NSTouchBar, makeItemForIdentifier identifier: NSTouchBarItemIdentifier) -> NSTouchBarItem? { 149 | switch identifier { 150 | case NSTouchBarItemIdentifier.backButton: 151 | let item = NSCustomTouchBarItem(identifier: .backButton) 152 | item.view = backButton 153 | return item 154 | case NSTouchBarItemIdentifier.forwardButton: 155 | let item = NSCustomTouchBarItem(identifier: .forwardButton) 156 | item.view = forwardButton 157 | return item 158 | case NSTouchBarItemIdentifier.scrubber: 159 | let item = NSCustomTouchBarItem(identifier: .scrubber) 160 | item.viewController = scrubberController 161 | return item 162 | case .miniPlayer: 163 | return miniPlayer.touchBarItem 164 | default: return nil 165 | } 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /PodcastMenu/TouchBarMiniPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchBarMiniPlayer.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 30/09/17. 6 | // Copyright © 2017 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | @available(OSX 10.12.2, *) 12 | final class TouchBarMiniPlayer: NSTouchBar { 13 | 14 | static func instantiate() -> TouchBarMiniPlayer { 15 | let nibName = String(describing: self) 16 | 17 | guard let nib = NSNib(nibNamed: nibName, bundle: nil) else { 18 | fatalError("Missing required nib \(nibName), bundle is probably damaged") 19 | } 20 | 21 | var nibObjects = NSArray() 22 | 23 | guard nib.instantiate(withOwner: nil, topLevelObjects: &nibObjects) else { 24 | fatalError("Unable to load nib, something is seriously wrong") 25 | } 26 | 27 | return nibObjects.first(where: { $0 is TouchBarMiniPlayer }) as! TouchBarMiniPlayer 28 | } 29 | 30 | override func awakeFromNib() { 31 | super.awakeFromNib() 32 | 33 | NotificationCenter.default.addObserver(forName: .OvercastDidPlay, object: nil, queue: .main) { [weak self] _ in 34 | self?.activateButtonPlayingState() 35 | } 36 | NotificationCenter.default.addObserver(forName: .OvercastDidPause, object: nil, queue: .main) { [weak self] _ in 37 | self?.activateButtonPausedState() 38 | } 39 | } 40 | 41 | func updateUI(oldInfo: PlaybackInfo?, newInfo: PlaybackInfo?) { 42 | guard oldInfo != newInfo, nowPlayingController != nil else { return } 43 | 44 | playPauseButton.isHidden = (newInfo == nil) 45 | 46 | nowPlayingController.updateUI(oldInfo: oldInfo, newInfo: newInfo) 47 | } 48 | 49 | private func activateButtonPausedState() { 50 | playPauseButton.image = #imageLiteral(resourceName: "play_touchbar") 51 | } 52 | 53 | private func activateButtonPlayingState() { 54 | playPauseButton.image = #imageLiteral(resourceName: "pause_touchbar") 55 | } 56 | 57 | @IBOutlet weak var touchBarItem: NSGroupTouchBarItem! 58 | 59 | @IBOutlet private weak var playPauseButton: NSButton! 60 | 61 | @IBOutlet private weak var nowPlayingController: TouchBarNowPlayingController! 62 | 63 | @IBAction private func playPauseAction(_ sender: NSButton) { 64 | NotificationCenter.default.post(name: .OvercastCommandTogglePlaying, object: nil) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /PodcastMenu/TouchBarMiniPlayer.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 52 | 53 | 54 | 55 | 66 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /PodcastMenu/TouchBarNowPlayingController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchBarNowPlayingController.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 30/09/17. 6 | // Copyright © 2017 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class TouchBarNowPlayingController: NSViewController { 12 | 13 | @IBOutlet private weak var artworkImageView: NSImageView! 14 | @IBOutlet private weak var titleLabel: NSTextField! 15 | @IBOutlet private weak var timeRemainingLabel: NSTextField! 16 | 17 | func updateUI(oldInfo: PlaybackInfo?, newInfo: PlaybackInfo?) { 18 | let hide = (newInfo == nil) 19 | 20 | artworkImageView.isHidden = hide 21 | titleLabel.isHidden = hide 22 | timeRemainingLabel.isHidden = hide 23 | 24 | let title = newInfo?.title ?? "" 25 | let timeRemaining = newInfo?.timeRemaining ?? "" 26 | 27 | titleLabel.stringValue = title 28 | timeRemainingLabel.stringValue = "-\(timeRemaining)" 29 | 30 | guard oldInfo?.artworkURL != newInfo?.artworkURL else { return } 31 | 32 | guard let artworkURL = newInfo?.artworkURL else { 33 | artworkImageView.image = nil 34 | return 35 | } 36 | 37 | updateArtwork(with: artworkURL) 38 | } 39 | 40 | private func updateArtwork(with url: URL) { 41 | _ = ImageCache.shared.fetchImage(at: url, completion: { [weak self] (requestedURL, image) in 42 | guard requestedURL == url else { return } 43 | 44 | self?.artworkImageView.image = image 45 | }) 46 | } 47 | 48 | override func viewDidLoad() { 49 | super.viewDidLoad() 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /PodcastMenu/TouchBarPrivate.h: -------------------------------------------------------------------------------- 1 | // 2 | // TouchBarPrivate.h 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 30/09/17. 6 | // Copyright © 2017 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | 10 | #import 11 | 12 | extern void DFRElementSetControlStripPresenceForIdentifier(NSString *__nonnull, BOOL); 13 | extern void DFRSystemModalShowsCloseBoxWhenFrontMost(BOOL); 14 | 15 | @interface NSTouchBarItem (Private) 16 | 17 | + (void)addSystemTrayItem:(NSTouchBarItem *__nonnull)item; 18 | 19 | @end 20 | 21 | @interface NSTouchBar (Private) 22 | 23 | + (void)presentSystemModalFunctionBar:(NSTouchBar *__nonnull)touchBar placement:(long long)placement systemTrayItemIdentifier:(NSString *__nonnull)identifier; 24 | + (void)dismissSystemModalFunctionBar:(NSTouchBar *__nonnull)touchBar; 25 | 26 | @end 27 | -------------------------------------------------------------------------------- /PodcastMenu/TouchBarScrubberViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchBarScrubberViewController.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 12/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | @available(macOS 10.12.2, *) 12 | private final class ScrubberItem: NSObject { 13 | 14 | let model: OvercastModel 15 | 16 | init(model: OvercastModel) { 17 | self.model = model 18 | 19 | super.init() 20 | } 21 | 22 | var poster: URL { 23 | return model.poster 24 | } 25 | 26 | var link: URL? { 27 | return model.link 28 | } 29 | 30 | } 31 | 32 | @available(OSX 10.12.2, *) 33 | protocol TouchBarScrubberViewControllerDelegate: class { 34 | 35 | func didSelectLink(_ linkURL: URL) 36 | 37 | } 38 | 39 | @available(OSX 10.12.2, *) 40 | class TouchBarScrubberViewController: NSViewController { 41 | 42 | weak var delegate: TouchBarScrubberViewControllerDelegate? 43 | 44 | init() { 45 | super.init(nibName: nil, bundle: nil)! 46 | } 47 | 48 | required init?(coder: NSCoder) { 49 | fatalError("init(coder:) has not been implemented") 50 | } 51 | 52 | override func loadView() { 53 | view = NSView() 54 | view.addSubview(scrubber) 55 | } 56 | 57 | override func viewDidLoad() { 58 | super.viewDidLoad() 59 | 60 | scrubber.register(ScrubberRemoteImageItemView.self, forItemIdentifier: Constants.itemIdentifier) 61 | } 62 | 63 | var currentEpisodeTitle: String? = nil { 64 | didSet { 65 | currentEpisode = episodes.first(where: { $0.title == self.currentEpisodeTitle }) 66 | } 67 | } 68 | 69 | var currentEpisode: Episode? = nil { 70 | didSet { 71 | guard let index = items.index(where: { $0.model.title == currentEpisode?.title }) else { return } 72 | 73 | scrubber.selectedIndex = index 74 | } 75 | } 76 | 77 | var episodes: [Episode] = [] { 78 | didSet { 79 | consolidateItems() 80 | } 81 | } 82 | 83 | var podcasts: [Podcast] = [] { 84 | didSet { 85 | consolidateItems() 86 | } 87 | } 88 | 89 | private func consolidateItems() { 90 | let models = (episodes as [OvercastModel]) + (podcasts as [OvercastModel]) 91 | items = models.map({ ScrubberItem(model: $0) }) 92 | } 93 | 94 | fileprivate var items: [ScrubberItem] = [] { 95 | didSet { 96 | DispatchQueue.main.async { 97 | self.scrubber.reloadData() 98 | } 99 | } 100 | } 101 | 102 | fileprivate lazy var scrubber: NSScrubber = { 103 | let s = NSScrubber() 104 | 105 | let layout = NSScrubberFlowLayout() 106 | layout.itemSize = NSSize(width: 30, height: 30) 107 | layout.itemSpacing = 1.0 108 | 109 | s.scrubberLayout = layout 110 | s.selectionOverlayStyle = .outlineOverlay 111 | s.mode = .free 112 | s.showsAdditionalContentIndicators = true 113 | s.dataSource = self 114 | s.delegate = self 115 | s.autoresizingMask = [.viewWidthSizable, .viewHeightSizable] 116 | 117 | return s 118 | }() 119 | 120 | } 121 | 122 | @available(OSX 10.12.2, *) 123 | extension TouchBarScrubberViewController: NSScrubberDataSource, NSScrubberDelegate { 124 | 125 | struct Constants { 126 | static let itemIdentifier = "scrubberItem" 127 | } 128 | 129 | func numberOfItems(for scrubber: NSScrubber) -> Int { 130 | return items.count 131 | } 132 | 133 | func scrubber(_ scrubber: NSScrubber, viewForItemAt index: Int) -> NSScrubberItemView { 134 | var item = scrubber.makeItem(withIdentifier: Constants.itemIdentifier, owner: scrubber) as? ScrubberRemoteImageItemView 135 | 136 | if item == nil { 137 | item = ScrubberRemoteImageItemView() 138 | item?.identifier = Constants.itemIdentifier 139 | item?.imageAlignment = .alignTop 140 | } 141 | 142 | item?.indexInScrubber = index 143 | item?.imageUrl = items[index].poster 144 | 145 | return item ?? NSScrubberItemView() 146 | } 147 | 148 | func scrubber(_ scrubber: NSScrubber, didSelectItemAt selectedIndex: Int) { 149 | guard selectedIndex < scrubber.numberOfItems else { return } 150 | 151 | let selectedItem = items[selectedIndex] 152 | guard let link = selectedItem.model.link else { return } 153 | 154 | delegate?.didSelectLink(link) 155 | } 156 | 157 | } 158 | -------------------------------------------------------------------------------- /PodcastMenu/VUController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VUController.swift 3 | // PodcastMenu 4 | // 5 | // Created by Guilherme Rambo on 11/05/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class VUController: OvercastLoudnessDelegate { 12 | 13 | var statusItem: NSStatusItem 14 | var timeoutTimer: Timer! 15 | 16 | init(statusItem: NSStatusItem) { 17 | self.statusItem = statusItem 18 | 19 | NotificationCenter.default.addObserver(forName: Notification.Name.OvercastDidPause, object: nil, queue: nil) { [weak self] _ in 20 | self?.resetToDefaultImage() 21 | } 22 | } 23 | 24 | func loudnessDidChange(_ value: Double) { 25 | guard Preferences.enableVU else { return resetToDefaultImage() } 26 | 27 | statusItem.image = imageForLoudness(value) 28 | } 29 | 30 | fileprivate lazy var baseImage = NSImage(named: "podcast")! 31 | fileprivate lazy var baseImageCG: CGImage = NSImage(named: "podcast")!.cgImage 32 | fileprivate let startAngle = CGFloat(Double.pi / 2.0 * -1.0) 33 | fileprivate let endAngle = CGFloat(2.0 * Double.pi) + CGFloat(Double.pi / 2.0 * -1.0) 34 | 35 | fileprivate func imageForLoudness(_ value: Double) -> NSImage { 36 | let image = NSImage(size: statusItem.image!.size) 37 | let w = image.size.width 38 | let h = image.size.height 39 | 40 | image.lockFocus() 41 | 42 | let ctx = NSGraphicsContext.current()!.cgContext 43 | 44 | let maskBounds = CGRect(x: 0.0, y: 0.0, width: w, height: h) 45 | ctx.clip(to: maskBounds, mask: baseImageCG) 46 | 47 | if !statusItem.button!.isHighlighted { 48 | ctx.setFillColor(Theme.Colors.iconFill.withAlphaComponent(0.1).cgColor) 49 | ctx.fill(maskBounds) 50 | } 51 | 52 | let relativeLoudness = value / Constants.maxLoudness 53 | let radius = max(w * CGFloat(relativeLoudness), h * CGFloat(relativeLoudness)) * CGFloat(0.9) 54 | 55 | ctx.setFillColor(Theme.Colors.tint.cgColor) 56 | ctx.addArc(center: CGPoint(x: w / 2.0, y: h / 2.0), radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false) 57 | ctx.fillPath() 58 | 59 | image.unlockFocus() 60 | 61 | return image 62 | } 63 | 64 | fileprivate func resetToDefaultImage() { 65 | statusItem.image = baseImage 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /PodcastMenu/look.js: -------------------------------------------------------------------------------- 1 | PodcastMenuLook = new function(){ 2 | this.scrollHidden = true; 3 | 4 | function overrideContextMenu() { 5 | document.addEventListener('contextmenu', function(e) { 6 | e.preventDefault(); 7 | }, false); 8 | } 9 | overrideContextMenu(); 10 | 11 | function injectStyle() { 12 | document.styleSheets[0].insertRule('html { overflow: auto; }', 0); 13 | document.styleSheets[0].insertRule('body { position: absolute; top: 5px; left: 0; bottom: 5px; right: 5px; padding: 5px; overflow-y: scroll; overflow-x: hidden; }', 0); 14 | document.styleSheets[0].insertRule('::-webkit-scrollbar { width: 1px; opacity:0; }', 0); 15 | document.styleSheets[0].insertRule('::-webkit-scrollbar-track { background: #eee; }', 0); 16 | document.styleSheets[0].insertRule('::-webkit-scrollbar-thumb { -webkit-border-radius: 10px; border-radius: 10px; background: rgba(252,126,15,0.8); }', 0); 17 | document.styleSheets[0].insertRule('::-webkit-scrollbar-thumb:window-inactive { background: rgba(252,126,15,0.4); }', 0); 18 | document.styleSheets[0].insertRule('input.podcastsearchbox { width: 115px; }', 0); 19 | 20 | // DARK MODE 21 | document.styleSheets[0].insertRule('body.pm-dark-mode { color: #fff !important; background-color: #303333 !important; }'); 22 | document.styleSheets[0].insertRule('a.pm-dark-mode, .linkcolor.pm-dark-mode, .extendedepisodecell.usernewepisode.pm-dark-mode .title { color: #49B5BE !important; }'); 23 | document.styleSheets[0].insertRule('.ocbutton.pm-dark-mode, .ocborderedbutton.pm-dark-mode, .ocsegmentedbutton.pm-dark-mode { display: inline-block !important; }'); 24 | document.styleSheets[0].insertRule('.ocborderedbutton.pm-dark-mode, .ocsegmentedbutton.pm-dark-mode { border: 1px solid #49B5BE !important; }'); 25 | document.styleSheets[0].insertRule('.ocborderedbutton.pm-dark-mode:active, .ocsegmentedbuttonselected.pm-dark-mode { background-color: #49B5BE !important; }'); 26 | document.styleSheets[0].insertRule('.feedcell.pm-dark-mode, .episodecell.pm-dark-mode { color: #FFF !important; }'); 27 | document.styleSheets[0].insertRule('.feedcell.pm-dark-mode:hover, .episodecell.pm-dark-mode:hover, .extendedepisodecell.pm-dark-mode:hover { background-color: rgba(15, 126, 252, 0.05) !important; }'); 28 | document.styleSheets[0].insertRule('.extendedepisodecell.pm-dark-mode { color: #FFF !important; }'); 29 | document.styleSheets[0].insertRule('.art.pm-dark-mode { border: 1px solid #666 !important; }'); 30 | document.styleSheets[0].insertRule('input.podcastsearchbox.pm-dark-mode { background-color:black !important; box-shadow:none !important; border:1px solid #6f6f6f !important; color:white !important; }'); 31 | document.styleSheets[0].insertRule('a.autocomplete_result.pm-dark-mode { color: #eee !important; }'); 32 | document.styleSheets[0].insertRule('.autocomplete_result.pm-dark-mode h4 { color: #eee !important; }'); 33 | document.styleSheets[0].insertRule('.ocfeedlistinput.pm-dark-mode .wildcard_result { background-color: #BFE6DE !important; }'); 34 | document.styleSheets[0].insertRule('.ocfeedlistinput.pm-dark-mode .excluded_result { background-color: #BFE6DE !important; }'); 35 | document.styleSheets[0].insertRule('#upload_progress.pm-dark-mode { color: #49B5BE !important; }'); 36 | document.styleSheets[0].insertRule('#upload_progress.pm-dark-mode::-webkit-progress-bar { background-color: #49B5BE !important; }'); 37 | document.styleSheets[0].insertRule('#progresssliderbackground.pm-dark-mode { border: 1px solid #666 !important; }'); 38 | document.styleSheets[0].insertRule('#progresssliderbackground.pm-dark-mode::-webkit-progress-value { background-color: #BFE6DE !important; }'); 39 | document.styleSheets[0].insertRule('#progressslider.pm-dark-mode::-webkit-slider-thumb { background: #49B5BE !important; }'); 40 | document.styleSheets[0].insertRule('.progresssliderloadedrange.pm-dark-mode { background-color: #49B5BE !important; }'); 41 | document.styleSheets[0].insertRule('#speedcontrol.pm-dark-mode::-webkit-slider-runnable-track { background-color: #BFE6DE !important; }'); 42 | document.styleSheets[0].insertRule('#speedcontrol.pm-dark-mode::-webkit-slider-thumb { background: #49B5BE !important; }'); 43 | document.styleSheets[0].insertRule('#stripe-checkout-button.pm-dark-mode { background-color: #49B5BE !important; color: #fff !important; }'); 44 | document.styleSheets[0].insertRule('.adtext.pm-dark-mode { color: #FFF !important; }'); 45 | document.styleSheets[0].insertRule('input.pure-input-1.pm-dark-mode { background-color:black !important; box-shadow:none !important; color:white !important; }'); 46 | document.styleSheets[0].insertRule('.nav.pm-dark-mode { border-bottom: 1px solid #444 !important; }'); 47 | } 48 | injectStyle(); 49 | 50 | this.toggleDarkMode = function(enabled) { 51 | if (enabled) { 52 | $("*").addClass("pm-dark-mode"); 53 | } else { 54 | $("*").removeClass("pm-dark-mode"); 55 | } 56 | } 57 | 58 | this.hideScroll = function() { 59 | if (this.scrollHidden) return; 60 | this.scrollHidden = true; 61 | 62 | var self = this; 63 | 64 | var currentRule = null; 65 | var currentOpacity = 1.0; 66 | var hideScrollAnimStep = function() { 67 | var targetSheet = document.styleSheets[document.styleSheets.length - 1]; 68 | 69 | // if (currentRule != null) targetSheet.removeRule(currentRule); 70 | 71 | currentOpacity -= 0.05; 72 | if (currentOpacity <= 0.1) currentOpacity = 0; 73 | 74 | currentRule = "opacity:"+currentOpacity; 75 | targetSheet.addRule("::-webkit-scrollbar", currentRule); 76 | 77 | if (currentOpacity <= 0.0) { 78 | currentRule = null; 79 | return; 80 | } 81 | 82 | requestAnimationFrame(hideScrollAnimStep); 83 | } 84 | hideScrollAnimStep(); 85 | } 86 | 87 | this.showScroll = function() { 88 | if (!this.scrollHidden) return; 89 | this.scrollHidden = false; 90 | 91 | var self = this; 92 | 93 | if (self.showStyle) return; 94 | 95 | var currentRule = null; 96 | var currentOpacity = 0.0; 97 | var showScrollAnimStep = function() { 98 | var targetSheet = document.styleSheets[document.styleSheets.length - 1]; 99 | 100 | // if (currentRule != null) targetSheet.removeRule(currentRule); 101 | 102 | currentOpacity += 0.1; 103 | if (currentOpacity >= 0.9) currentOpacity = 1; 104 | 105 | currentRule = "opacity:"+currentOpacity; 106 | targetSheet.addRule("::-webkit-scrollbar", currentRule); 107 | 108 | if (currentOpacity >= 1.0) { 109 | currentRule = null; 110 | return; 111 | } 112 | 113 | requestAnimationFrame(showScrollAnimStep); 114 | } 115 | showScrollAnimStep(); 116 | } 117 | }(); 118 | -------------------------------------------------------------------------------- /PodcastMenu/overcast.js: -------------------------------------------------------------------------------- 1 | var progressbar = document.querySelector('#progressbar'); 2 | 3 | if (progressbar && typeof(progressbar) != 'undefined') { 4 | location.hash = "#progressbar"; 5 | document.querySelector('#progressbar').style.paddingTop = '10px'; 6 | } 7 | 8 | var audio = null; 9 | 10 | var bridge = null; 11 | if (typeof(window.webkit) != 'undefined') { 12 | if (typeof(window.webkit.messageHandlers) != 'undefined') { 13 | bridge = window.webkit.messageHandlers.PodcastMenuApp; 14 | } 15 | } 16 | 17 | function installAudioPipeline() { 18 | try { 19 | if (audio == null || typeof(audio) == 'undefined') return; 20 | 21 | audio.addEventListener('pause', function(){ 22 | bridge.postMessage('pause'); 23 | }, false); 24 | audio.addEventListener('play', function(){ 25 | bridge.postMessage('play'); 26 | }, false); 27 | } catch(e) { 28 | console.log(e); 29 | } 30 | } 31 | 32 | $(function(){ 33 | var findAudioInterval = setInterval(function(){ 34 | audio = document.querySelector('audio'); 35 | if (audio != null) { 36 | clearInterval(findAudioInterval); 37 | installAudioPipeline(); 38 | } else { 39 | bridge.postMessage('pause'); 40 | } 41 | }, 500); 42 | 43 | // hide account button 44 | document.querySelector('a[href="/account"]').style.display = 'none'; 45 | }); 46 | -------------------------------------------------------------------------------- /PodcastMenu/test-artwork.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/PodcastMenu/test-artwork.jpg -------------------------------------------------------------------------------- /PodcastMenuTests/Data/Episodes.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "podcast": { 3 | "name": "the iphreaks show", 4 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=dd114ffed5d5beb9a97a78837bd08c9c393ab98509a4df16dbad7c5cc6de7064&w=160&u=https%3A%2F%2Fs3.amazonaws.com%2Fdevchat.tv%2FiPhreaks-thumb.jpg" 5 | }, 6 | "title": "124 iPS Siesta with Paul Cantrell", 7 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=dd114ffed5d5beb9a97a78837bd08c9c393ab98509a4df16dbad7c5cc6de7064&w=160&u=https%3A%2F%2Fs3.amazonaws.com%2Fdevchat.tv%2FiPhreaks-thumb.jpg", 8 | "date": "oct 15, 2015", 9 | "time": { 10 | "type": "duration", 11 | "value": "01:01:35" 12 | }, 13 | "link": "https://overcast.fm/+B8CZIz4aA" 14 | }, { 15 | "podcast": { 16 | "name": "build phase", 17 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=81d2b6e70c7f8e3eb7ec2907733d2d3758a1a300c5df73f0ee95cd2cd84cac6e&w=160&u=https%3A%2F%2Fmedia.simplecast.com%2Fpodcast%2Fimage%2F272%2F1437489285-artwork.jpg" 18 | }, 19 | "title": "112: Embarrassment Factor", 20 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=81d2b6e70c7f8e3eb7ec2907733d2d3758a1a300c5df73f0ee95cd2cd84cac6e&w=160&u=https%3A%2F%2Fmedia.simplecast.com%2Fpodcast%2Fimage%2F272%2F1437489285-artwork.jpg", 21 | "date": "nov 10, 2016", 22 | "time": { 23 | "type": "remaining", 24 | "value": "00:55:25" 25 | }, 26 | "link": "https://overcast.fm/+F7xkKwdCw" 27 | }, { 28 | "podcast": { 29 | "name": "mac power users", 30 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=4b51f5a4e2e74849ff8e139d7367002b52ac19c91e15837b9ea755b9f35bb1c5&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F16%2Fmpu_artwork.png" 31 | }, 32 | "title": "349: MPU+: A Corn-u-copia of Nerdiness", 33 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=4b51f5a4e2e74849ff8e139d7367002b52ac19c91e15837b9ea755b9f35bb1c5&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F16%2Fmpu_artwork.png", 34 | "date": "nov 10, 2016", 35 | "time": { 36 | "type": "duration", 37 | "value": "01:33:18" 38 | }, 39 | "link": "https://overcast.fm/+Eh4Af8hZE" 40 | }, { 41 | "podcast": { 42 | "name": "runtime", 43 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=53fbb7659d07c8a6263dac36c356ff1584dab8f19f7a7224d3fb9f310f4e5ea9&w=160&u=https%3A%2F%2Fmedia.simplecast.com%2Fpodcast%2Fimage%2F2070%2F1471485227-artwork.jpg" 44 | }, 45 | "title": "23: Interviewing & Inverted Trees", 46 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=53fbb7659d07c8a6263dac36c356ff1584dab8f19f7a7224d3fb9f310f4e5ea9&w=160&u=https%3A%2F%2Fmedia.simplecast.com%2Fpodcast%2Fimage%2F2070%2F1471485227-artwork.jpg", 47 | "date": "nov 10, 2016", 48 | "time": { 49 | "type": "remaining", 50 | "value": "00:09:32" 51 | }, 52 | "link": "https://overcast.fm/+GuhhQj6X4" 53 | }, { 54 | "podcast": { 55 | "name": "rocket", 56 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=880247e1dc20d358a4ed80eac5ae81bfba259188557361dadefee42a9c319e11&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F14%2Frocket_artwork.png" 57 | }, 58 | "title": "96: Let’s Have A Normal Show", 59 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=880247e1dc20d358a4ed80eac5ae81bfba259188557361dadefee42a9c319e11&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F14%2Frocket_artwork.png", 60 | "date": "nov 10, 2016", 61 | "time": { 62 | "type": "duration", 63 | "value": "00:51:31" 64 | }, 65 | "link": "https://overcast.fm/+EBcYEJ45Q" 66 | }, { 67 | "podcast": { 68 | "name": "bom senso cast", 69 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=be28c153557d42af2ffddf3ec2f707659f48c1c8dd591d6e83dac9dd43e9bda5&w=160&u=http%3A%2F%2Fcanaldobomsenso.com.br%2Fcdn%2Favatar.jpg" 70 | }, 71 | "title": "#24 - Trump eleito e Reflexões sobre liberalismo", 72 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=be28c153557d42af2ffddf3ec2f707659f48c1c8dd591d6e83dac9dd43e9bda5&w=160&u=http%3A%2F%2Fcanaldobomsenso.com.br%2Fcdn%2Favatar.jpg", 73 | "date": "nov 10, 2016", 74 | "time": { 75 | "type": "duration", 76 | "value": "01:23:13" 77 | }, 78 | "link": "https://overcast.fm/+Gh8gXrWYw" 79 | }, { 80 | "podcast": { 81 | "name": "canvas", 82 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=9ec2c13edef9250c5dae26a96182d86265917de95f51115339f530864bbcdb56&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F25%2Fcanvas_artwork.png" 83 | }, 84 | "title": "23: Workflow - Variables and Built-in Actions", 85 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=9ec2c13edef9250c5dae26a96182d86265917de95f51115339f530864bbcdb56&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F25%2Fcanvas_artwork.png", 86 | "date": "nov 10, 2016", 87 | "time": { 88 | "type": "remaining", 89 | "value": "00:43:59" 90 | }, 91 | "link": "https://overcast.fm/+F1iIfpo9U" 92 | }, { 93 | "podcast": { 94 | "name": "consult", 95 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=319bd79aa273d8129fed46224b9a2164b18aa57b470b1ad958517f9ca1965687&w=160&u=http%3A%2F%2Fi1.sndcdn.com%2Favatars-000157258706-h6vk5c-original.png" 96 | }, 97 | "title": "14: JP Simard on Realm", 98 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=319bd79aa273d8129fed46224b9a2164b18aa57b470b1ad958517f9ca1965687&w=160&u=http%3A%2F%2Fi1.sndcdn.com%2Favatars-000157258706-h6vk5c-original.png", 99 | "date": "nov 11, 2016", 100 | "time": { 101 | "type": "duration", 102 | "value": "00:57:04" 103 | }, 104 | "link": "https://overcast.fm/+E5ErOFCOU" 105 | }, { 106 | "podcast": { 107 | "name": "mamilos", 108 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=d947fb3dc2711c81426cc3a4d2cb04109e1a15870dc145c2f4cf3fec357e61a2&w=160&u=http%3A%2F%2Fassets.b9.com.br%2Fwp-content%2Fuploads%2F2015%2F08%2FAvatar-mamilos.png" 109 | }, 110 | "title": "#89 Trump, e agora?", 111 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=d947fb3dc2711c81426cc3a4d2cb04109e1a15870dc145c2f4cf3fec357e61a2&w=160&u=http%3A%2F%2Fassets.b9.com.br%2Fwp-content%2Fuploads%2F2015%2F08%2FAvatar-mamilos.png", 112 | "date": "nov 11, 2016", 113 | "time": { 114 | "type": "duration", 115 | "value": "01:37:58" 116 | }, 117 | "link": "https://overcast.fm/+Dz33lfcMs" 118 | }] -------------------------------------------------------------------------------- /PodcastMenuTests/Data/Podcasts.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "name": "Accidental Tech Podcast", 3 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=fa452eb81c067c757727fc62b4d7b909c0bc5d8c17bfc05de51fb49ff9d04d5d&w=160&u=http%3A%2F%2Fstatic1.squarespace.com%2Fstatic%2F513abd71e4b0fe58c655c105%2Ft%2F52c45a37e4b0a77a5034aa84%2F1388599866232%2F1500w%2FArtwork.jpg", 4 | "link": "https://overcast.fm/itunes617416468/accidental-tech-podcast" 5 | }, { 6 | "name": "AntiCast", 7 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=f8e2134d7e67b904f7c2f8943d6fe1f4780cc1a5b49dfa57469b696d25f0426f&w=160&u=http%3A%2F%2Fi1.sndcdn.com%2Favatars-000242439656-0umewh-original.jpg", 8 | "link": "https://overcast.fm/itunes453067879/anticast" 9 | }, { 10 | "name": "Anxious Machine", 11 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=457f65dbc1648343bef1d75586ff6caa9e3733f71cf10daa143a27d330729ec4&w=160&u=http%3A%2F%2Fstatic.libsyn.com%2Fp%2Fassets%2Fe%2Fd%2Fc%2F0%2Fedc0c8516a5923c2%2Fam-3000.jpg", 12 | "link": "https://overcast.fm/itunes928943009/anxious-machine" 13 | }, { 14 | "name": "App Store Launch .me", 15 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=3d7d7465414d9a27f7eab49e09c2b3110719837fcef4d3f4a68b5c5a95c15495&w=160&u=http%3A%2F%2Fstatic1.squarespace.com%2Fstatic%2F55141899e4b021a56f30aa1d%2Ft%2F55436bc9e4b0350a22f63fab%2F1430481872004%2F1500w%2FASLS-logo-NO-Subtext.png", 16 | "link": "https://overcast.fm/itunes985947572/app-store-launch-me" 17 | }, { 18 | "name": "The Atheist Experience", 19 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=b235fd5e18446f5c1c0eb74b48f906cf89af93e293ac86002cdfce8e8010b9a6&w=160&u=http%3A%2F%2Fwww.atheist-experience.com%2Fimages%2Fpodcast.png", 20 | "link": "https://overcast.fm/itunes118720919/the-atheist-experience" 21 | }, { 22 | "name": "Bom Senso Cast", 23 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=be28c153557d42af2ffddf3ec2f707659f48c1c8dd591d6e83dac9dd43e9bda5&w=160&u=http%3A%2F%2Fcanaldobomsenso.com.br%2Fcdn%2Favatar.jpg", 24 | "link": "https://overcast.fm/itunes1110639332/bom-senso-cast" 25 | }, { 26 | "name": "Braincast", 27 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=dc649087ddfc7b3c4d345e60d2f02e311575c95ff439e4c2d65dce3b38044950&w=160&u=http%3A%2F%2Fassets.b9.com.br%2Fwp-content%2Fuploads%2F2016%2F02%2FAvatar-Braincast.png", 28 | "link": "https://overcast.fm/itunes504897783/braincast" 29 | }, { 30 | "name": "Build Phase", 31 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=81d2b6e70c7f8e3eb7ec2907733d2d3758a1a300c5df73f0ee95cd2cd84cac6e&w=160&u=https%3A%2F%2Fmedia.simplecast.com%2Fpodcast%2Fimage%2F272%2F1437489285-artwork.jpg", 32 | "link": "https://overcast.fm/itunes681232605/build-phase" 33 | }, { 34 | "name": "Canvas", 35 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=9ec2c13edef9250c5dae26a96182d86265917de95f51115339f530864bbcdb56&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F25%2Fcanvas_artwork.png", 36 | "link": "https://overcast.fm/itunes1073124209/canvas" 37 | }, { 38 | "name": "CocoaHeads Brasil", 39 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=a30ffbc2c7539d968fce0545940f0548a653536515cea4f2a120543bf923430d&w=160&u=http%3A%2F%2Fi1.sndcdn.com%2Favatars-000212331705-kvc2ec-original.jpg", 40 | "link": "https://overcast.fm/itunes1044808957/cocoaheads-brasil" 41 | }, { 42 | "name": "Connected", 43 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=4af3da9fedf77722bba8da77ef41f7d6e13a34c3be24e9c8454e22db26cc5b56&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F5%2Fconnected_artwork.png", 44 | "link": "https://overcast.fm/itunes909109652/connected" 45 | }, { 46 | "name": "Consult", 47 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=319bd79aa273d8129fed46224b9a2164b18aa57b470b1ad958517f9ca1965687&w=160&u=http%3A%2F%2Fi1.sndcdn.com%2Favatars-000157258706-h6vk5c-original.png", 48 | "link": "https://overcast.fm/itunes1018251429/consult" 49 | }, { 50 | "name": "Core Intuition", 51 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=77e5c59eb90414cb866eab1f3307686224d0c4b0bf686684b07abaf30f82dc9f&w=160&u=http%3A%2F%2Fcoreint.org%2Fimages%2Fnewlogo_1400.png", 52 | "link": "https://overcast.fm/itunes281777685/core-intuition" 53 | }, { 54 | "name": "Debug", 55 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=7b7c8d8dd9f954d3d2e1d97b515826366b974d26ef23ed2c3df4363d9207040b&w=160&u=http%3A%2F%2Fmaster.mobilenations.com%2Fbroadcasting%2Fpodcast_debug_1400.jpg", 56 | "link": "https://overcast.fm/itunes578812394/debug" 57 | }, { 58 | "name": "Developing Perspective", 59 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=3c3bab96a3caaa4f0f30b182ad2ea8cea275789f59853ac008f90545cdeebb0a&w=160&u=http%3A%2F%2Fdevelopingperspective.s3.amazonaws.com%2Flogo1400.png", 60 | "link": "https://overcast.fm/itunes452019300/developing-perspective" 61 | }, { 62 | "name": "Don't Make Me Code", 63 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=3df1d8097b916c0a6f8e3480c76707092410519b307aa1a8e7478cb0505ce147&w=160&u=http%3A%2F%2Fd3aeja1uqhkije.cloudfront.net%2Fpodcasts%2Fdont-make-me-code%2Fdont-make-me-code.jpg", 64 | "link": "https://overcast.fm/itunes1107191368/dont-make-me-code" 65 | }, { 66 | "name": "Dragões de Garagem", 67 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=8a006e299031060594bc386beda80c7ebea3ff2dac584ba5d497a54e85536b60&w=160&u=http%3A%2F%2Fdragoesdegaragem.com%2Fwp-content%2Fuploads%2F2016%2F06%2Fitunes.jpg", 68 | "link": "https://overcast.fm/itunes655285497/drag-es-de-garagem" 69 | }, { 70 | "name": "Edge Cases", 71 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=7588e50663bc537a3c338432c80408c2937891cf67a76c6d1278c96f02803b26&w=160&u=http%3A%2F%2Fwww.edgecasesshow.com%2Fedge-cases-logo.png", 72 | "link": "https://overcast.fm/p256090-tUGamn" 73 | }, { 74 | "name": "Fatal Error", 75 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=aeec13480efeedcdf74983e92fa8f8233c9d1299c4758f1b474fc6bca42f803f&w=160&u=http%3A%2F%2Fstatic1.squarespace.com%2Fstatic%2F578d8d3bf5e231fc9f850e55%2Ft%2F57c21f428419c2d24d5830ff%2F1472339782483%2F1500w%2Ffatal%2Berror%2Bicon.png", 76 | "link": "https://overcast.fm/itunes1139051496/fatal-error" 77 | }, { 78 | "name": "Giant Robots Smashing Into Other Giant Robots", 79 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=858754d8eb921cb70311e23256eca2d00e732dcb58b5f8ad36bf56bbb1302709&w=160&u=https%3A%2F%2Fmedia.simplecast.com%2Fpodcast%2Fimage%2F271%2F1437963534-artwork.jpg", 80 | "link": "https://overcast.fm/itunes535121941/giant-robots-smashing-into-other-giant-robots" 81 | }, { 82 | "name": "Godless Bitches", 83 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=629b4f6e1c231c6fc9c813e88a4b8b83acf7e4f4cb59495e67e98487c2406bbc&w=160&u=http%3A%2F%2Fdeow9bq0xqvbj.cloudfront.net%2Fimage-logo%2F386323%2Fgb24.jpg", 84 | "link": "https://overcast.fm/itunes457955142/godless-bitches" 85 | }, { 86 | "name": "Grok Podcast", 87 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=ca9fb6974fcdeac90dcfd6e676c6690cfc1b406d72c5194d94fedaeea3579eee&w=160&u=http%3A%2F%2Fwww.grokpodcast.com%2Fimages%2Flogo_itunes_grande.png", 88 | "link": "https://overcast.fm/itunes393122038/grok-podcast" 89 | }, { 90 | "name": "GVCAST", 91 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=d1e3bacc410cfad94038d9e6ab2400a63d27bf0b0601f9b74963cab0f6aa1088&w=160&u=http%3A%2F%2Fdeow9bq0xqvbj.cloudfront.net%2Fimage-logo%2F817538%2FGVCast3000.jpg", 92 | "link": "https://overcast.fm/itunes1036444741/gvcast" 93 | }, { 94 | "name": "Hipsters Ponto Tech", 95 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=ec12e82864a6ebc538aa3884548e33bfbf2bd8d3da1aa52cb5b9e645e460917f&w=160&u=http%3A%2F%2Fhipsters.tech%2Fwp-content%2Fuploads%2F2016%2F07%2Fhipsters-logo.png", 96 | "link": "https://overcast.fm/itunes1133325943/hipsters-ponto-tech" 97 | }, { 98 | "name": "iMore show", 99 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=7dfbe0518c80cf7a9c2336835e79d7d87e25b52696586f4b2a9fe1c6fd7e4d08&w=160&u=http%3A%2F%2Fwww.mobilenations.com%2Fbroadcasting%2Fimoreshow_1400.jpg", 100 | "link": "https://overcast.fm/itunes261058960/imore-show" 101 | }, { 102 | "name": "The iPhreaks Show", 103 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=dd114ffed5d5beb9a97a78837bd08c9c393ab98509a4df16dbad7c5cc6de7064&w=160&u=https%3A%2F%2Fs3.amazonaws.com%2Fdevchat.tv%2FiPhreaks-thumb.jpg", 104 | "link": "https://overcast.fm/itunes634022060/the-iphreaks-show" 105 | }, { 106 | "name": "Iterate", 107 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=b2318a94a83df1037c0a79262f15edf786b93ab6ce2d3365f97160f02106b0b0&w=160&u=http%3A%2F%2Fmaster.mobilenations.com%2Fbroadcasting%2Fpodcast_iterate_1400.jpg", 108 | "link": "https://overcast.fm/itunes447063932/iterate" 109 | }, { 110 | "name": "Mac Power Users", 111 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=4b51f5a4e2e74849ff8e139d7367002b52ac19c91e15837b9ea755b9f35bb1c5&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F16%2Fmpu_artwork.png", 112 | "link": "https://overcast.fm/p296832-lrxke5" 113 | }, { 114 | "name": "Mamilos", 115 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=d947fb3dc2711c81426cc3a4d2cb04109e1a15870dc145c2f4cf3fec357e61a2&w=160&u=http%3A%2F%2Fassets.b9.com.br%2Fwp-content%2Fuploads%2F2015%2F08%2FAvatar-mamilos.png", 116 | "link": "https://overcast.fm/itunes942491627/mamilos" 117 | }, { 118 | "name": "Neighbors", 119 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=cbe78bc6349d43b6214ed39c25c57bee02b5e711287836e8001b21c86e18455c&w=160&u=http%3A%2F%2Fstatic.libsyn.com%2Fp%2Fassets%2Fb%2Fd%2F9%2Fc%2Fbd9ca2a6369d4869%2Fneighbors-wpln-logo.jpg", 120 | "link": "https://overcast.fm/itunes596532094/neighbors" 121 | }, { 122 | "name": "NerdCast", 123 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=fc38ad317ae48488793f8040592bdf68a17b41dc6db2e9fa816937fb2421f7aa&w=160&u=https%3A%2F%2Fjovemnerd.com.br%2Fwp-content%2Fthemes%2Fjovemnerd%2Fassets%2Fimages%2Fnc-feed.jpg", 124 | "link": "https://overcast.fm/p2798-ZLK2zx" 125 | }, { 126 | "name": "The Non Prophets", 127 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=c5fb5e650c603e4cc373374212eb98f9f238e230d73c672da61dcb56a809eae2&w=160&u=http%3A%2F%2Fwww.nonprophetsradio.com%2Fimages%2Fnonprophets.png", 128 | "link": "https://overcast.fm/itunes78100566/the-non-prophets" 129 | }, { 130 | "name": "NSBrief", 131 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=f178bfc8b3ed37dda72c37db27c20c79b427724cdf7044f57e53262da1fa0cc0&w=160&u=http%3A%2F%2Fnsbrief.com%2Fnsbriefpodcastcover.png", 132 | "link": "https://overcast.fm/itunes399822861/nsbrief" 133 | }, { 134 | "name": "O Melhor Podcast do Brasil", 135 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=45f7d5f4e0e9bf1fcaa34b207a66804f7c814c44c9c6f26f8bf70cbd4c366f0b&w=160&u=http%3A%2F%2Fi1.sndcdn.com%2Favatars-000215258383-17qpad-original.jpg", 136 | "link": "https://overcast.fm/itunes1099618000/o-melhor-podcast-do-brasil" 137 | }, { 138 | "name": "PapoTech", 139 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=a532d6114bd5178c2456fda0b8c01aaf1951db5e076b637e8810b82c05551a3c&w=160&u=http%3A%2F%2Fwww.papotech.com.br%2Fpodcast%2Fpapotech.jpg", 140 | "link": "https://overcast.fm/itunes81607169/papotech" 141 | }, { 142 | "name": "Planet Money", 143 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=3ed744f5711d3390ac4f1f0ad7de0cfa1870af576c7991166e7edb6263423106&w=160&u=https%3A%2F%2Fmedia.npr.org%2Fassets%2Fimg%2F2015%2F12%2F18%2Fplanetmoney_sq-c7d1c6f957f3b7f701f8e1d5546695cebd523720.jpg%3Fs%3D1400", 144 | "link": "https://overcast.fm/itunes290783428/planet-money" 145 | }, { 146 | "name": "Projeto Humanos", 147 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=41e1a39d04927fa1ca09d85cc0b4e658e6ae560836d8b8479607a21ab3c34527&w=160&u=http%3A%2F%2Fwww.anticast.com.br%2Fhumans.png", 148 | "link": "https://overcast.fm/itunes1023477643/projeto-humanos" 149 | }, { 150 | "name": "The raywenderlich.com PodcastThe raywenderlich.com Podcast", 151 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=770c902e938fd9d54964a409a918b57d434441f629250d7fb2553f8958fb39c7&w=160&u=http%3A%2F%2Fcdn1.raywenderlich.com%2Fdownloads%2FRW-Podcast-Icon-2015-1400.png", 152 | "link": "https://overcast.fm/itunes773910890/the-raywenderlich-com-podcastthe-raywenderlich-com-podcast" 153 | }, { 154 | "name": "The Record", 155 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=32e7bad2cb529b9f2f5804483a0b50b64fece60cf5091c882143f098fea2c786&w=160&u=http%3A%2F%2Ftherecord.co%2Fimages%2FTheRecordFull.jpg", 156 | "link": "https://overcast.fm/itunes791861057/the-record" 157 | }, { 158 | "name": "Rocket", 159 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=880247e1dc20d358a4ed80eac5ae81bfba259188557361dadefee42a9c319e11&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F14%2Frocket_artwork.png", 160 | "link": "https://overcast.fm/p263622-j3i9Mh" 161 | }, { 162 | "name": "Runtime", 163 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=53fbb7659d07c8a6263dac36c356ff1584dab8f19f7a7224d3fb9f310f4e5ea9&w=160&u=https%3A%2F%2Fmedia.simplecast.com%2Fpodcast%2Fimage%2F2070%2F1471485227-artwork.jpg", 164 | "link": "https://overcast.fm/itunes1122203945/runtime" 165 | }, { 166 | "name": "The Talk Show With John Gruber", 167 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=cb6d284ebae728d75590eac7c51efb7e666abc9b9e0d121be1f46a0b7c3c4d37&w=160&u=http%3A%2F%2Fdaringfireball.net%2Fthetalkshow%2Fgraphics%2Fdf-the-talk-show-album-art.png", 168 | "link": "https://overcast.fm/itunes528458508/the-talk-show-with-john-gruber" 169 | }, { 170 | "name": "Thoroughly Considered", 171 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=4e53fa1ee8197b35095b1566287dcc1d7a01d8b9a384ec5353bb686c606dc396&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F22%2Ftc_artwork.png", 172 | "link": "https://overcast.fm/itunes1041995154/thoroughly-considered" 173 | }, { 174 | "name": "Under the Radar", 175 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=6bed8896749c57a1df1d2e0eb9c9231fed70ceecb4a9cf23118a2f6b56eb3630&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F23%2Fradar_artwork.png", 176 | "link": "https://overcast.fm/p361069-2mWgLV" 177 | }, { 178 | "name": "Upgrade", 179 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=960c802ade02ddfa942a3f685f66baf1e34e2bc72b69d0b3d2f9e286a9ed8389&w=160&u=http%3A%2F%2Frelayfm.s3.amazonaws.com%2Fuploads%2Fbroadcast%2Fimage%2F11%2Fupgrade_artwork.png", 180 | "link": "https://overcast.fm/itunes918152703/upgrade" 181 | }, { 182 | "name": "Vector", 183 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=356e3a8a0d9c5ed791fc7848f989192817cb9182e14b76d9c9888434bc2925d3&w=160&u=http%3A%2F%2Fmaster.mobilenations.com%2Fbroadcasting%2Fpodcast_vector_1400.jpg", 184 | "link": "https://overcast.fm/itunes677992290/vector" 185 | }, { 186 | "name": "Welcome to Macintosh", 187 | "poster": "https://d1eedt7bo0oujw.cloudfront.net/art?s=813a74c10e9e0bee5314708a9a7f3f83d796e2a9b9cabb9723fd804ce3f780c6&w=160&u=http%3A%2F%2Fstatic1.squarespace.com%2Fstatic%2F54a9fbc7e4b0970e0f1ca699%2Ft%2F5503b513e4b052040ef51a82%2F1426306325300%2F1500w%2FAA.jpg", 188 | "link": "https://overcast.fm/itunes970061020/welcome-to-macintosh" 189 | }] -------------------------------------------------------------------------------- /PodcastMenuTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /PodcastMenuTests/PodcastMenuTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PodcastMenuTests.swift 3 | // PodcastMenuTests 4 | // 5 | // Created by Guilherme Rambo on 11/11/16. 6 | // Copyright © 2016 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import PodcastMenu 11 | 12 | class PodcastMenuTests: XCTestCase { 13 | 14 | private lazy var testEpisodesData: Data = { 15 | let url = Bundle(for: PodcastMenuTests.self).url(forResource: "Episodes", withExtension: "json")! 16 | return try! Data(contentsOf: url) 17 | }() 18 | 19 | private lazy var testPodcastsData: Data = { 20 | let url = Bundle(for: PodcastMenuTests.self).url(forResource: "Podcasts", withExtension: "json")! 21 | return try! Data(contentsOf: url) 22 | }() 23 | 24 | private lazy var expectedTestEpisodeDate: Date = { 25 | let formatter = DateFormatter() 26 | formatter.locale = Locale(identifier: "en-US") 27 | formatter.dateFormat = "MMM dd, yyyy" 28 | 29 | return formatter.date(from: "nov 10, 2016")! 30 | }() 31 | 32 | override func setUp() { 33 | super.setUp() 34 | // Put setup code here. This method is called before the invocation of each test method in the class. 35 | } 36 | 37 | override func tearDown() { 38 | // Put teardown code here. This method is called after the invocation of each test method in the class. 39 | super.tearDown() 40 | } 41 | 42 | func testParsingPodcasts() { 43 | let result = PodcastsAdapter(input: JSON(data: testPodcastsData)).adapt() 44 | 45 | switch result { 46 | case .error(let error): 47 | XCTFail("Expected to succeed but failed with error \(error)") 48 | case .success(let podcasts): 49 | guard podcasts.count == 47 else { 50 | XCTFail("Podcasts count should be 47, got \(podcasts.count)") 51 | return 52 | } 53 | 54 | let podcast = podcasts[2] 55 | XCTAssertEqual(podcast.name, "Anxious Machine") 56 | XCTAssertEqual(podcast.link, URL(string: "https://overcast.fm/itunes928943009/anxious-machine")) 57 | XCTAssertEqual(podcast.poster, URL(string: "http://static.libsyn.com/p/assets/e/d/c/0/edc0c8516a5923c2/am-3000.jpg")) 58 | } 59 | } 60 | 61 | func testParsingEpisodes() { 62 | let result = EpisodesAdapter(input: JSON(data: testEpisodesData)).adapt() 63 | 64 | switch result { 65 | case .error(let error): 66 | XCTFail("Expected to succeed but failed with error \(error)") 67 | case .success(let episodes): 68 | guard episodes.count == 9 else { 69 | XCTFail("Episodes count should be 9, got \(episodes.count)") 70 | return 71 | } 72 | 73 | let episode = episodes[1] 74 | 75 | XCTAssertEqual(episode.podcast.name, "build phase") 76 | XCTAssertEqual(episode.podcast.poster, URL(string: "https://media.simplecast.com/podcast/image/272/1437489285-artwork.jpg")!) 77 | XCTAssertEqual(episode.title, "112: Embarrassment Factor") 78 | XCTAssertEqual(episode.link, URL(string: "https://overcast.fm/+F7xkKwdCw")!) 79 | XCTAssertEqual(episode.date, expectedTestEpisodeDate) 80 | 81 | switch episode.time { 82 | case .duration(_): XCTFail("Expected time type to be \"remaining\"") 83 | case .remaining(let time): XCTAssertEqual(time, "00:55:25") 84 | } 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | If you want to support my open source projects financially, you can do so by purchasing a copy of [BrowserFreedom](https://getbrowserfreedom.com), [Mediunic](https://itunes.apple.com/app/mediunic-medium-client/id1088945121?mt=12) or sending Bitcoin to `3DH9B42m6k2A89hy1Diz3Vr3cpDNQTQCbJ` 😁 2 | 3 | # PodcastMenu 4 | 5 | PodcastMenu is a simple app which puts [Overcast](https://overcast.fm) on your Mac's menu bar so you can listen to your favorite podcasts while you work. 6 | 7 | # Easy Playback Controls 8 | While the app is playing a podcast, you can use your keyboard's media keys to control playback: 9 | 10 | * Play/pause to play or pause the currently playing episode 11 | * Forward/backward to advance/go back 30 seconds 12 | 13 | # Download 14 | 15 | [⬇️ Click here to download the latest release](https://github.com/insidegui/PodcastMenu/raw/master/Releases/PodcastMenu_v1.3.zip). 16 | 17 | ![screenshot](screenshot2.png) 18 | ![touchbar](touchbar.png) 19 | 20 | ## Disclaimer 21 | 22 | This is not an "official" app, I'm not affiliated with Overcast, I just love it and really wanted it to have a Mac app 😁 23 | 24 | NOTE: This app is free and open source but its license prohibits anyone from distributing it (free or paid) on any App Store. 25 | 26 | ## Compatibility 27 | 28 | PodcastMenu is only available for OS X 10.11 or later. 29 | 30 | # Get Overcast 31 | 32 | If you love podcasts and you're not using Overcast, you definitely should. 33 | 34 | [Overcast is available on the App Store](https://itunes.apple.com/app/overcast-podcast-player/id888422857). 35 | -------------------------------------------------------------------------------- /Releases/PodcastMenu_v1.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Releases/PodcastMenu_v1.0.zip -------------------------------------------------------------------------------- /Releases/PodcastMenu_v1.1.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Releases/PodcastMenu_v1.1.2.zip -------------------------------------------------------------------------------- /Releases/PodcastMenu_v1.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Releases/PodcastMenu_v1.1.zip -------------------------------------------------------------------------------- /Releases/PodcastMenu_v1.2.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Releases/PodcastMenu_v1.2.1.zip -------------------------------------------------------------------------------- /Releases/PodcastMenu_v1.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Releases/PodcastMenu_v1.2.zip -------------------------------------------------------------------------------- /Releases/PodcastMenu_v1.3.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Releases/PodcastMenu_v1.3.zip -------------------------------------------------------------------------------- /Releases/appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | PodcastMenu Changelog 5 | https://github.com/insidegui/PodcastMenu/raw/master/Releases/appcast.xml 6 | Most recent changes with links to updates. 7 | en 8 | 9 | 10 | Version 1.3 11 | 13 |
  • Dark mode
  • 14 |
  • Touch bar support
  • 15 |
  • Share menu
  • 16 |
  • Added log out option to menu
  • 17 | 18 | ]]>
    19 | Sun, 9 Apr 2017 18:00:00 -0300 20 | 21 | 10.11 22 |
    23 | 24 | 25 | Version 1.2.1 26 | 28 |
  • Improved error handling
  • 29 |
  • Fixed a memory leak which caused the app to consume too much memory
  • 30 |
  • Fixed issues with the look of the configuration button
  • 31 |
  • Moved "Reload" option from contextual menu to the configuration button
  • 32 | 33 | ]]>
    34 | Tue, 28 Jun 2016 20:45:00 -0300 35 | 36 | 10.11 37 |
    38 | 39 | 40 | Version 1.2 41 | 43 |
  • Scrollbar now looks nicer and is less intrusive
  • 44 |
  • Fixed a bug where the mouse would stop working for some users
  • 45 |
  • Improved media keys handling and coordination with other media apps
  • 46 |
  • Added option to pass media keys pressed to other running media apps
  • 47 |
  • Preventing system sleep while playing an episode
  • 48 | 49 | ]]>
    50 | Wed, 15 May 2016 17:30:00 +0000 51 | 52 | 10.11 53 |
    54 | 55 | 56 | Version 1.1.2 57 | 59 |
  • Fixed an issue with playback rates higher than 1x
  • 60 |
  • Improved configuration button look
  • 61 | 62 | ]]>
    63 | Wed, 12 May 2016 17:40:00 +0000 64 | 65 | 10.11 66 |
    67 | 68 | 69 | Version 1.1 70 | 72 |
  • The menubar icon now works as a VU meter while playing an episode
  • 73 |
  • Added options menu to the lower-right corner
  • 74 | 75 | ]]>
    76 | Wed, 11 May 2016 03:08:00 +0000 77 | 78 | 10.11 79 |
    80 | 81 | 82 | Version 1.0 83 | 85 |
  • First release
  • 86 | 87 | ]]>
    88 | Tue, 10 May 2016 18:30:00 +0000 89 | 90 | 10.11 91 |
    92 |
    93 |
    94 | -------------------------------------------------------------------------------- /Resources/PodcastMenu Icon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Resources/PodcastMenu Icon.sketch -------------------------------------------------------------------------------- /Resources/PodcastMenu.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Resources/PodcastMenu.sketch -------------------------------------------------------------------------------- /Resources/Touch Bar Stuff.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Resources/Touch Bar Stuff.sketch -------------------------------------------------------------------------------- /Resources/exported/podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Resources/exported/podcast.png -------------------------------------------------------------------------------- /Resources/exported/podcast@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Resources/exported/podcast@2x.png -------------------------------------------------------------------------------- /Resources/exported/podcast@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/Resources/exported/podcast@3x.png -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | carthage bootstrap --platform macOS 4 | -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/screenshot2.png -------------------------------------------------------------------------------- /touchbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/PodcastMenu/d9dd659a080e3d00fc17cd280ba94ff6a032983e/touchbar.png --------------------------------------------------------------------------------