diff --git a/Spottie.xcodeproj/project.pbxproj b/Spottie.xcodeproj/project.pbxproj index d0be6a2..bd9de96 100644 --- a/Spottie.xcodeproj/project.pbxproj +++ b/Spottie.xcodeproj/project.pbxproj @@ -58,6 +58,17 @@ 474BAFB8266876030006EB16 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFB7266876030006EB16 /* VolumeSlider.swift */; }; 474BAFBA26687AB60006EB16 /* WebAPIDeviceObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */; }; 474BAFBC2668B0170006EB16 /* RepeatMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFBB2668B0170006EB16 /* RepeatMode.swift */; }; + 474BAFC1266B84CD0006EB16 /* CarouselRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFC0266B84CD0006EB16 /* CarouselRow.swift */; }; + 474BAFC3266B85440006EB16 /* CarouselRowItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFC2266B85440006EB16 /* CarouselRowItem.swift */; }; + 475798C1266E2DD700AADF2F /* RecommendationGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798C0266E2DD700AADF2F /* RecommendationGroup.swift */; }; + 475798C3266E309A00AADF2F /* RecommendationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798C2266E309A00AADF2F /* RecommendationItem.swift */; }; + 475798C5266E348200AADF2F /* RecommendationLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798C4266E348200AADF2F /* RecommendationLink.swift */; }; + 475798CD266F0F7F00AADF2F /* WebAPIPlaylistObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798CC266F0F7F00AADF2F /* WebAPIPlaylistObject.swift */; }; + 475798CF266F101600AADF2F /* WebAPIPublicUserObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798CE266F101600AADF2F /* WebAPIPublicUserObject.swift */; }; + 475798D1266F103100AADF2F /* WebAPIPlaylistTrackObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798D0266F103100AADF2F /* WebAPIPlaylistTrackObject.swift */; }; + 475798D3266F12EB00AADF2F /* PersonalizedRecommendationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798D2266F12EB00AADF2F /* PersonalizedRecommendationsResponse.swift */; }; + 475798D5266F1B9B00AADF2F /* WebAPIPlaylistTracksRefObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475798D4266F1B9B00AADF2F /* WebAPIPlaylistTracksRefObject.swift */; }; + 47C56F602679A789003EA20A /* PlayerCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C56F5F2679A789003EA20A /* PlayerCommands.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -114,6 +125,17 @@ 474BAFB7266876030006EB16 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = ""; }; 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIDeviceObject.swift; sourceTree = ""; }; 474BAFBB2668B0170006EB16 /* RepeatMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatMode.swift; sourceTree = ""; }; + 474BAFC0266B84CD0006EB16 /* CarouselRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselRow.swift; sourceTree = ""; }; + 474BAFC2266B85440006EB16 /* CarouselRowItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselRowItem.swift; sourceTree = ""; }; + 475798C0266E2DD700AADF2F /* RecommendationGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendationGroup.swift; sourceTree = ""; }; + 475798C2266E309A00AADF2F /* RecommendationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendationItem.swift; sourceTree = ""; }; + 475798C4266E348200AADF2F /* RecommendationLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendationLink.swift; sourceTree = ""; }; + 475798CC266F0F7F00AADF2F /* WebAPIPlaylistObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIPlaylistObject.swift; sourceTree = ""; }; + 475798CE266F101600AADF2F /* WebAPIPublicUserObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIPublicUserObject.swift; sourceTree = ""; }; + 475798D0266F103100AADF2F /* WebAPIPlaylistTrackObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIPlaylistTrackObject.swift; sourceTree = ""; }; + 475798D2266F12EB00AADF2F /* PersonalizedRecommendationsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizedRecommendationsResponse.swift; sourceTree = ""; }; + 475798D4266F1B9B00AADF2F /* WebAPIPlaylistTracksRefObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIPlaylistTracksRefObject.swift; sourceTree = ""; }; + 47C56F5F2679A789003EA20A /* PlayerCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCommands.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -150,6 +172,7 @@ 4730616B26565EB8001E3A1F /* Backend */, 4730617526588C40001E3A1F /* ViewModels */, 4730615D26565706001E3A1F /* Views */, + 47C56F5E2679A779003EA20A /* Commands */, 4730614D265656ED001E3A1F /* SpottieApp.swift */, 47306151265656EF001E3A1F /* Assets.xcassets */, 47306156265656EF001E3A1F /* Info.plist */, @@ -263,6 +286,10 @@ 470201B4265B56350030ECA9 /* WebAPIImageObject.swift */, 470201B6265B56860030ECA9 /* WebAPIArtistObject.swift */, 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */, + 475798CC266F0F7F00AADF2F /* WebAPIPlaylistObject.swift */, + 475798CE266F101600AADF2F /* WebAPIPublicUserObject.swift */, + 475798D0266F103100AADF2F /* WebAPIPlaylistTrackObject.swift */, + 475798D4266F1B9B00AADF2F /* WebAPIPlaylistTracksRefObject.swift */, 474BAFBB2668B0170006EB16 /* RepeatMode.swift */, ); path = Base; @@ -272,6 +299,10 @@ isa = PBXGroup; children = ( 473061A32659FFF5001E3A1F /* CurrentlyPlayingContextObject.swift */, + 475798C0266E2DD700AADF2F /* RecommendationGroup.swift */, + 475798C2266E309A00AADF2F /* RecommendationItem.swift */, + 475798C4266E348200AADF2F /* RecommendationLink.swift */, + 475798D2266F12EB00AADF2F /* PersonalizedRecommendationsResponse.swift */, ); path = API; sourceTree = ""; @@ -279,10 +310,20 @@ 474BAFBD266B79900006EB16 /* Carousel */ = { isa = PBXGroup; children = ( + 474BAFC0266B84CD0006EB16 /* CarouselRow.swift */, + 474BAFC2266B85440006EB16 /* CarouselRowItem.swift */, ); path = Carousel; sourceTree = ""; }; + 47C56F5E2679A779003EA20A /* Commands */ = { + isa = PBXGroup; + children = ( + 47C56F5F2679A789003EA20A /* PlayerCommands.swift */, + ); + path = Commands; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -358,11 +399,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 475798D5266F1B9B00AADF2F /* WebAPIPlaylistTracksRefObject.swift in Sources */, 47306150265656ED001E3A1F /* ContentView.swift in Sources */, 473061792658A02E001E3A1F /* SpotifyEvent.swift in Sources */, 470201BD265B80970030ECA9 /* FakePlayerViewModel.swift in Sources */, 4730618326590931001E3A1F /* TrackObject.swift in Sources */, 470201BB265B7C190030ECA9 /* PlayerStateProtocol.swift in Sources */, + 475798C5266E348200AADF2F /* RecommendationLink.swift in Sources */, 474BAFBA26687AB60006EB16 /* WebAPIDeviceObject.swift in Sources */, 4730615F2656573B001E3A1F /* Sidebar.swift in Sources */, 470201CC265CF9610030ECA9 /* RepeatButton.swift in Sources */, @@ -373,6 +416,7 @@ 4730618726591DF7001E3A1F /* ArtistObject.swift in Sources */, 470201BF265BB4320030ECA9 /* PreviousTrackButton.swift in Sources */, 470201C3265CF29B0030ECA9 /* NowPlaying.swift in Sources */, + 475798C3266E309A00AADF2F /* RecommendationItem.swift in Sources */, 4730616A26565BB7001E3A1F /* BottomBar.swift in Sources */, 473061722656629F001E3A1F /* Nothing.swift in Sources */, 474BAFBC2668B0170006EB16 /* RepeatMode.swift in Sources */, @@ -385,6 +429,7 @@ 4730619B26591FFC001E3A1F /* TrackSeekedEvent.swift in Sources */, 470201B1265B54720030ECA9 /* WebAPISimplifiedAlbumObject.swift in Sources */, 470201B5265B56350030ECA9 /* WebAPIImageObject.swift in Sources */, + 47C56F602679A789003EA20A /* PlayerCommands.swift in Sources */, 4730617426587EF6001E3A1F /* WebsocketClient.swift in Sources */, 473061612656580F001E3A1F /* Home.swift in Sources */, 470201C1265BB43C0030ECA9 /* NextTrackButton.swift in Sources */, @@ -395,16 +440,23 @@ 470201B3265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift in Sources */, 470201B7265B56860030ECA9 /* WebAPIArtistObject.swift in Sources */, 473061A6265B4A10001E3A1F /* WebAPITrackObject.swift in Sources */, + 475798C1266E2DD700AADF2F /* RecommendationGroup.swift in Sources */, + 474BAFC3266B85440006EB16 /* CarouselRowItem.swift in Sources */, 473061852659164E001E3A1F /* AlbumObject.swift in Sources */, 474BAFB8266876030006EB16 /* VolumeSlider.swift in Sources */, 4730619D26592023001E3A1F /* PlaybackPausedEvent.swift in Sources */, + 475798D3266F12EB00AADF2F /* PersonalizedRecommendationsResponse.swift in Sources */, + 475798D1266F103100AADF2F /* WebAPIPlaylistTrackObject.swift in Sources */, + 475798CF266F101600AADF2F /* WebAPIPublicUserObject.swift in Sources */, 4730618F26591E9C001E3A1F /* ContextChangedEvent.swift in Sources */, + 474BAFC1266B84CD0006EB16 /* CarouselRow.swift in Sources */, 4730616526565841001E3A1F /* Library.swift in Sources */, 4730619926591F92001E3A1F /* VolumeChangedEvent.swift in Sources */, 4730619726591F14001E3A1F /* MetadataAvailableEvent.swift in Sources */, 4730617B265903A6001E3A1F /* EventBroker.swift in Sources */, 470201CA265CF9380030ECA9 /* ShuffleButton.swift in Sources */, 4730619326591EE1001E3A1F /* TrackChangedEvent.swift in Sources */, + 475798CD266F0F7F00AADF2F /* WebAPIPlaylistObject.swift in Sources */, 4730619526591EF9001E3A1F /* PlaybackResumedEvent.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Spottie/Backend/HTTPClient.swift b/Spottie/Backend/HTTPClient.swift index 50c51c6..dfbad34 100644 --- a/Spottie/Backend/HTTPClient.swift +++ b/Spottie/Backend/HTTPClient.swift @@ -20,7 +20,6 @@ struct HTTPClient { .tryMap { result -> Response in let response = result.response as! HTTPURLResponse let contentType = response.allHeaderFields["Content-Type"] as? String - print(response.allHeaderFields) if let contentType = contentType { if contentType.contains("application/json") { diff --git a/Spottie/Backend/SpotifyAPI.swift b/Spottie/Backend/SpotifyAPI.swift index 2d8f4a7..3eff921 100644 --- a/Spottie/Backend/SpotifyAPI.swift +++ b/Spottie/Backend/SpotifyAPI.swift @@ -21,6 +21,25 @@ extension SpotifyAPI { return client.run(req, decoder).map(\.value).eraseToAnyPublisher() } + static func load(_ uri: String) -> AnyPublisher { + let queryItems = [ + URLQueryItem(name: "uri", value: uri), + URLQueryItem(name: "play", value: "true") + ] + var urlComponents = URLComponents(url: base.appendingPathComponent("/player/load"), resolvingAgainstBaseURL: false)! + urlComponents.queryItems = queryItems + + var req = URLRequest(url: urlComponents.url!) + req.httpMethod = "POST" + return client.run(req).print().map(\.value).eraseToAnyPublisher() + } + + static func togglePlayPause() -> AnyPublisher { + var req = URLRequest(url: base.appendingPathComponent("/player/play-pause")) + req.httpMethod = "POST" + return client.run(req).map(\.value).eraseToAnyPublisher() + } + static func pause() -> AnyPublisher { var req = URLRequest(url: base.appendingPathComponent("/player/pause")) req.httpMethod = "POST" @@ -85,4 +104,31 @@ extension SpotifyAPI { req.httpMethod = "POST" return client.run(req).print().map(\.value).eraseToAnyPublisher() } + + static func getPersonalizedRecommendations() -> AnyPublisher { + let queryItems = [ + URLQueryItem(name: "timestamp", value: "2021-06-07T10:54:29.467Z"), + URLQueryItem(name: "platform", value: "web"), + URLQueryItem(name: "content_limit", value: "10"), + URLQueryItem(name: "limit", value: "20"), + URLQueryItem(name: "types", value: "album,playlist,artist,show,station,episode"), + URLQueryItem(name: "image_style", value: "gradient_overlay"), + URLQueryItem(name: "country", value: "SG"), + URLQueryItem(name: "locale", value: "en"), + URLQueryItem(name: "market", value: "from_token") + ] + + var urlComponents = URLComponents( + url: base.appendingPathComponent("/web-api/v1/views/personalized-recommendations"), + resolvingAgainstBaseURL: false)! + urlComponents.queryItems = queryItems + + var req = URLRequest(url: urlComponents.url!) + req.httpMethod = "GET" + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase; + + return client.run(req, decoder).print().map(\.value).eraseToAnyPublisher() + } } diff --git a/Spottie/Backend/Types/API/PersonalizedRecommendationsResponse.swift b/Spottie/Backend/Types/API/PersonalizedRecommendationsResponse.swift new file mode 100644 index 0000000..12158c5 --- /dev/null +++ b/Spottie/Backend/Types/API/PersonalizedRecommendationsResponse.swift @@ -0,0 +1,16 @@ +// +// PersonalizedRecommendationsResponse.swift +// Spottie +// +// Created by Lee Jun Kit on 8/6/21. +// + +import Foundation + +struct PersonalizedRecommendationsResponse: Decodable { + var content: Content + + struct Content: Decodable { + var items: [RecommendationGroup] + } +} diff --git a/Spottie/Backend/Types/API/RecommendationGroup.swift b/Spottie/Backend/Types/API/RecommendationGroup.swift new file mode 100644 index 0000000..0c0c514 --- /dev/null +++ b/Spottie/Backend/Types/API/RecommendationGroup.swift @@ -0,0 +1,38 @@ +// +// RecommendationGroup.swift +// Spottie +// +// Created by Lee Jun Kit on 7/6/21. +// + +import Foundation + +struct RecommendationGroup: Decodable, Hashable, Identifiable { + var id: String + var name: String + var rendering: String + var items: [RecommendationItem] + + enum CodingKeys: String, CodingKey { + case id + case name + case rendering + case items + case content + } + + struct Content: Decodable { + var items: [RecommendationItem] + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try values.decode(String.self, forKey:.id) + self.name = try values.decode(String.self, forKey:.name) + self.rendering = try values.decode(String.self, forKey:.rendering) + + let contentContainer = try values.decode(Content.self, forKey:.content) + self.items = contentContainer.items + } +} diff --git a/Spottie/Backend/Types/API/RecommendationItem.swift b/Spottie/Backend/Types/API/RecommendationItem.swift new file mode 100644 index 0000000..c19c72a --- /dev/null +++ b/Spottie/Backend/Types/API/RecommendationItem.swift @@ -0,0 +1,85 @@ +// +// RecommendationItem.swift +// Spottie +// +// Created by Lee Jun Kit on 7/6/21. +// + +import Foundation + +enum RecommendationItemData { + case link(RecommendationLink) + case album(WebAPISimplifiedAlbumObject) + case artist(WebAPIArtistObject) + case playlist(WebAPIPlaylistObject) +} + +struct RecommendationItem: Hashable, Identifiable, Decodable { + static func == (lhs: RecommendationItem, rhs: RecommendationItem) -> Bool { + if lhs.type == rhs.type { + if (lhs.type == .unknown) { + return true + } + } + + return lhs.id == rhs.id; + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + + enum RecommendationItemType: String, Decodable, CaseIterable { + case link + case album + case artist + case playlist + case unknown + } + + var id: String + var type: RecommendationItemType + var data: RecommendationItemData? + + enum CodingKeys: String, CodingKey { + case type + case data + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + var typeRawValue = try values.decode(String.self, forKey:.type) + print("Encountered RecommendationItemType \(typeRawValue)") + + if (!(RecommendationItemType.allCases.map { + $0.rawValue + }.contains(typeRawValue))) { + typeRawValue = "unknown" + } + + self.type = RecommendationItemType(rawValue: typeRawValue)! + + // attempt to decode RecommendationItemData + let container = try decoder.singleValueContainer() + switch(self.type) { + case .link: + data = RecommendationItemData.link(try container.decode(RecommendationLink.self)) + self.id = "spotify:link:\(UUID().uuidString)" + case .artist: + let artist = try container.decode(WebAPIArtistObject.self) + self.id = artist.uri + data = RecommendationItemData.artist(artist) + case .album: + let album = try container.decode(WebAPISimplifiedAlbumObject.self) + self.id = album.uri + data = RecommendationItemData.album(album) + case .playlist: + let playlist = try container.decode(WebAPIPlaylistObject.self) + self.id = playlist.uri + data = RecommendationItemData.playlist(playlist) + case .unknown: + self.id = "spotify:unknown:\(UUID().uuidString)" + break + } + } +} diff --git a/Spottie/Backend/Types/API/RecommendationLink.swift b/Spottie/Backend/Types/API/RecommendationLink.swift new file mode 100644 index 0000000..7d28ba9 --- /dev/null +++ b/Spottie/Backend/Types/API/RecommendationLink.swift @@ -0,0 +1,12 @@ +// +// RecommendationLink.swift +// Spottie +// +// Created by Lee Jun Kit on 7/6/21. +// + +import Foundation + +struct RecommendationLink: Decodable { + +} diff --git a/Spottie/Backend/Types/API/RecommendationList.swift b/Spottie/Backend/Types/API/RecommendationList.swift new file mode 100644 index 0000000..20222a0 --- /dev/null +++ b/Spottie/Backend/Types/API/RecommendationList.swift @@ -0,0 +1,22 @@ +// +// RecommendationsObject.swift +// Spottie +// +// Created by Lee Jun Kit on 7/6/21. +// + +import Foundation + +struct RecommendationList: Decodable { + var id: String + var name: String + var rendering: String + var items: [Item] + + struct Item: Decodable { + var type: String + var name: String + var uri: String + var href: String + } +} diff --git a/Spottie/Backend/Types/Base/WebAPIPlaylistObject.swift b/Spottie/Backend/Types/Base/WebAPIPlaylistObject.swift new file mode 100644 index 0000000..7df6705 --- /dev/null +++ b/Spottie/Backend/Types/Base/WebAPIPlaylistObject.swift @@ -0,0 +1,18 @@ +// +// WebAPIPlaylistObject.swift +// Spottie +// +// Created by Lee Jun Kit on 8/6/21. +// + +import Foundation + +struct WebAPIPlaylistObject: Decodable { + var id: String + var uri: String + var name: String + var owner: WebAPIPublicUserObject + var description: String? + var images: [WebAPIImageObject] + var tracks: WebAPIPlaylistTracksRefObject +} diff --git a/Spottie/Backend/Types/Base/WebAPIPlaylistTrackObject.swift b/Spottie/Backend/Types/Base/WebAPIPlaylistTrackObject.swift new file mode 100644 index 0000000..2ebf80e --- /dev/null +++ b/Spottie/Backend/Types/Base/WebAPIPlaylistTrackObject.swift @@ -0,0 +1,15 @@ +// +// WebAPIPlaylistTrackObject.swift +// Spottie +// +// Created by Lee Jun Kit on 8/6/21. +// + +import Foundation + +struct WebAPIPlaylistTrackObject: Decodable { + var addedAt: String + var addedBy: WebAPIPublicUserObject + var isLocal: Bool + var track: WebAPITrackObject +} diff --git a/Spottie/Backend/Types/Base/WebAPIPlaylistTracksRefObject.swift b/Spottie/Backend/Types/Base/WebAPIPlaylistTracksRefObject.swift new file mode 100644 index 0000000..bbc8f19 --- /dev/null +++ b/Spottie/Backend/Types/Base/WebAPIPlaylistTracksRefObject.swift @@ -0,0 +1,13 @@ +// +// WebAPIPlaylistTracksRefObject.swift +// Spottie +// +// Created by Lee Jun Kit on 8/6/21. +// + +import Foundation + +struct WebAPIPlaylistTracksRefObject: Decodable { + var href: String + var total: Int +} diff --git a/Spottie/Backend/Types/Base/WebAPIPublicUserObject.swift b/Spottie/Backend/Types/Base/WebAPIPublicUserObject.swift new file mode 100644 index 0000000..3b20a76 --- /dev/null +++ b/Spottie/Backend/Types/Base/WebAPIPublicUserObject.swift @@ -0,0 +1,15 @@ +// +// WebAPIPublicUserObject.swift +// Spottie +// +// Created by Lee Jun Kit on 8/6/21. +// + +import Foundation + +struct WebAPIPublicUserObject: Decodable { + var id: String + var uri: String + var displayName: String? + var images: [WebAPIImageObject]? +} diff --git a/Spottie/Commands/PlayerCommands.swift b/Spottie/Commands/PlayerCommands.swift new file mode 100644 index 0000000..d6e80e2 --- /dev/null +++ b/Spottie/Commands/PlayerCommands.swift @@ -0,0 +1,33 @@ +// +// PlayerCommands.swift +// Spottie +// +// Created by Lee Jun Kit on 16/6/21. +// + +import SwiftUI +import Combine + +struct PlayerCommands: Commands { + struct MenuContent: View { + private var viewModel: ViewModel = ViewModel() + var body: some View { + Button("Toggle Play/Pause") { + SpotifyAPI.togglePlayPause().sink { _ in } receiveValue: { _ in }.store(in: &viewModel.cancellables) + } + .keyboardShortcut(" ", modifiers: []) + } + } + + var body: some Commands { + CommandMenu("Player") { + MenuContent() + } + } +} + +extension PlayerCommands.MenuContent { + class ViewModel: ObservableObject { + var cancellables = [AnyCancellable]() + } +} diff --git a/Spottie/SpottieApp.swift b/Spottie/SpottieApp.swift index e110713..e97a3ee 100644 --- a/Spottie/SpottieApp.swift +++ b/Spottie/SpottieApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine @main struct SpottieApp: App { @@ -16,5 +17,8 @@ struct SpottieApp: App { ContentView() .environmentObject(playerViewModel) } + .commands { + PlayerCommands() + } } } diff --git a/Spottie/ViewModels/PlayerViewModel.swift b/Spottie/ViewModels/PlayerViewModel.swift index 76fe3fa..e92a0a3 100644 --- a/Spottie/ViewModels/PlayerViewModel.swift +++ b/Spottie/ViewModels/PlayerViewModel.swift @@ -44,6 +44,8 @@ class PlayerViewModel: PlayerStateProtocol { } token.store(in: &cancellables) + + } func handleInitialStateUpdate(context ctx: CurrentlyPlayingContextObject) { diff --git a/Spottie/Views/Carousel/CarouselRow.swift b/Spottie/Views/Carousel/CarouselRow.swift new file mode 100644 index 0000000..4f0b83f --- /dev/null +++ b/Spottie/Views/Carousel/CarouselRow.swift @@ -0,0 +1,62 @@ +// +// CarouselRow.swift +// Spottie +// +// Created by Lee Jun Kit on 5/6/21. +// + +import SwiftUI + +struct CarouselRow: View { + var viewModel: ViewModel + var body: some View { + VStack(alignment: .leading) { + Text(viewModel.title) + .font(.title) + .padding(.leading) + Text(viewModel.subtitle) + .font(.body) + .foregroundColor(.secondary) + .padding(.leading) + .padding(.bottom, 8) + HStack(alignment: .top, spacing: 40) { + ForEach(viewModel.items) { item in + CarouselRowItem(viewModel: CarouselRowItem.ViewModel.init(item)) + .onTapGesture { + viewModel.onItemPressed(item.id) + } + } + } + .padding([.leading, .trailing]) + } + } +} + +extension CarouselRow { + class ViewModel: ObservableObject { + @Published var title: String + @Published var subtitle = "Unwind with these calming playlists." + @Published var items: [RecommendationItem] + let onItemPressed: (String) -> Void + + init(_ recommendationGroup: RecommendationGroup, numberOfItemsToShow: Int, onItemPressed: @escaping (String) -> Void) { + self.onItemPressed = onItemPressed + + title = recommendationGroup.name + + let numItems = recommendationGroup.items.count + if (numItems < numberOfItemsToShow) { + items = recommendationGroup.items + } else { + items = Array(recommendationGroup.items[0...numberOfItemsToShow - 1]) + } + } + } +} + +//struct CarouselRow_Previews: PreviewProvider { +//// static let group = RecommendationGroup() +//// static var previews: some View { +//// CarouselRow(viewModel: <#T##CarouselRow.ViewModel#>.init()) +//// } +//} diff --git a/Spottie/Views/Carousel/CarouselRowItem.swift b/Spottie/Views/Carousel/CarouselRowItem.swift new file mode 100644 index 0000000..a74f9bc --- /dev/null +++ b/Spottie/Views/Carousel/CarouselRowItem.swift @@ -0,0 +1,81 @@ +// +// CarouselRowItem.swift +// Spottie +// +// Created by Lee Jun Kit on 5/6/21. +// + +import SwiftUI +import SDWebImageSwiftUI + +struct CarouselRowItem: View { + @ObservedObject var viewModel: ViewModel + + var body: some View { + VStack(alignment: .leading) { + WebImage(url: viewModel.artworkURL) + .resizable() + .aspectRatio(1.0, contentMode: .fill) + .cornerRadius(5) + Text(viewModel.title) + .lineLimit(1) + .foregroundColor(.primary) + .font(.headline) + .padding(.vertical, 4) + Text(viewModel.subtitle) + .lineLimit(2) + .foregroundColor(.secondary) + } + .frame(maxWidth: 200) + .padding() + .background( + Color(NSColor.alternatingContentBackgroundColors[1]) + .opacity(viewModel.isHovering ? 1.0 : 0.3) + ) + .onHover { isHovered in + viewModel.isHovering = isHovered + } + } +} + +extension CarouselRowItem { + class ViewModel: ObservableObject { + @Published var title: String + @Published var subtitle: String + @Published var artworkURL: URL + @Published var isHovering = false + + init(_ item: RecommendationItem) { + if let data = item.data { + switch data { + case let .album(album): + self.title = album.name + self.subtitle = album.artists[0].name + self.artworkURL = album.getArtworkURL()! + case let .artist(artist): + self.title = artist.name + self.subtitle = artist.name + self.artworkURL = URL(string: artist.images[0].url)! + case let.playlist(playlist): + self.title = playlist.name + self.subtitle = playlist.description ?? "" + self.artworkURL = URL(string: playlist.images[0].url)! + default: + self.title = "" + self.subtitle = "" + self.artworkURL = URL(string: "https://mosaic.scdn.co/640/ab67616d00001e020b7c6d46885f7434c99e6d8bab67616d00001e0244bb39545f5d7176080d17aeab67616d00001e027679b9d0724cc7421189ac44ab67616d00001e028863bc11d2aa12b54f5aeb36")! + } + } else { + self.title = "" + self.subtitle = "" + self.artworkURL = URL(string: "https://mosaic.scdn.co/640/ab67616d00001e020b7c6d46885f7434c99e6d8bab67616d00001e0244bb39545f5d7176080d17aeab67616d00001e027679b9d0724cc7421189ac44ab67616d00001e028863bc11d2aa12b54f5aeb36")! + } + } + } +} + +//struct CarouselRowItem_Previews: PreviewProvider { +// static var previews: some View { +// CarouselRowItem() +// } +//} diff --git a/Spottie/Views/Home.swift b/Spottie/Views/Home.swift index 53f097b..54024a6 100644 --- a/Spottie/Views/Home.swift +++ b/Spottie/Views/Home.swift @@ -6,17 +6,55 @@ // import SwiftUI +import Combine -struct Home: View { - @EnvironmentObject var viewModel: M +struct Home: View { + @ObservedObject var viewModel: ViewModel = ViewModel() var body: some View { - Text("\(viewModel.trackName) by \(viewModel.artistName)") + GeometryReader { reader in + ScrollView(.vertical) { + LazyVStack(alignment: .leading) { + ForEach(viewModel.recommendationGroups) { group in + CarouselRow(viewModel: CarouselRow.ViewModel.init( + group, + numberOfItemsToShow: numberOfItemsToShowInRow(reader), + onItemPressed: viewModel.load + )) + .padding() + } + } + } + } + } + + func numberOfItemsToShowInRow(_ reader: GeometryProxy) -> Int { + let screenPadding = 16 + let itemPadding = 40 + let rowPadding = 16 + let itemWidth = 200 + return (Int(reader.size.width) - (2 * screenPadding) - (2 * rowPadding) - itemPadding) / (itemWidth + itemPadding) + } +} + +extension Home { + class ViewModel: ObservableObject { + @Published var recommendationGroups: [RecommendationGroup] = [] + private var cancellables = [AnyCancellable]() + init() { + SpotifyAPI.getPersonalizedRecommendations().sink { _ in } receiveValue: { response in + self.recommendationGroups = Array(response!.content.items.dropFirst(2)) + }.store(in: &cancellables) + } + + func load(_ uri: String) { + SpotifyAPI.load(uri).sink { _ in } receiveValue: { _ in }.store(in: &cancellables) + } } } struct Home_Previews: PreviewProvider { static var previews: some View { - Home() + Home() .environmentObject(FakePlayerViewModel()) } } diff --git a/Spottie/Views/Search.swift b/Spottie/Views/Search.swift index 989a46c..346bb6c 100644 --- a/Spottie/Views/Search.swift +++ b/Spottie/Views/Search.swift @@ -8,22 +8,8 @@ import SwiftUI struct Search: View { - let rows: [GridItem] = - Array(repeating: .init(.fixed(20)), count: 2) var body: some View { - ScrollView(.horizontal) { - LazyHGrid(rows: rows, alignment: .top) { - ForEach((0...79), id: \.self) { - let codepoint = $0 + 0x1f600 - let codepointString = String(format: "%02X", codepoint) - Text("\(codepointString)") - .font(.footnote) - let emoji = String(Character(UnicodeScalar(codepoint)!)) - Text("\(emoji)") - .font(.largeTitle) - } - } - } + Text("Hello Search") } } diff --git a/Spottie/Views/Sidebar.swift b/Spottie/Views/Sidebar.swift index 6ad543b..3895ca3 100644 --- a/Spottie/Views/Sidebar.swift +++ b/Spottie/Views/Sidebar.swift @@ -13,7 +13,7 @@ struct Sidebar: View { var body: some View { List { NavigationLink( - destination: Home(), + destination: Home(), tag: Screen.home, selection: $state, label: {