├── .gitignore ├── LICENSE ├── MusicKitPlayer.xcodeproj ├── MusicKit_Info.plist ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── WorkspaceSettings.xcsettings ├── Package.swift ├── README.md ├── Sources └── MusicKitPlayer │ ├── API │ ├── API.swift │ ├── Library.swift │ ├── MusicKitPlayer.swift │ ├── MusicKitUtils.swift │ ├── Player.swift │ └── Queue.swift │ ├── Info.plist │ ├── Internal │ ├── AuthorizationWindow.swift │ ├── MKDecoder.swift │ ├── MKWebController.swift │ ├── MKWebpage.swift │ └── URLRequestManager.swift │ ├── Managers │ ├── NowPlayingManager.swift │ └── QueueManager.swift │ ├── MusicKitPlayer.h │ └── Utils │ ├── ArrayUtil.swift │ └── StringFormatUtil.swift └── jwt.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | .DS_Store 6 | 7 | ## User settings 8 | xcuserdata/ 9 | 10 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 11 | *.xcscmblueprint 12 | *.xccheckout 13 | 14 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 15 | build/ 16 | DerivedData/ 17 | *.moved-aside 18 | *.pbxuser 19 | !default.pbxuser 20 | *.mode1v3 21 | !default.mode1v3 22 | *.mode2v3 23 | !default.mode2v3 24 | *.perspectivev3 25 | !default.perspectivev3 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | 30 | ## App packaging 31 | *.ipa 32 | *.dSYM.zip 33 | *.dSYM 34 | 35 | ## Playgrounds 36 | timeline.xctimeline 37 | playground.xcworkspace 38 | 39 | # Swift Package Manager 40 | # 41 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 42 | # Packages/ 43 | # Package.pins 44 | # Package.resolved 45 | # *.xcodeproj 46 | # 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | # .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | # 59 | # Pods/ 60 | # 61 | # Add this line if you want to avoid checking in source code from the Xcode workspace 62 | # *.xcworkspace 63 | 64 | # Carthage 65 | # 66 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 67 | # Carthage/Checkouts 68 | 69 | Carthage/Build/ 70 | 71 | # Accio dependency management 72 | Dependencies/ 73 | .accio/ 74 | 75 | # fastlane 76 | # 77 | # It is recommended to not store the screenshots in the git repo. 78 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 79 | # For more information about the recommended setup visit: 80 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 81 | 82 | fastlane/report.xml 83 | fastlane/Preview.html 84 | fastlane/screenshots/**/*.png 85 | fastlane/test_output 86 | 87 | # Code Injection 88 | # 89 | # After new code Injection tools there's a generated folder /iOSInjectionProject 90 | # https://github.com/johnno1962/injectionforxcode 91 | 92 | iOSInjectionProject/ 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nate Thompson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MusicKitPlayer.xcodeproj/MusicKit_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 | FMWK 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /MusicKitPlayer.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 43ABBA8F24B599D30027CA77 /* URLRequestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ABBA8E24B599D30027CA77 /* URLRequestManager.swift */; }; 11 | 43CBD2F6242F2F550023F7B7 /* MKWebpage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBD2F5242F2F550023F7B7 /* MKWebpage.swift */; }; 12 | OBJ_37 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_12 /* API.swift */; }; 13 | OBJ_38 /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_13 /* Library.swift */; }; 14 | OBJ_39 /* MusicKitPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_14 /* MusicKitPlayer.swift */; }; 15 | OBJ_40 /* MusicKitUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_15 /* MusicKitUtils.swift */; }; 16 | OBJ_41 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_16 /* Player.swift */; }; 17 | OBJ_42 /* Queue.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_17 /* Queue.swift */; }; 18 | OBJ_43 /* AuthorizationWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_19 /* AuthorizationWindow.swift */; }; 19 | OBJ_44 /* MKDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_20 /* MKDecoder.swift */; }; 20 | OBJ_45 /* MKWebController.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_21 /* MKWebController.swift */; }; 21 | OBJ_46 /* NowPlayingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_23 /* NowPlayingManager.swift */; }; 22 | OBJ_47 /* QueueManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_24 /* QueueManager.swift */; }; 23 | OBJ_48 /* ArrayUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_26 /* ArrayUtil.swift */; }; 24 | OBJ_49 /* StringFormatUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_27 /* StringFormatUtil.swift */; }; 25 | OBJ_56 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_6 /* Package.swift */; }; 26 | /* End PBXBuildFile section */ 27 | 28 | /* Begin PBXCopyFilesBuildPhase section */ 29 | 43CBD2F3242F07800023F7B7 /* CopyFiles */ = { 30 | isa = PBXCopyFilesBuildPhase; 31 | buildActionMask = 2147483647; 32 | dstPath = ""; 33 | dstSubfolderSpec = 7; 34 | files = ( 35 | ); 36 | runOnlyForDeploymentPostprocessing = 0; 37 | }; 38 | /* End PBXCopyFilesBuildPhase section */ 39 | 40 | /* Begin PBXFileReference section */ 41 | 43ABBA8E24B599D30027CA77 /* URLRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestManager.swift; sourceTree = ""; }; 42 | 43CBD2F5242F2F550023F7B7 /* MKWebpage.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = MKWebpage.swift; sourceTree = ""; tabWidth = 4; }; 43 | "MusicKit::MusicKit::Product" /* MusicKitPlayer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MusicKitPlayer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 44 | OBJ_10 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 45 | OBJ_12 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; 46 | OBJ_13 /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = ""; }; 47 | OBJ_14 /* MusicKitPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicKitPlayer.swift; sourceTree = ""; }; 48 | OBJ_15 /* MusicKitUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicKitUtils.swift; sourceTree = ""; }; 49 | OBJ_16 /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; 50 | OBJ_17 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = ""; }; 51 | OBJ_19 /* AuthorizationWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationWindow.swift; sourceTree = ""; }; 52 | OBJ_20 /* MKDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MKDecoder.swift; sourceTree = ""; }; 53 | OBJ_21 /* MKWebController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MKWebController.swift; sourceTree = ""; }; 54 | OBJ_23 /* NowPlayingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingManager.swift; sourceTree = ""; }; 55 | OBJ_24 /* QueueManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueManager.swift; sourceTree = ""; }; 56 | OBJ_26 /* ArrayUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUtil.swift; sourceTree = ""; }; 57 | OBJ_27 /* StringFormatUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringFormatUtil.swift; sourceTree = ""; }; 58 | OBJ_31 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 59 | OBJ_6 /* Package.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 60 | OBJ_9 /* MusicKitPlayer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MusicKitPlayer.h; sourceTree = ""; }; 61 | /* End PBXFileReference section */ 62 | 63 | /* Begin PBXFrameworksBuildPhase section */ 64 | OBJ_50 /* Frameworks */ = { 65 | isa = PBXFrameworksBuildPhase; 66 | buildActionMask = 0; 67 | files = ( 68 | ); 69 | runOnlyForDeploymentPostprocessing = 0; 70 | }; 71 | /* End PBXFrameworksBuildPhase section */ 72 | 73 | /* Begin PBXGroup section */ 74 | OBJ_11 /* API */ = { 75 | isa = PBXGroup; 76 | children = ( 77 | OBJ_14 /* MusicKitPlayer.swift */, 78 | OBJ_16 /* Player.swift */, 79 | OBJ_17 /* Queue.swift */, 80 | OBJ_12 /* API.swift */, 81 | OBJ_13 /* Library.swift */, 82 | OBJ_15 /* MusicKitUtils.swift */, 83 | ); 84 | path = API; 85 | sourceTree = ""; 86 | }; 87 | OBJ_18 /* Internal */ = { 88 | isa = PBXGroup; 89 | children = ( 90 | 43CBD2F5242F2F550023F7B7 /* MKWebpage.swift */, 91 | OBJ_21 /* MKWebController.swift */, 92 | OBJ_20 /* MKDecoder.swift */, 93 | 43ABBA8E24B599D30027CA77 /* URLRequestManager.swift */, 94 | OBJ_19 /* AuthorizationWindow.swift */, 95 | ); 96 | path = Internal; 97 | sourceTree = ""; 98 | }; 99 | OBJ_22 /* Managers */ = { 100 | isa = PBXGroup; 101 | children = ( 102 | OBJ_23 /* NowPlayingManager.swift */, 103 | OBJ_24 /* QueueManager.swift */, 104 | ); 105 | path = Managers; 106 | sourceTree = ""; 107 | }; 108 | OBJ_25 /* Utils */ = { 109 | isa = PBXGroup; 110 | children = ( 111 | OBJ_26 /* ArrayUtil.swift */, 112 | OBJ_27 /* StringFormatUtil.swift */, 113 | ); 114 | path = Utils; 115 | sourceTree = ""; 116 | }; 117 | OBJ_29 /* Products */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | "MusicKit::MusicKit::Product" /* MusicKitPlayer.framework */, 121 | ); 122 | name = Products; 123 | sourceTree = BUILT_PRODUCTS_DIR; 124 | }; 125 | OBJ_5 = { 126 | isa = PBXGroup; 127 | children = ( 128 | OBJ_6 /* Package.swift */, 129 | OBJ_7 /* Sources */, 130 | OBJ_29 /* Products */, 131 | OBJ_31 /* README.md */, 132 | ); 133 | sourceTree = ""; 134 | }; 135 | OBJ_7 /* Sources */ = { 136 | isa = PBXGroup; 137 | children = ( 138 | OBJ_8 /* MusicKitPlayer */, 139 | ); 140 | name = Sources; 141 | sourceTree = SOURCE_ROOT; 142 | }; 143 | OBJ_8 /* MusicKitPlayer */ = { 144 | isa = PBXGroup; 145 | children = ( 146 | OBJ_9 /* MusicKitPlayer.h */, 147 | OBJ_10 /* Info.plist */, 148 | OBJ_11 /* API */, 149 | OBJ_18 /* Internal */, 150 | OBJ_22 /* Managers */, 151 | OBJ_25 /* Utils */, 152 | ); 153 | name = MusicKitPlayer; 154 | path = Sources/MusicKitPlayer; 155 | sourceTree = SOURCE_ROOT; 156 | }; 157 | /* End PBXGroup section */ 158 | 159 | /* Begin PBXNativeTarget section */ 160 | "MusicKit::MusicKit" /* MusicKitPlayer */ = { 161 | isa = PBXNativeTarget; 162 | buildConfigurationList = OBJ_33 /* Build configuration list for PBXNativeTarget "MusicKitPlayer" */; 163 | buildPhases = ( 164 | OBJ_36 /* Sources */, 165 | 43CBD2F3242F07800023F7B7 /* CopyFiles */, 166 | OBJ_50 /* Frameworks */, 167 | ); 168 | buildRules = ( 169 | ); 170 | dependencies = ( 171 | ); 172 | name = MusicKitPlayer; 173 | productName = MusicKit; 174 | productReference = "MusicKit::MusicKit::Product" /* MusicKitPlayer.framework */; 175 | productType = "com.apple.product-type.framework"; 176 | }; 177 | "MusicKit::SwiftPMPackageDescription" /* MusicKitPlayerPackageDescription */ = { 178 | isa = PBXNativeTarget; 179 | buildConfigurationList = OBJ_52 /* Build configuration list for PBXNativeTarget "MusicKitPlayerPackageDescription" */; 180 | buildPhases = ( 181 | OBJ_55 /* Sources */, 182 | ); 183 | buildRules = ( 184 | ); 185 | dependencies = ( 186 | ); 187 | name = MusicKitPlayerPackageDescription; 188 | productName = MusicKitPackageDescription; 189 | productType = "com.apple.product-type.framework"; 190 | }; 191 | /* End PBXNativeTarget section */ 192 | 193 | /* Begin PBXProject section */ 194 | OBJ_1 /* Project object */ = { 195 | isa = PBXProject; 196 | attributes = { 197 | LastSwiftMigration = 9999; 198 | LastUpgradeCheck = 9999; 199 | }; 200 | buildConfigurationList = OBJ_2 /* Build configuration list for PBXProject "MusicKitPlayer" */; 201 | compatibilityVersion = "Xcode 3.2"; 202 | developmentRegion = en; 203 | hasScannedForEncodings = 0; 204 | knownRegions = ( 205 | en, 206 | ); 207 | mainGroup = OBJ_5; 208 | productRefGroup = OBJ_29 /* Products */; 209 | projectDirPath = ""; 210 | projectRoot = ""; 211 | targets = ( 212 | "MusicKit::MusicKit" /* MusicKitPlayer */, 213 | "MusicKit::SwiftPMPackageDescription" /* MusicKitPlayerPackageDescription */, 214 | ); 215 | }; 216 | /* End PBXProject section */ 217 | 218 | /* Begin PBXSourcesBuildPhase section */ 219 | OBJ_36 /* Sources */ = { 220 | isa = PBXSourcesBuildPhase; 221 | buildActionMask = 0; 222 | files = ( 223 | OBJ_37 /* API.swift in Sources */, 224 | OBJ_38 /* Library.swift in Sources */, 225 | OBJ_39 /* MusicKitPlayer.swift in Sources */, 226 | OBJ_40 /* MusicKitUtils.swift in Sources */, 227 | OBJ_41 /* Player.swift in Sources */, 228 | OBJ_42 /* Queue.swift in Sources */, 229 | OBJ_43 /* AuthorizationWindow.swift in Sources */, 230 | OBJ_44 /* MKDecoder.swift in Sources */, 231 | OBJ_45 /* MKWebController.swift in Sources */, 232 | 43CBD2F6242F2F550023F7B7 /* MKWebpage.swift in Sources */, 233 | OBJ_46 /* NowPlayingManager.swift in Sources */, 234 | OBJ_47 /* QueueManager.swift in Sources */, 235 | OBJ_48 /* ArrayUtil.swift in Sources */, 236 | 43ABBA8F24B599D30027CA77 /* URLRequestManager.swift in Sources */, 237 | OBJ_49 /* StringFormatUtil.swift in Sources */, 238 | ); 239 | runOnlyForDeploymentPostprocessing = 0; 240 | }; 241 | OBJ_55 /* Sources */ = { 242 | isa = PBXSourcesBuildPhase; 243 | buildActionMask = 0; 244 | files = ( 245 | OBJ_56 /* Package.swift in Sources */, 246 | ); 247 | runOnlyForDeploymentPostprocessing = 0; 248 | }; 249 | /* End PBXSourcesBuildPhase section */ 250 | 251 | /* Begin XCBuildConfiguration section */ 252 | OBJ_3 /* Debug */ = { 253 | isa = XCBuildConfiguration; 254 | buildSettings = { 255 | CLANG_ENABLE_OBJC_ARC = YES; 256 | COMBINE_HIDPI_IMAGES = YES; 257 | COPY_PHASE_STRIP = NO; 258 | DEBUG_INFORMATION_FORMAT = dwarf; 259 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 260 | ENABLE_NS_ASSERTIONS = YES; 261 | GCC_OPTIMIZATION_LEVEL = 0; 262 | GCC_PREPROCESSOR_DEFINITIONS = ( 263 | "$(inherited)", 264 | "SWIFT_PACKAGE=1", 265 | "DEBUG=1", 266 | ); 267 | MACOSX_DEPLOYMENT_TARGET = 10.10; 268 | ONLY_ACTIVE_ARCH = YES; 269 | OTHER_SWIFT_FLAGS = "$(inherited) -DXcode"; 270 | PRODUCT_NAME = "$(TARGET_NAME)"; 271 | SDKROOT = macosx; 272 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 273 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SWIFT_PACKAGE DEBUG"; 274 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 275 | USE_HEADERMAP = NO; 276 | }; 277 | name = Debug; 278 | }; 279 | OBJ_34 /* Debug */ = { 280 | isa = XCBuildConfiguration; 281 | buildSettings = { 282 | ENABLE_TESTABILITY = YES; 283 | FRAMEWORK_SEARCH_PATHS = ( 284 | "$(inherited)", 285 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 286 | ); 287 | HEADER_SEARCH_PATHS = "$(inherited)"; 288 | INFOPLIST_FILE = MusicKitPlayer.xcodeproj/MusicKit_Info.plist; 289 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 290 | MACOSX_DEPLOYMENT_TARGET = 10.14; 291 | MARKETING_VERSION = 0.1; 292 | OTHER_CFLAGS = "$(inherited)"; 293 | OTHER_LDFLAGS = "$(inherited)"; 294 | OTHER_SWIFT_FLAGS = "$(inherited)"; 295 | PRODUCT_BUNDLE_IDENTIFIER = MusicKitPlayer; 296 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 297 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 298 | SKIP_INSTALL = YES; 299 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 300 | SWIFT_VERSION = 5.0; 301 | TARGET_NAME = MusicKitPlayer; 302 | }; 303 | name = Debug; 304 | }; 305 | OBJ_35 /* Release */ = { 306 | isa = XCBuildConfiguration; 307 | buildSettings = { 308 | ENABLE_TESTABILITY = YES; 309 | FRAMEWORK_SEARCH_PATHS = ( 310 | "$(inherited)", 311 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 312 | ); 313 | HEADER_SEARCH_PATHS = "$(inherited)"; 314 | INFOPLIST_FILE = MusicKitPlayer.xcodeproj/MusicKit_Info.plist; 315 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 316 | MACOSX_DEPLOYMENT_TARGET = 10.14; 317 | MARKETING_VERSION = 0.1; 318 | OTHER_CFLAGS = "$(inherited)"; 319 | OTHER_LDFLAGS = "$(inherited)"; 320 | OTHER_SWIFT_FLAGS = "$(inherited)"; 321 | PRODUCT_BUNDLE_IDENTIFIER = MusicKitPlayer; 322 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 323 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 324 | SKIP_INSTALL = YES; 325 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 326 | SWIFT_VERSION = 5.0; 327 | TARGET_NAME = MusicKitPlayer; 328 | }; 329 | name = Release; 330 | }; 331 | OBJ_4 /* Release */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | CLANG_ENABLE_OBJC_ARC = YES; 335 | COMBINE_HIDPI_IMAGES = YES; 336 | COPY_PHASE_STRIP = YES; 337 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 338 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 339 | GCC_OPTIMIZATION_LEVEL = s; 340 | GCC_PREPROCESSOR_DEFINITIONS = ( 341 | "$(inherited)", 342 | "SWIFT_PACKAGE=1", 343 | ); 344 | MACOSX_DEPLOYMENT_TARGET = 10.10; 345 | OTHER_SWIFT_FLAGS = "$(inherited) -DXcode"; 346 | PRODUCT_NAME = "$(TARGET_NAME)"; 347 | SDKROOT = macosx; 348 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 349 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SWIFT_PACKAGE"; 350 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 351 | USE_HEADERMAP = NO; 352 | }; 353 | name = Release; 354 | }; 355 | OBJ_53 /* Debug */ = { 356 | isa = XCBuildConfiguration; 357 | buildSettings = { 358 | LD = /usr/bin/true; 359 | OTHER_SWIFT_FLAGS = "-swift-version 5 -I $(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2 -target x86_64-apple-macosx10.10 -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk -package-description-version 5.1"; 360 | PRODUCT_NAME = MusicKitPlayerPackageDescription; 361 | SWIFT_VERSION = 5.0; 362 | }; 363 | name = Debug; 364 | }; 365 | OBJ_54 /* Release */ = { 366 | isa = XCBuildConfiguration; 367 | buildSettings = { 368 | LD = /usr/bin/true; 369 | OTHER_SWIFT_FLAGS = "-swift-version 5 -I $(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2 -target x86_64-apple-macosx10.10 -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk -package-description-version 5.1"; 370 | PRODUCT_NAME = MusicKitPlayerPackageDescription; 371 | SWIFT_VERSION = 5.0; 372 | }; 373 | name = Release; 374 | }; 375 | /* End XCBuildConfiguration section */ 376 | 377 | /* Begin XCConfigurationList section */ 378 | OBJ_2 /* Build configuration list for PBXProject "MusicKitPlayer" */ = { 379 | isa = XCConfigurationList; 380 | buildConfigurations = ( 381 | OBJ_3 /* Debug */, 382 | OBJ_4 /* Release */, 383 | ); 384 | defaultConfigurationIsVisible = 0; 385 | defaultConfigurationName = Release; 386 | }; 387 | OBJ_33 /* Build configuration list for PBXNativeTarget "MusicKitPlayer" */ = { 388 | isa = XCConfigurationList; 389 | buildConfigurations = ( 390 | OBJ_34 /* Debug */, 391 | OBJ_35 /* Release */, 392 | ); 393 | defaultConfigurationIsVisible = 0; 394 | defaultConfigurationName = Release; 395 | }; 396 | OBJ_52 /* Build configuration list for PBXNativeTarget "MusicKitPlayerPackageDescription" */ = { 397 | isa = XCConfigurationList; 398 | buildConfigurations = ( 399 | OBJ_53 /* Debug */, 400 | OBJ_54 /* Release */, 401 | ); 402 | defaultConfigurationIsVisible = 0; 403 | defaultConfigurationName = Release; 404 | }; 405 | /* End XCConfigurationList section */ 406 | }; 407 | rootObject = OBJ_1 /* Project object */; 408 | } 409 | -------------------------------------------------------------------------------- /MusicKitPlayer.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MusicKitPlayer.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MusicKitPlayer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MusicKitPlayer", 8 | platforms: [ 9 | .macOS(.v10_14), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "MusicKitPlayer", 15 | targets: ["MusicKitPlayer"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "MusicKitPlayer", 26 | dependencies: []), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MusicKit-macOS 2 | A Swift framework that brings Apple Music to the Mac. 3 | 4 | This framework is essentially a Swift wrapper around Apple's [MusicKit JS](https://developer.apple.com/documentation/musickitjs) API, with a structure very similar to that of MusicKit JS. 5 | 6 | Internally, the JS framework runs in a web view and is interfaced with by sending it JavaScript strings and interpreting the response. Because of this, MusicKit for Mac is highly asynchronous, though a lot of care has been put into making the API as easy to use as possible. 7 | 8 | ## Configuration 9 | MusicKit needs to be authenticated with a developer token. You can follow [Apple's documentation](https://developer.apple.com/documentation/applemusicapi/getting_keys_and_creating_tokens) on creating and signing a token. [This JavaScript file](/jwt.js) may help with generating tokens. Keep in mind that these tokens expire after a maximum of 6 months. 10 | 11 | After your app launches, configure MusicKit with your developer token. When `onSuccess` is called, MusicKit is set up and the rest of the API can be used. 12 | ```swift 13 | MusicKit.shared.configure( 14 | withDeveloperToken: "...", 15 | appName: "My App", 16 | appBuild:"1.0", 17 | onSuccess: { 18 | // MusicKit is ready to use! 19 | }, onError: { error in 20 | // Error configuring or loading 21 | }) 22 | ``` 23 | 24 | ## Usage 25 | After configuration, users can sign in to Apple Music. The authorization UI is completely handled by MusicKit for Mac and can be invoked with this simple function call: 26 | ```swift 27 | MusicKit.shared.authorize(onSuccess: { _ in 28 | // Signed in! 29 | }, onError: { error in 30 | // Error signing in 31 | }) 32 | ``` 33 | 34 | Now we're ready to play some music. Use a playlist URL to set the playlist to the queue and play it: 35 | ```swift 36 | let rushPlaylistURL = "https://itunes.apple.com/us/playlist/rush-deep-cuts/pl.20e85b7fb46347479317bd6b0fb5f0d0" 37 | MusicKit.shared.setQueue(url: rushPlaylistURL, onSuccess: { 38 | MusicKit.shared.player.play() 39 | }, onError: { error in 40 | // Error setting queue to URL 41 | }) 42 | ``` 43 | 44 | To control playback, use the player object: 45 | ```swift 46 | MusicKit.shared.player.pause() 47 | 48 | MusicKit.shared.player.skipToNextItem() 49 | 50 | MusicKit.shared.player.seek(to: 30.0) 51 | 52 | MusicKit.shared.player.getCurrentPlaybackProgress { progress in 53 | print(progress) 54 | } 55 | ``` 56 | 57 | MusicKit also has a bunch of event listeners. Here's an example that prints the name the currently playing media item when it changes: 58 | ```swift 59 | MusicKit.shared.addEventListener(event: .mediaItemDidChange) { 60 | MusicKit.shared.player.getNowPlayingItem { nowPlayingItem in 61 | if let item = nowPlayingItem { 62 | print(item.attributes.name) 63 | } 64 | } 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/API/API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/11/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Represents the Apple Music API 12 | open class API { 13 | public var library: Library 14 | 15 | private var mkWebController: MKWebController 16 | 17 | init(webController: MKWebController) { 18 | mkWebController = webController 19 | library = Library(webController: webController) 20 | } 21 | 22 | public func getRecommendations( 23 | onSuccess: @escaping ([Recommendation]) -> Void, 24 | onError: @escaping (Error) -> Void) 25 | { 26 | mkWebController.evaluateJavaScriptWithPromise( 27 | "music.api.recommendations()", 28 | type: [Recommendation].self, 29 | decodingStrategy: .jsonSerialization, 30 | onSuccess: onSuccess, 31 | onError: onError) 32 | } 33 | 34 | /// Fetch the recently played resources for the user. 35 | public func getRecentPlayed( 36 | limit: Int = 10, 37 | offset: Int = 0, 38 | onSuccess: @escaping ([MediaCollection]) -> Void, 39 | onError: @escaping (Error) -> Void) 40 | { 41 | mkWebController.evaluateJavaScriptWithPromise( 42 | "music.api.recentPlayed({ limit: \(limit), offset: \(offset) })", 43 | type: [MediaCollection].self, 44 | decodingStrategy: .jsonSerialization, 45 | onSuccess: onSuccess, 46 | onError: onError) 47 | } 48 | 49 | /// Fetch the resources in heavy rotation for the user. 50 | public func getHeavyRotation( 51 | limit: Int = 10, 52 | offset: Int = 0, 53 | onSuccess: @escaping ([MediaCollection]) -> Void, 54 | onError: @escaping (Error) -> Void) 55 | { 56 | mkWebController.evaluateJavaScriptWithPromise( 57 | "music.api.historyHeavyRotation({ limit: \(limit), offset: \(offset) })", 58 | type: [MediaCollection].self, 59 | decodingStrategy: .jsonSerialization, 60 | onSuccess: onSuccess, 61 | onError: onError) 62 | } 63 | 64 | public func addToLibrary( 65 | songs: [MediaID], 66 | onSuccess: (() -> Void)? = nil, 67 | onError: @escaping (Error) -> Void) 68 | { 69 | addToLibrary(songs: songs, albums: nil, playlists: nil, onSuccess: onSuccess, onError: onError) 70 | } 71 | 72 | public func addToLibrary( 73 | albums: [MediaID], 74 | onSuccess: (() -> Void)? = nil, 75 | onError: @escaping (Error) -> Void) 76 | { 77 | addToLibrary(songs: nil, albums: albums, playlists: nil, onSuccess: onSuccess, onError: onError) 78 | } 79 | 80 | public func addToLibrary( 81 | playlists: [MediaID], 82 | onSuccess: (() -> Void)? = nil, 83 | onError: @escaping (Error) -> Void) 84 | { 85 | addToLibrary(songs: nil, albums: nil, playlists: playlists, onSuccess: onSuccess, onError: onError) 86 | } 87 | 88 | public func addToLibrary( 89 | songs: [MediaID]?, 90 | albums: [MediaID]?, 91 | playlists: [MediaID]?, 92 | onSuccess: (() -> Void)? = nil, 93 | onError: @escaping (Error) -> Void) 94 | { 95 | var params = [String]() 96 | if let songs = songs { 97 | params.append("songs: \(songs.description)") 98 | } 99 | if let albums = albums { 100 | params.append("albums: \(albums.description)") 101 | } 102 | if let playlists = playlists { 103 | params.append("playlists: \(playlists.description)") 104 | } 105 | let paramsString = params.joined(separator: ", ") 106 | 107 | mkWebController.evaluateJavaScriptWithPromise( 108 | "music.api.addToLibrary({ \(paramsString) })", 109 | onSuccess: onSuccess, 110 | onError: onError) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/API/Library.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Library.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/11/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Represents the user's cloud library 12 | open class Library { 13 | private var mkWebController: MKWebController 14 | 15 | init(webController: MKWebController) { 16 | mkWebController = webController 17 | } 18 | 19 | public func getSong( 20 | id: MediaID, 21 | onSuccess: @escaping (Song) -> Void, 22 | onError: @escaping (Error) -> Void) 23 | { 24 | URLRequestManager.shared.request( 25 | "https://api.music.apple.com/v1/me/library/songs/\(id.description)", 26 | requiresUserToken: false, 27 | type: [Song].self, 28 | decodingStrategy: .jsonSerialization, 29 | onSuccess: { songs in 30 | if !songs.isEmpty { 31 | onSuccess(songs[0]) 32 | } else { 33 | onError(MKError.emptyResponse) 34 | } 35 | }, 36 | onError: onError) 37 | } 38 | 39 | 40 | public func getSongs( 41 | ids: [MediaID], 42 | onSuccess: @escaping ([Song]) -> Void, 43 | onError: @escaping (Error) -> Void) 44 | { 45 | var idsFormatted = "" 46 | for id in ids { 47 | idsFormatted.append(id) 48 | idsFormatted.append(",") 49 | } 50 | URLRequestManager.shared.request( 51 | "https://api.music.apple.com/v1/me/library/songs?ids=\(idsFormatted)", 52 | requiresUserToken: true, 53 | type: [Song].self, 54 | decodingStrategy: .jsonSerialization, 55 | onSuccess: onSuccess, 56 | onError: onError) 57 | } 58 | 59 | 60 | public func getSongs( 61 | limit: Int = 25, 62 | offset: Int = 0, 63 | onSuccess: @escaping ([Song], Metadata?) -> Void, 64 | onError: @escaping (Error) -> Void) 65 | { 66 | URLRequestManager.shared.request( 67 | "https://api.music.apple.com/v1/me/library/songs?limit=\(limit.description)&offset=\(offset.description)", 68 | requiresUserToken: true, 69 | type: [Song].self, 70 | decodingStrategy: .jsonSerialization, 71 | onSuccess: onSuccess, 72 | onError: onError) 73 | } 74 | 75 | 76 | public func getAlbums( 77 | ids: [MediaID], 78 | onSuccess: @escaping ([Album]) -> Void, 79 | onError: @escaping (Error) -> Void) 80 | { 81 | let jsString = "music.api.library.albums(\(ids.count > 0 ? ids.description : "null"), null)" 82 | mkWebController.evaluateJavaScriptWithPromise( 83 | jsString, 84 | type: [Album].self, 85 | decodingStrategy: .jsonSerialization, 86 | onSuccess: onSuccess, 87 | onError: onError) 88 | } 89 | 90 | public func getAlbums( 91 | limit: Int, 92 | offset: Int = 0, 93 | onSuccess: @escaping ([Album]) -> Void, 94 | onError: @escaping (Error) -> Void) 95 | { 96 | let jsString = "music.api.library.albums(null, { limit: \(limit), offset: \(offset) })" 97 | mkWebController.evaluateJavaScriptWithPromise( 98 | jsString, 99 | type: [Album].self, 100 | decodingStrategy: .jsonSerialization, 101 | onSuccess: onSuccess, 102 | onError: onError) 103 | } 104 | 105 | public func getPlaylists( 106 | ids: [MediaID], 107 | onSuccess: @escaping ([LibraryPlaylist]) -> Void, 108 | onError: @escaping (Error) -> Void) 109 | { 110 | let jsString = "music.api.library.playlists(\(ids.count > 0 ? ids.description : "null"), null)" 111 | mkWebController.evaluateJavaScriptWithPromise( 112 | jsString, 113 | type: [LibraryPlaylist].self, 114 | decodingStrategy: .jsonSerialization, 115 | onSuccess: onSuccess, 116 | onError: onError) 117 | } 118 | 119 | public func getPlaylists( 120 | limit: Int, 121 | offset: Int = 0, 122 | onSuccess: @escaping ([LibraryPlaylist]) -> Void, 123 | onError: @escaping (Error) -> Void) 124 | { 125 | let jsString = "music.api.library.playlists(null, { limit: \(limit), offset: \(offset) })" 126 | mkWebController.evaluateJavaScriptWithPromise( 127 | jsString, 128 | type: [LibraryPlaylist].self, 129 | decodingStrategy: .jsonSerialization, 130 | onSuccess: onSuccess, 131 | onError: onError) 132 | } 133 | 134 | public func getSongs( 135 | inPlaylist id: MediaID, 136 | limit: Int = 10, 137 | offset: Int = 0, 138 | onSuccess: @escaping ([Song], Metadata?) -> Void, 139 | onError: @escaping (Error) -> Void) 140 | { 141 | URLRequestManager.shared.request( 142 | "https://api.music.apple.com/v1/me/library/playlists/\(id)/tracks?limit=\(limit)&offset=\(offset)", 143 | requiresUserToken: true, 144 | type: [Song].self, 145 | decodingStrategy: .jsonSerialization, 146 | onSuccess: onSuccess, 147 | onError: onError) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/API/MusicKitPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicKitPlayer.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/10/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | open class MusicKitPlayer { 12 | 13 | static public var shared = MusicKitPlayer() 14 | private var mkWebController = MKWebController() 15 | 16 | public var player: Player 17 | public var api: API 18 | 19 | init() { 20 | player = Player(webController: mkWebController) 21 | api = API(webController: mkWebController) 22 | } 23 | 24 | /// Configures MusicKit. This must be called before MusicKit can be used. 25 | /// - Parameters: 26 | /// - developerToken: Your MusicKit developer token 27 | /// - appName: The name of your app 28 | /// - appBuild: Your app's build version 29 | /// - appURL: A URL that represents your app. This is surfaced in the Access Request dialog 30 | /// in the sign-in flow and in the name of the web process shown in Activity Monitor. 31 | /// - appIconURL: A URL that points to your app icon, surfaced in the Access Request dialog 32 | /// in the sign-in flow. The preferred size is 120x120. 33 | /// - onSuccess: Called when MusicKit is ready to use. 34 | /// - onError: Called if there is an error loading or configuring MusicKit. 35 | public func configure(withDeveloperToken developerToken: String, 36 | appName: String, 37 | appBuild: String, 38 | appURL: URL, 39 | appIconURL: URL?, 40 | onSuccess: (() -> Void)? = nil, 41 | onError: @escaping (Error) -> Void) 42 | { 43 | mkWebController.addEventListener(for: .musicKitDidLoad) { 44 | onSuccess?() 45 | } 46 | 47 | mkWebController.loadWebView(withDeveloperToken: developerToken, 48 | appName: appName, 49 | appBuild: appBuild, 50 | baseURL: appURL, 51 | appIconURL: appIconURL, 52 | onError: onError) 53 | } 54 | 55 | /// Returns a promise containing a music user token when a user has authenticated and authorized the app. 56 | public func authorize(onSuccess: ((String) -> Void)? = nil, onError: @escaping (Error) -> Void) { 57 | mkWebController.evaluateJavaScriptWithPromise( 58 | "music.authorize()", 59 | type: String.self, 60 | decodingStrategy: .typeCasting, 61 | onSuccess: onSuccess, 62 | onError: onError) 63 | } 64 | 65 | /// Unauthorizes the app for the current user. 66 | public func unauthorize(onSuccess: (() -> Void)? = nil) { 67 | mkWebController.evaluateJavaScriptWithPromise( 68 | "music.unauthorize()", 69 | onSuccess: onSuccess) 70 | } 71 | 72 | public func getIsAuthorized(onSuccess: @escaping (Bool) -> Void) { 73 | mkWebController.evaluateJavaScript( 74 | "music.isAuthorized", 75 | type: Bool.self, 76 | decodingStrategy: .typeCasting, 77 | onSuccess: onSuccess) 78 | } 79 | 80 | internal func getDeveloperToken( 81 | onSuccess: ((String) -> Void)?, 82 | onError: @escaping (Error) -> Void) 83 | { 84 | mkWebController.evaluateJavaScript( 85 | "music.developerToken", 86 | type: String.self, 87 | decodingStrategy: .typeCasting, 88 | onSuccess: onSuccess, 89 | onError: onError) 90 | } 91 | 92 | 93 | internal func getUserToken( 94 | onSuccess: ((String?) -> Void)?, 95 | onError: @escaping (Error) -> Void) 96 | { 97 | // Value for musicUserToken is undefined if no user is logged in. 98 | // We need JS to return null instead of undefined so we can decode it. 99 | mkWebController.evaluateJavaScript( 100 | "JSON.stringify(music.musicUserToken !== undefined ? music.musicUserToken : null)", 101 | type: String?.self, 102 | decodingStrategy: .jsonString, 103 | onSuccess: onSuccess, 104 | onError: onError) 105 | } 106 | 107 | /// Sets a music player's playback queue using a URL. 108 | public func setQueue(url: String, onSuccess: (() -> Void)? = nil, onError: @escaping (Error) -> Void) { 109 | mkWebController.evaluateJavaScriptWithPromise( 110 | "music.setQueue({ url: '\(url)' })", 111 | onSuccess: onSuccess, 112 | onError: onError) 113 | } 114 | 115 | /// Sets a music player's playback queue to a single Song. 116 | public func setQueue(song id: MediaID, onSuccess: (() -> Void)? = nil, onError: @escaping (Error) -> Void) { 117 | mkWebController.evaluateJavaScriptWithPromise( 118 | "music.setQueue({ song: '\(id)' })", 119 | onSuccess: onSuccess, 120 | onError: onError) 121 | } 122 | 123 | public func setQueue(songs ids: [MediaID], onSuccess: (() -> Void)? = nil, onError: @escaping (Error) -> Void) { 124 | mkWebController.evaluateJavaScriptWithPromise( 125 | "music.setQueue({ songs: \(ids.description) })", 126 | onSuccess: onSuccess, 127 | onError: onError) 128 | } 129 | 130 | /// Sets a music player's playback queue to a single Playlist. 131 | public func setQueue(playlist id: MediaID, onSuccess: (() -> Void)? = nil, onError: @escaping (Error) -> Void) { 132 | mkWebController.evaluateJavaScriptWithPromise( 133 | "music.setQueue({ playlist: '\(id)' })", 134 | onSuccess: onSuccess, 135 | onError: onError) 136 | } 137 | 138 | /// Sets a music player's playback queue to a single Album. 139 | public func setQueue(album id: MediaID, onSuccess: (() -> Void)? = nil, onError: @escaping (Error) -> Void) { 140 | mkWebController.evaluateJavaScriptWithPromise( 141 | "music.setQueue({ album: '\(id)' })", 142 | onSuccess: onSuccess, 143 | onError: onError) 144 | } 145 | 146 | public func addEventListener(for event: MKEvent, callback: @escaping () -> Void) { 147 | mkWebController.addEventListener(for: event, callback: callback) 148 | } 149 | 150 | /// Set to true to enable logging errors with additional information useful for debugging the API. 151 | public var enhancedErrorLogging = false 152 | } 153 | 154 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/API/MusicKitUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicKitUtils.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/10/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | public enum MKEvent: String { 12 | case authorizationStatusDidChange 13 | case authorizationStatusWillChange 14 | case eligibleForSubscribeView 15 | case loaded 16 | case mediaCanPlay 17 | case mediaItemDidChange 18 | case mediaItemWillChange 19 | case mediaPlaybackError 20 | case metadataDidChange 21 | case musicKitDidLoad 22 | case playbackBitrateDidChange 23 | case playbackDurationDidChange 24 | case playbackProgressDidChange 25 | case playbackStateDidChange 26 | case playbackStateWillChange 27 | case playbackTargetAvailableDidChange 28 | case playbackTimeDidChange 29 | case playbackVolumeDidChange 30 | case primaryPlayerDidChange 31 | case queueItemsDidChange 32 | case queuePositionDidChange 33 | case storefrontCountryCodeDidChange 34 | case storefrontIdentifierDidChange 35 | case userTokenDidChange 36 | } 37 | 38 | 39 | /// The playback bit rate of the music player 40 | public enum PlaybackBitrate: Int, Codable { 41 | /// The bit rate is 256 kbps. 42 | case high = 256 43 | /// The bit rate is 64 kbps. 44 | case standard = 64 45 | } 46 | 47 | /// The playback states of the music player 48 | public enum PlaybackStates: Int, Codable { 49 | /// The player has not attempted to start playback. 50 | case none = 0 51 | /// Loading of the media item has begun. 52 | case loading = 1 53 | /// The player is currently playing media. 54 | case playing = 2 55 | /// Playback has been paused. 56 | case paused = 3 57 | /// Plaback has been stopped. 58 | case stopped = 4 59 | /// Playback of the media item has ended. 60 | case ended = 5 61 | /// The player has started a seek operation. 62 | case seeking = 6 63 | /// Playback is delayed pending the completion of another operation. 64 | case waiting = 8 65 | /// The player is trying to fetch media data but cannot retrieve the data. 66 | case stalled = 9 67 | /// Playback of all media items in the queue has ended. 68 | case completed = 10 69 | } 70 | 71 | /// The repeat mode for the music player. 72 | public enum PlayerRepeatMode: Int, Codable, CaseIterable { 73 | /// No repeat mode specified. 74 | case none = 0 75 | /// The current media item will be repeated. 76 | case one = 1 77 | /// The current queue will be repeated. 78 | case all = 2 79 | 80 | } 81 | 82 | /// The shuffle mode for the music player. 83 | public enum PlayerShuffleMode: Int, Codable, CaseIterable { 84 | /// This value indicates that shuffle mode is off. 85 | case off = 0 86 | /// This value indicates that songs are being shuffled in the current queue. 87 | case shuffle = 1 // JS docs call this "songs" for some reason 88 | } 89 | 90 | /// A single audio or video item 91 | public struct MediaItem: Codable { 92 | public let assetURL: String? 93 | public let attributes: MediaItemAttributes 94 | public let flavor: String? 95 | public let id: String 96 | public let type: String 97 | } 98 | 99 | public struct MediaItemAttributes: Codable { 100 | public let albumName: String? 101 | public let artistName: String? 102 | public let artwork: MediaItemArtwork? 103 | public let composerName: String? 104 | public let contentRating: ContentRating? 105 | public let discNumber: Int? 106 | public let durationInMillis: Int? 107 | public let genreNames: [String] = [] 108 | public let isrc: String? 109 | public let name: String? 110 | public let playParams: PlayParams 111 | public let releaseDate: String? 112 | public let trackNumber: Int? 113 | public let url: String? 114 | 115 | public var durationInSecs: Int? { 116 | guard let millis = durationInMillis else { return nil } 117 | return millis / 1000 118 | } 119 | 120 | public var isExplicit: Bool { 121 | switch contentRating { 122 | case .explicit: 123 | return true 124 | default: 125 | return false 126 | } 127 | } 128 | } 129 | 130 | public struct MediaItemArtwork: Codable { 131 | let url: String 132 | 133 | public func url(forImageOfSize size: NSSize) -> URL? { 134 | // For some reason the url seems to sometimes contain 2000x2000 instead of {w}x{h}. 135 | return URL(string: url 136 | .replacingOccurrences(of: "2000x2000", with: "\(Int(size.width))x\(Int(size.height))") 137 | .replacingOccurrences(of: "{w}x{h}", with: "\(Int(size.width))x\(Int(size.height))")) 138 | } 139 | 140 | public func nsImage(ofSize size: NSSize) -> NSImage? { 141 | guard let url = url(forImageOfSize: size) else { return nil } 142 | return NSImage(contentsOf: url) 143 | } 144 | } 145 | 146 | /// An object that represents play parameters for resources. 147 | /// 148 | /// [https://developer.apple.com/documentation/applemusicapi/playparameters](https://developer.apple.com/documentation/applemusicapi/playparameters) 149 | public struct PlayParams: Codable { 150 | /// The ID of the content to use for playback. 151 | public let id: String 152 | public let catalogId: String? 153 | public let purchasedId: String? 154 | public let isLibrary: Bool? 155 | /// The kind of the content to use for playback. 156 | public let kind: String 157 | public let reporting: Bool? 158 | 159 | public init(from decoder: Decoder) throws { 160 | let container = try decoder.container(keyedBy: PlayParamsKeys.self) 161 | // For some reason, id is sometimes a string and sometimes a number, try decoding both 162 | var idNumber: Int? = nil 163 | var idString: String? = nil 164 | do { 165 | idNumber = try container.decode(Int.self, forKey: .id) 166 | } catch { 167 | idString = try container.decode(String.self, forKey: .id) 168 | } 169 | id = idString ?? String(idNumber ?? -1) 170 | catalogId = try? container.decode(String.self, forKey: .catalogId) 171 | isLibrary = try? container.decode(Bool.self, forKey: .isLibrary) 172 | kind = try container.decode(String.self, forKey: .kind) 173 | reporting = try? container.decode(Bool.self, forKey: .reporting) 174 | purchasedId = try? container.decode(String.self, forKey: .purchasedId) 175 | } 176 | 177 | public enum PlayParamsKeys: String, CodingKey { 178 | case id 179 | case catalogId 180 | case isLibrary 181 | case kind 182 | case reporting 183 | case purchasedId 184 | } 185 | } 186 | 187 | public enum ContentRating: String, Codable { 188 | case clean 189 | case explicit 190 | } 191 | 192 | 193 | /// A Song identifier 194 | public typealias MediaID = String 195 | 196 | /// A Resource object that represents a song. 197 | /// 198 | /// [https://developer.apple.com/documentation/applemusicapi/song](https://developer.apple.com/documentation/applemusicapi/song) 199 | public struct Song: Codable { 200 | /// The attributes for the song. 201 | public let attributes: Attributes 202 | /// A URL subpath that fetches the resource as the primary object. This member is only present in responses. 203 | public let href: String 204 | /// Persistent identifier of the resource. 205 | public let id: MediaID 206 | /// The type of resource. This value will always be songs. Value: `songs` 207 | public let type: String 208 | 209 | /// The attributes for a song object. 210 | /// 211 | /// [https://developer.apple.com/documentation/applemusicapi/song/attributes](https://developer.apple.com/documentation/applemusicapi/song/attributes) 212 | public struct Attributes: Codable { 213 | /// The localized name of the song. 214 | public let name: String 215 | /// The name of the album the song appears on. 216 | public let albumName: String? 217 | /// The artist’s name. 218 | public let artistName: String? 219 | /// The number of the song in the album’s track list. 220 | public let trackNumber: Int? 221 | /// The duration of the song in milliseconds. 222 | public let durationInMillis: Int 223 | /// The release date of the song in `YYYY-MM-DD` format. 224 | public let releaseDate: String? 225 | /// The parameters to use to playback the song. 226 | public let playParams: PlayParams? 227 | /// The album artwork. 228 | public let artwork: Artwork? 229 | /// The genre names the song is associated with. 230 | public let genreNames: [String] 231 | 232 | /// The song’s composer. 233 | // composerName 234 | /// The Recording Industry Association of America (RIAA) rating of the content. The possible values for this rating are clean and explicit. No value means no rating. 235 | // contentRating 236 | /// The disc number the song appears on. 237 | // discNumber 238 | /// The notes about the song that appear in the iTunes Store. 239 | // editorialNotes 240 | /// The International Standard Recording Code (ISRC) for the song. 241 | // isrc 242 | /// The URL for sharing a song in the iTunes Store. 243 | // url 244 | 245 | public var trackTime: String { 246 | return String(milliseconds: durationInMillis) 247 | } 248 | } 249 | } 250 | 251 | 252 | public enum SongType { 253 | case song 254 | case librarySong 255 | } 256 | 257 | 258 | public struct LibraryAlbum: Codable { 259 | public let attributes: Attributes 260 | public let href: String 261 | public let id: MediaID 262 | public let type: String 263 | 264 | public struct Attributes: Codable { 265 | public let artistName: String? 266 | public let artwork: Artwork? 267 | public let dateAdded: String 268 | public let genreNames: [String] 269 | public let name: String 270 | public let playParams: PlayParams? 271 | public let releaseDate: String? 272 | public let trackCount: Int 273 | } 274 | } 275 | 276 | 277 | public struct Album: Codable { 278 | public let attributes: Attributes 279 | public let href: String 280 | public let id: MediaID 281 | public let type: String 282 | 283 | public struct Attributes: Codable { 284 | public let artistName: String 285 | public let artwork: Artwork 286 | public let copyright: String 287 | public let genreNames: [String] 288 | public let isCompilation: Bool 289 | public let isComplete: Bool 290 | public let isMasteredForItunes: Bool 291 | public let isSingle: Bool 292 | public let name: String 293 | public let playParams: PlayParams? 294 | public let releaseDate: String 295 | public let trackCount: Int 296 | public let url: String 297 | } 298 | } 299 | 300 | 301 | public struct LibraryPlaylist: Codable { 302 | public let attributes: Attributes 303 | public let href: String 304 | public let id: MediaID 305 | public let type: String 306 | 307 | public struct Attributes: Codable { 308 | public let artwork: Artwork? 309 | public let canEdit: Bool 310 | public let dateAdded: String 311 | public let description: Description? 312 | public let hasCatalog: Bool 313 | public let name: String 314 | public let playParams: PlayParams? 315 | } 316 | } 317 | 318 | 319 | public struct Playlist: Codable { 320 | public let attributes: Attributes 321 | public let href: String 322 | public let id: MediaID 323 | public let type: String 324 | 325 | public struct Attributes: Codable { 326 | public let artwork: Artwork 327 | public let curatorName: String? 328 | public let curatorSocialHandle: String? 329 | public let description: Description? 330 | public let isChart: Bool? 331 | public let lastModifiedDate: String 332 | public let name: String 333 | public let nextUpdateDate: String? 334 | public let playParams: PlayParams? 335 | public let playlistType: String 336 | public let url: String 337 | } 338 | } 339 | 340 | 341 | public struct Station: Codable { 342 | public let href: String 343 | public let id: MediaID 344 | public let type: String 345 | public let attributes: Attributes 346 | 347 | public struct Attributes: Codable { 348 | public let artwork: Artwork 349 | public let isLive: Bool 350 | public let name: String 351 | public let playParams: PlayParams 352 | public let url: String 353 | } 354 | } 355 | 356 | 357 | public enum MediaCollection: Decodable { 358 | case album(album: Album) 359 | case libraryAlbum(album: LibraryAlbum) 360 | case playlist(playlist: Playlist) 361 | case libraryPlaylist(playlist: LibraryPlaylist) 362 | case station(station: Station) 363 | 364 | public init(from decoder: Decoder) throws { 365 | let values = try decoder.container(keyedBy: CodingKeys.self) 366 | let typeString = try values.decode(String.self, forKey: .type) 367 | 368 | guard let type = CollectionType(rawValue: typeString) else { 369 | throw MKError.decodingFailed( 370 | underlyingError: DecodingError.unexpectedType(expected: typeString)) 371 | } 372 | 373 | switch type { 374 | case .albums: 375 | self = .album(album: try Album(from: decoder)) 376 | case .libraryAlbums: 377 | self = .libraryAlbum(album: try LibraryAlbum(from: decoder)) 378 | case .playlists: 379 | self = .playlist(playlist: try Playlist(from: decoder)) 380 | case .libraryPlaylists: 381 | self = .libraryPlaylist(playlist: try LibraryPlaylist(from: decoder)) 382 | case .stations: 383 | self = .station(station: try Station(from: decoder)) 384 | } 385 | } 386 | 387 | enum CodingKeys: String, CodingKey { 388 | case type 389 | } 390 | 391 | public enum CollectionType: String { 392 | case albums 393 | case libraryAlbums = "library-albums" 394 | case playlists 395 | case libraryPlaylists = "library-playlists" 396 | case stations 397 | } 398 | } 399 | 400 | public struct Recommendation: Decodable { 401 | public let attributes: Attributes 402 | public let href: String 403 | public let id: String 404 | public let relationships: Relationships 405 | public let type: String 406 | 407 | public struct Attributes: Decodable { 408 | public let isGroupRecommendation: Bool 409 | public let kind: String 410 | public let nextUpdateDate: String 411 | public let resourceTypes: [String] 412 | public let title: Title 413 | 414 | public struct Title: Decodable { 415 | public let stringForDisplay: String 416 | } 417 | } 418 | 419 | public struct Relationships: Decodable { 420 | public let contents: Contents 421 | public let href: String? 422 | 423 | public struct Contents: Decodable { 424 | public let data: [MediaCollection] 425 | } 426 | } 427 | } 428 | 429 | /// An object that represents artwork. 430 | /// 431 | /// [https://developer.apple.com/documentation/applemusicapi/artwork](https://developer.apple.com/documentation/applemusicapi/artwork) 432 | public struct Artwork: Codable { 433 | /// The maximum height available for the image. 434 | let height: Int? 435 | /// The maximum width available for the image. 436 | let width: Int? 437 | /// The URL to request the image asset. The image filename must be preceded by {w}x{h}, as placeholders for the width and height values as described above (for example, {w}x{h}bb.jpeg). 438 | let url: String? 439 | 440 | public init(height: Int?, width: Int?, url: String?) { 441 | self.height = height 442 | self.width = width 443 | self.url = url 444 | } 445 | 446 | var maxSize: NSSize? { 447 | guard let width = width, 448 | let height = height else { return nil } 449 | return NSSize(width: width, height: height) 450 | } 451 | 452 | public func url(forImageOfSize size: NSSize) -> URL? { 453 | guard let urlString = url else { return nil } 454 | return URL(string: urlString 455 | .replacingOccurrences(of: "{w}x{h}", with: "\(Int(size.width))x\(Int(size.height))")) 456 | } 457 | 458 | public func nsImage(ofSize size: NSSize) -> NSImage? { 459 | guard let url = url(forImageOfSize: size) else { return nil } 460 | return NSImage(contentsOf: url) 461 | } 462 | } 463 | 464 | 465 | public struct Description: Codable { 466 | public let standard: String 467 | public let short: String? 468 | } 469 | 470 | 471 | 472 | public enum MKError: Error, LocalizedError { 473 | case javaScriptError(underlyingError: Error) 474 | case promiseRejected(context: Error) 475 | case requestFailed(underlyingError: Error) 476 | case decodingFailed(underlyingError: Error) 477 | case navigationFailed(withError: Error) 478 | case loadingFailed(message: String) 479 | case timeoutError(timeout: Int) 480 | case emptyResponse 481 | 482 | public var errorDescription: String? { 483 | switch self { 484 | case .javaScriptError(let error): 485 | return "Error evaluating JavaScript \(String(describing: error))" 486 | case .promiseRejected(let context): 487 | return "MusicKit rejected promise: \(context)" 488 | case .requestFailed(let error): 489 | return "URL Request failed: \(error)" 490 | case .decodingFailed(let error): 491 | return "Error decoding JavaScript result: \(String(describing: error))" 492 | case .navigationFailed(let error): 493 | return "MusicKit webpage navigation failed \(String(describing: error))" 494 | case .loadingFailed(let message): 495 | return "MusicKit failed to load. \n\(message)" 496 | case .timeoutError(let timeout): 497 | return "MusicKit was not loaded after a timeout of \(timeout) seconds" 498 | case .emptyResponse: 499 | return "Response is empty" 500 | } 501 | } 502 | 503 | public var failureReason: String? { 504 | switch self { 505 | case .javaScriptError(let error): 506 | return "JavaScript evaluation returned an error: \(error)" 507 | case .promiseRejected(let context): 508 | return "MusicKit rejected promise: \(context)" 509 | case .requestFailed(let error): 510 | return "URL Request failed: \(error)" 511 | case .decodingFailed(let error): 512 | return "Error decoding JavaScript result: \(String(describing: error))" 513 | case .navigationFailed(let error): 514 | return "MusicKit webpage navigation failed \(String(describing: error))" 515 | case .loadingFailed(let message): 516 | return message 517 | case .timeoutError(let timeout): 518 | return "MusicKit was not loaded after a timeout of \(timeout) seconds" 519 | case .emptyResponse: 520 | return "Response is empty" 521 | } 522 | } 523 | 524 | public var underlyingError: Error? { 525 | switch self { 526 | case .javaScriptError(let error): 527 | return error 528 | case .requestFailed(let error): 529 | return error 530 | case .decodingFailed(let error): 531 | return error 532 | case .navigationFailed(let error): 533 | return error 534 | case .promiseRejected(let error): 535 | return error 536 | case .loadingFailed, .timeoutError, .emptyResponse: 537 | return nil 538 | } 539 | } 540 | } 541 | 542 | extension Dictionary: Error where Key == String, Value == String {} 543 | extension Array: Error where Element: Error {} 544 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/API/Player.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Player.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/11/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | open class Player { 12 | public var queue: Queue 13 | private var mkWebController: MKWebController 14 | 15 | init(webController: MKWebController) { 16 | mkWebController = webController 17 | queue = Queue(webController: mkWebController) 18 | } 19 | 20 | // MARK: Properties 21 | /// The current playback duration. 22 | public func getCurrentPlaybackDuration(onSuccess: @escaping (Int) -> Void) { 23 | mkWebController.evaluateJavaScript( 24 | "music.player.currentPlaybackDuration", 25 | type: Int.self, 26 | decodingStrategy: .typeCasting, 27 | onSuccess: onSuccess) 28 | } 29 | 30 | /// The current playback progress. 31 | public func getCurrentPlaybackProgress(onSuccess: @escaping (Double) -> Void) { 32 | mkWebController.evaluateJavaScript( 33 | "music.player.currentPlaybackProgress", 34 | type: Double.self, 35 | decodingStrategy: .typeCasting, 36 | onSuccess: onSuccess) 37 | } 38 | 39 | /// The current position of the playhead. 40 | public func getCurrentPlaybackTime(onSuccess: @escaping (Int) -> Void) { 41 | mkWebController.evaluateJavaScript( 42 | "music.player.currentPlaybackTime", 43 | type: Int.self, 44 | decodingStrategy: .typeCasting, 45 | onSuccess: onSuccess) 46 | } 47 | 48 | public func getCurrentPlaybackTimeRemaining(onSuccess: @escaping (Int) -> Void) { 49 | mkWebController.evaluateJavaScript( 50 | "music.player.currentPlaybackTimeRemaining", 51 | type: Int.self, 52 | decodingStrategy: .typeCasting, 53 | onSuccess: onSuccess) 54 | } 55 | 56 | /// A Boolean value indicating whether the player is currently playing. 57 | public func getIsPlaying(onSuccess: @escaping (Bool) -> Void) { 58 | mkWebController.evaluateJavaScript( 59 | "music.player.isPlaying", 60 | type: Bool.self, 61 | decodingStrategy: .typeCasting, 62 | onSuccess: onSuccess) 63 | } 64 | 65 | /// The currently-playing media item, or the media item, within an queue, that you have designated to begin playback. 66 | public func getNowPlayingItem(onSuccess: @escaping (MediaItem?) -> Void) { 67 | mkWebController.evaluateJavaScript( 68 | "JSON.stringify(music.player.nowPlayingItem)", 69 | type: MediaItem?.self, 70 | decodingStrategy: .jsonString, 71 | onSuccess: onSuccess) 72 | } 73 | 74 | /// The index of the now playing item in the current playback queue. If there is no now playing item, the index is -1. 75 | public func getNowPlayingItemIndex(onSuccess: @escaping (Int) -> Void) { 76 | mkWebController.evaluateJavaScript( 77 | "music.player.nowPlayingItemIndex", 78 | type: Int.self, 79 | decodingStrategy: .typeCasting, 80 | onSuccess: onSuccess) 81 | } 82 | 83 | /// The current playback state of the music player. 84 | public func getPlaybackState(onSuccess: @escaping (PlaybackStates) -> Void) { 85 | mkWebController.evaluateJavaScript( 86 | "music.player.playbackState", 87 | type: PlaybackStates.self, 88 | decodingStrategy: .enumType, 89 | onSuccess: onSuccess) 90 | } 91 | 92 | /// The current playback rate for the player. 93 | public func getPlaybackBitrate(onSuccess: @escaping (PlaybackBitrate) -> Void) { 94 | mkWebController.evaluateJavaScript( 95 | "music.player.bitrate", 96 | type: PlaybackBitrate.self, 97 | decodingStrategy: .enumType, 98 | onSuccess: onSuccess) 99 | } 100 | 101 | /// The current repeat mode of the music player. 102 | public func getRepeatMode(onSuccess: @escaping (PlayerRepeatMode) -> Void) { 103 | mkWebController.evaluateJavaScript( 104 | "music.player.repeatMode", 105 | type: PlayerRepeatMode.self, 106 | decodingStrategy: .enumType, 107 | onSuccess: onSuccess) 108 | } 109 | 110 | /// The current shuffle mode of the music player. 111 | public func getShuffleMode(onSuccess: @escaping (PlayerShuffleMode) -> Void) { 112 | mkWebController.evaluateJavaScript( 113 | "music.player.shuffleMode", 114 | type: PlayerShuffleMode.self, 115 | decodingStrategy: .enumType, 116 | onSuccess: onSuccess) 117 | } 118 | 119 | /// A number indicating the current volume of the music player. 120 | public func getVolume(onSuccess: @escaping (Double) -> Void) { 121 | mkWebController.evaluateJavaScript( 122 | "music.player.volume", 123 | type: Double.self, 124 | decodingStrategy: .typeCasting, 125 | onSuccess: onSuccess) 126 | } 127 | 128 | // MARK: Methods 129 | 130 | /// Begins playing the media item at the specified index in the queue immediately. 131 | public func changeToMediaAtIndex(_ index: Int, onSuccess: (() -> Void)? = nil) { 132 | mkWebController.evaluateJavaScriptWithPromise( 133 | "music.player.changeToMediaAtIndex(\(index))", 134 | onSuccess: onSuccess) 135 | } 136 | 137 | /// Sets the volume to 0. 138 | public func mute() { 139 | mkWebController.evaluateJavaScript("music.player.mute()") 140 | } 141 | 142 | /// Pauses playback of the current item. 143 | public func pause() { 144 | mkWebController.evaluateJavaScript("music.player.pause()") 145 | } 146 | 147 | /// Initiates playback of the current item. 148 | public func play() { 149 | mkWebController.evaluateJavaScript("music.player.play()") 150 | } 151 | 152 | public func togglePlayPause() { 153 | getPlaybackState { playbackState in 154 | switch playbackState { 155 | case .playing: 156 | MusicKitPlayer.shared.player.pause() 157 | case .paused, .stopped, .ended, .none: 158 | MusicKitPlayer.shared.player.play() 159 | default: 160 | break 161 | } 162 | } 163 | } 164 | 165 | // /// Prepares a music player for playback. 166 | // public func prepareToPlay(mediaItem: MediaItem, onSuccess: (() -> Void)? = nil, onError: @escaping (Error) -> Void) { 167 | // mkWebController.evaluateJavaScriptWithPromise( 168 | // "music.player.prepareToPlay()", 169 | // onSuccess: onSuccess, 170 | // onError: onError) 171 | // } 172 | 173 | /// Sets the playback point to a specified time. 174 | public func seek(to time: Double, onSuccess: (() -> Void)? = nil) { 175 | mkWebController.evaluateJavaScriptWithPromise( 176 | "music.player.seekToTime(\(time))", 177 | onSuccess: onSuccess) 178 | } 179 | 180 | // /// Displays the playback target picker if a playback target is available. 181 | // public func showPlaybackTargetPicker() { 182 | // mkWebController.evaluateJavaScript("music.player.showPlaybackTargetPicker()") 183 | // } 184 | 185 | /// Starts playback of the next media item in the playback queue. 186 | /// Returns the current media item position. 187 | public func skipToNextItem(onSuccess: ((Int) -> Void)? = nil) { 188 | mkWebController.evaluateJavaScriptWithPromise( 189 | "music.player.skipToNextItem()", 190 | type: Int.self, 191 | decodingStrategy: .typeCasting, 192 | onSuccess: onSuccess) 193 | } 194 | 195 | /// Starts playback of the previous media item in the playback queue. 196 | /// Returns the current media position. 197 | public func skipToPreviousItem(onSuccess: ((Int) -> Void)? = nil) { 198 | mkWebController.evaluateJavaScriptWithPromise( 199 | "music.player.skipToPreviousItem()", 200 | type: Int.self, 201 | decodingStrategy: .typeCasting, 202 | onSuccess: onSuccess) 203 | } 204 | 205 | /// Stops the currently playing media item. 206 | public func stop() { 207 | mkWebController.evaluateJavaScript("music.player.stop()") 208 | } 209 | 210 | /// Sets the repeat mode of the player. 211 | /// - Parameters: 212 | /// - mode: The repeat mode. 213 | /// - onSuccess: Called when the action is completed successfully. 214 | public func setRepeatMode(_ mode: PlayerRepeatMode, onSuccess: (() -> Void)? = nil) { 215 | mkWebController.evaluateJavaScript( 216 | "music.player.repeatMode = \(mode.rawValue)", 217 | onSuccess: onSuccess) 218 | } 219 | 220 | /// Sets the shuffle mode of the player. 221 | /// - Parameters: 222 | /// - mode: The shuffle mode: shuffle or off. 223 | /// - onSuccess: Called when the action is completed successfully. 224 | public func setShuffleMode(_ mode: PlayerShuffleMode, onSuccess: (() -> Void)? = nil) { 225 | mkWebController.evaluateJavaScript( 226 | "music.player.shuffleMode = \(mode.rawValue)", 227 | onSuccess: onSuccess) 228 | } 229 | 230 | /// Sets the volume of the player 231 | /// - Parameters: 232 | /// - volume: The volume level. Must be between 0 and 1. 233 | /// - onSuccess: Called when the action is completed successfully. 234 | public func setVolume(_ volume: Double, onSuccess: (() -> Void)? = nil) { 235 | mkWebController.evaluateJavaScript( 236 | "music.player.volume = \(volume)", 237 | onSuccess: onSuccess) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/API/Queue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Queue.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/11/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | /// The current playback queue of the music player. 13 | open class Queue { 14 | private var mkWebController: MKWebController 15 | 16 | init(webController: MKWebController) { 17 | mkWebController = webController 18 | } 19 | 20 | // MARK: Properties 21 | 22 | /// A Boolean value indicating whether the queue has no items. 23 | public func getIsEmpty(onSuccess: @escaping (Bool) -> Void) { 24 | mkWebController.evaluateJavaScript( 25 | "music.player.queue.isEmpty", 26 | type: Bool.self, 27 | decodingStrategy: .typeCasting, 28 | onSuccess: onSuccess) 29 | } 30 | 31 | /// An array of all the media items in the queue. 32 | public func getItems(onSuccess: @escaping ([MediaItem]) -> Void) { 33 | mkWebController.evaluateJavaScript( 34 | "JSON.stringify(music.player.queue.items)", 35 | type: [MediaItem].self, 36 | decodingStrategy: .jsonString, 37 | onSuccess: onSuccess) 38 | } 39 | 40 | /// The number of items in the queue. 41 | public func getLength(onSuccess: @escaping (Int) -> Void) { 42 | mkWebController.evaluateJavaScript( 43 | "music.player.queue.length", 44 | type: Int.self, 45 | decodingStrategy: .typeCasting, 46 | onSuccess: onSuccess) 47 | } 48 | 49 | // /// The next playable media item in the queue. 50 | // public func getNextPlayableItem(onSuccess: @escaping (MediaItem?) -> Void) { 51 | // 52 | // } 53 | // 54 | // /// The previous playable media item in the queue. 55 | // public func getPreviousPlayableItem(onSuccess: @escaping (Bool?) -> Void) { 56 | // 57 | // } 58 | 59 | /// The current queue position. 60 | public func getPosition(onSuccess: @escaping (Int) -> Void) { 61 | mkWebController.evaluateJavaScript( 62 | "music.player.queue.position", 63 | type: Int.self, 64 | decodingStrategy: .typeCasting, 65 | onSuccess: onSuccess) 66 | } 67 | 68 | // MARK: Methods 69 | 70 | /// Inserts the song defined by the given ID into the current queue immediately after the currently playing media item. 71 | public func prepend( 72 | song: MediaID, 73 | type: SongType = .song, 74 | onSuccess: (() -> Void)? = nil) 75 | { 76 | prepend(songs: [song], type: type, onSuccess: onSuccess) 77 | } 78 | 79 | /// Inserts the songs defined by the given IDs into the current queue immediately after the currently playing media item. 80 | public func prepend( 81 | songs: [MediaID], 82 | type: SongType = .song, 83 | onSuccess: (() -> Void )? = nil) 84 | { 85 | var jsString: String 86 | switch type { 87 | case .song: 88 | jsString = """ 89 | music.api.songs(\(songs.description), null).then(function(songs) { 90 | music.player.queue.prepend(songs); 91 | }) 92 | """ 93 | case .librarySong: 94 | jsString = """ 95 | music.api.library.songs(\(songs.description), null).then(function(songs) { 96 | music.player.queue.prepend(songs); 97 | }) 98 | """ 99 | } 100 | mkWebController.evaluateJavaScript(jsString, onSuccess: onSuccess) 101 | } 102 | 103 | /// Inserts the song defined by the given ID after the last media item in the current queue. 104 | public func append( 105 | song: MediaID, 106 | type: SongType = .song, 107 | onSuccess: (() -> Void)? = nil) 108 | { 109 | append(songs: [song], type: type, onSuccess: onSuccess) 110 | } 111 | 112 | /// Inserts the songs defined by the given IDs after the last media item in the current queue. 113 | public func append( 114 | songs: [MediaID], 115 | type: SongType = .song, 116 | onSuccess: (() -> Void)? = nil) 117 | { 118 | var jsString: String 119 | switch type { 120 | case .song: 121 | jsString = """ 122 | music.api.songs(\(songs.description), null).then(function(songs) { 123 | music.player.queue.append(songs); 124 | }) 125 | """ 126 | case .librarySong: 127 | jsString = """ 128 | music.api.library.songs(\(songs.description), null).then(function(songs) { 129 | music.player.queue.append(songs); 130 | }) 131 | """ 132 | } 133 | mkWebController.evaluateJavaScript(jsString, onSuccess: onSuccess) 134 | } 135 | 136 | public func remove(index: Int, onSuccess: (() -> Void)? = nil) { 137 | mkWebController.evaluateJavaScript( 138 | "music.player.queue.remove(\(index))", 139 | onSuccess: onSuccess) 140 | } 141 | 142 | public func remove(indexes: IndexSet, onSuccess: (() -> Void)? = nil) { 143 | mkWebController.evaluateJavaScript(""" 144 | \(Array(indexes).description).reverse().forEach(function(element) { 145 | music.player.queue.remove(element); 146 | }) 147 | """, 148 | onSuccess: onSuccess) 149 | } 150 | 151 | // /// Returns the index in the playback queue for a media item descriptor. 152 | // public func indexForItem(descriptor: Descriptor) -> Int{ 153 | // return 0 154 | // } 155 | } 156 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSHumanReadableCopyright 22 | Copyright © 2020 Nate Thompson. All rights reserved. 23 | 24 | 25 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Internal/AuthorizationWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorizationWindow.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 1/4/19. 6 | // Copyright © 2019 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import WebKit 11 | 12 | class AuthorizeWindowController: NSWindowController { 13 | init(webView: WKWebView) { 14 | let viewController = AuthorizeViewController(webView: webView) 15 | let window = NSWindow(contentViewController: viewController) 16 | window.title = "Sign In" 17 | window.styleMask = [.titled, .closable] 18 | 19 | super.init(window: window) 20 | } 21 | 22 | required init?(coder: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | } 26 | 27 | 28 | 29 | 30 | 31 | class AuthorizeViewController: NSViewController { 32 | var webView: WKWebView! 33 | 34 | init(webView: WKWebView) { 35 | super.init(nibName: nil, bundle: nil) 36 | self.webView = webView 37 | self.webView.uiDelegate = self 38 | self.webView.navigationDelegate = self 39 | } 40 | 41 | required init?(coder: NSCoder) { 42 | fatalError("init(coder:) has not been implemented") 43 | } 44 | 45 | override func loadView() { 46 | view = webView 47 | view.frame.size = NSSize(width: 500.0, height: 630.0) 48 | webView.autoresizingMask = [.width, .height] 49 | } 50 | } 51 | 52 | 53 | 54 | 55 | 56 | extension AuthorizeViewController: WKUIDelegate, WKNavigationDelegate { 57 | func webViewDidClose(_ webView: WKWebView) { 58 | view.window?.close() 59 | } 60 | 61 | func webView( 62 | _ webView: WKWebView, 63 | decidePolicyFor navigationAction: WKNavigationAction, 64 | decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) 65 | { 66 | if navigationAction.navigationType == .linkActivated, 67 | let url = navigationAction.request.url, 68 | url.host != "idmsa.apple.com" 69 | { 70 | NSWorkspace.shared.open(url) 71 | } 72 | decisionHandler(.allow) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Internal/MKDecoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MKDecoder.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 3/23/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class MKDecoder { 12 | /// The strategy used to decode a JavaScript response. 13 | enum Strategy { 14 | /// Works with top-level objects of NSArray or NSDictionary. 15 | case jsonSerialization 16 | /// Decodes a JSON string. For use with JSON.stringify() in the JavaScript code. 17 | case jsonString 18 | /// Works on JS primitive types (e.g. string, boolean, number). 19 | case typeCasting 20 | /// For enums with associated RawValue types of Int or String. 21 | case enumType 22 | } 23 | 24 | 25 | /// Decodes the response to the given type using the given strategy. 26 | /// - Parameters: 27 | /// - response: The result from JavaScript. 28 | /// - type: The type to try to decode the response to. 29 | /// - strategy: The strategy used to decode the response. Different types need to be decoded in different ways, 30 | /// and this function attempts to decode the response using the strategy given. For more information, see MKDecoder.Strategy. 31 | func decodeJSResponse( 32 | _ response: Any, 33 | to type: T.Type, 34 | withStrategy strategy: Strategy) throws -> T 35 | { 36 | switch strategy { 37 | case .jsonSerialization: 38 | if JSONSerialization.isValidJSONObject(response) { 39 | // Top level object is NSArray or NSDictionary 40 | do { 41 | let responseData = try JSONSerialization.data(withJSONObject: response, options: []) 42 | return try JSONDecoder().decode(type.self, from: responseData) 43 | } catch { 44 | throw MKError.decodingFailed(underlyingError: error) 45 | } 46 | } else { 47 | throw MKError.decodingFailed(underlyingError: DecodingError.invalidJSONObject) 48 | } 49 | 50 | case .jsonString: 51 | // For use with JSON.stringify() in the JS code 52 | guard let jsonString = response as? String else { 53 | throw MKError.decodingFailed(underlyingError: 54 | DecodingError.unexpectedType(expected: "String")) 55 | } 56 | 57 | let responseData = jsonString.data(using: .utf8)! 58 | 59 | do { 60 | return try JSONDecoder().decode(type.self, from: responseData) 61 | } catch { 62 | throw MKError.decodingFailed(underlyingError: error) 63 | } 64 | 65 | case .typeCasting: 66 | // Works on raw JSON types (e.g. string, boolean, number) 67 | // JSONDecoder doesn't work with fragments https://bugs.swift.org/browse/SR-6163 68 | if let castResponse = response as? T { 69 | return castResponse 70 | } else { 71 | throw MKError.decodingFailed(underlyingError: 72 | DecodingError.typeCastingFailed(type: String(describing: type))) 73 | } 74 | 75 | case .enumType: 76 | // Synthesizes an integer or string into a case of the specified Swift enum 77 | if let _ = response as? Int { 78 | // RawValue type should be Int 79 | do { 80 | let fragmentArray = "[\(response)]".data(using: .utf8)! 81 | let decodedFragment = try JSONDecoder().decode([T].self, from: fragmentArray) 82 | return decodedFragment[0] 83 | } catch { 84 | throw MKError.decodingFailed(underlyingError: error) 85 | } 86 | 87 | } else if let _ = response as? String { 88 | // RawValue type should be String 89 | do { 90 | let fragmentArray = "[\"\(response)\"]".data(using: .utf8)! 91 | let decodedFragment = try JSONDecoder().decode([T].self, from: fragmentArray) 92 | return decodedFragment[0] 93 | } catch { 94 | throw MKError.decodingFailed(underlyingError: error) 95 | } 96 | } else { 97 | throw MKError.decodingFailed(underlyingError: 98 | DecodingError.unexpectedType(expected: "Int or String")) 99 | } 100 | } 101 | } 102 | } 103 | 104 | 105 | // MARK: Errors 106 | 107 | enum DecodingError: Error, CustomStringConvertible { 108 | case invalidJSONObject 109 | case unexpectedType(expected: String) 110 | case typeCastingFailed(type: String) 111 | 112 | var description: String { 113 | switch self { 114 | case .invalidJSONObject: 115 | return "Failed to decode invalid JSON object" 116 | case .unexpectedType(let expected): 117 | return "Unexpected Type, was expecting \(expected)" 118 | case .typeCastingFailed(let type): 119 | return "Failed to decode by type casting to \(type)" 120 | } 121 | } 122 | } 123 | 124 | 125 | /// A wrapper for decoding errors to bundle additional information. 126 | struct EnhancedDecodingError: Error, CustomStringConvertible { 127 | let underlyingError: Error 128 | let jsString: String 129 | let response: Any 130 | let decodingStrategy: MKDecoder.Strategy 131 | 132 | var description: String { 133 | return """ 134 | Error decoding JavaScript result: 135 | Underlying error: \(String(describing: underlyingError)) 136 | JavaScript input: \(jsString) 137 | JavaScript response: \(String(describing: response)) 138 | Decoding strategy: \(String(describing: decodingStrategy)) 139 | """ 140 | } 141 | 142 | func logIfNeeded() { 143 | if MusicKitPlayer.shared.enhancedErrorLogging { 144 | NSLog(self.description) 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Internal/MKWebController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MKWebController.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 1/5/19. 6 | // Copyright © 2019 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import WebKit 11 | 12 | 13 | class MKWebController: NSWindowController { 14 | private var webView: WKWebView! 15 | private var contentController = WKUserContentController() 16 | private var decoder = MKDecoder() 17 | 18 | /// A dictionary containing responses to pending promises. Keyed by the UUID assigned to the 19 | /// promise, beginning with an underscore. 20 | private var promiseDict = [String: PromiseResponse]() 21 | 22 | /// A dictionary containing listeners to events, keyed by the event. 23 | private var eventListenerDict = [MKEvent: [() -> Void]]() 24 | 25 | private var isMusicKitLoaded = false { 26 | didSet { 27 | if isMusicKitLoaded { 28 | musicKitDidLoad() 29 | } 30 | } 31 | } 32 | 33 | /// For setting up the framework. Guaranteed to be the called first before public 34 | /// musicKitDidLoad event listeners. 35 | private var musicKitDidLoad: () -> Void = { 36 | RemoteCommandController.setup() 37 | NowPlayingInfoManager.setup() 38 | QueueManager.setup() 39 | } 40 | 41 | /// Holds the error handler of the configure function while loading. 42 | private var loadErrorHandler: ((Error) -> Void)? = nil 43 | 44 | private static var defaultErrorHandler: (Error) -> Void { 45 | return { error in 46 | #if DEBUG 47 | print(error) 48 | #else 49 | NSLog(String(describing: error)) 50 | #endif 51 | } 52 | } 53 | 54 | init() { 55 | let preferences = WKPreferences() 56 | 57 | let configuration = WKWebViewConfiguration() 58 | configuration.preferences = preferences 59 | configuration.userContentController = contentController 60 | 61 | webView = WKWebView(frame: .zero, configuration: configuration) 62 | 63 | let viewController = NSViewController(nibName: nil, bundle: nil) 64 | viewController.view = webView 65 | let window = NSWindow(contentViewController: viewController) 66 | super.init(window: window) 67 | 68 | // Clear WKWebView cache so we get an error if the MusicKit script fails to load 69 | let websiteDataTypes = NSSet(array: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) 70 | let date = Date(timeIntervalSince1970: 0) 71 | WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set, modifiedSince: date, completionHandler:{ }) 72 | 73 | // Adds message handler used in WKScriptMessageHandler extension 74 | contentController.add(self, name: "musicKitLoaded") 75 | contentController.add(self, name: "eventListenerCallback") 76 | contentController.add(self, name: "log") 77 | contentController.add(self, name: "throwLoadingError") 78 | 79 | webView.uiDelegate = self 80 | webView.navigationDelegate = self 81 | } 82 | 83 | 84 | required init?(coder: NSCoder) { 85 | fatalError("init(coder:) has not been implemented") 86 | } 87 | 88 | override func showWindow(_ sender: Any?) { 89 | fatalError("Don't do this") 90 | } 91 | 92 | 93 | // MARK: Loading Webpage 94 | 95 | func loadWebView( 96 | withDeveloperToken developerToken: String, 97 | appName: String, 98 | appBuild: String, 99 | baseURL: URL, 100 | appIconURL: URL?, 101 | onError: @escaping (Error) -> Void) 102 | { 103 | loadErrorHandler = onError 104 | 105 | let htmlString = MKWebpage.html( 106 | withDeveloperToken: developerToken, 107 | appName: appName, 108 | appBuild: appBuild, 109 | appIconURL: appIconURL) 110 | 111 | if baseURL.host == nil { 112 | onError(MKError.loadingFailed(message: "Invalid appURL")) 113 | return 114 | } 115 | 116 | webView.loadHTMLString(htmlString, baseURL: baseURL) 117 | 118 | // Ensure that MusicKit has loaded after a few seconds 119 | DispatchQueue.main.asyncAfter(deadline: .now() + 10) { 120 | self.evaluateJavaScript("MusicKit.getInstance()", onError: { error in 121 | // only throw error if loadWebView's onError hasn't already been fired 122 | if self.loadErrorHandler != nil { 123 | self.throwLoadingError(.timeoutError(timeout: 10)) 124 | } 125 | }) 126 | } 127 | } 128 | 129 | 130 | private func throwLoadingError(_ error: MKError) { 131 | if let loadError = loadErrorHandler { 132 | loadErrorHandler = nil 133 | loadError(error) 134 | } else { 135 | MKWebController.defaultErrorHandler(error) 136 | } 137 | } 138 | 139 | 140 | 141 | // MARK: Event Listeners 142 | 143 | func addEventListener(for event: MKEvent, callback: @escaping () -> Void) { 144 | if eventListenerDict[event] != nil { 145 | eventListenerDict[event]!.append(callback) 146 | } else { 147 | eventListenerDict[event] = [callback] 148 | } 149 | 150 | // If MusicKit isn't loaded, we have to wait to add the event listeners 151 | if isMusicKitLoaded { 152 | addEventListenerToMKJS(for: event) 153 | } 154 | } 155 | 156 | /// MKEvents that currently have added event listeners 157 | private var currentListeningEvents = Set() 158 | 159 | private func addEventListenerToMKJS(for event: MKEvent) { 160 | // We only need to add one JS listener for each event. When the 161 | // JS event listener is called, all of the matching event listeners 162 | // in eventListenerDict will be called. 163 | if !currentListeningEvents.contains(event) { 164 | let eventName = event.rawValue 165 | evaluateJavaScript(""" 166 | music.addEventListener('\(eventName)', function() { 167 | \(postCallback(named: eventName)) 168 | }) 169 | """) 170 | } 171 | currentListeningEvents.insert(event) 172 | } 173 | 174 | 175 | 176 | // MARK: Evaluate JavaScript 177 | 178 | /// Evaluates JavaScript String. 179 | func evaluateJavaScript( 180 | _ javaScriptString: String, 181 | onSuccess: (() -> Void)? = nil, 182 | onError: @escaping (Error) -> Void = defaultErrorHandler) 183 | { 184 | DispatchQueue.main.async { 185 | self.webView.evaluateJavaScript(javaScriptString) { (response, error) in 186 | if let underlyingError = error as? WKError, 187 | underlyingError.code == WKError.javaScriptResultTypeIsUnsupported 188 | { 189 | // This error occurs when JavaScript returns a promise, 190 | // which should mean the JS evaluation was successful. 191 | onSuccess?() 192 | } else if let error = error { 193 | onError(error) 194 | EnhancedJSError(underlyingError: error, jsString: javaScriptString).logIfNeeded() 195 | } else { 196 | onSuccess?() 197 | } 198 | } 199 | } 200 | } 201 | 202 | 203 | 204 | /// Evaluates JavaScript and passes decoded return value to completionHandler. 205 | func evaluateJavaScript( 206 | _ javaScriptString: String, 207 | type: T.Type, 208 | decodingStrategy strategy: MKDecoder.Strategy, 209 | onSuccess: ((T) -> Void)?, 210 | onError: @escaping (Error) -> Void = defaultErrorHandler) 211 | { 212 | guard let onSuccess = onSuccess else { 213 | evaluateJavaScript(javaScriptString, onError: onError) 214 | return 215 | } 216 | 217 | DispatchQueue.main.async { 218 | self.webView.evaluateJavaScript(javaScriptString) { (response, error) in 219 | self.handleResponse( 220 | response, 221 | to: javaScriptString, 222 | withError: error, 223 | decodeTo: type, 224 | withStrategy: strategy, 225 | onSuccess: onSuccess, 226 | onError: onError) 227 | } 228 | } 229 | } 230 | 231 | 232 | 233 | /// Evaluates JavaScript for void Promise and calls onSuccess handler when Promise is fulfilled. 234 | func evaluateJavaScriptWithPromise( 235 | _ javaScriptString: String, 236 | onSuccess: (() -> Void)?, 237 | onError: @escaping (Error) -> Void = defaultErrorHandler) 238 | { 239 | guard let onSuccess = onSuccess else { 240 | evaluateJavaScript(javaScriptString, onError: onError) 241 | return 242 | } 243 | 244 | evaluateJavaScriptWithPromise( 245 | javaScriptString, 246 | returnsValue: false, 247 | onSuccess: { response in 248 | onSuccess() 249 | }, onError: onError) 250 | } 251 | 252 | /// Evaluates JavaScript for Promise and passes decoded response to onSuccess handler. 253 | func evaluateJavaScriptWithPromise( 254 | _ javaScriptString: String, 255 | type: T.Type, 256 | decodingStrategy strategy: MKDecoder.Strategy, 257 | onSuccess: ((T) -> Void)?, 258 | onError: @escaping (Error) -> Void = defaultErrorHandler) 259 | { 260 | guard let onSuccess = onSuccess else { 261 | evaluateJavaScript(javaScriptString, onError: onError) 262 | return 263 | } 264 | 265 | evaluateJavaScriptWithPromise( 266 | javaScriptString, 267 | returnsValue: true, 268 | onSuccess: { response in 269 | self.handleResponse( 270 | response, 271 | to: javaScriptString, 272 | withError: nil, 273 | decodeTo: type, 274 | withStrategy: strategy, 275 | onSuccess: onSuccess, 276 | onError: onError) 277 | }, onError: onError) 278 | } 279 | 280 | 281 | /// Evaluates JavaScript for Promise and passes response to onSuccess handler. 282 | private func evaluateJavaScriptWithPromise( 283 | _ javaScriptString: String, 284 | returnsValue: Bool, 285 | onSuccess: @escaping (Any) -> Void, 286 | onError: @escaping (Error) -> Void) 287 | { 288 | // Create a PromiseResponse struct, which encapsulates both the success and error handlers. 289 | let promiseResponse = PromiseResponse( 290 | onSuccess: { response in 291 | // handle promise response 292 | onSuccess(response) 293 | }, onError: { error in 294 | if let errorDict = try? self.decoder.decodeJSResponse( 295 | error, to: [String: String].self, withStrategy: .jsonString) 296 | { 297 | onError(MKError.promiseRejected(context: errorDict)) 298 | } else { 299 | onError(MKError.promiseRejected(context: ["unknown": String(describing: error)])) 300 | } 301 | }) 302 | 303 | // Add PromiseResponse to dictionary to run after the message handler is called 304 | promiseDict[promiseResponse.id] = promiseResponse 305 | 306 | DispatchQueue.main.async { 307 | self.contentController.add(self, name: promiseResponse.successID) 308 | self.contentController.add(self, name: promiseResponse.errorID) 309 | 310 | let promiseString = self.promise(successID: promiseResponse.successID, 311 | errorID: promiseResponse.errorID, 312 | returnsValue: returnsValue) 313 | self.evaluateJavaScript(javaScriptString + promiseString, onError: onError) 314 | } 315 | } 316 | 317 | 318 | 319 | // MARK: Handle JS Response 320 | 321 | private func handleResponse( 322 | _ response: Any?, 323 | to jsString: String, 324 | withError error: Error?, 325 | decodeTo type: T.Type, 326 | withStrategy strategy: MKDecoder.Strategy, 327 | onSuccess: (T) -> Void, 328 | onError: (Error) -> Void) 329 | { 330 | if let error = error { 331 | onError(MKError.javaScriptError(underlyingError: error)) 332 | EnhancedJSError(underlyingError: error, jsString: jsString).logIfNeeded() 333 | } else { 334 | do { 335 | let decodedResponse = try self.decoder.decodeJSResponse( 336 | response!, to: type, withStrategy: strategy) 337 | onSuccess(decodedResponse) 338 | } catch { 339 | onError(error) 340 | 341 | EnhancedDecodingError(underlyingError: error, 342 | jsString: jsString, 343 | response: response!, 344 | decodingStrategy: strategy).logIfNeeded() 345 | } 346 | } 347 | } 348 | 349 | 350 | 351 | // MARK: Boilerplate JavaScript 352 | 353 | private func postCallback(named name: String) -> String { 354 | return """ 355 | try { 356 | webkit.messageHandlers.eventListenerCallback.postMessage('\(name)'); 357 | } catch(err) { 358 | log(err); 359 | } 360 | """ 361 | } 362 | 363 | 364 | 365 | /// Generates a JS string that will call message handler when promise is run. 366 | /// promiseName must be valid JS variable name. 367 | private func promise(successID: String, errorID: String, returnsValue: Bool) -> String { 368 | let promise = """ 369 | .then(function(response) { 370 | try { 371 | webkit.messageHandlers.\(successID).postMessage(response); 372 | } catch(err) { 373 | log(err); 374 | } 375 | }) 376 | """ 377 | let voidPromise = """ 378 | .then(function() { 379 | try { 380 | webkit.messageHandlers.\(successID).postMessage(''); 381 | } catch(err) { 382 | log(err); 383 | } 384 | }) 385 | """ 386 | let catchError = """ 387 | .catch(function(error) { 388 | let errorString = JSON.stringify(error); 389 | console.log(error); 390 | try { 391 | webkit.messageHandlers.\(errorID).postMessage(errorString); 392 | } catch(err) { 393 | log(err); 394 | } 395 | }); 396 | """ 397 | return (returnsValue ? promise : voidPromise) + catchError 398 | } 399 | } 400 | 401 | 402 | 403 | // MARK: Script Message Handler 404 | 405 | extension MKWebController: WKScriptMessageHandler { 406 | func userContentController( 407 | _ userContentController: WKUserContentController, 408 | didReceive message: WKScriptMessage) 409 | { 410 | // Remember to add message handler for each message in init() 411 | if message.name == "musicKitLoaded" { 412 | isMusicKitLoaded = true 413 | 414 | // Add registered event listeners to MKJS and call musicKitDidLoad listeners 415 | for eventListeners in eventListenerDict { 416 | if eventListeners.key == .musicKitDidLoad { 417 | for eventListener in eventListeners.value { 418 | eventListener() 419 | } 420 | } else { 421 | addEventListenerToMKJS(for: eventListeners.key) 422 | } 423 | } 424 | 425 | } else if message.name == "eventListenerCallback" { 426 | guard let eventName = message.body as? String, 427 | let event = MKEvent(rawValue: eventName), 428 | let callbacks = eventListenerDict[event] else 429 | { 430 | NSLog("Error: no callback function for event listener \(String(describing: message.body))") 431 | return 432 | } 433 | for callback in callbacks { 434 | callback() 435 | } 436 | 437 | } else if message.name == "log" { 438 | NSLog(String(describing: message.body)) 439 | 440 | } else if message.name == "throwLoadingError" { 441 | let errorMessage = (message.body as? String ?? "Error loading webpage") 442 | throwLoadingError(.loadingFailed(message: errorMessage)) 443 | 444 | // For promise response, message name should contain a UUID, unique to the pair 445 | // of success and error handlers, in the format "success_" and "error_". 446 | // promiseDict is keyed by "_". 447 | } else if let promiseResponse = 448 | promiseDict[removingPrefixBefore("_", in: message.name)] 449 | { 450 | if message.name.hasPrefix("success") { 451 | promiseResponse.onSuccess(message.body) 452 | promiseDict.removeValue(forKey: message.name) 453 | } else if message.name.hasPrefix("error") { 454 | promiseResponse.onError(message.body) 455 | promiseDict.removeValue(forKey: message.name) 456 | } 457 | 458 | } else { 459 | NSLog("Unhandled script message: \(message.name) - \(message.body)") 460 | } 461 | } 462 | 463 | private func removingPrefixBefore( 464 | _ character: String.Element, 465 | in string: String) -> String 466 | { 467 | var newString = string 468 | if let i = newString.firstIndex(of: character) { 469 | let start = newString.startIndex 470 | let end = newString.index(before: i) 471 | newString.removeSubrange(start...end) 472 | return newString 473 | } else { 474 | return string 475 | } 476 | } 477 | } 478 | 479 | 480 | 481 | // MARK: UI Delegate 482 | extension MKWebController: WKUIDelegate { 483 | func webView( 484 | _ webView: WKWebView, 485 | createWebViewWith configuration: WKWebViewConfiguration, 486 | for navigationAction: WKNavigationAction, 487 | windowFeatures: WKWindowFeatures) -> WKWebView? 488 | { 489 | let authWebView = WKWebView(frame: .zero, configuration: configuration) 490 | let authWindow = AuthorizeWindowController(webView: authWebView) 491 | authWindow.showWindow(nil) 492 | 493 | return authWebView 494 | } 495 | } 496 | 497 | // MARK: Navigation Delegate 498 | extension MKWebController: WKNavigationDelegate { 499 | func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 500 | throwLoadingError(.navigationFailed(withError: error)) 501 | } 502 | 503 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 504 | throwLoadingError(.navigationFailed(withError: error)) 505 | } 506 | } 507 | 508 | 509 | 510 | 511 | // MARK: Models 512 | extension MKWebController { 513 | /// Encapsulates success and error handlers for promise and 514 | private struct PromiseResponse { 515 | let onSuccess: (Any) -> Void 516 | let onError: (Any) -> Void 517 | 518 | let id: String 519 | let successID: String 520 | let errorID: String 521 | 522 | init(onSuccess: @escaping (Any) -> Void, 523 | onError: @escaping (Any) -> Void) 524 | { 525 | self.onSuccess = onSuccess 526 | self.onError = onError 527 | 528 | // Create unique UUID for promise response, and variants for success and error. 529 | self.id = "_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" 530 | self.successID = "success\(id)" 531 | self.errorID = "error\(id)" 532 | } 533 | } 534 | 535 | 536 | struct EnhancedJSError: Error, CustomStringConvertible { 537 | let underlyingError: Error 538 | let jsString: String 539 | 540 | var description: String { 541 | return """ 542 | Evaluation of JavaScript string produced an error: 543 | \(String(describing: underlyingError)) 544 | JavaScript string: \(jsString) 545 | """ 546 | } 547 | 548 | func logIfNeeded() { 549 | if MusicKitPlayer.shared.enhancedErrorLogging { 550 | if let underlyingError = underlyingError as? WKError, 551 | underlyingError.code == WKError.javaScriptResultTypeIsUnsupported 552 | { 553 | // Filter out unsupported type errors, which are common when 554 | // JavaScript evaluation returns a promise. 555 | } else { 556 | NSLog(self.description) 557 | } 558 | } 559 | } 560 | } 561 | } 562 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Internal/MKWebpage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MKWebpage.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 3/28/20. 6 | // 7 | 8 | import Foundation 9 | 10 | enum MKWebpage { 11 | static func html( 12 | withDeveloperToken developerToken: String, 13 | appName: String, 14 | appBuild: String, 15 | appIconURL: URL?) -> String 16 | { 17 | var iconLinkHTML = "" 18 | if let appIconURL = appIconURL { 19 | iconLinkHTML = "" 20 | } 21 | 22 | return """ 23 | 24 | 25 | 26 | MusicKit 27 | \(iconLinkHTML) 28 | 29 | 73 | 74 | 77 | 78 | 79 | 80 | 81 | """ 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Internal/URLRequestManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequestManager.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 7/8/20. 6 | // 7 | 8 | import Foundation 9 | 10 | class URLRequestManager { 11 | static var shared = URLRequestManager() 12 | 13 | func request( 14 | _ endpoint: String, 15 | requiresUserToken: Bool, 16 | type: T.Type, 17 | decodingStrategy: MKDecoder.Strategy, 18 | onSuccess: @escaping (T, Metadata?) -> Void, 19 | onError: @escaping (Error) -> Void) 20 | { 21 | MusicKitPlayer.shared.getDeveloperToken(onSuccess: { developerToken in 22 | MusicKitPlayer.shared.getUserToken(onSuccess: { userToken in 23 | 24 | if requiresUserToken && userToken == nil { 25 | onError(MKError.requestFailed(underlyingError: URLRequestError.requiresUserToken)) 26 | return 27 | } 28 | 29 | self.request( 30 | endpoint, 31 | developerToken: developerToken, 32 | userToken: userToken, 33 | type: type, 34 | decodingStrategy: decodingStrategy, 35 | onSuccess: onSuccess, 36 | onError: onError) 37 | 38 | }, onError: onError) 39 | }, onError: onError) 40 | } 41 | 42 | 43 | func request( 44 | _ endpoint: String, 45 | requiresUserToken: Bool, 46 | type: T.Type, 47 | decodingStrategy: MKDecoder.Strategy, 48 | onSuccess: @escaping (T) -> Void, 49 | onError: @escaping (Error) -> Void) 50 | { 51 | self.request( 52 | endpoint, 53 | requiresUserToken: requiresUserToken, 54 | type: type, 55 | decodingStrategy: decodingStrategy, 56 | onSuccess: { (data, _) in 57 | onSuccess(data) 58 | }, onError: onError) 59 | } 60 | 61 | 62 | private func request( 63 | _ endpoint: String, 64 | developerToken: String, 65 | userToken: String?, 66 | type: T.Type, 67 | decodingStrategy: MKDecoder.Strategy, 68 | onSuccess: @escaping (T, Metadata?) -> Void, 69 | onError: @escaping (Error) -> Void) 70 | { 71 | guard let endpointURL = URL(string: endpoint) else { 72 | onError(URLRequestError.invalidURL) 73 | return 74 | } 75 | 76 | var request = URLRequest(url: endpointURL) 77 | request.setValue("Bearer \(developerToken)", forHTTPHeaderField: "Authorization") 78 | request.setValue(userToken, forHTTPHeaderField: "Music-User-Token") 79 | 80 | let task = URLSession.shared.dataTask(with: request) { data, response, error in 81 | if let error = error { 82 | onError(MKError.requestFailed(underlyingError: error)) 83 | return 84 | } 85 | 86 | do { 87 | let decodedResponse = try JSONDecoder().decode(Response.self, from: data!) 88 | if let decodedData = decodedResponse.data { 89 | onSuccess(decodedData, decodedResponse.meta) 90 | } else if let errors = decodedResponse.errors { 91 | onError(MKError.requestFailed(underlyingError: errors)) 92 | } else { 93 | onError(MKError.decodingFailed(underlyingError: 94 | DecodingError.unexpectedType(expected: "values for keys named \"data\" or \"errors\""))) 95 | } 96 | } catch { 97 | if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode >= 400 { 98 | onError(MKError.requestFailed(underlyingError: URLRequestError.failed(withStatusCode: statusCode))) 99 | } else { 100 | onError(MKError.decodingFailed(underlyingError: error)) 101 | } 102 | } 103 | } 104 | task.resume() 105 | } 106 | 107 | 108 | struct Response: Decodable { 109 | let data: T? 110 | let meta: Metadata? 111 | let errors: [[String: String]]? 112 | } 113 | 114 | 115 | enum URLRequestError: Error, CustomStringConvertible { 116 | case failed(withStatusCode: Int) 117 | case requiresUserToken 118 | case invalidURL 119 | 120 | var description: String { 121 | switch self { 122 | case .failed(let statusCode): 123 | return "URL request failed with status code \(statusCode)" 124 | case .requiresUserToken: 125 | return "This endpoint requires that a user is signed in" 126 | case .invalidURL: 127 | return "The URL supplied for the endpoint is invalid" 128 | } 129 | } 130 | } 131 | } 132 | 133 | 134 | /// Metadata returned from the MusicKit API 135 | public struct Metadata: Decodable { 136 | public let total: Int 137 | } 138 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Managers/NowPlayingManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingManager.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/16/19. 6 | // Copyright © 2019 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import MediaPlayer 11 | 12 | enum RemoteCommandController { 13 | private static let remoteCommandCenter = MPRemoteCommandCenter.shared() 14 | 15 | static func setup() { 16 | remoteCommandCenter.playCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in 17 | MusicKitPlayer.shared.player.play() 18 | return .success 19 | } 20 | 21 | remoteCommandCenter.pauseCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in 22 | MusicKitPlayer.shared.player.pause() 23 | return .success 24 | } 25 | 26 | remoteCommandCenter.togglePlayPauseCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in 27 | MusicKitPlayer.shared.player.getIsPlaying { isPlaying in 28 | if isPlaying { 29 | MusicKitPlayer.shared.player.pause() 30 | } else { 31 | MusicKitPlayer.shared.player.play() 32 | } 33 | } 34 | return .success 35 | } 36 | 37 | remoteCommandCenter.previousTrackCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in 38 | MusicKitPlayer.shared.player.getCurrentPlaybackTime { playbackTime in 39 | if playbackTime < 2 { 40 | MusicKitPlayer.shared.player.skipToPreviousItem() 41 | } else { 42 | MusicKitPlayer.shared.player.seek(to: 0) 43 | } 44 | } 45 | return .success 46 | } 47 | 48 | remoteCommandCenter.nextTrackCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in 49 | MusicKitPlayer.shared.player.skipToNextItem() 50 | return .success 51 | } 52 | 53 | remoteCommandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in 54 | let event = event as! MPChangePlaybackPositionCommandEvent 55 | MusicKitPlayer.shared.player.seek(to: event.positionTime) 56 | return .success 57 | } 58 | } 59 | } 60 | 61 | 62 | enum NowPlayingInfoManager { 63 | private static let infoCenter = MPNowPlayingInfoCenter.default() 64 | 65 | static func setup() { 66 | MusicKitPlayer.shared.addEventListener(for: .playbackStateDidChange) { 67 | MusicKitPlayer.shared.player.getPlaybackState(onSuccess: { state in 68 | switch state { 69 | case .playing: 70 | updateInfo() 71 | // Dirty hack: steal focus back as current now playing app from WKWebView process. 72 | // WebKit gives info to MPNowPlayingInfoCenter, but its implementation is incomplete. 73 | updateState(.paused) 74 | updateState(.playing) 75 | case .paused: 76 | updateState(.paused) 77 | case .stopped, .ended: 78 | updateState(.stopped) 79 | case .loading: 80 | updateInfo() 81 | default: 82 | break 83 | } 84 | 85 | }) 86 | } 87 | } 88 | 89 | private static func updateState(_ state: MPNowPlayingPlaybackState) { 90 | infoCenter.playbackState = state 91 | } 92 | 93 | private static func updateInfo() { 94 | var nowPlayingInfo = [String: Any]() 95 | 96 | MusicKitPlayer.shared.player.getNowPlayingItem { nowPlayingItem in 97 | MusicKitPlayer.shared.player.getCurrentPlaybackDuration { duration in 98 | MusicKitPlayer.shared.player.getCurrentPlaybackTime { playbackTime in 99 | 100 | nowPlayingInfo[MPMediaItemPropertyMediaType] = MPNowPlayingInfoMediaType.audio.rawValue 101 | 102 | nowPlayingInfo[MPMediaItemPropertyTitle] = nowPlayingItem?.attributes.name ?? "Unknown" 103 | nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = nowPlayingItem?.attributes.albumName 104 | nowPlayingInfo[MPMediaItemPropertyArtist] = nowPlayingItem?.attributes.artistName 105 | nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration 106 | nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: playbackTime) 107 | 108 | nowPlayingInfo[MPMediaItemPropertyComposer] = nowPlayingItem?.attributes.composerName 109 | nowPlayingInfo[MPMediaItemPropertyGenre] = nowPlayingItem?.attributes.genreNames.first 110 | nowPlayingInfo[MPMediaItemPropertyReleaseDate] = nowPlayingItem?.attributes.releaseDate 111 | nowPlayingInfo[MPMediaItemPropertyAlbumTrackNumber] = nowPlayingItem?.attributes.trackNumber 112 | nowPlayingInfo[MPMediaItemPropertyDiscNumber] = nowPlayingItem?.attributes.discNumber 113 | 114 | if let contentRating = nowPlayingItem?.attributes.contentRating { 115 | nowPlayingInfo[MPMediaItemPropertyIsExplicit] = contentRating == .explicit 116 | } 117 | 118 | if let artwork = nowPlayingItem?.attributes.artwork { 119 | let mediaItemArtwork = MPMediaItemArtwork( 120 | boundsSize: CGSize(width: 2000, height: 2000), 121 | requestHandler: { size -> NSImage in 122 | return artwork.nsImage(ofSize: size) ?? NSImage() 123 | }) 124 | nowPlayingInfo[MPMediaItemPropertyArtwork] = mediaItemArtwork 125 | } 126 | 127 | infoCenter.nowPlayingInfo = nowPlayingInfo 128 | } 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Managers/QueueManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueueManager.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 3/11/19. 6 | // Copyright © 2019 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum QueueManager { 12 | public static var queue: [MediaItem] = [] 13 | 14 | private static var currentQueuePosition = -1 15 | private static var previousQueuePosition = -1 16 | 17 | private static var currentlyUpdatingQueue = false 18 | private static var didUpdateQueue = false 19 | 20 | /// Specifies how queue should be reordered on next queue update. 21 | private static var queueMoveInstruction: (from: IndexSet, to: Int)? = nil 22 | 23 | private static var queueDidUpdateListeners: [(QueueDidUpdateEvent) -> Void] = [] 24 | 25 | static func setup() { 26 | loadQueue(with: .setupQueue) 27 | 28 | MusicKitPlayer.shared.addEventListener(for: .queueItemsDidChange) { 29 | // Keep table view from reloading all data if queue was just uploaded 30 | // as the result of a user action 31 | if didUpdateQueue && !currentlyUpdatingQueue { 32 | self.loadQueue(with: .userModified) 33 | didUpdateQueue = false 34 | } else { 35 | self.loadQueue(with: .queueItemsDidChange) 36 | } 37 | } 38 | 39 | MusicKitPlayer.shared.addEventListener(for: .queuePositionDidChange) { 40 | self.loadQueue(with: .queuePositionDidChange) 41 | } 42 | } 43 | 44 | public static func addEventListener(_ listener: @escaping (QueueDidUpdateEvent) -> Void) { 45 | queueDidUpdateListeners.append(listener) 46 | } 47 | 48 | enum LoadQueueEvent: Equatable { 49 | case setupQueue 50 | case queueItemsDidChange 51 | case queuePositionDidChange 52 | case userModified 53 | } 54 | 55 | public enum QueueDidUpdateEvent: Equatable { 56 | case setupQueue 57 | case queueItemsDidChange 58 | case queuePositionDidChange(by: Int) 59 | case userModified 60 | case error 61 | 62 | init(_ event: LoadQueueEvent, positionChange: Int = 0) { 63 | if positionChange != 0 && event != .queuePositionDidChange && event != .queueItemsDidChange { 64 | // Queue position shouldn't have changed if the event isn't queuePositionDidChange 65 | assertionFailure("Queue Position change is nonzero and LoadQueueEvent is \(event)") 66 | } 67 | 68 | switch event { 69 | case .setupQueue: 70 | self = .setupQueue 71 | case .queueItemsDidChange: 72 | self = .queueItemsDidChange 73 | case .queuePositionDidChange: 74 | self = .queuePositionDidChange(by: positionChange) 75 | case .userModified: 76 | self = .userModified 77 | } 78 | } 79 | } 80 | 81 | private static func queueDidUpdate(with event: QueueDidUpdateEvent) { 82 | for listener in queueDidUpdateListeners { 83 | listener(event) 84 | } 85 | } 86 | 87 | private static func loadQueue(with event: LoadQueueEvent) { 88 | guard !currentlyUpdatingQueue else { return } 89 | 90 | MusicKitPlayer.shared.player.queue.getPosition { position in 91 | MusicKitPlayer.shared.player.queue.getItems { items in 92 | let queueSizeChange = items.count - self.queue.count 93 | 94 | self.queue = items 95 | self.previousQueuePosition = self.currentQueuePosition 96 | self.currentQueuePosition = position 97 | 98 | // Reorder queue if needed after append (i.e. inserted in middle of queue) 99 | if let move = self.queueMoveInstruction { 100 | // Ensure that append was was successful. 101 | // Append can fail if too many items are added at once. 102 | if move.from.count == queueSizeChange { 103 | self.queue.move(with: move.from, to: move.to) 104 | self.updateQueue() 105 | } else { 106 | NSLog("Error reordering queue - move instruction: \(move)") 107 | self.queueMoveInstruction = nil 108 | queueDidUpdate(with: .error) 109 | return 110 | } 111 | self.queueMoveInstruction = nil 112 | } 113 | 114 | let change = self.currentQueuePosition - self.previousQueuePosition 115 | queueDidUpdate(with: QueueDidUpdateEvent(event, positionChange: change)) 116 | } 117 | } 118 | } 119 | 120 | /// Updates MusicKit JS queue with contents of local queue 121 | private static func updateQueue() { 122 | currentlyUpdatingQueue = true 123 | 124 | MusicKitPlayer.shared.player.queue.getLength { queueLength in 125 | MusicKitPlayer.shared.player.queue.getPosition { currentPosition in 126 | let afterNowPlaying = (currentPosition + 1).. Void)? = nil) { 199 | let queueIndex = convertToQueueIndex(upNextIndex: index) 200 | MusicKitPlayer.shared.player.changeToMediaAtIndex(queueIndex, 201 | onSuccess: onSuccess) 202 | } 203 | 204 | /// Calculates indexe of queue array from indexe of array slice starting after now playing item 205 | public static func convertToQueueIndex(upNextIndex index: Int) -> Int { 206 | return index + currentQueuePosition + 1 207 | } 208 | 209 | /// Calculates indexes of queue array from indexes of array slice starting after now playing item 210 | static func convertToQueueIndexes(upNextIndexes indexes: IndexSet) -> IndexSet { 211 | return IndexSet(indexes.map{ $0 + currentQueuePosition + 1 }) 212 | } 213 | 214 | /// The number of songs after the now playing item 215 | public static var upNextCount: Int { 216 | return queue.count - currentQueuePosition - 1 217 | } 218 | 219 | /// Moves array element from one index to another. 220 | /// Arguments are based on indexes starting after now playing item. 221 | public static func move(from src: Int, to dest: Int) { 222 | let from = convertToQueueIndex(upNextIndex: src) 223 | let to = convertToQueueIndex(upNextIndex: dest) 224 | queue.move(from: from, to: to) 225 | updateQueue() 226 | } 227 | 228 | /// Moves array elements to a specified location in the array. 229 | /// Arguments are based on indexes starting after now playing item. 230 | public static func move(with src: IndexSet, to dest: Int) { 231 | let from = convertToQueueIndexes(upNextIndexes: src) 232 | let to = convertToQueueIndex(upNextIndex: dest) 233 | queue.move(with: from, to: to) 234 | updateQueue() 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/MusicKitPlayer.h: -------------------------------------------------------------------------------- 1 | // 2 | // MusicKitPlayer.h 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 2/10/20. 6 | // Copyright © 2020 Nate Thompson. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for MusicKitPlayer. 12 | FOUNDATION_EXPORT double MusicKitPlayerVersionNumber; 13 | 14 | //! Project version string for MusicKitPlayer. 15 | FOUNDATION_EXPORT const unsigned char MusicKitPlayerVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /Sources/MusicKitPlayer/Utils/ArrayUtil.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArrayUtil.swift 3 | // MusicKitPlayer 4 | // 5 | // Created by Nate Thompson on 3/9/19. 6 | // Copyright © 2019 Nate Thompson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Array { 12 | // These move functions from sooop on GitHub 13 | // https://gist.github.com/sooop/3c964900d429516ba48bd75050d0de0a 14 | mutating func move(from start: Index, to end: Index) { 15 | guard (0..= 500 { 31 | seconds += 1 32 | } 33 | 34 | let minutes = seconds / 60 35 | let hours = minutes / 60 36 | 37 | if hours == 0 { 38 | self = String(format: "%d:%02d", minutes, seconds % 60) 39 | } else { 40 | self = String(format: "%d:%02d:%02d", hours, minutes % 60, seconds % 60) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /jwt.js: -------------------------------------------------------------------------------- 1 | // This is a demo of generating a developer token from your private key 2 | 3 | const jwt = require('jsonwebtoken'); 4 | const fs = require('fs'); 5 | 6 | const privateKey = fs.readFileSync('YOUR_PRIVATE_KEY.p8').toString(); 7 | 8 | const now = Math.floor(Date.now() / 1000); 9 | const sixMonths = now + 15777000; 10 | 11 | var token = jwt.sign({ 12 | iss: 'YOUR_TEAM_ID', 13 | iat: now, 14 | exp: sixMonths 15 | }, privateKey, { algorithm: 'ES256', keyid: 'YOUR_KEY_ID' }); 16 | 17 | console.log('Token expires on ' + new Date(sixMonths * 1000).toUTCString()); 18 | console.log(token); 19 | --------------------------------------------------------------------------------