├── .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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------