├── .github └── workflows │ └── swift.yml ├── .gitignore ├── README.md ├── SpotifyAPIExampleApp.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── pschorn.xcuserdatad │ │ └── IDEFindNavigatorScopes.plist └── xcshareddata │ └── xcschemes │ └── SpotifyAPIExampleApp.xcscheme └── SpotifyAPIExampleApp ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ └── Contents.json ├── Contents.json ├── spotify album placeholder.imageset │ ├── Contents.json │ └── Spotify Album Placeholder.png ├── spotify logo black.imageset │ ├── Contents.json │ └── Spotify_Icon_RGB_Black.png ├── spotify logo green.imageset │ ├── Contents.json │ └── Spotify_Icon_RGB_Green.png └── spotify logo white.imageset │ ├── Contents.json │ └── Spotify_Icon_RGB_White.png ├── Info.plist ├── Model ├── PlaylistDeduplicator.swift └── Spotify.swift ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── Project Utilities ├── AlertItem.swift ├── ImageNames.swift ├── MiscellaneousUtilities.swift └── SpotifyAPIExtensions.swift ├── SpotifyAPIExampleAppApp.swift └── Views ├── Album Views ├── AlbumGridItemView.swift ├── AlbumTrackCellView.swift ├── AlbumTracksView.swift └── SavedAlbumsGridView.swift ├── DebugMenuView.swift ├── ExamplesListView.swift ├── LoginView.swift ├── Playlist Views ├── PlaylistCellView.swift └── PlaylistsListView.swift ├── RecentlyPlayedView.swift ├── RootView.swift ├── SearchForTracksView.swift └── TrackView.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macos-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Build 13 | run: | 14 | env DEVELOPER_DIR="/Applications/Xcode_13.2.1.app" \ 15 | xcrun \ 16 | xcodebuild \ 17 | -scheme SpotifyAPIExampleApp \ 18 | -destination "platform=iOS Simulator,name=iPhone 12,OS=15.2" \ 19 | build 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | SpotifyAPIExampleApp.xcodeproj/xcuserdata/ 2 | /build 3 | Package.resolved 4 | .vscode 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpotifyAPIExampleApp 2 | 3 | An Example App that demonstrates the usage of [SpotifyAPI](https://github.com/Peter-Schorn/SpotifyAPI), a Swift library for the Spotify web API. 4 | 5 | Requires Xcode 12 and iOS 14. 6 | 7 | **The following was written for the main branch**; differences between it and the other branches may not be reflected here. 8 | 9 | ## Setup 10 | 11 | To compile and run this application, go to https://developer.spotify.com/dashboard/login and create an app. Take note of the client id and client secret. Then click on "edit settings" and add the following redirect URI: 12 | ``` 13 | spotify-api-example-app://login-callback 14 | ``` 15 | 16 | Next, set the `CLIENT_ID` and `CLIENT_SECRET` [environment variables][env] in the scheme: 17 | 18 | ![Screen Shot 2021-06-10 at 8 36 45 PM](https://user-images.githubusercontent.com/58197311/121617977-9bba1480-ca2b-11eb-8e9e-f1bfdc2563af.png) 19 | 20 | 21 | To expirement with this app, add your own views to the `List` in [`ExamplesListView.swift`][examples list]. 22 | 23 | ## How the Authorization Process Works 24 | 25 | **This app uses the [Authorization Code Flow][auth code flow] to authorize with the Spotify web API.** 26 | 27 | The first step in setting up the authorization process for an app like this is to [register a URL scheme for your app][url scheme]. To do this, navigate to the Info tab of the target inside your project and add a URL scheme to the URL Types section. For this app, the scheme `spotify-api-example-app` is used. 28 | 29 | Screen-Shot-2020-10-20-at-3-38-06-AM 30 | 31 | When another app, such as the web broswer, opens a URL containing this scheme (e.g., `spotify-api-example-app://login-callback`), the URL is delivered to this app to handle it. This is how your app receives redirects from Spotify. 32 | 33 | The next step is to create the authorization URL using [`AuthorizationCodeFlowManager.makeAuthorizationURL(redirectURI:showDialog:state:scopes:)`][make auth URL] and then open it in a browser or web view so that the user can login and grant your app access to their Spotify account. In this app, this step is performed by [`Spotify.authorize()`][authorize], which is called when the user [taps the login button][login button] in [`LoginView.swift`][login view]: 34 | 35 | IMG-67-DE87-F2410-C-1 36 | 37 | When the user presses "agree" or "cancel", the system redirects back to this app and calls the [`onOpenURL(perform:)`][on open URL] view modifier in [`Rootview.swift`][root view], which calls through to the `handleURL(_:)` method directly below. After validating the URL scheme, this method requests the access and refresh tokens using [`AuthorizationCodeFlowManager.requestAccessAndRefreshTokens(redirectURIWithQuery:state:)`][request tokens], the final step in the authorization process. 38 | 39 | When the access and refresh tokens are successfully retrieved, the [`SpotifyAPI.authorizationManagerDidChange`][auth did change publisher] PassthroughSubject emits a signal. This subject is subscribed to in the [init method of `Spotify`][spotify init subscribe]. The subscription calls [`Spotify.authorizationManagerDidChange()`][auth did change method] everytime this subject emits. This method saves the authorization information to persistent storage in the keychain and sets the [`@Published var isAuthorized`][is authorized] property of [`Spotify`][spotify file] to `true`, which dismisses [`LoginView`][login view file] and allows the user to interact with the rest of the app. 40 | 41 | A subscription is also made to [`SpotifyAPI.authorizationManagerDidDeauthorize`][did deauth publisher], which emits every time [`AuthorizationCodeFlowManagerBase.deauthorize()`][auth base deauth] is called. 42 | 43 | Every time the authorization information changes (e.g., when the access token, which expires after an hour, gets refreshed), [`Spotify.authorizationManagerDidChange()`][auth did change method] is called so that the authorization information in the keychain can be updated. When the user taps the [`logoutButton`][logout button] in [`Rootview.swift`][root view], [`AuthorizationCodeFlowManagerBase.deauthorize()`][auth base deauth] is called, which causes [`SpotifyAPI.authorizationManagerDidDeauthorize`][did deauth publisher] to emit a signal, which, in turn, causes [`Spotify.authorizationManagerDidDeauthorize()`][did deauth method] to be called. 44 | 45 | See [Saving authorization information to persistent storage][persistent storage]. 46 | 47 | The next time the app is quit and relaunched, the authorization information will be retrieved from the keychain in the [init method of `Spotify`][spotify init keychain], which prevents the user from having to login again. 48 | 49 | [env]: https://help.apple.com/xcode/mac/11.4/index.html?localePath=en.lproj#/dev3ec8a1cb4 50 | [examples list]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/main/SpotifyAPIExampleApp/Views/ExamplesListView.swift 51 | [auth code flow]: https://github.com/Peter-Schorn/SpotifyAPI#authorizing-with-the-authorization-code-flow 52 | [url scheme]: https://developer.apple.com/documentation/xcode/allowing_apps_and_websites_to_link_to_your_content/defining_a_custom_url_scheme_for_your_app 53 | [make auth URL]: https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/authorizationcodeflowbackendmanager/makeauthorizationurl(redirecturi:showdialog:state:scopes:) 54 | [authorize]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Model/Spotify.swift#L160-L185 55 | [login button]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Views/LoginView.swift#L89 56 | [login view]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/main/SpotifyAPIExampleApp/Views/LoginView.swift 57 | [on open URL]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Views/RootView.swift#L32 58 | [root view]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/main/SpotifyAPIExampleApp/Views/RootView.swift 59 | [request tokens]: https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/authorizationcodeflowbackendmanager/requestaccessandrefreshtokens(redirecturiwithquery:state:) 60 | 61 | [auth did change publisher]: https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/spotifyapi/authorizationmanagerdidchange 62 | [spotify init subscribe]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Model/Spotify.swift#L97-L104 63 | [auth did change method]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Model/Spotify.swift#L187-L235 64 | [is authorized]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Model/Spotify.swift#L67 65 | [is authorized true]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Model/Spotify.swift#L208 66 | [persistent storage]:https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/saving-the-authorization-information-to-persistent-storage/ 67 | [spotify file]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/main/SpotifyAPIExampleApp/Model/Spotify.swift 68 | [did deauth method]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Model/Spotify.swift#L237-L271 69 | [logout button]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Views/RootView.swift#L116-L131 70 | [did deauth publisher]: https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/spotifyapi/authorizationmanagerdiddeauthorize 71 | 72 | [auth base deauth]: https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/authorizationcodeflowmanagerbase/deauthorize() 73 | [login view file]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/main/SpotifyAPIExampleApp/Views/LoginView.swift 74 | [spotify init keychain]: https://github.com/Peter-Schorn/SpotifyAPIExampleApp/blob/8d41edb66c43df27b0c675526f531116e3df8fcc/SpotifyAPIExampleApp/Model/Spotify.swift#L114 75 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 470812ED2515E4D700794934 /* SpotifyAPIExampleAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470812EC2515E4D700794934 /* SpotifyAPIExampleAppApp.swift */; }; 11 | 470812EF2515E4D700794934 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470812EE2515E4D700794934 /* RootView.swift */; }; 12 | 470812F12515E4DA00794934 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 470812F02515E4DA00794934 /* Assets.xcassets */; }; 13 | 470812F42515E4DA00794934 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 470812F32515E4DA00794934 /* Preview Assets.xcassets */; }; 14 | 470813032515E8A200794934 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 470813022515E8A200794934 /* KeychainAccess */; }; 15 | 4708130A2515EA2800794934 /* Spotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470813092515EA2800794934 /* Spotify.swift */; }; 16 | 4708130E2515EB6B00794934 /* ImageNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4708130D2515EB6B00794934 /* ImageNames.swift */; }; 17 | 4725249B253F8B9B00F3FD13 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4725249A253F8B9B00F3FD13 /* LoginView.swift */; }; 18 | 47363AEB26112991007EEF6A /* RecentlyPlayedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47363AEA26112991007EEF6A /* RecentlyPlayedView.swift */; }; 19 | 473F415E2516FF5C009E52C3 /* AlbumTracksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473F415D2516FF5C009E52C3 /* AlbumTracksView.swift */; }; 20 | 475CB239259F34450092653A /* AlbumTrackCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475CB238259F34450092653A /* AlbumTrackCellView.swift */; }; 21 | 47A93562261A808600134BC3 /* SpotifyAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 47A93561261A808600134BC3 /* SpotifyAPI */; }; 22 | 47AAFC4025A003E800DC1A02 /* PlaylistDeduplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AAFC3F25A003E800DC1A02 /* PlaylistDeduplicator.swift */; }; 23 | 47AAFC4325A0098900DC1A02 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AAFC4225A0098900DC1A02 /* AlertItem.swift */; }; 24 | 47B9615025169BEF0069E441 /* ExamplesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B9614F25169BEF0069E441 /* ExamplesListView.swift */; }; 25 | 47B9615325169C030069E441 /* PlaylistsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B9615225169C030069E441 /* PlaylistsListView.swift */; }; 26 | 47B961592516A6F50069E441 /* PlaylistCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B961582516A6F50069E441 /* PlaylistCellView.swift */; }; 27 | 47B9615E2516BA990069E441 /* SearchForTracksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B9615D2516BA990069E441 /* SearchForTracksView.swift */; }; 28 | 47B961662516EDDE0069E441 /* SavedAlbumsGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B961652516EDDE0069E441 /* SavedAlbumsGridView.swift */; }; 29 | 47B9616A2516F3DF0069E441 /* AlbumGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B961692516F3DF0069E441 /* AlbumGridItemView.swift */; }; 30 | 47C332A7251688D9009ED5CA /* MiscellaneousUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C332A6251688D9009ED5CA /* MiscellaneousUtilities.swift */; }; 31 | 47C5818C255B1858007EE0CA /* SpotifyAPIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C5818B255B1858007EE0CA /* SpotifyAPIExtensions.swift */; }; 32 | 47D760CA2541738100A08663 /* TrackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D760C92541738100A08663 /* TrackView.swift */; }; 33 | 47D86875266A26D2006F971B /* DebugMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D86874266A26D2006F971B /* DebugMenuView.swift */; }; 34 | /* End PBXBuildFile section */ 35 | 36 | /* Begin PBXFileReference section */ 37 | 470812E92515E4D700794934 /* SpotifyAPIExampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SpotifyAPIExampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 38 | 470812EC2515E4D700794934 /* SpotifyAPIExampleAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAPIExampleAppApp.swift; sourceTree = ""; }; 39 | 470812EE2515E4D700794934 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 40 | 470812F02515E4DA00794934 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 41 | 470812F32515E4DA00794934 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 42 | 470812F52515E4DA00794934 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43 | 470813092515EA2800794934 /* Spotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Spotify.swift; sourceTree = ""; }; 44 | 4708130D2515EB6B00794934 /* ImageNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageNames.swift; sourceTree = ""; }; 45 | 4725249A253F8B9B00F3FD13 /* LoginView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 46 | 47363AEA26112991007EEF6A /* RecentlyPlayedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyPlayedView.swift; sourceTree = ""; }; 47 | 473F415D2516FF5C009E52C3 /* AlbumTracksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumTracksView.swift; sourceTree = ""; }; 48 | 475CB238259F34450092653A /* AlbumTrackCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumTrackCellView.swift; sourceTree = ""; }; 49 | 47AAFC3F25A003E800DC1A02 /* PlaylistDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDeduplicator.swift; sourceTree = ""; }; 50 | 47AAFC4225A0098900DC1A02 /* AlertItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertItem.swift; sourceTree = ""; }; 51 | 47B9614F25169BEF0069E441 /* ExamplesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesListView.swift; sourceTree = ""; }; 52 | 47B9615225169C030069E441 /* PlaylistsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsListView.swift; sourceTree = ""; }; 53 | 47B961582516A6F50069E441 /* PlaylistCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistCellView.swift; sourceTree = ""; }; 54 | 47B9615D2516BA990069E441 /* SearchForTracksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchForTracksView.swift; sourceTree = ""; }; 55 | 47B961652516EDDE0069E441 /* SavedAlbumsGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedAlbumsGridView.swift; sourceTree = ""; }; 56 | 47B961692516F3DF0069E441 /* AlbumGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumGridItemView.swift; sourceTree = ""; }; 57 | 47C332A6251688D9009ED5CA /* MiscellaneousUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiscellaneousUtilities.swift; sourceTree = ""; }; 58 | 47C5818B255B1858007EE0CA /* SpotifyAPIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAPIExtensions.swift; sourceTree = ""; }; 59 | 47D760C92541738100A08663 /* TrackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackView.swift; sourceTree = ""; }; 60 | 47D86874266A26D2006F971B /* DebugMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugMenuView.swift; sourceTree = ""; }; 61 | /* End PBXFileReference section */ 62 | 63 | /* Begin PBXFrameworksBuildPhase section */ 64 | 470812E62515E4D700794934 /* Frameworks */ = { 65 | isa = PBXFrameworksBuildPhase; 66 | buildActionMask = 2147483647; 67 | files = ( 68 | 470813032515E8A200794934 /* KeychainAccess in Frameworks */, 69 | 47A93562261A808600134BC3 /* SpotifyAPI in Frameworks */, 70 | ); 71 | runOnlyForDeploymentPostprocessing = 0; 72 | }; 73 | /* End PBXFrameworksBuildPhase section */ 74 | 75 | /* Begin PBXGroup section */ 76 | 470812E02515E4D700794934 = { 77 | isa = PBXGroup; 78 | children = ( 79 | 470812EB2515E4D700794934 /* SpotifyAPIExampleApp */, 80 | 470812EA2515E4D700794934 /* Products */, 81 | 47A93557261A7FF500134BC3 /* Frameworks */, 82 | ); 83 | sourceTree = ""; 84 | }; 85 | 470812EA2515E4D700794934 /* Products */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | 470812E92515E4D700794934 /* SpotifyAPIExampleApp.app */, 89 | ); 90 | name = Products; 91 | sourceTree = ""; 92 | }; 93 | 470812EB2515E4D700794934 /* SpotifyAPIExampleApp */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | 470812EC2515E4D700794934 /* SpotifyAPIExampleAppApp.swift */, 97 | 470813112515EB8000794934 /* Model */, 98 | 470813082515E9F500794934 /* Views */, 99 | 4708130C2515EB6200794934 /* Project Utilities */, 100 | 470812F02515E4DA00794934 /* Assets.xcassets */, 101 | 470812F52515E4DA00794934 /* Info.plist */, 102 | 470812F22515E4DA00794934 /* Preview Content */, 103 | ); 104 | path = SpotifyAPIExampleApp; 105 | sourceTree = ""; 106 | }; 107 | 470812F22515E4DA00794934 /* Preview Content */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | 470812F32515E4DA00794934 /* Preview Assets.xcassets */, 111 | ); 112 | path = "Preview Content"; 113 | sourceTree = ""; 114 | }; 115 | 470813082515E9F500794934 /* Views */ = { 116 | isa = PBXGroup; 117 | children = ( 118 | 470812EE2515E4D700794934 /* RootView.swift */, 119 | 4725249A253F8B9B00F3FD13 /* LoginView.swift */, 120 | 47B9614F25169BEF0069E441 /* ExamplesListView.swift */, 121 | 47B9615D2516BA990069E441 /* SearchForTracksView.swift */, 122 | 47D760C92541738100A08663 /* TrackView.swift */, 123 | 47363AEA26112991007EEF6A /* RecentlyPlayedView.swift */, 124 | 47D86874266A26D2006F971B /* DebugMenuView.swift */, 125 | 473F41602516FF62009E52C3 /* Album Views */, 126 | 47B9615B2516A6FE0069E441 /* Playlist Views */, 127 | ); 128 | path = Views; 129 | sourceTree = ""; 130 | }; 131 | 4708130C2515EB6200794934 /* Project Utilities */ = { 132 | isa = PBXGroup; 133 | children = ( 134 | 4708130D2515EB6B00794934 /* ImageNames.swift */, 135 | 47AAFC4225A0098900DC1A02 /* AlertItem.swift */, 136 | 47C332A6251688D9009ED5CA /* MiscellaneousUtilities.swift */, 137 | 47C5818B255B1858007EE0CA /* SpotifyAPIExtensions.swift */, 138 | ); 139 | path = "Project Utilities"; 140 | sourceTree = ""; 141 | }; 142 | 470813112515EB8000794934 /* Model */ = { 143 | isa = PBXGroup; 144 | children = ( 145 | 470813092515EA2800794934 /* Spotify.swift */, 146 | 47AAFC3F25A003E800DC1A02 /* PlaylistDeduplicator.swift */, 147 | ); 148 | path = Model; 149 | sourceTree = ""; 150 | }; 151 | 473F41602516FF62009E52C3 /* Album Views */ = { 152 | isa = PBXGroup; 153 | children = ( 154 | 47B961652516EDDE0069E441 /* SavedAlbumsGridView.swift */, 155 | 47B961692516F3DF0069E441 /* AlbumGridItemView.swift */, 156 | 473F415D2516FF5C009E52C3 /* AlbumTracksView.swift */, 157 | 475CB238259F34450092653A /* AlbumTrackCellView.swift */, 158 | ); 159 | path = "Album Views"; 160 | sourceTree = ""; 161 | }; 162 | 47A93557261A7FF500134BC3 /* Frameworks */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | ); 166 | name = Frameworks; 167 | sourceTree = ""; 168 | }; 169 | 47B9615B2516A6FE0069E441 /* Playlist Views */ = { 170 | isa = PBXGroup; 171 | children = ( 172 | 47B9615225169C030069E441 /* PlaylistsListView.swift */, 173 | 47B961582516A6F50069E441 /* PlaylistCellView.swift */, 174 | ); 175 | path = "Playlist Views"; 176 | sourceTree = ""; 177 | }; 178 | /* End PBXGroup section */ 179 | 180 | /* Begin PBXNativeTarget section */ 181 | 470812E82515E4D700794934 /* SpotifyAPIExampleApp */ = { 182 | isa = PBXNativeTarget; 183 | buildConfigurationList = 470812F82515E4DA00794934 /* Build configuration list for PBXNativeTarget "SpotifyAPIExampleApp" */; 184 | buildPhases = ( 185 | 470812E52515E4D700794934 /* Sources */, 186 | 470812E62515E4D700794934 /* Frameworks */, 187 | 470812E72515E4D700794934 /* Resources */, 188 | ); 189 | buildRules = ( 190 | ); 191 | dependencies = ( 192 | ); 193 | name = SpotifyAPIExampleApp; 194 | packageProductDependencies = ( 195 | 470813022515E8A200794934 /* KeychainAccess */, 196 | 47A93561261A808600134BC3 /* SpotifyAPI */, 197 | ); 198 | productName = SpotifyAPIExampleApp; 199 | productReference = 470812E92515E4D700794934 /* SpotifyAPIExampleApp.app */; 200 | productType = "com.apple.product-type.application"; 201 | }; 202 | /* End PBXNativeTarget section */ 203 | 204 | /* Begin PBXProject section */ 205 | 470812E12515E4D700794934 /* Project object */ = { 206 | isa = PBXProject; 207 | attributes = { 208 | LastSwiftUpdateCheck = 1200; 209 | LastUpgradeCheck = 1200; 210 | TargetAttributes = { 211 | 470812E82515E4D700794934 = { 212 | CreatedOnToolsVersion = 12.0; 213 | }; 214 | }; 215 | }; 216 | buildConfigurationList = 470812E42515E4D700794934 /* Build configuration list for PBXProject "SpotifyAPIExampleApp" */; 217 | compatibilityVersion = "Xcode 9.3"; 218 | developmentRegion = en; 219 | hasScannedForEncodings = 0; 220 | knownRegions = ( 221 | en, 222 | Base, 223 | ); 224 | mainGroup = 470812E02515E4D700794934; 225 | packageReferences = ( 226 | 470813012515E8A200794934 /* XCRemoteSwiftPackageReference "KeychainAccess" */, 227 | 47A9355E261A808600134BC3 /* XCRemoteSwiftPackageReference "SpotifyAPI" */, 228 | ); 229 | productRefGroup = 470812EA2515E4D700794934 /* Products */; 230 | projectDirPath = ""; 231 | projectRoot = ""; 232 | targets = ( 233 | 470812E82515E4D700794934 /* SpotifyAPIExampleApp */, 234 | ); 235 | }; 236 | /* End PBXProject section */ 237 | 238 | /* Begin PBXResourcesBuildPhase section */ 239 | 470812E72515E4D700794934 /* Resources */ = { 240 | isa = PBXResourcesBuildPhase; 241 | buildActionMask = 2147483647; 242 | files = ( 243 | 470812F42515E4DA00794934 /* Preview Assets.xcassets in Resources */, 244 | 470812F12515E4DA00794934 /* Assets.xcassets in Resources */, 245 | ); 246 | runOnlyForDeploymentPostprocessing = 0; 247 | }; 248 | /* End PBXResourcesBuildPhase section */ 249 | 250 | /* Begin PBXSourcesBuildPhase section */ 251 | 470812E52515E4D700794934 /* Sources */ = { 252 | isa = PBXSourcesBuildPhase; 253 | buildActionMask = 2147483647; 254 | files = ( 255 | 4708130E2515EB6B00794934 /* ImageNames.swift in Sources */, 256 | 47B961592516A6F50069E441 /* PlaylistCellView.swift in Sources */, 257 | 473F415E2516FF5C009E52C3 /* AlbumTracksView.swift in Sources */, 258 | 47B9615E2516BA990069E441 /* SearchForTracksView.swift in Sources */, 259 | 475CB239259F34450092653A /* AlbumTrackCellView.swift in Sources */, 260 | 47D86875266A26D2006F971B /* DebugMenuView.swift in Sources */, 261 | 47363AEB26112991007EEF6A /* RecentlyPlayedView.swift in Sources */, 262 | 470812EF2515E4D700794934 /* RootView.swift in Sources */, 263 | 47AAFC4025A003E800DC1A02 /* PlaylistDeduplicator.swift in Sources */, 264 | 47AAFC4325A0098900DC1A02 /* AlertItem.swift in Sources */, 265 | 47C332A7251688D9009ED5CA /* MiscellaneousUtilities.swift in Sources */, 266 | 4708130A2515EA2800794934 /* Spotify.swift in Sources */, 267 | 47D760CA2541738100A08663 /* TrackView.swift in Sources */, 268 | 47B9615325169C030069E441 /* PlaylistsListView.swift in Sources */, 269 | 4725249B253F8B9B00F3FD13 /* LoginView.swift in Sources */, 270 | 47B961662516EDDE0069E441 /* SavedAlbumsGridView.swift in Sources */, 271 | 47B9616A2516F3DF0069E441 /* AlbumGridItemView.swift in Sources */, 272 | 47C5818C255B1858007EE0CA /* SpotifyAPIExtensions.swift in Sources */, 273 | 470812ED2515E4D700794934 /* SpotifyAPIExampleAppApp.swift in Sources */, 274 | 47B9615025169BEF0069E441 /* ExamplesListView.swift in Sources */, 275 | ); 276 | runOnlyForDeploymentPostprocessing = 0; 277 | }; 278 | /* End PBXSourcesBuildPhase section */ 279 | 280 | /* Begin XCBuildConfiguration section */ 281 | 470812F62515E4DA00794934 /* Debug */ = { 282 | isa = XCBuildConfiguration; 283 | buildSettings = { 284 | ALWAYS_SEARCH_USER_PATHS = NO; 285 | CLANG_ANALYZER_NONNULL = YES; 286 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 287 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 288 | CLANG_CXX_LIBRARY = "libc++"; 289 | CLANG_ENABLE_MODULES = YES; 290 | CLANG_ENABLE_OBJC_ARC = YES; 291 | CLANG_ENABLE_OBJC_WEAK = YES; 292 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 293 | CLANG_WARN_BOOL_CONVERSION = YES; 294 | CLANG_WARN_COMMA = YES; 295 | CLANG_WARN_CONSTANT_CONVERSION = YES; 296 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 297 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 298 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 299 | CLANG_WARN_EMPTY_BODY = YES; 300 | CLANG_WARN_ENUM_CONVERSION = YES; 301 | CLANG_WARN_INFINITE_RECURSION = YES; 302 | CLANG_WARN_INT_CONVERSION = YES; 303 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 304 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 305 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 306 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 307 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 308 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 309 | CLANG_WARN_STRICT_PROTOTYPES = YES; 310 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 311 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 312 | CLANG_WARN_UNREACHABLE_CODE = YES; 313 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 314 | COPY_PHASE_STRIP = NO; 315 | DEBUG_INFORMATION_FORMAT = dwarf; 316 | ENABLE_STRICT_OBJC_MSGSEND = YES; 317 | ENABLE_TESTABILITY = YES; 318 | ENABLE_TESTING_SEARCH_PATHS = YES; 319 | GCC_C_LANGUAGE_STANDARD = gnu11; 320 | GCC_DYNAMIC_NO_PIC = NO; 321 | GCC_NO_COMMON_BLOCKS = YES; 322 | GCC_OPTIMIZATION_LEVEL = 0; 323 | GCC_PREPROCESSOR_DEFINITIONS = ( 324 | "DEBUG=1", 325 | "$(inherited)", 326 | ); 327 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 328 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 329 | GCC_WARN_UNDECLARED_SELECTOR = YES; 330 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 331 | GCC_WARN_UNUSED_FUNCTION = YES; 332 | GCC_WARN_UNUSED_VARIABLE = YES; 333 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 334 | LD_RUNPATH_SEARCH_PATHS = ( 335 | "$(PLATFORM_DIR)/Developer/usr/lib", 336 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 337 | ); 338 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 339 | MTL_FAST_MATH = YES; 340 | ONLY_ACTIVE_ARCH = YES; 341 | OTHER_LDFLAGS = "-ObjC"; 342 | SDKROOT = iphoneos; 343 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 344 | SWIFT_OBJC_BRIDGING_HEADER = ""; 345 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 346 | }; 347 | name = Debug; 348 | }; 349 | 470812F72515E4DA00794934 /* Release */ = { 350 | isa = XCBuildConfiguration; 351 | buildSettings = { 352 | ALWAYS_SEARCH_USER_PATHS = NO; 353 | CLANG_ANALYZER_NONNULL = YES; 354 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 355 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 356 | CLANG_CXX_LIBRARY = "libc++"; 357 | CLANG_ENABLE_MODULES = YES; 358 | CLANG_ENABLE_OBJC_ARC = YES; 359 | CLANG_ENABLE_OBJC_WEAK = YES; 360 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 361 | CLANG_WARN_BOOL_CONVERSION = YES; 362 | CLANG_WARN_COMMA = YES; 363 | CLANG_WARN_CONSTANT_CONVERSION = YES; 364 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 365 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 366 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 367 | CLANG_WARN_EMPTY_BODY = YES; 368 | CLANG_WARN_ENUM_CONVERSION = YES; 369 | CLANG_WARN_INFINITE_RECURSION = YES; 370 | CLANG_WARN_INT_CONVERSION = YES; 371 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 372 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 373 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 374 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 375 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 376 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 377 | CLANG_WARN_STRICT_PROTOTYPES = YES; 378 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 379 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 380 | CLANG_WARN_UNREACHABLE_CODE = YES; 381 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 382 | COPY_PHASE_STRIP = NO; 383 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 384 | ENABLE_NS_ASSERTIONS = NO; 385 | ENABLE_STRICT_OBJC_MSGSEND = YES; 386 | ENABLE_TESTING_SEARCH_PATHS = YES; 387 | GCC_C_LANGUAGE_STANDARD = gnu11; 388 | GCC_NO_COMMON_BLOCKS = YES; 389 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 390 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 391 | GCC_WARN_UNDECLARED_SELECTOR = YES; 392 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 393 | GCC_WARN_UNUSED_FUNCTION = YES; 394 | GCC_WARN_UNUSED_VARIABLE = YES; 395 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 396 | LD_RUNPATH_SEARCH_PATHS = ( 397 | "$(PLATFORM_DIR)/Developer/usr/lib", 398 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 399 | ); 400 | MTL_ENABLE_DEBUG_INFO = NO; 401 | MTL_FAST_MATH = YES; 402 | OTHER_LDFLAGS = "-ObjC"; 403 | SDKROOT = iphoneos; 404 | SWIFT_COMPILATION_MODE = wholemodule; 405 | SWIFT_OBJC_BRIDGING_HEADER = ""; 406 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 407 | VALIDATE_PRODUCT = YES; 408 | }; 409 | name = Release; 410 | }; 411 | 470812F92515E4DA00794934 /* Debug */ = { 412 | isa = XCBuildConfiguration; 413 | buildSettings = { 414 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 415 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 416 | CODE_SIGN_STYLE = Automatic; 417 | DEVELOPMENT_ASSET_PATHS = "\"SpotifyAPIExampleApp/Preview Content\""; 418 | DEVELOPMENT_TEAM = NB77K8UB8Y; 419 | ENABLE_PREVIEWS = YES; 420 | FRAMEWORK_SEARCH_PATHS = ( 421 | "$(inherited)", 422 | "$(PROJECT_DIR)", 423 | ); 424 | INFOPLIST_FILE = SpotifyAPIExampleApp/Info.plist; 425 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 426 | LD_RUNPATH_SEARCH_PATHS = ( 427 | "$(inherited)", 428 | "@executable_path/Frameworks", 429 | ); 430 | PRODUCT_BUNDLE_IDENTIFIER = "Peter-Schorn.SpotifyAPIExampleApp"; 431 | PRODUCT_NAME = "$(TARGET_NAME)"; 432 | SWIFT_VERSION = 5.0; 433 | TARGETED_DEVICE_FAMILY = "1,2"; 434 | }; 435 | name = Debug; 436 | }; 437 | 470812FA2515E4DA00794934 /* Release */ = { 438 | isa = XCBuildConfiguration; 439 | buildSettings = { 440 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 441 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 442 | CODE_SIGN_STYLE = Automatic; 443 | DEVELOPMENT_ASSET_PATHS = "\"SpotifyAPIExampleApp/Preview Content\""; 444 | DEVELOPMENT_TEAM = NB77K8UB8Y; 445 | ENABLE_PREVIEWS = YES; 446 | FRAMEWORK_SEARCH_PATHS = ( 447 | "$(inherited)", 448 | "$(PROJECT_DIR)", 449 | ); 450 | INFOPLIST_FILE = SpotifyAPIExampleApp/Info.plist; 451 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 452 | LD_RUNPATH_SEARCH_PATHS = ( 453 | "$(inherited)", 454 | "@executable_path/Frameworks", 455 | ); 456 | PRODUCT_BUNDLE_IDENTIFIER = "Peter-Schorn.SpotifyAPIExampleApp"; 457 | PRODUCT_NAME = "$(TARGET_NAME)"; 458 | SWIFT_VERSION = 5.0; 459 | TARGETED_DEVICE_FAMILY = "1,2"; 460 | }; 461 | name = Release; 462 | }; 463 | /* End XCBuildConfiguration section */ 464 | 465 | /* Begin XCConfigurationList section */ 466 | 470812E42515E4D700794934 /* Build configuration list for PBXProject "SpotifyAPIExampleApp" */ = { 467 | isa = XCConfigurationList; 468 | buildConfigurations = ( 469 | 470812F62515E4DA00794934 /* Debug */, 470 | 470812F72515E4DA00794934 /* Release */, 471 | ); 472 | defaultConfigurationIsVisible = 0; 473 | defaultConfigurationName = Release; 474 | }; 475 | 470812F82515E4DA00794934 /* Build configuration list for PBXNativeTarget "SpotifyAPIExampleApp" */ = { 476 | isa = XCConfigurationList; 477 | buildConfigurations = ( 478 | 470812F92515E4DA00794934 /* Debug */, 479 | 470812FA2515E4DA00794934 /* Release */, 480 | ); 481 | defaultConfigurationIsVisible = 0; 482 | defaultConfigurationName = Release; 483 | }; 484 | /* End XCConfigurationList section */ 485 | 486 | /* Begin XCRemoteSwiftPackageReference section */ 487 | 470813012515E8A200794934 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { 488 | isa = XCRemoteSwiftPackageReference; 489 | repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess"; 490 | requirement = { 491 | kind = upToNextMajorVersion; 492 | minimumVersion = 4.2.1; 493 | }; 494 | }; 495 | 47A9355E261A808600134BC3 /* XCRemoteSwiftPackageReference "SpotifyAPI" */ = { 496 | isa = XCRemoteSwiftPackageReference; 497 | repositoryURL = "https://github.com/Peter-Schorn/SpotifyAPI.git"; 498 | requirement = { 499 | kind = upToNextMajorVersion; 500 | minimumVersion = 2.0.0; 501 | }; 502 | }; 503 | /* End XCRemoteSwiftPackageReference section */ 504 | 505 | /* Begin XCSwiftPackageProductDependency section */ 506 | 470813022515E8A200794934 /* KeychainAccess */ = { 507 | isa = XCSwiftPackageProductDependency; 508 | package = 470813012515E8A200794934 /* XCRemoteSwiftPackageReference "KeychainAccess" */; 509 | productName = KeychainAccess; 510 | }; 511 | 47A93561261A808600134BC3 /* SpotifyAPI */ = { 512 | isa = XCSwiftPackageProductDependency; 513 | package = 47A9355E261A808600134BC3 /* XCRemoteSwiftPackageReference "SpotifyAPI" */; 514 | productName = SpotifyAPI; 515 | }; 516 | /* End XCSwiftPackageProductDependency section */ 517 | }; 518 | rootObject = 470812E12515E4D700794934 /* Project object */; 519 | } 520 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp.xcodeproj/project.xcworkspace/xcuserdata/pschorn.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp.xcodeproj/xcshareddata/xcschemes/SpotifyAPIExampleApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 59 | 60 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify album placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Spotify Album Placeholder.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify album placeholder.imageset/Spotify Album Placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peter-Schorn/SpotifyAPIExampleApp/837e5885a347385d8f15fabbac2bbbcc5e50c58b/SpotifyAPIExampleApp/Assets.xcassets/spotify album placeholder.imageset/Spotify Album Placeholder.png -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify logo black.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Spotify_Icon_RGB_Black.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify logo black.imageset/Spotify_Icon_RGB_Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peter-Schorn/SpotifyAPIExampleApp/837e5885a347385d8f15fabbac2bbbcc5e50c58b/SpotifyAPIExampleApp/Assets.xcassets/spotify logo black.imageset/Spotify_Icon_RGB_Black.png -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify logo green.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Spotify_Icon_RGB_Green.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify logo green.imageset/Spotify_Icon_RGB_Green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peter-Schorn/SpotifyAPIExampleApp/837e5885a347385d8f15fabbac2bbbcc5e50c58b/SpotifyAPIExampleApp/Assets.xcassets/spotify logo green.imageset/Spotify_Icon_RGB_Green.png -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify logo white.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Spotify_Icon_RGB_White.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Assets.xcassets/spotify logo white.imageset/Spotify_Icon_RGB_White.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Peter-Schorn/SpotifyAPIExampleApp/837e5885a347385d8f15fabbac2bbbcc5e50c58b/SpotifyAPIExampleApp/Assets.xcassets/spotify logo white.imageset/Spotify_Icon_RGB_White.png -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSApplicationQueriesSchemes 6 | spotify 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleURLTypes 22 | 23 | 24 | CFBundleTypeRole 25 | Editor 26 | CFBundleURLName 27 | com.Peter-Schorn.SpotifyAPIExampleApp 28 | CFBundleURLSchemes 29 | 30 | spotify-api-example-app 31 | 32 | 33 | 34 | CFBundleVersion 35 | 1 36 | LSRequiresIPhoneOS 37 | 38 | UIApplicationSceneManifest 39 | 40 | UIApplicationSupportsMultipleScenes 41 | 42 | 43 | UIApplicationSupportsIndirectInputEvents 44 | 45 | UILaunchScreen 46 | 47 | UIRequiredDeviceCapabilities 48 | 49 | armv7 50 | 51 | UISupportedInterfaceOrientations 52 | 53 | UIInterfaceOrientationPortrait 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | UISupportedInterfaceOrientations~ipad 58 | 59 | UIInterfaceOrientationPortrait 60 | UIInterfaceOrientationPortraitUpsideDown 61 | UIInterfaceOrientationLandscapeLeft 62 | UIInterfaceOrientationLandscapeRight 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Model/PlaylistDeduplicator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SwiftUI 4 | import SpotifyWebAPI 5 | 6 | /// Encapsulates the logic for removing duplicates from a playlist. 7 | class PlaylistDeduplicator: ObservableObject { 8 | 9 | @Published var isDeduplicating = false 10 | 11 | /// The total number of tracks/episodes in the playlist. 12 | @Published var totalItems: Int 13 | 14 | let spotify: Spotify 15 | 16 | let playlist: Playlist 17 | 18 | let alertPublisher = PassthroughSubject() 19 | 20 | private var seenPlaylists: Set = [] 21 | 22 | /// The uri of an item in the playlist, along with its position in the 23 | /// playlist. 24 | private var duplicates: [(uri: SpotifyURIConvertible, position: Int)] = [] 25 | 26 | private var cancellables: Set = [] 27 | 28 | init(spotify: Spotify, playlist: Playlist) { 29 | self.spotify = spotify 30 | self.playlist = playlist 31 | self._totalItems = Published(initialValue: playlist.items.total) 32 | } 33 | 34 | /// Find the duplicates in the playlist. 35 | func findAndRemoveDuplicates() { 36 | 37 | self.isDeduplicating = true 38 | 39 | self.seenPlaylists = [] 40 | self.duplicates = [] 41 | 42 | self.spotify.api.playlistItems(playlist.uri) 43 | .extendPagesConcurrently(self.spotify.api) 44 | .receive(on: DispatchQueue.main) 45 | .sink( 46 | receiveCompletion: { completion in 47 | print("received completion:", completion) 48 | switch completion { 49 | case .finished: 50 | // We've finished finding the duplicates; now we 51 | // need to remove them if there are any. 52 | if self.duplicates.isEmpty { 53 | self.isDeduplicating = false 54 | self.alertPublisher.send(.init( 55 | title: "\(self.playlist.name) does not " + 56 | "have any duplicates", 57 | message: "" 58 | )) 59 | return 60 | } 61 | self.removeDuplicates() 62 | case .failure(let error): 63 | print("couldn't check for duplicates:\n\(error)") 64 | self.isDeduplicating = false 65 | self.alertPublisher.send(.init( 66 | title: "Couldn't check for duplicates for " + 67 | "\(self.playlist.name)", 68 | message: error.localizedDescription 69 | )) 70 | } 71 | }, 72 | receiveValue: self.receivePlaylistItemsPage(page:) 73 | ) 74 | .store(in: &cancellables) 75 | 76 | } 77 | 78 | func receivePlaylistItemsPage(page: PlaylistItems) { 79 | 80 | print("received page at offset \(page.offset)") 81 | 82 | let playlistItems = page.items 83 | .map(\.item) 84 | .enumerated() 85 | 86 | for (index, playlistItem) in playlistItems { 87 | 88 | guard let playlistItem = playlistItem else { 89 | continue 90 | } 91 | 92 | // skip local tracks 93 | if case .track(let track) = playlistItem { 94 | if track.isLocal { continue } 95 | } 96 | 97 | for seenPlaylist in self.seenPlaylists { 98 | guard let uri = playlistItem.uri else { 99 | continue 100 | } 101 | 102 | if playlistItem.isProbablyTheSameAs(seenPlaylist) { 103 | // To determine the actual index of the item in the 104 | // playlist, we must take into account the offset of the 105 | // current page. 106 | let playlistIndex = index + page.offset 107 | self.duplicates.append( 108 | (uri: uri, position: playlistIndex) 109 | ) 110 | } 111 | } 112 | self.seenPlaylists.insert(playlistItem) 113 | 114 | } 115 | 116 | } 117 | 118 | /// Remove the duplicates in the playlist. 119 | func removeDuplicates() { 120 | 121 | DispatchQueue.global().async { 122 | 123 | print( 124 | "will remove \(self.duplicates.count) duplicates " + 125 | "for \(self.playlist.name)" 126 | ) 127 | 128 | let urisWithPositionsContainers = URIsWithPositionsContainer.chunked( 129 | urisWithSinglePosition: self.duplicates 130 | ) 131 | 132 | var receivedError = false 133 | 134 | let semaphore = DispatchSemaphore(value: 0) 135 | 136 | for (index, container) in urisWithPositionsContainers.enumerated() { 137 | 138 | self.spotify.api.removeSpecificOccurrencesFromPlaylist( 139 | self.playlist.uri, of: container 140 | ) 141 | .sink( 142 | receiveCompletion: { completion in 143 | print("completion for request \(index): \(completion)") 144 | switch completion { 145 | case .finished: 146 | semaphore.signal() 147 | case .failure(let error): 148 | print( 149 | "\(index): couldn't remove duplicates\n\(error)" 150 | ) 151 | DispatchQueue.main.async { 152 | self.isDeduplicating = false 153 | self.alertPublisher.send(.init( 154 | title: "Couldn't Remove Duplicates from " + 155 | "\(self.playlist.name)", 156 | message: error.localizedDescription 157 | )) 158 | } 159 | receivedError = true 160 | semaphore.signal() 161 | // Do not try to remove any more duplicates from 162 | // the playlist if we get an error because the 163 | // indices of the items may be invalid. 164 | break 165 | } 166 | }, 167 | receiveValue: { _ in } 168 | ) 169 | .store(in: &self.cancellables) 170 | 171 | semaphore.wait() 172 | 173 | } 174 | 175 | if receivedError { return } 176 | 177 | print("finished removing duplicates from playlist") 178 | 179 | DispatchQueue.main.async { 180 | self.isDeduplicating = false 181 | // Update the number of items in the playlist by subtracting 182 | // the duplicates that were removed. 183 | self.totalItems = self.playlist.items.total - self.duplicates.count 184 | self.alertPublisher.send(.init( 185 | title: "Removed \(self.duplicates.count) duplicates from " + 186 | "\(self.playlist.name)", 187 | message: "" 188 | )) 189 | } 190 | 191 | } 192 | 193 | } 194 | 195 | } 196 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Model/Spotify.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import UIKit 4 | import SwiftUI 5 | import KeychainAccess 6 | import SpotifyWebAPI 7 | 8 | /** 9 | A helper class that wraps around an instance of `SpotifyAPI` and provides 10 | convenience methods for authorizing your application. 11 | 12 | Its most important role is to handle changes to the authorization information 13 | and save them to persistent storage in the keychain. 14 | */ 15 | final class Spotify: ObservableObject { 16 | 17 | private static let clientId: String = { 18 | if let clientId = ProcessInfo.processInfo 19 | .environment["CLIENT_ID"] { 20 | return clientId 21 | } 22 | fatalError("Could not find 'CLIENT_ID' in environment variables") 23 | }() 24 | 25 | private static let clientSecret: String = { 26 | if let clientSecret = ProcessInfo.processInfo 27 | .environment["CLIENT_SECRET"] { 28 | return clientSecret 29 | } 30 | fatalError("Could not find 'CLIENT_SECRET' in environment variables") 31 | }() 32 | 33 | /// The key in the keychain that is used to store the authorization 34 | /// information: "authorizationManager". 35 | let authorizationManagerKey = "authorizationManager" 36 | 37 | /// The URL that Spotify will redirect to after the user either authorizes 38 | /// or denies authorization for your application. 39 | let loginCallbackURL = URL( 40 | string: "spotify-api-example-app://login-callback" 41 | )! 42 | 43 | /// A cryptographically-secure random string used to ensure than an incoming 44 | /// redirect from Spotify was the result of a request made by this app, and 45 | /// not an attacker. **This value is regenerated after each authorization** 46 | /// **process completes.** 47 | var authorizationState = String.randomURLSafe(length: 128) 48 | 49 | /** 50 | Whether or not the application has been authorized. If `true`, then you can 51 | begin making requests to the Spotify web API using the `api` property of 52 | this class, which contains an instance of `SpotifyAPI`. 53 | 54 | When `false`, `LoginView` is presented, which prompts the user to login. 55 | When this is set to `true`, `LoginView` is dismissed. 56 | 57 | This property provides a convenient way for the user interface to be 58 | updated based on whether the user has logged in with their Spotify account 59 | yet. For example, you could use this property disable UI elements that 60 | require the user to be logged in. 61 | 62 | This property is updated by `authorizationManagerDidChange()`, which is 63 | called every time the authorization information changes, and 64 | `authorizationManagerDidDeauthorize()`, which is called every time 65 | `SpotifyAPI.authorizationManager.deauthorize()` is called. 66 | */ 67 | @Published var isAuthorized = false 68 | 69 | /// If `true`, then the app is retrieving access and refresh tokens. Used by 70 | /// `LoginView` to present an activity indicator. 71 | @Published var isRetrievingTokens = false 72 | 73 | @Published var currentUser: SpotifyUser? = nil 74 | 75 | /// The keychain to store the authorization information in. 76 | let keychain = Keychain(service: "com.Peter-Schorn.SpotifyAPIExampleApp") 77 | 78 | /// An instance of `SpotifyAPI` that you use to make requests to the Spotify 79 | /// web API. 80 | let api = SpotifyAPI( 81 | authorizationManager: AuthorizationCodeFlowManager( 82 | clientId: Spotify.clientId, 83 | clientSecret: Spotify.clientSecret 84 | ) 85 | ) 86 | 87 | var cancellables: Set = [] 88 | 89 | // MARK: - Methods - 90 | 91 | init() { 92 | 93 | // Configure the loggers. 94 | self.api.apiRequestLogger.logLevel = .trace 95 | // self.api.logger.logLevel = .trace 96 | 97 | // MARK: Important: Subscribe to `authorizationManagerDidChange` BEFORE 98 | // MARK: retrieving `authorizationManager` from persistent storage 99 | self.api.authorizationManagerDidChange 100 | // We must receive on the main thread because we are updating the 101 | // @Published `isAuthorized` property. 102 | .receive(on: RunLoop.main) 103 | .sink(receiveValue: authorizationManagerDidChange) 104 | .store(in: &cancellables) 105 | 106 | self.api.authorizationManagerDidDeauthorize 107 | .receive(on: RunLoop.main) 108 | .sink(receiveValue: authorizationManagerDidDeauthorize) 109 | .store(in: &cancellables) 110 | 111 | 112 | // MARK: Check to see if the authorization information is saved in 113 | // MARK: the keychain. 114 | if let authManagerData = keychain[data: self.authorizationManagerKey] { 115 | 116 | do { 117 | // Try to decode the data. 118 | let authorizationManager = try JSONDecoder().decode( 119 | AuthorizationCodeFlowManager.self, 120 | from: authManagerData 121 | ) 122 | print("found authorization information in keychain") 123 | 124 | /* 125 | This assignment causes `authorizationManagerDidChange` to emit 126 | a signal, meaning that `authorizationManagerDidChange()` will 127 | be called. 128 | 129 | Note that if you had subscribed to 130 | `authorizationManagerDidChange` after this line, then 131 | `authorizationManagerDidChange()` would not have been called 132 | and the @Published `isAuthorized` property would not have been 133 | properly updated. 134 | 135 | We do not need to update `isAuthorized` here because it is 136 | already done in `authorizationManagerDidChange()`. 137 | */ 138 | self.api.authorizationManager = authorizationManager 139 | 140 | } catch { 141 | print("could not decode authorizationManager from data:\n\(error)") 142 | } 143 | } 144 | else { 145 | print("did NOT find authorization information in keychain") 146 | } 147 | 148 | } 149 | 150 | /** 151 | A convenience method that creates the authorization URL and opens it in the 152 | browser. 153 | 154 | You could also configure it to accept parameters for the authorization 155 | scopes. 156 | 157 | This is called when the user taps the "Log in with Spotify" button in 158 | `LoginView`. 159 | */ 160 | func authorize() { 161 | 162 | let url = self.api.authorizationManager.makeAuthorizationURL( 163 | redirectURI: self.loginCallbackURL, 164 | showDialog: true, 165 | // This same value **MUST** be provided for the state parameter of 166 | // `authorizationManager.requestAccessAndRefreshTokens(redirectURIWithQuery:state:)`. 167 | // Otherwise, an error will be thrown. 168 | state: self.authorizationState, 169 | scopes: [ 170 | .userReadPlaybackState, 171 | .userModifyPlaybackState, 172 | .playlistModifyPrivate, 173 | .playlistModifyPublic, 174 | .userLibraryRead, 175 | .userLibraryModify, 176 | .userReadRecentlyPlayed 177 | ] 178 | )! 179 | 180 | // You can open the URL however you like. For example, you could open 181 | // it in a web view instead of the browser. 182 | // See https://developer.apple.com/documentation/webkit/wkwebview 183 | UIApplication.shared.open(url) 184 | 185 | } 186 | 187 | /** 188 | Saves changes to `api.authorizationManager` to the keychain. 189 | 190 | This method is called every time the authorization information changes. For 191 | example, when the access token gets automatically refreshed, (it expires 192 | after an hour) this method will be called. 193 | 194 | It will also be called after the access and refresh tokens are retrieved 195 | using `requestAccessAndRefreshTokens(redirectURIWithQuery:state:)`. 196 | 197 | Read the full documentation for 198 | [SpotifyAPI.authorizationManagerDidChange][1]. 199 | 200 | [1]: https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/spotifyapi/authorizationmanagerdidchange 201 | */ 202 | func authorizationManagerDidChange() { 203 | 204 | withAnimation(LoginView.animation) { 205 | // Update the @Published `isAuthorized` property. When set to 206 | // `true`, `LoginView` is dismissed, allowing the user to interact 207 | // with the rest of the app. 208 | self.isAuthorized = self.api.authorizationManager.isAuthorized() 209 | } 210 | 211 | print( 212 | "Spotify.authorizationManagerDidChange: isAuthorized:", 213 | self.isAuthorized 214 | ) 215 | 216 | self.retrieveCurrentUser() 217 | 218 | do { 219 | // Encode the authorization information to data. 220 | let authManagerData = try JSONEncoder().encode( 221 | self.api.authorizationManager 222 | ) 223 | 224 | // Save the data to the keychain. 225 | self.keychain[data: self.authorizationManagerKey] = authManagerData 226 | print("did save authorization manager to keychain") 227 | 228 | } catch { 229 | print( 230 | "couldn't encode authorizationManager for storage " + 231 | "in keychain:\n\(error)" 232 | ) 233 | } 234 | 235 | } 236 | 237 | /** 238 | Removes `api.authorizationManager` from the keychain and sets `currentUser` 239 | to `nil`. 240 | 241 | This method is called every time `api.authorizationManager.deauthorize` is 242 | called. 243 | */ 244 | func authorizationManagerDidDeauthorize() { 245 | 246 | withAnimation(LoginView.animation) { 247 | self.isAuthorized = false 248 | } 249 | 250 | self.currentUser = nil 251 | 252 | do { 253 | /* 254 | Remove the authorization information from the keychain. 255 | 256 | If you don't do this, then the authorization information that you 257 | just removed from memory by calling 258 | `SpotifyAPI.authorizationManager.deauthorize()` will be retrieved 259 | again from persistent storage after this app is quit and 260 | relaunched. 261 | */ 262 | try self.keychain.remove(self.authorizationManagerKey) 263 | print("did remove authorization manager from keychain") 264 | 265 | } catch { 266 | print( 267 | "couldn't remove authorization manager " + 268 | "from keychain: \(error)" 269 | ) 270 | } 271 | } 272 | 273 | /** 274 | Retrieve the current user. 275 | 276 | - Parameter onlyIfNil: Only retrieve the user if `self.currentUser` 277 | is `nil`. 278 | */ 279 | func retrieveCurrentUser(onlyIfNil: Bool = true) { 280 | 281 | if onlyIfNil && self.currentUser != nil { 282 | return 283 | } 284 | 285 | guard self.isAuthorized else { return } 286 | 287 | self.api.currentUserProfile() 288 | .receive(on: RunLoop.main) 289 | .sink( 290 | receiveCompletion: { completion in 291 | if case .failure(let error) = completion { 292 | print("couldn't retrieve current user: \(error)") 293 | } 294 | }, 295 | receiveValue: { user in 296 | self.currentUser = user 297 | } 298 | ) 299 | .store(in: &cancellables) 300 | 301 | } 302 | 303 | } 304 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Project Utilities/AlertItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct AlertItem: Identifiable { 5 | 6 | let id = UUID() 7 | let title: Text 8 | let message: Text 9 | 10 | init(title: String, message: String) { 11 | self.title = Text(title) 12 | self.message = Text(message) 13 | } 14 | 15 | init(title: Text, message: Text) { 16 | self.title = title 17 | self.message = message 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Project Utilities/ImageNames.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// The names of the image assets. 5 | enum ImageName: String { 6 | 7 | case spotifyLogoGreen = "spotify logo green" 8 | case spotifyLogoWhite = "spotify logo white" 9 | case spotifyLogoBlack = "spotify logo black" 10 | case spotifyAlbumPlaceholder = "spotify album placeholder" 11 | } 12 | 13 | extension Image { 14 | 15 | /// Creates an image using `ImageName`, an enum which contains the names of 16 | /// all the image assets. 17 | init(_ name: ImageName) { 18 | self.init(name.rawValue) 19 | } 20 | 21 | } 22 | 23 | extension UIImage { 24 | 25 | /// Creates an image using `ImageName`, an enum which contains the names of 26 | /// all the image assets. 27 | convenience init?(_ name: ImageName) { 28 | self.init(named: name.rawValue) 29 | } 30 | 31 | } 32 | 33 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Project Utilities/MiscellaneousUtilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import SpotifyWebAPI 4 | 5 | extension View { 6 | 7 | /// Type erases self to `AnyView`. Equivalent to `AnyView(self)`. 8 | func eraseToAnyView() -> AnyView { 9 | return AnyView(self) 10 | } 11 | 12 | } 13 | 14 | extension ProcessInfo { 15 | 16 | /// Whether or not this process is running within the context of a SwiftUI 17 | /// preview. 18 | var isPreviewing: Bool { 19 | return self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Project Utilities/SpotifyAPIExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SpotifyWebAPI 4 | 5 | extension SpotifyAPI where AuthorizationManager: SpotifyScopeAuthorizationManager { 6 | 7 | /** 8 | Makes a call to `availableDevices()` and plays the content on the active 9 | device if one exists. Else, plays content on the first available device. 10 | 11 | See [Using the Player Endpoints][1]. 12 | 13 | - Parameter playbackRequest: A request to play content. 14 | 15 | [1]: https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi/using-the-player-endpoints 16 | */ 17 | func getAvailableDeviceThenPlay( 18 | _ playbackRequest: PlaybackRequest 19 | ) -> AnyPublisher { 20 | 21 | return self.availableDevices().flatMap { 22 | devices -> AnyPublisher in 23 | 24 | // A device must have an id and must not be restricted in order to 25 | // accept web API commands. 26 | let usableDevices = devices.filter { device in 27 | !device.isRestricted && device.id != nil 28 | } 29 | 30 | // If there is an active device, then it's usually a good idea to 31 | // use that one. For example, if content is already playing, then it 32 | // will be playing on the active device. If not, then just use the 33 | // first available device. 34 | let device = usableDevices.first(where: \.isActive) 35 | ?? usableDevices.first 36 | 37 | if let deviceId = device?.id { 38 | return self.play(playbackRequest, deviceId: deviceId) 39 | } 40 | else { 41 | return SpotifyGeneralError.other( 42 | "no active or available devices", 43 | localizedDescription: 44 | "There are no devices available to play content on. " + 45 | "Try opening the Spotify app on one of your devices." 46 | ) 47 | .anyFailingPublisher() 48 | } 49 | 50 | } 51 | .eraseToAnyPublisher() 52 | 53 | } 54 | 55 | } 56 | 57 | extension PlaylistItem { 58 | 59 | /// Returns `true` if this playlist item is probably the same as `other` by 60 | /// comparing the name, artist/show name, and duration. 61 | func isProbablyTheSameAs(_ other: Self) -> Bool { 62 | 63 | // don't return true if both URIs are `nil`. 64 | if let uri = self.uri, uri == other.uri { 65 | return true 66 | } 67 | 68 | switch (self, other) { 69 | case (.track(let track), .track(let otherTrack)): 70 | return track.isProbablyTheSameAs(otherTrack) 71 | case (.episode(let episode), .episode(let otherEpisode)): 72 | return episode.isProbablyTheSameAs(otherEpisode) 73 | default: 74 | return false 75 | } 76 | 77 | } 78 | 79 | } 80 | 81 | extension Track { 82 | 83 | 84 | /// Returns `true` if this track is probably the same as `other` by 85 | /// comparing the name, artist name, and duration. 86 | func isProbablyTheSameAs(_ other: Self) -> Bool { 87 | 88 | if self.name != other.name || 89 | self.artists?.first?.name != other.artists?.first?.name { 90 | return false 91 | } 92 | 93 | switch (self.durationMS, other.durationMS) { 94 | case (.some(let durationMS), .some(let otherDurationMS)): 95 | // use a relative tolerance of 10% and an absolute tolerance of 96 | // ten seconds 97 | return durationMS.isApproximatelyEqual( 98 | to: otherDurationMS, 99 | absoluteTolerance: 10_000, // 10 seconds 100 | relativeTolerance: 0.1, 101 | norm: { Double($0) } 102 | ) 103 | case (nil, nil): 104 | return true 105 | default: 106 | return false 107 | } 108 | 109 | } 110 | 111 | } 112 | 113 | extension Episode { 114 | 115 | /// Returns `true` if this episode is probably the same as `other` by 116 | /// comparing the name, show name, and duration. 117 | func isProbablyTheSameAs(_ other: Self) -> Bool { 118 | 119 | return self.name == other.name && 120 | self.show?.name == other.show?.name && 121 | // use a relative tolerance of 10% and an absolute tolerance of 122 | // ten seconds 123 | self.durationMS.isApproximatelyEqual( 124 | to: other.durationMS, 125 | absoluteTolerance: 10_000, // 10 seconds 126 | relativeTolerance: 0.1, 127 | norm: { Double($0) } 128 | ) 129 | 130 | } 131 | 132 | 133 | } 134 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/SpotifyAPIExampleAppApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | 5 | @main 6 | struct SpotifyAPIExampleAppApp: App { 7 | 8 | @StateObject var spotify = Spotify() 9 | 10 | init() { 11 | SpotifyAPILogHandler.bootstrap() 12 | } 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | RootView() 17 | .environmentObject(spotify) 18 | } 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/Album Views/AlbumGridItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | import SpotifyExampleContent 5 | 6 | struct AlbumGridItemView: View { 7 | 8 | @EnvironmentObject var spotify: Spotify 9 | 10 | /// The cover image for the album. 11 | @State private var image = Image(ImageName.spotifyAlbumPlaceholder) 12 | 13 | @State private var loadImageCancellable: AnyCancellable? = nil 14 | @State private var didRequestImage = false 15 | 16 | var album: Album 17 | 18 | var body: some View { 19 | NavigationLink( 20 | destination: AlbumTracksView(album: album, image: image) 21 | ) { 22 | VStack { 23 | image 24 | .resizable() 25 | .aspectRatio(contentMode: .fit) 26 | .cornerRadius(5) 27 | Text(album.name) 28 | .font(.callout) 29 | .lineLimit(3) 30 | // This is necessary to ensure that the text wraps to the 31 | // next line if it is too long. 32 | .fixedSize(horizontal: false, vertical: true) 33 | Spacer() 34 | } 35 | .onAppear(perform: loadImage) 36 | } 37 | .buttonStyle(PlainButtonStyle()) 38 | .padding(5) 39 | } 40 | 41 | func loadImage() { 42 | 43 | // Return early if the image has already been requested. We can't just 44 | // check if `self.image == nil` because the image might have already 45 | // been requested, but not loaded yet. 46 | if self.didRequestImage { return } 47 | self.didRequestImage = true 48 | 49 | guard let spotifyImage = album.images?.largest else { 50 | return 51 | } 52 | 53 | // print("loading image for '\(album.name)'") 54 | 55 | // Note that a `Set` is NOT being used so that each time 56 | // a request to load the image is made, the previous cancellable 57 | // assigned to `loadImageCancellable` is deallocated, which cancels the 58 | // publisher. 59 | self.loadImageCancellable = spotifyImage.load() 60 | .receive(on: RunLoop.main) 61 | .sink( 62 | receiveCompletion: { _ in }, 63 | receiveValue: { image in 64 | self.image = image 65 | } 66 | ) 67 | } 68 | 69 | 70 | } 71 | 72 | struct AlbumGridItemView_Previews: PreviewProvider { 73 | 74 | static let spotify = Spotify() 75 | 76 | static var previews: some View { 77 | AlbumGridItemView(album: .jinx) 78 | .environmentObject(spotify) 79 | .padding() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/Album Views/AlbumTrackCellView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | import SpotifyExampleContent 5 | 6 | struct AlbumTrackCellView: View { 7 | 8 | @EnvironmentObject var spotify: Spotify 9 | 10 | @State private var playTrackCancellable: AnyCancellable? = nil 11 | 12 | let index: Int 13 | let track: Track 14 | let album: Album 15 | 16 | @Binding var alert: AlertItem? 17 | 18 | var body: some View { 19 | Button(action: playTrack, label: { 20 | Text("\(index + 1). \(track.name)") 21 | .lineLimit(1) 22 | .frame(maxWidth: .infinity, alignment: .leading) 23 | .padding() 24 | .contentShape(Rectangle()) 25 | }) 26 | .buttonStyle(PlainButtonStyle()) 27 | } 28 | 29 | func playTrack() { 30 | 31 | let alertTitle = "Couldn't play \(track.name)" 32 | 33 | guard let trackURI = track.uri else { 34 | self.alert = AlertItem( 35 | title: alertTitle, 36 | message: "Missing data" 37 | ) 38 | return 39 | } 40 | 41 | let playbackRequest: PlaybackRequest 42 | 43 | if let albumURI = self.album.uri { 44 | // Play the track in the context of its album. Always prefer 45 | // providing a context; otherwise, the back and forwards buttons may 46 | // not work. 47 | playbackRequest = PlaybackRequest( 48 | context: .contextURI(albumURI), 49 | offset: .uri(trackURI) 50 | ) 51 | } 52 | else { 53 | playbackRequest = PlaybackRequest(trackURI) 54 | } 55 | 56 | self.playTrackCancellable = self.spotify.api 57 | .getAvailableDeviceThenPlay(playbackRequest) 58 | .receive(on: RunLoop.main) 59 | .sink(receiveCompletion: { completion in 60 | if case .failure(let error) = completion { 61 | self.alert = AlertItem( 62 | title: alertTitle, 63 | message: error.localizedDescription 64 | ) 65 | print("\(alertTitle): \(error)") 66 | } 67 | }) 68 | 69 | } 70 | 71 | } 72 | 73 | struct AlbumTrackCellView_Previews: PreviewProvider { 74 | 75 | static let album = Album.abbeyRoad 76 | static let tracks = Album.abbeyRoad.tracks!.items 77 | 78 | static var previews: some View { 79 | ScrollView { 80 | LazyVStack(spacing: 0) { 81 | ForEach(Array(tracks.enumerated()), id: \.offset) { track in 82 | AlbumTrackCellView( 83 | index: track.offset, 84 | track: track.element, 85 | album: album, 86 | alert: .constant(nil) 87 | ) 88 | Divider() 89 | } 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/Album Views/AlbumTracksView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | import SpotifyExampleContent 5 | 6 | struct AlbumTracksView: View { 7 | 8 | @EnvironmentObject var spotify: Spotify 9 | 10 | @State private var alert: AlertItem? = nil 11 | 12 | @State private var loadTracksCancellable: AnyCancellable? = nil 13 | @State private var playAlbumCancellable: AnyCancellable? = nil 14 | 15 | @State private var isLoadingTracks = false 16 | @State private var couldntLoadTracks = false 17 | 18 | @State var allTracks: [Track] = [] 19 | 20 | let album: Album 21 | let image: Image 22 | 23 | init(album: Album, image: Image) { 24 | self.album = album 25 | self.image = image 26 | } 27 | 28 | /// Used by the preview provider to provide sample data. 29 | fileprivate init(album: Album, image: Image, tracks: [Track]) { 30 | self.album = album 31 | self.image = image 32 | self._allTracks = State(initialValue: tracks) 33 | } 34 | 35 | /// The album and artist name; e.g., "Abbey Road - The Beatles". 36 | var albumAndArtistName: String { 37 | var title = album.name 38 | if let artistName = album.artists?.first?.name { 39 | title += " - \(artistName)" 40 | } 41 | return title 42 | } 43 | 44 | var body: some View { 45 | ScrollView { 46 | LazyVStack(spacing: 0) { 47 | albumImageWithPlayButton 48 | .padding(30) 49 | Text(albumAndArtistName) 50 | .font(.title) 51 | .bold() 52 | .padding(.horizontal) 53 | .padding(.top, -10) 54 | Text("\(album.tracks?.total ?? 0) Tracks") 55 | .foregroundColor(.secondary) 56 | .font(.title2) 57 | .padding(.vertical, 10) 58 | if allTracks.isEmpty { 59 | Group { 60 | if isLoadingTracks { 61 | HStack { 62 | ProgressView() 63 | .padding() 64 | Text("Loading Tracks") 65 | .font(.title) 66 | .foregroundColor(.secondary) 67 | } 68 | } 69 | else if couldntLoadTracks { 70 | Text("Couldn't Load Tracks") 71 | .font(.title) 72 | .foregroundColor(.secondary) 73 | } 74 | } 75 | .padding(.top, 20) 76 | } 77 | else { 78 | ForEach( 79 | Array(allTracks.enumerated()), 80 | id: \.offset 81 | ) { track in 82 | AlbumTrackCellView( 83 | index: track.offset, 84 | track: track.element, 85 | album: album, 86 | alert: $alert 87 | ) 88 | Divider() 89 | } 90 | } 91 | } 92 | } 93 | .navigationBarTitle("", displayMode: .inline) 94 | .alert(item: $alert) { alert in 95 | Alert(title: alert.title, message: alert.message) 96 | } 97 | .onAppear(perform: loadTracks) 98 | } 99 | 100 | var albumImageWithPlayButton: some View { 101 | ZStack { 102 | image 103 | .resizable() 104 | .aspectRatio(contentMode: .fit) 105 | .cornerRadius(20) 106 | .shadow(radius: 20) 107 | Button(action: playAlbum, label: { 108 | Image(systemName: "play.circle") 109 | .resizable() 110 | .background(Color.black.opacity(0.5)) 111 | .clipShape(Circle()) 112 | .frame(width: 100, height: 100) 113 | }) 114 | } 115 | } 116 | 117 | /// Loads the album tracks. 118 | func loadTracks() { 119 | 120 | // Don't try to load any tracks if we're in preview mode 121 | if ProcessInfo.processInfo.isPreviewing { return } 122 | 123 | guard let tracks = self.album.tracks else { 124 | return 125 | } 126 | 127 | // the `album` already contains the first page of tracks, but we need to 128 | // load additional pages if they exist. the `extendPages` method 129 | // immediately republishes the page that was passed in and then requests 130 | // additional pages. 131 | 132 | self.isLoadingTracks = true 133 | self.allTracks = [] 134 | self.loadTracksCancellable = self.spotify.api.extendPages(tracks) 135 | .map(\.items) 136 | .receive(on: RunLoop.main) 137 | .sink( 138 | receiveCompletion: { completion in 139 | self.isLoadingTracks = false 140 | switch completion { 141 | case .finished: 142 | self.couldntLoadTracks = false 143 | case .failure(let error): 144 | self.couldntLoadTracks = true 145 | self.alert = AlertItem( 146 | title: "Couldn't Load Tracks", 147 | message: error.localizedDescription 148 | ) 149 | } 150 | }, 151 | receiveValue: { tracks in 152 | self.allTracks.append(contentsOf: tracks) 153 | } 154 | ) 155 | 156 | } 157 | 158 | func playAlbum() { 159 | guard let albumURI = album.uri else { 160 | print("missing album uri for '\(album.name)'") 161 | return 162 | } 163 | let playbackRequest = PlaybackRequest( 164 | context: .contextURI(albumURI), offset: nil 165 | ) 166 | print("playing album '\(album.name)'") 167 | self.playAlbumCancellable = spotify.api 168 | .getAvailableDeviceThenPlay(playbackRequest) 169 | .receive(on: RunLoop.main) 170 | .sink(receiveCompletion: { completion in 171 | print("Received play album completion: \(completion)") 172 | if case .failure(let error) = completion { 173 | self.alert = AlertItem( 174 | title: "Couldn't Play Album", 175 | message: error.localizedDescription 176 | ) 177 | } 178 | }) 179 | } 180 | 181 | } 182 | 183 | struct AlbumTracksView_Previews: PreviewProvider { 184 | 185 | static let spotify = Spotify() 186 | static let album = Album.darkSideOfTheMoon 187 | static let tracks: [Track] = album.tracks!.items 188 | 189 | static var previews: some View { 190 | NavigationView { 191 | AlbumTracksView( 192 | album: album, 193 | image: Image(ImageName.spotifyAlbumPlaceholder), 194 | tracks: tracks 195 | ) 196 | .environmentObject(spotify) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/Album Views/SavedAlbumsGridView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | 5 | struct SavedAlbumsGridView: View { 6 | 7 | @EnvironmentObject var spotify: Spotify 8 | 9 | @State private var savedAlbums: [Album] = [] 10 | 11 | @State private var alert: AlertItem? = nil 12 | 13 | @State private var didRequestAlbums = false 14 | @State private var isLoadingAlbums = false 15 | @State private var couldntLoadAlbums = false 16 | 17 | @State private var loadAlbumsCancellable: AnyCancellable? = nil 18 | 19 | let columns = [ 20 | GridItem(.adaptive(minimum: 100, maximum: 200)) 21 | ] 22 | 23 | init() { } 24 | 25 | /// Used only by the preview provider to provide sample data. 26 | fileprivate init(sampleAlbums: [Album]) { 27 | self._savedAlbums = State(initialValue: sampleAlbums) 28 | } 29 | 30 | var body: some View { 31 | Group { 32 | if savedAlbums.isEmpty { 33 | if isLoadingAlbums { 34 | HStack { 35 | ProgressView() 36 | .padding() 37 | Text("Loading Albums") 38 | .font(.title) 39 | .foregroundColor(.secondary) 40 | } 41 | } 42 | else if couldntLoadAlbums { 43 | Text("Couldn't Load Albums") 44 | .font(.title) 45 | .foregroundColor(.secondary) 46 | } 47 | else { 48 | Text("No Albums") 49 | .font(.title) 50 | .foregroundColor(.secondary) 51 | } 52 | } 53 | else { 54 | ScrollView { 55 | LazyVGrid(columns: columns) { 56 | // WARNING: do not use `\.self` for the id. This is 57 | // extremely expensive and causes lag when scrolling 58 | // because the hash of the entire album instance, which 59 | // is very large, must be calculated. 60 | ForEach(savedAlbums, id: \.id) { album in 61 | AlbumGridItemView(album: album) 62 | } 63 | } 64 | .padding() 65 | .accessibility(identifier: "Saved Albums Grid") 66 | } 67 | } 68 | 69 | } 70 | .navigationTitle("Saved Albums") 71 | .navigationBarItems(trailing: refreshButton) 72 | .alert(item: $alert) { alert in 73 | Alert(title: alert.title, message: alert.message) 74 | } 75 | .onAppear { 76 | if !self.didRequestAlbums { 77 | self.retrieveSavedAlbums() 78 | } 79 | } 80 | } 81 | 82 | var refreshButton: some View { 83 | Button(action: retrieveSavedAlbums) { 84 | Image(systemName: "arrow.clockwise") 85 | .font(.title) 86 | .scaleEffect(0.8) 87 | } 88 | .disabled(isLoadingAlbums) 89 | 90 | } 91 | 92 | func retrieveSavedAlbums() { 93 | 94 | // Don't try to load any albums if we're in preview mode. 95 | if ProcessInfo.processInfo.isPreviewing { return } 96 | 97 | self.didRequestAlbums = true 98 | self.isLoadingAlbums = true 99 | self.savedAlbums = [] 100 | 101 | print("retrieveSavedAlbums") 102 | 103 | self.loadAlbumsCancellable = spotify.api 104 | .currentUserSavedAlbums() 105 | .extendPages(spotify.api) 106 | .receive(on: RunLoop.main) 107 | .sink( 108 | receiveCompletion: { completion in 109 | self.isLoadingAlbums = false 110 | switch completion { 111 | case .finished: 112 | self.couldntLoadAlbums = false 113 | case .failure(let error): 114 | self.couldntLoadAlbums = true 115 | self.alert = AlertItem( 116 | title: "Couldn't Retrieve Albums", 117 | message: error.localizedDescription 118 | ) 119 | } 120 | }, 121 | receiveValue: { savedAlbums in 122 | let albums = savedAlbums.items 123 | .map(\.item) 124 | /* 125 | Remove albums that have a `nil` id so that this 126 | property can be used as the id for the ForEach above. 127 | (The id must be unique, otherwise the app will crash.) 128 | In theory, the id should never be `nil` when the albums 129 | are retrieved using the `currentUserSavedAlbums()` 130 | endpoint. 131 | 132 | Using \.self in the ForEach is extremely expensive as 133 | this involves calculating the hash of the entire 134 | `Album` instance, which is very large. 135 | */ 136 | .filter { $0.id != nil } 137 | 138 | self.savedAlbums.append(contentsOf: albums) 139 | 140 | } 141 | ) 142 | } 143 | 144 | } 145 | 146 | struct SavedAlbumsView_Previews: PreviewProvider { 147 | 148 | static let spotify = Spotify() 149 | 150 | static let sampleAlbums: [Album] = [ 151 | .jinx, .abbeyRoad, .darkSideOfTheMoon, .meddle, .inRainbows, 152 | .skiptracing 153 | ] 154 | 155 | static var previews: some View { 156 | 157 | NavigationView { 158 | SavedAlbumsGridView(sampleAlbums: sampleAlbums) 159 | .environmentObject(spotify) 160 | } 161 | 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/DebugMenuView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | struct DebugMenuView: View { 5 | 6 | @EnvironmentObject var spotify: Spotify 7 | 8 | @State private var cancellables: Set = [] 9 | 10 | var body: some View { 11 | List { 12 | Button("Make Access Token Expired") { 13 | self.spotify.api.authorizationManager.setExpirationDate( 14 | to: Date() 15 | ) 16 | } 17 | Button("Refresh Access Token") { 18 | self.spotify.api.authorizationManager.refreshTokens( 19 | onlyIfExpired: false 20 | ) 21 | .sink(receiveCompletion: { completion in 22 | print("refresh tokens completion: \(completion)") 23 | 24 | }) 25 | .store(in: &self.cancellables) 26 | } 27 | Button("Print SpotifyAPI") { 28 | print( 29 | """ 30 | --- SpotifyAPI --- 31 | \(self.spotify.api) 32 | ------------------ 33 | """ 34 | ) 35 | } 36 | 37 | } 38 | .navigationBarTitle("Debug Menu") 39 | } 40 | } 41 | 42 | struct DebugMenuView_Previews: PreviewProvider { 43 | static var previews: some View { 44 | NavigationView { 45 | DebugMenuView() 46 | } 47 | .environmentObject(Spotify()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/ExamplesListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ExamplesListView: View { 4 | 5 | var body: some View { 6 | List { 7 | 8 | NavigationLink( 9 | "Playlists", destination: PlaylistsListView() 10 | ) 11 | NavigationLink( 12 | "Saved Albums", destination: SavedAlbumsGridView() 13 | ) 14 | NavigationLink( 15 | "Search For Tracks", destination: SearchForTracksView() 16 | ) 17 | NavigationLink( 18 | "Recently Played Tracks", destination: RecentlyPlayedView() 19 | ) 20 | NavigationLink( 21 | "Debug Menu", destination: DebugMenuView() 22 | ) 23 | 24 | // This is the location where you can add your own views to test out 25 | // your application. Each view receives an instance of `Spotify` 26 | // from the environment. 27 | 28 | } 29 | .listStyle(PlainListStyle()) 30 | 31 | } 32 | } 33 | 34 | struct ExamplesListView_Previews: PreviewProvider { 35 | 36 | static let spotify: Spotify = { 37 | let spotify = Spotify() 38 | spotify.isAuthorized = true 39 | return spotify 40 | }() 41 | 42 | static var previews: some View { 43 | NavigationView { 44 | ExamplesListView() 45 | .environmentObject(spotify) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/LoginView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | /** 5 | A view that presents a button to login with Spotify. 6 | 7 | It is presented when `isAuthorized` is `false`. 8 | 9 | When the user taps the button, the authorization URL is opened in the browser, 10 | which prompts them to login with their Spotify account and authorize this 11 | application. 12 | 13 | After Spotify redirects back to this app and the access and refresh tokens have 14 | been retrieved, dismiss this view by setting `isAuthorized` to `true`. 15 | */ 16 | struct LoginView: ViewModifier { 17 | 18 | /// Always show this view for debugging purposes. Most importantly, this is 19 | /// useful for the preview provider. 20 | fileprivate static var debugAlwaysShowing = false 21 | 22 | /// The animation that should be used for presenting and dismissing this 23 | /// view. 24 | static let animation = Animation.spring() 25 | 26 | @Environment(\.colorScheme) var colorScheme 27 | 28 | @EnvironmentObject var spotify: Spotify 29 | 30 | /// After the app first launches, add a short delay before showing this view 31 | /// so that the animation can be seen. 32 | @State private var finishedViewLoadDelay = false 33 | 34 | let backgroundGradient = LinearGradient( 35 | gradient: Gradient( 36 | colors: [ 37 | Color(red: 0.467, green: 0.765, blue: 0.267), 38 | Color(red: 0.190, green: 0.832, blue: 0.437) 39 | ] 40 | ), 41 | startPoint: .leading, endPoint: .trailing 42 | ) 43 | 44 | var spotifyLogo: ImageName { 45 | colorScheme == .dark ? .spotifyLogoWhite 46 | : .spotifyLogoBlack 47 | } 48 | 49 | func body(content: Content) -> some View { 50 | content 51 | .blur( 52 | radius: spotify.isAuthorized && !Self.debugAlwaysShowing ? 0 : 3 53 | ) 54 | .overlay( 55 | ZStack { 56 | if !spotify.isAuthorized || Self.debugAlwaysShowing { 57 | Color.black.opacity(0.25) 58 | .edgesIgnoringSafeArea(.all) 59 | if self.finishedViewLoadDelay || Self.debugAlwaysShowing { 60 | loginView 61 | } 62 | } 63 | } 64 | ) 65 | .onAppear { 66 | // After the app first launches, add a short delay before 67 | // showing this view so that the animation can be seen. 68 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 69 | withAnimation(LoginView.animation) { 70 | self.finishedViewLoadDelay = true 71 | } 72 | } 73 | } 74 | } 75 | 76 | var loginView: some View { 77 | spotifyButton 78 | .padding() 79 | .padding(.vertical, 50) 80 | .background(Color(.secondarySystemBackground)) 81 | .cornerRadius(20) 82 | .overlay(retrievingTokensView) 83 | .shadow(radius: 5) 84 | .transition( 85 | AnyTransition.scale(scale: 1.2) 86 | .combined(with: .opacity) 87 | ) 88 | } 89 | 90 | var spotifyButton: some View { 91 | 92 | Button(action: spotify.authorize) { 93 | HStack { 94 | Image(spotifyLogo) 95 | .interpolation(.high) 96 | .resizable() 97 | .aspectRatio(contentMode: .fit) 98 | .frame(height: 40) 99 | Text("Log in with Spotify") 100 | .font(.title) 101 | } 102 | .padding() 103 | .background(backgroundGradient) 104 | .clipShape(Capsule()) 105 | .shadow(radius: 5) 106 | } 107 | .accessibility(identifier: "Log in with Spotify Identifier") 108 | .buttonStyle(PlainButtonStyle()) 109 | // Prevent the user from trying to login again 110 | // if a request to retrieve the access and refresh 111 | // tokens is currently in progress. 112 | .allowsHitTesting(!spotify.isRetrievingTokens) 113 | .padding(.bottom, 5) 114 | 115 | } 116 | 117 | var retrievingTokensView: some View { 118 | VStack { 119 | Spacer() 120 | if spotify.isRetrievingTokens { 121 | HStack { 122 | ProgressView() 123 | .padding() 124 | Text("Authenticating") 125 | } 126 | .padding(.bottom, 20) 127 | } 128 | } 129 | } 130 | 131 | } 132 | 133 | struct LoginView2: ViewModifier { 134 | 135 | /// Always show this view for debugging purposes. Most importantly, this is 136 | /// useful for the preview provider. 137 | fileprivate static var debugAlwaysShowing = false 138 | 139 | /// The animation that should be used for presenting and dismissing this 140 | /// view. 141 | static let animation = Animation.spring() 142 | 143 | @Environment(\.colorScheme) var colorScheme 144 | 145 | @EnvironmentObject var spotify: Spotify 146 | 147 | /// After the app first launches, add a short delay before showing this view 148 | /// so that the animation can be seen. 149 | @State private var finishedViewLoadDelay = false 150 | 151 | let backgroundGradient = LinearGradient( 152 | gradient: Gradient( 153 | colors: [ 154 | Color(red: 0.467, green: 0.765, blue: 0.267), 155 | Color(red: 0.190, green: 0.832, blue: 0.437) 156 | ] 157 | ), 158 | startPoint: .leading, endPoint: .trailing 159 | ) 160 | 161 | var spotifyLogo: ImageName { 162 | colorScheme == .dark ? .spotifyLogoWhite 163 | : .spotifyLogoBlack 164 | } 165 | 166 | func body(content: Content) -> some View { 167 | content 168 | .blur( 169 | radius: spotify.isAuthorized && !Self.debugAlwaysShowing ? 0 : 3 170 | ) 171 | .overlay( 172 | ZStack { 173 | if !spotify.isAuthorized || Self.debugAlwaysShowing { 174 | Color.black.opacity(0.25) 175 | .edgesIgnoringSafeArea(.all) 176 | if self.finishedViewLoadDelay || Self.debugAlwaysShowing { 177 | loginView 178 | } 179 | } 180 | } 181 | ) 182 | .onAppear { 183 | // After the app first launches, add a short delay before 184 | // showing this view so that the animation can be seen. 185 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 186 | withAnimation(LoginView.animation) { 187 | self.finishedViewLoadDelay = true 188 | } 189 | } 190 | } 191 | } 192 | 193 | var loginView: some View { 194 | spotifyButton 195 | .padding() 196 | .padding(.vertical, 50) 197 | .background(Color(.secondarySystemBackground)) 198 | .cornerRadius(20) 199 | .overlay(retrievingTokensView) 200 | .shadow(radius: 5) 201 | .transition( 202 | AnyTransition.scale(scale: 1.2) 203 | .combined(with: .opacity) 204 | ) 205 | } 206 | 207 | var spotifyButton: some View { 208 | 209 | Button(action: spotify.authorize) { 210 | HStack { 211 | Image(spotifyLogo) 212 | .interpolation(.high) 213 | .resizable() 214 | .aspectRatio(contentMode: .fit) 215 | .frame(height: 40) 216 | Text("Log in with Spotify") 217 | .font(.title) 218 | } 219 | .padding() 220 | .background(backgroundGradient) 221 | .clipShape(Capsule()) 222 | .shadow(radius: 5) 223 | } 224 | .accessibility(identifier: "Log in with Spotify Identifier") 225 | .buttonStyle(PlainButtonStyle()) 226 | // Prevent the user from trying to login again 227 | // if a request to retrieve the access and refresh 228 | // tokens is currently in progress. 229 | .allowsHitTesting(!spotify.isRetrievingTokens) 230 | .padding(.bottom, 5) 231 | 232 | } 233 | 234 | var retrievingTokensView: some View { 235 | VStack { 236 | Spacer() 237 | if spotify.isRetrievingTokens { 238 | HStack { 239 | ProgressView() 240 | .padding() 241 | Text("Authenticating") 242 | } 243 | .padding(.bottom, 20) 244 | } 245 | } 246 | } 247 | 248 | } 249 | 250 | struct LoginView_Previews: PreviewProvider { 251 | 252 | static let spotify = Spotify() 253 | 254 | static var previews: some View { 255 | RootView() 256 | .environmentObject(spotify) 257 | .onAppear(perform: onAppear) 258 | } 259 | 260 | static func onAppear() { 261 | spotify.isAuthorized = false 262 | spotify.isRetrievingTokens = true 263 | LoginView.debugAlwaysShowing = true 264 | } 265 | 266 | } 267 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/Playlist Views/PlaylistCellView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | 5 | struct PlaylistCellView: View { 6 | 7 | @ObservedObject var spotify: Spotify 8 | 9 | @ObservedObject var playlistDeduplicator: PlaylistDeduplicator 10 | 11 | let playlist: Playlist 12 | 13 | /// The cover image for the playlist. 14 | @State private var image = Image(ImageName.spotifyAlbumPlaceholder) 15 | 16 | @State private var didRequestImage = false 17 | 18 | @State private var alert: AlertItem? = nil 19 | 20 | // MARK: Cancellables 21 | @State private var loadImageCancellable: AnyCancellable? = nil 22 | @State private var playPlaylistCancellable: AnyCancellable? = nil 23 | 24 | init(spotify: Spotify, playlist: Playlist) { 25 | self.spotify = spotify 26 | self.playlist = playlist 27 | self.playlistDeduplicator = PlaylistDeduplicator( 28 | spotify: spotify, playlist: playlist 29 | ) 30 | } 31 | 32 | var body: some View { 33 | Button(action: playPlaylist, label: { 34 | HStack { 35 | image 36 | .resizable() 37 | .aspectRatio(contentMode: .fit) 38 | .frame(width: 70, height: 70) 39 | .padding(.trailing, 5) 40 | Text("\(playlist.name) - \(playlistDeduplicator.totalItems) items") 41 | if playlistDeduplicator.isDeduplicating { 42 | ProgressView() 43 | .padding(.leading, 5) 44 | } 45 | Spacer() 46 | } 47 | // Ensure the hit box extends across the entire width of the frame. 48 | // See https://bit.ly/2HqNk4S 49 | .contentShape(Rectangle()) 50 | .contextMenu { 51 | // you can only remove duplicates from a playlist you own 52 | if let currentUserId = spotify.currentUser?.id, 53 | playlist.owner?.id == currentUserId { 54 | 55 | Button("Remove Duplicates") { 56 | playlistDeduplicator.findAndRemoveDuplicates() 57 | } 58 | .disabled(playlistDeduplicator.isDeduplicating) 59 | } 60 | } 61 | }) 62 | .buttonStyle(PlainButtonStyle()) 63 | .alert(item: $alert) { alert in 64 | Alert(title: alert.title, message: alert.message) 65 | } 66 | .onAppear(perform: loadImage) 67 | .onReceive(playlistDeduplicator.alertPublisher) { alert in 68 | self.alert = alert 69 | } 70 | } 71 | 72 | /// Loads the image for the playlist. 73 | func loadImage() { 74 | 75 | // Return early if the image has already been requested. We can't just 76 | // check if `self.image == nil` because the image might have already 77 | // been requested, but not loaded yet. 78 | if self.didRequestImage { 79 | // print("already requested image for '\(playlist.name)'") 80 | return 81 | } 82 | self.didRequestImage = true 83 | 84 | guard let spotifyImage = playlist.images.largest else { 85 | // print("no image found for '\(playlist.name)'") 86 | return 87 | } 88 | 89 | // print("loading image for '\(playlist.name)'") 90 | 91 | // Note that a `Set` is NOT being used so that each time 92 | // a request to load the image is made, the previous cancellable 93 | // assigned to `loadImageCancellable` is deallocated, which cancels the 94 | // publisher. 95 | self.loadImageCancellable = spotifyImage.load() 96 | .receive(on: RunLoop.main) 97 | .sink( 98 | receiveCompletion: { _ in }, 99 | receiveValue: { image in 100 | // print("received image for '\(playlist.name)'") 101 | self.image = image 102 | } 103 | ) 104 | } 105 | 106 | func playPlaylist() { 107 | 108 | let playbackRequest = PlaybackRequest( 109 | context: .contextURI(playlist), offset: nil 110 | ) 111 | self.playPlaylistCancellable = self.spotify.api 112 | .getAvailableDeviceThenPlay(playbackRequest) 113 | .receive(on: RunLoop.main) 114 | .sink(receiveCompletion: { completion in 115 | if case .failure(let error) = completion { 116 | self.alert = AlertItem( 117 | title: "Couldn't Play Playlist \(playlist.name)", 118 | message: error.localizedDescription 119 | ) 120 | } 121 | }) 122 | 123 | } 124 | 125 | } 126 | 127 | struct PlaylistCellView_Previews: PreviewProvider { 128 | 129 | static let spotify = Spotify() 130 | 131 | static var previews: some View { 132 | List { 133 | PlaylistCellView(spotify: spotify, playlist: .thisIsMildHighClub) 134 | PlaylistCellView(spotify: spotify, playlist: .thisIsRadiohead) 135 | PlaylistCellView(spotify: spotify, playlist: .modernPsychedelia) 136 | PlaylistCellView(spotify: spotify, playlist: .rockClassics) 137 | PlaylistCellView(spotify: spotify, playlist: .menITrust) 138 | } 139 | .environmentObject(spotify) 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/Playlist Views/PlaylistsListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | 5 | struct PlaylistsListView: View { 6 | 7 | @EnvironmentObject var spotify: Spotify 8 | 9 | @State private var playlists: [Playlist] = [] 10 | 11 | @State private var cancellables: Set = [] 12 | 13 | @State private var isLoadingPlaylists = false 14 | @State private var couldntLoadPlaylists = false 15 | 16 | @State private var alert: AlertItem? = nil 17 | 18 | init() { } 19 | 20 | /// Used only by the preview provider to provide sample data. 21 | fileprivate init(samplePlaylists: [Playlist]) { 22 | self._playlists = State(initialValue: samplePlaylists) 23 | } 24 | 25 | var body: some View { 26 | VStack { 27 | if playlists.isEmpty { 28 | if isLoadingPlaylists { 29 | HStack { 30 | ProgressView() 31 | .padding() 32 | Text("Loading Playlists") 33 | .font(.title) 34 | .foregroundColor(.secondary) 35 | } 36 | } 37 | else if couldntLoadPlaylists { 38 | Text("Couldn't Load Playlists") 39 | .font(.title) 40 | .foregroundColor(.secondary) 41 | } 42 | else { 43 | Text("No Playlists Found") 44 | .font(.title) 45 | .foregroundColor(.secondary) 46 | } 47 | } 48 | else { 49 | Text( 50 | """ 51 | Tap on a playlist to play it. Tap and hold on a Playlist \ 52 | to remove duplicates. 53 | """ 54 | ) 55 | .font(.caption) 56 | .foregroundColor(.secondary) 57 | List { 58 | ForEach(playlists, id: \.uri) { playlist in 59 | PlaylistCellView(spotify: spotify, playlist: playlist) 60 | } 61 | } 62 | .listStyle(PlainListStyle()) 63 | .accessibility(identifier: "Playlists List View") 64 | } 65 | } 66 | .navigationTitle("Playlists") 67 | .navigationBarItems(trailing: refreshButton) 68 | .alert(item: $alert) { alert in 69 | Alert(title: alert.title, message: alert.message) 70 | } 71 | .onAppear(perform: retrievePlaylists) 72 | 73 | } 74 | 75 | var refreshButton: some View { 76 | Button(action: retrievePlaylists) { 77 | Image(systemName: "arrow.clockwise") 78 | .font(.title) 79 | .scaleEffect(0.8) 80 | } 81 | .disabled(isLoadingPlaylists) 82 | 83 | } 84 | 85 | func retrievePlaylists() { 86 | 87 | // Don't try to load any playlists if we're in preview mode. 88 | if ProcessInfo.processInfo.isPreviewing { return } 89 | 90 | self.isLoadingPlaylists = true 91 | self.playlists = [] 92 | spotify.api.currentUserPlaylists(limit: 50) 93 | // Gets all pages of playlists. 94 | .extendPages(spotify.api) 95 | .receive(on: RunLoop.main) 96 | .sink( 97 | receiveCompletion: { completion in 98 | self.isLoadingPlaylists = false 99 | switch completion { 100 | case .finished: 101 | self.couldntLoadPlaylists = false 102 | case .failure(let error): 103 | self.couldntLoadPlaylists = true 104 | self.alert = AlertItem( 105 | title: "Couldn't Retrieve Playlists", 106 | message: error.localizedDescription 107 | ) 108 | } 109 | }, 110 | // We will receive a value for each page of playlists. You could 111 | // use Combine's `collect()` operator to wait until all of the 112 | // pages have been retrieved. 113 | receiveValue: { playlistsPage in 114 | let playlists = playlistsPage.items 115 | self.playlists.append(contentsOf: playlists) 116 | } 117 | ) 118 | .store(in: &cancellables) 119 | 120 | } 121 | 122 | 123 | } 124 | 125 | struct PlaylistsListView_Previews: PreviewProvider { 126 | 127 | static let spotify = Spotify() 128 | 129 | static let playlists: [Playlist] = [ 130 | .menITrust, .modernPsychedelia, .menITrust, 131 | .lucyInTheSkyWithDiamonds, .rockClassics, 132 | .thisIsMFDoom, .thisIsSonicYouth, .thisIsMildHighClub, 133 | .thisIsSkinshape 134 | ] 135 | 136 | static var previews: some View { 137 | NavigationView { 138 | PlaylistsListView(samplePlaylists: playlists) 139 | .environmentObject(spotify) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/RecentlyPlayedView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | import SpotifyExampleContent 5 | 6 | struct RecentlyPlayedView: View { 7 | 8 | @EnvironmentObject var spotify: Spotify 9 | 10 | @State private var recentlyPlayed: [Track] 11 | 12 | @State private var alert: AlertItem? = nil 13 | 14 | @State private var nextPageHref: URL? = nil 15 | @State private var isLoadingPage = false 16 | @State private var didRequestFirstPage = false 17 | 18 | @State private var loadRecentlyPlayedCancellable: AnyCancellable? = nil 19 | 20 | init() { 21 | self._recentlyPlayed = State(initialValue: []) 22 | } 23 | 24 | fileprivate init(recentlyPlayed: [Track]) { 25 | self._recentlyPlayed = State(initialValue: recentlyPlayed) 26 | } 27 | 28 | var body: some View { 29 | Group { 30 | if recentlyPlayed.isEmpty { 31 | if isLoadingPage { 32 | HStack { 33 | ProgressView() 34 | .padding() 35 | Text("Loading Tracks") 36 | .font(.title) 37 | .foregroundColor(.secondary) 38 | } 39 | } 40 | else { 41 | Text("No Recently Played Tracks") 42 | .font(.title) 43 | .foregroundColor(.secondary) 44 | } 45 | } 46 | else { 47 | List { 48 | ForEach( 49 | Array(recentlyPlayed.enumerated()), 50 | id: \.offset 51 | ) { item in 52 | 53 | TrackView(track: item.element) 54 | // Each track in the list will be loaded lazily. We 55 | // take advantage of this feature in order to detect 56 | // when the user has scrolled to *near* the bottom 57 | // of the list based on the offset of this item. 58 | .onAppear { 59 | self.loadNextPageIfNeeded(offset: item.offset) 60 | } 61 | 62 | } 63 | } 64 | } 65 | } 66 | .navigationTitle("Recently Played") 67 | .navigationBarItems(trailing: refreshButton) 68 | .onAppear { 69 | // don't try to load any tracks if we're previewing because sample 70 | // tracks have already been provided 71 | if ProcessInfo.processInfo.isPreviewing { 72 | return 73 | } 74 | 75 | print("onAppear") 76 | // the `onAppear` can be called multiple times, but we only want to 77 | // load the first page once 78 | if !self.didRequestFirstPage { 79 | self.didRequestFirstPage = true 80 | self.loadRecentlyPlayed() 81 | } 82 | } 83 | .alert(item: $alert) { alert in 84 | Alert(title: alert.title, message: alert.message) 85 | } 86 | 87 | 88 | } 89 | 90 | var refreshButton: some View { 91 | Button(action: self.loadRecentlyPlayed) { 92 | Image(systemName: "arrow.clockwise") 93 | .font(.title) 94 | .scaleEffect(0.8) 95 | } 96 | .disabled(isLoadingPage) 97 | 98 | } 99 | 100 | } 101 | 102 | extension RecentlyPlayedView { 103 | 104 | // Normally, you would extract these methods into a separate model class. 105 | 106 | /// Determines whether or not to load the next page based on the offset of 107 | /// the just-loaded item in the list. 108 | func loadNextPageIfNeeded(offset: Int) { 109 | 110 | let threshold = self.recentlyPlayed.count - 5 111 | 112 | print( 113 | """ 114 | loadNextPageIfNeeded threshold: \(threshold); offset: \(offset); \ 115 | total: \(self.recentlyPlayed.count) 116 | """ 117 | ) 118 | 119 | // load the next page if this track is the fifth from the bottom of the 120 | // list 121 | guard offset == threshold else { 122 | return 123 | } 124 | 125 | guard let nextPageHref = self.nextPageHref else { 126 | print("no more paged to load: nextPageHref was nil") 127 | return 128 | } 129 | 130 | guard !self.isLoadingPage else { 131 | return 132 | } 133 | 134 | self.loadNextPage(href: nextPageHref) 135 | 136 | } 137 | 138 | /// Loads the next page of results from the provided URL. 139 | func loadNextPage(href: URL) { 140 | 141 | print("loading next page") 142 | self.isLoadingPage = true 143 | 144 | self.loadRecentlyPlayedCancellable = self.spotify.api 145 | .getFromHref( 146 | href, 147 | responseType: CursorPagingObject.self 148 | ) 149 | .receive(on: RunLoop.main) 150 | .sink( 151 | receiveCompletion: self.receiveRecentlyPlayedCompletion(_:), 152 | receiveValue: { playHistory in 153 | let tracks = playHistory.items.map(\.track) 154 | print( 155 | "received next page with \(tracks.count) items" 156 | ) 157 | self.nextPageHref = playHistory.next 158 | self.recentlyPlayed += tracks 159 | } 160 | ) 161 | 162 | } 163 | 164 | /// Loads the first page. Called when this view appears. 165 | func loadRecentlyPlayed() { 166 | 167 | print("loading first page") 168 | self.isLoadingPage = true 169 | self.recentlyPlayed = [] 170 | 171 | self.loadRecentlyPlayedCancellable = self.spotify.api 172 | .recentlyPlayed() 173 | .receive(on: RunLoop.main) 174 | .sink( 175 | receiveCompletion: self.receiveRecentlyPlayedCompletion(_:), 176 | receiveValue: { playHistory in 177 | let tracks = playHistory.items.map(\.track) 178 | print( 179 | "received first page with \(tracks.count) items" 180 | ) 181 | self.nextPageHref = playHistory.next 182 | self.recentlyPlayed = tracks 183 | } 184 | ) 185 | 186 | } 187 | 188 | func receiveRecentlyPlayedCompletion( 189 | _ completion: Subscribers.Completion 190 | ) { 191 | if case .failure(let error) = completion { 192 | let title = "Couldn't retrieve recently played tracks" 193 | print("\(title): \(error)") 194 | self.alert = AlertItem( 195 | title: title, 196 | message: error.localizedDescription 197 | ) 198 | } 199 | self.isLoadingPage = false 200 | } 201 | 202 | } 203 | 204 | struct RecentlyPlayedView_Previews: PreviewProvider { 205 | 206 | static let tracks: [Track] = [ 207 | .because, .comeTogether, .faces, .illWind, 208 | .odeToViceroy, .reckoner, .theEnd, .time 209 | ] 210 | 211 | static var previews: some View { 212 | ForEach([tracks], id: \.self) { tracks in 213 | NavigationView { 214 | RecentlyPlayedView(recentlyPlayed: tracks) 215 | .listStyle(PlainListStyle()) 216 | } 217 | } 218 | .environmentObject(Spotify()) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | 5 | struct RootView: View { 6 | 7 | @EnvironmentObject var spotify: Spotify 8 | 9 | @State private var alert: AlertItem? = nil 10 | 11 | @State private var cancellables: Set = [] 12 | 13 | var body: some View { 14 | NavigationView { 15 | ExamplesListView() 16 | .navigationBarTitle("Spotify Example App") 17 | .navigationBarItems(trailing: logoutButton) 18 | .disabled(!spotify.isAuthorized) 19 | } 20 | // The login view is presented if `Spotify.isAuthorized` == `false. When 21 | // the login button is tapped, `Spotify.authorize()` is called. After 22 | // the login process successfully completes, `Spotify.isAuthorized` will 23 | // be set to `true` and `LoginView` will be dismissed, allowing the user 24 | // to interact with the rest of the app. 25 | .modifier(LoginView()) 26 | // Presented if an error occurs during the process of authorizing with 27 | // the user's Spotify account. 28 | .alert(item: $alert) { alert in 29 | Alert(title: alert.title, message: alert.message) 30 | } 31 | // Called when a redirect is received from Spotify. 32 | .onOpenURL(perform: handleURL(_:)) 33 | 34 | } 35 | 36 | /** 37 | Handle the URL that Spotify redirects to after the user Either authorizes 38 | or denies authorization for the application. 39 | 40 | This method is called by the `onOpenURL(perform:)` view modifier directly 41 | above. 42 | */ 43 | func handleURL(_ url: URL) { 44 | 45 | // **Always** validate URLs; they offer a potential attack vector into 46 | // your app. 47 | guard url.scheme == self.spotify.loginCallbackURL.scheme else { 48 | print("not handling URL: unexpected scheme: '\(url)'") 49 | self.alert = AlertItem( 50 | title: "Cannot Handle Redirect", 51 | message: "Unexpected URL" 52 | ) 53 | return 54 | } 55 | 56 | print("received redirect from Spotify: '\(url)'") 57 | 58 | // This property is used to display an activity indicator in `LoginView` 59 | // indicating that the access and refresh tokens are being retrieved. 60 | spotify.isRetrievingTokens = true 61 | 62 | // Complete the authorization process by requesting the access and 63 | // refresh tokens. 64 | spotify.api.authorizationManager.requestAccessAndRefreshTokens( 65 | redirectURIWithQuery: url, 66 | // This value must be the same as the one used to create the 67 | // authorization URL. Otherwise, an error will be thrown. 68 | state: spotify.authorizationState 69 | ) 70 | .receive(on: RunLoop.main) 71 | .sink(receiveCompletion: { completion in 72 | // Whether the request succeeded or not, we need to remove the 73 | // activity indicator. 74 | self.spotify.isRetrievingTokens = false 75 | 76 | /* 77 | After the access and refresh tokens are retrieved, 78 | `SpotifyAPI.authorizationManagerDidChange` will emit a signal, 79 | causing `Spotify.authorizationManagerDidChange()` to be called, 80 | which will dismiss the loginView if the app was successfully 81 | authorized by setting the @Published `Spotify.isAuthorized` 82 | property to `true`. 83 | 84 | The only thing we need to do here is handle the error and show it 85 | to the user if one was received. 86 | */ 87 | if case .failure(let error) = completion { 88 | print("couldn't retrieve access and refresh tokens:\n\(error)") 89 | let alertTitle: String 90 | let alertMessage: String 91 | if let authError = error as? SpotifyAuthorizationError, 92 | authError.accessWasDenied { 93 | alertTitle = "You Denied The Authorization Request :(" 94 | alertMessage = "" 95 | } 96 | else { 97 | alertTitle = 98 | "Couldn't Authorization With Your Account" 99 | alertMessage = error.localizedDescription 100 | } 101 | self.alert = AlertItem( 102 | title: alertTitle, message: alertMessage 103 | ) 104 | } 105 | }) 106 | .store(in: &cancellables) 107 | 108 | // MARK: IMPORTANT: generate a new value for the state parameter after 109 | // MARK: each authorization request. This ensures an incoming redirect 110 | // MARK: from Spotify was the result of a request made by this app, and 111 | // MARK: and not an attacker. 112 | self.spotify.authorizationState = String.randomURLSafe(length: 128) 113 | 114 | } 115 | 116 | /// Removes the authorization information for the user. 117 | var logoutButton: some View { 118 | // Calling `spotify.api.authorizationManager.deauthorize` will cause 119 | // `SpotifyAPI.authorizationManagerDidDeauthorize` to emit a signal, 120 | // which will cause `Spotify.authorizationManagerDidDeauthorize()` to be 121 | // called. 122 | Button(action: spotify.api.authorizationManager.deauthorize, label: { 123 | Text("Logout") 124 | .foregroundColor(.white) 125 | .padding(7) 126 | .background( 127 | Color(red: 0.392, green: 0.720, blue: 0.197) 128 | ) 129 | .cornerRadius(10) 130 | .shadow(radius: 3) 131 | 132 | }) 133 | } 134 | } 135 | 136 | struct RootView_Previews: PreviewProvider { 137 | 138 | static let spotify: Spotify = { 139 | let spotify = Spotify() 140 | spotify.isAuthorized = true 141 | return spotify 142 | }() 143 | 144 | static var previews: some View { 145 | RootView() 146 | .environmentObject(spotify) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/SearchForTracksView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SpotifyWebAPI 3 | import Combine 4 | 5 | struct SearchForTracksView: View { 6 | 7 | @EnvironmentObject var spotify: Spotify 8 | 9 | @State private var isSearching = false 10 | 11 | @State var tracks: [Track] = [] 12 | 13 | @State private var alert: AlertItem? = nil 14 | 15 | @State private var searchText = "" 16 | @State private var searchCancellable: AnyCancellable? = nil 17 | 18 | /// Used by the preview provider to provide sample data. 19 | fileprivate init(sampleTracks: [Track]) { 20 | self._tracks = State(initialValue: sampleTracks) 21 | } 22 | 23 | init() { } 24 | 25 | var body: some View { 26 | VStack { 27 | searchBar 28 | .padding([.top, .horizontal]) 29 | Text("Tap on a track to play it.") 30 | .font(.caption) 31 | .foregroundColor(.secondary) 32 | Spacer() 33 | if tracks.isEmpty { 34 | if isSearching { 35 | HStack { 36 | ProgressView() 37 | .padding() 38 | Text("Searching") 39 | .font(.title) 40 | .foregroundColor(.secondary) 41 | } 42 | 43 | } 44 | else { 45 | Text("No Results") 46 | .font(.title) 47 | .foregroundColor(.secondary) 48 | } 49 | } 50 | else { 51 | List { 52 | ForEach(tracks, id: \.self) { track in 53 | TrackView(track: track) 54 | } 55 | } 56 | } 57 | Spacer() 58 | } 59 | .navigationTitle("Search For Tracks") 60 | .alert(item: $alert) { alert in 61 | Alert(title: alert.title, message: alert.message) 62 | } 63 | } 64 | 65 | /// A search bar. Essentially a textfield with a magnifying glass and an "x" 66 | /// button overlayed in front of it. 67 | var searchBar: some View { 68 | // `onCommit` is called when the user presses the return key. 69 | TextField("Search", text: $searchText, onCommit: search) 70 | .padding(.leading, 22) 71 | .overlay( 72 | HStack { 73 | Image(systemName: "magnifyingglass") 74 | .foregroundColor(.secondary) 75 | Spacer() 76 | if !searchText.isEmpty { 77 | // Clear the search text when the user taps the "x" 78 | // button. 79 | Button(action: { 80 | self.searchText = "" 81 | self.tracks = [] 82 | }, label: { 83 | Image(systemName: "xmark.circle.fill") 84 | .foregroundColor(.secondary) 85 | }) 86 | } 87 | } 88 | ) 89 | .padding(.vertical, 7) 90 | .padding(.horizontal, 7) 91 | .background(Color(.secondarySystemBackground)) 92 | .cornerRadius(10) 93 | } 94 | 95 | /// Performs a search for tracks based on `searchText`. 96 | func search() { 97 | 98 | self.tracks = [] 99 | 100 | if self.searchText.isEmpty { return } 101 | 102 | print("searching with query '\(self.searchText)'") 103 | self.isSearching = true 104 | 105 | self.searchCancellable = spotify.api.search( 106 | query: self.searchText, categories: [.track] 107 | ) 108 | .receive(on: RunLoop.main) 109 | .sink( 110 | receiveCompletion: { completion in 111 | self.isSearching = false 112 | if case .failure(let error) = completion { 113 | self.alert = AlertItem( 114 | title: "Couldn't Perform Search", 115 | message: error.localizedDescription 116 | ) 117 | } 118 | }, 119 | receiveValue: { searchResults in 120 | self.tracks = searchResults.tracks?.items ?? [] 121 | print("received \(self.tracks.count) tracks") 122 | } 123 | ) 124 | } 125 | 126 | } 127 | 128 | struct SearchView_Previews: PreviewProvider { 129 | 130 | static let spotify = Spotify() 131 | 132 | static let tracks: [Track] = [ 133 | .because, .comeTogether, .odeToViceroy, .illWind, 134 | .faces, .theEnd, .time, .theEnd, .reckoner 135 | ] 136 | 137 | static var previews: some View { 138 | NavigationView { 139 | SearchForTracksView(sampleTracks: tracks) 140 | .listStyle(PlainListStyle()) 141 | .environmentObject(spotify) 142 | 143 | } 144 | } 145 | 146 | } 147 | 148 | -------------------------------------------------------------------------------- /SpotifyAPIExampleApp/Views/TrackView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import SpotifyWebAPI 4 | 5 | struct TrackView: View { 6 | 7 | @EnvironmentObject var spotify: Spotify 8 | 9 | @State private var playRequestCancellable: AnyCancellable? = nil 10 | 11 | @State private var alert: AlertItem? = nil 12 | 13 | let track: Track 14 | 15 | var body: some View { 16 | Button(action: playTrack) { 17 | HStack { 18 | Text(trackDisplayName()) 19 | Spacer() 20 | } 21 | // Ensure the hit box extends across the entire width of the frame. 22 | // See https://bit.ly/2HqNk4S 23 | .contentShape(Rectangle()) 24 | } 25 | .buttonStyle(PlainButtonStyle()) 26 | .alert(item: $alert) { alert in 27 | Alert(title: alert.title, message: alert.message) 28 | } 29 | } 30 | 31 | /// The display name for the track. E.g., "Eclipse - Pink Floyd". 32 | func trackDisplayName() -> String { 33 | var displayName = track.name 34 | if let artistName = track.artists?.first?.name { 35 | displayName += " - \(artistName)" 36 | } 37 | return displayName 38 | } 39 | 40 | func playTrack() { 41 | 42 | let alertTitle = "Couldn't Play \(track.name)" 43 | 44 | guard let trackURI = track.uri else { 45 | self.alert = AlertItem( 46 | title: alertTitle, 47 | message: "missing URI" 48 | ) 49 | return 50 | } 51 | 52 | let playbackRequest: PlaybackRequest 53 | 54 | if let albumURI = track.album?.uri { 55 | // Play the track in the context of its album. Always prefer 56 | // providing a context; otherwise, the back and forwards buttons may 57 | // not work. 58 | playbackRequest = PlaybackRequest( 59 | context: .contextURI(albumURI), 60 | offset: .uri(trackURI) 61 | ) 62 | } 63 | else { 64 | playbackRequest = PlaybackRequest(trackURI) 65 | } 66 | 67 | // By using a single cancellable rather than a collection of 68 | // cancellables, the previous request always gets cancelled when a new 69 | // request to play a track is made. 70 | self.playRequestCancellable = 71 | self.spotify.api.getAvailableDeviceThenPlay(playbackRequest) 72 | .receive(on: RunLoop.main) 73 | .sink(receiveCompletion: { completion in 74 | if case .failure(let error) = completion { 75 | self.alert = AlertItem( 76 | title: alertTitle, 77 | message: error.localizedDescription 78 | ) 79 | } 80 | }) 81 | 82 | } 83 | } 84 | 85 | struct TrackView_Previews: PreviewProvider { 86 | 87 | static let tracks: [Track] = [ 88 | .because, .comeTogether, .faces, 89 | .illWind, .odeToViceroy, .reckoner, 90 | .theEnd, .time 91 | ] 92 | 93 | static var previews: some View { 94 | List(tracks, id: \.id) { track in 95 | TrackView(track: track) 96 | } 97 | .environmentObject(Spotify()) 98 | } 99 | } 100 | --------------------------------------------------------------------------------