diff --git a/Spottie.xcodeproj/project.pbxproj b/Spottie.xcodeproj/project.pbxproj index 9b080a5..ddb69ec 100644 --- a/Spottie.xcodeproj/project.pbxproj +++ b/Spottie.xcodeproj/project.pbxproj @@ -75,6 +75,9 @@ 475EE24C267EBDE7007BEBDC /* SearchResultsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475EE24B267EBDE7007BEBDC /* SearchResultsResponse.swift */; }; 475EE24F267EBFEA007BEBDC /* WebAPIPagingObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475EE24E267EBFEA007BEBDC /* WebAPIPagingObject.swift */; }; 4764B39D26833B9900AE471E /* HoverState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B39C26833B9900AE471E /* HoverState.swift */; }; + 4764B39F2684001A00AE471E /* TokenObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B39E2684001A00AE471E /* TokenObject.swift */; }; + 4764B3A1268407C600AE471E /* TrackListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B3A0268407C600AE471E /* TrackListItem.swift */; }; + 4764B3A526842ECA00AE471E /* WebAPIImageCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B3A426842ECA00AE471E /* WebAPIImageCollection.swift */; }; 47C56F602679A789003EA20A /* PlayerCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C56F5F2679A789003EA20A /* PlayerCommands.swift */; }; /* End PBXBuildFile section */ @@ -149,6 +152,9 @@ 475EE24B267EBDE7007BEBDC /* SearchResultsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsResponse.swift; sourceTree = ""; }; 475EE24E267EBFEA007BEBDC /* WebAPIPagingObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIPagingObject.swift; sourceTree = ""; }; 4764B39C26833B9900AE471E /* HoverState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverState.swift; sourceTree = ""; }; + 4764B39E2684001A00AE471E /* TokenObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenObject.swift; sourceTree = ""; }; + 4764B3A0268407C600AE471E /* TrackListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackListItem.swift; sourceTree = ""; }; + 4764B3A426842ECA00AE471E /* WebAPIImageCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIImageCollection.swift; sourceTree = ""; }; 47C56F5F2679A789003EA20A /* PlayerCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCommands.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -296,6 +302,7 @@ 4730618A26591E36001E3A1F /* CoverGroupObject.swift */, 4730618C26591E50001E3A1F /* ImageObject.swift */, 474BAFBB2668B0170006EB16 /* RepeatMode.swift */, + 4764B39E2684001A00AE471E /* TokenObject.swift */, ); path = Base; sourceTree = ""; @@ -323,6 +330,7 @@ 475EE247267D7784007BEBDC /* SearchField.swift */, 475EE249267DA451007BEBDC /* GreenPlayButton.swift */, 4764B39C26833B9900AE471E /* HoverState.swift */, + 4764B3A0268407C600AE471E /* TrackListItem.swift */, ); path = Components; sourceTree = ""; @@ -341,6 +349,7 @@ 475798D0266F103100AADF2F /* WebAPIPlaylistTrackObject.swift */, 475798D4266F1B9B00AADF2F /* WebAPIPlaylistTracksRefObject.swift */, 475EE24E267EBFEA007BEBDC /* WebAPIPagingObject.swift */, + 4764B3A426842ECA00AE471E /* WebAPIImageCollection.swift */, ); path = WebAPI; sourceTree = ""; @@ -445,6 +454,7 @@ 4730618926591E16001E3A1F /* DateObject.swift in Sources */, 4730616826565B03001E3A1F /* PlayPauseButton.swift in Sources */, 4730616326565828001E3A1F /* Search.swift in Sources */, + 4764B3A526842ECA00AE471E /* WebAPIImageCollection.swift in Sources */, 4730618726591DF7001E3A1F /* ArtistObject.swift in Sources */, 470201BF265BB4320030ECA9 /* PreviousTrackButton.swift in Sources */, 470201C3265CF29B0030ECA9 /* NowPlaying.swift in Sources */, @@ -482,8 +492,10 @@ 475798D1266F103100AADF2F /* WebAPIPlaylistTrackObject.swift in Sources */, 475798CF266F101600AADF2F /* WebAPIPublicUserObject.swift in Sources */, 4730618F26591E9C001E3A1F /* ContextChangedEvent.swift in Sources */, + 4764B3A1268407C600AE471E /* TrackListItem.swift in Sources */, 474BAFC1266B84CD0006EB16 /* CarouselRow.swift in Sources */, 4730616526565841001E3A1F /* Library.swift in Sources */, + 4764B39F2684001A00AE471E /* TokenObject.swift in Sources */, 4730619926591F92001E3A1F /* VolumeChangedEvent.swift in Sources */, 4730619726591F14001E3A1F /* MetadataAvailableEvent.swift in Sources */, 4764B39D26833B9900AE471E /* HoverState.swift in Sources */, diff --git a/Spottie/Backend/HTTPClient.swift b/Spottie/Backend/HTTPClient.swift index 58f0973..6081b2f 100644 --- a/Spottie/Backend/HTTPClient.swift +++ b/Spottie/Backend/HTTPClient.swift @@ -27,17 +27,14 @@ struct HTTPClient { return Response(value: Nothing() as! T, response: result.response) } + // check for empty body let response = result.response as! HTTPURLResponse - let contentType = response.allHeaderFields["Content-Type"] as? String - - if let contentType = contentType { - if contentType.contains("application/json") { - let value = try decoder.decode(T.self, from: result.data) - return Response(value: value, response: result.response) - } + if response.statusCode == 204 { + throw APIError.contentTypeError } - throw APIError.contentTypeError + let value = try decoder.decode(T.self, from: result.data) + return Response(value: value, response: result.response) } .receive(on: DispatchQueue.main) .eraseToAnyPublisher() diff --git a/Spottie/Backend/SpotifyAPI.swift b/Spottie/Backend/SpotifyAPI.swift index cdae9c3..5c76caf 100644 --- a/Spottie/Backend/SpotifyAPI.swift +++ b/Spottie/Backend/SpotifyAPI.swift @@ -145,7 +145,7 @@ extension SpotifyAPI { ] var urlComponents = URLComponents( - url: base.appendingPathComponent("/web-api/v1/search"), + url: URL(string: "https://api.spotify.com/v1/search")!, resolvingAgainstBaseURL: false)! urlComponents.queryItems = queryItems @@ -155,6 +155,22 @@ extension SpotifyAPI { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase; - return client.run(req, decoder).map(\.value).print().eraseToAnyPublisher() + // librespot-java web-api proxy has a bug where it doubly-percent encodes + // so we grab the token and perform the search ourselves + return + token("user-read-private") + .flatMap { tokenObj -> AnyPublisher, Error> in + let token = tokenObj!.token + req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return client.run(req, decoder) + } + .map(\.value) + .eraseToAnyPublisher() + } + + static func token(_ scopes: String) -> AnyPublisher { + var req = URLRequest(url: base.appendingPathComponent("/token/\(scopes)")) + req.httpMethod = "POST" + return client.run(req).print().map(\.value).eraseToAnyPublisher() } } diff --git a/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift b/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift index 46bf677..33d3ccf 100644 --- a/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift +++ b/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift @@ -11,7 +11,7 @@ enum RepeatState: String, Codable { case context } -struct CurrentlyPlayingContextObject: Codable { +struct CurrentlyPlayingContextObject: Decodable { var device: WebAPIDeviceObject var isPlaying: Bool var shuffleState: Bool diff --git a/Spottie/Backend/Types/Base/TokenObject.swift b/Spottie/Backend/Types/Base/TokenObject.swift new file mode 100644 index 0000000..f1e3a43 --- /dev/null +++ b/Spottie/Backend/Types/Base/TokenObject.swift @@ -0,0 +1,12 @@ +// +// TokenObject.swift +// Spottie +// +// Created by Lee Jun Kit on 24/6/21. +// + +import Foundation + +struct TokenObject: Decodable { + let token: String +} diff --git a/Spottie/Backend/Types/WebAPI/WebAPIArtistObject.swift b/Spottie/Backend/Types/WebAPI/WebAPIArtistObject.swift index ca059f8..73d8bfe 100644 --- a/Spottie/Backend/Types/WebAPI/WebAPIArtistObject.swift +++ b/Spottie/Backend/Types/WebAPI/WebAPIArtistObject.swift @@ -7,18 +7,10 @@ import Foundation -struct WebAPIArtistObject: Codable { +struct WebAPIArtistObject: Decodable, WebAPIImageCollection { var id: String var uri: String var name: String var images: [WebAPIImageObject] var popularity: Int - - func getArtworkURL() -> URL { - if (self.images.isEmpty) { - return URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")! - } - - return URL(string: self.images[0].url)! - } } diff --git a/Spottie/Backend/Types/WebAPI/WebAPIImageCollection.swift b/Spottie/Backend/Types/WebAPI/WebAPIImageCollection.swift new file mode 100644 index 0000000..4a0b116 --- /dev/null +++ b/Spottie/Backend/Types/WebAPI/WebAPIImageCollection.swift @@ -0,0 +1,34 @@ +// +// WebAPIImageCollection.swift +// Spottie +// +// Created by Lee Jun Kit on 24/6/21. +// + +import Foundation + +protocol WebAPIImageCollection { + var images: [WebAPIImageObject] { get } +} + +extension WebAPIImageCollection { + func getImageURL(_ forSize: WebAPIImageObject.ImageSize) -> URL { + if images.isEmpty { + // return a placeholder + return URL(string: "https://misc.scdn.co/liked-songs/liked-songs-64.png")! + } + + switch (forSize) { + case .small: + return URL(string: images[images.count - 1].url)! + case .large: + return URL(string: images[0].url)! + case .medium: + if images.count <= 2 { + return URL(string: images[0].url)! + } else { + return URL(string: images[images.count / 2].url)! + } + } + } +} diff --git a/Spottie/Backend/Types/WebAPI/WebAPIImageObject.swift b/Spottie/Backend/Types/WebAPI/WebAPIImageObject.swift index 4f8322b..909d98d 100644 --- a/Spottie/Backend/Types/WebAPI/WebAPIImageObject.swift +++ b/Spottie/Backend/Types/WebAPI/WebAPIImageObject.swift @@ -5,8 +5,14 @@ // Created by Lee Jun Kit on 24/5/21. // -struct WebAPIImageObject: Codable { - var url: String - var width: Int? - var height: Int? +import Foundation + +struct WebAPIImageObject: Decodable { + enum ImageSize { + case small, medium, large + } + + let url: String + let width: Int? + let height: Int? } diff --git a/Spottie/Backend/Types/WebAPI/WebAPIPlaylistObject.swift b/Spottie/Backend/Types/WebAPI/WebAPIPlaylistObject.swift index 46d0725..a0d76f9 100644 --- a/Spottie/Backend/Types/WebAPI/WebAPIPlaylistObject.swift +++ b/Spottie/Backend/Types/WebAPI/WebAPIPlaylistObject.swift @@ -7,7 +7,7 @@ import Foundation -struct WebAPIPlaylistObject: Decodable { +struct WebAPIPlaylistObject: Decodable, WebAPIImageCollection { var id: String var uri: String var name: String @@ -15,12 +15,4 @@ struct WebAPIPlaylistObject: Decodable { var description: String? var images: [WebAPIImageObject] var tracks: WebAPIPlaylistTracksRefObject - - func getArtworkURL() -> URL { - if (self.images.isEmpty) { - return URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")! - } - - return URL(string: self.images[0].url)! - } } diff --git a/Spottie/Backend/Types/WebAPI/WebAPISimplifiedAlbumObject.swift b/Spottie/Backend/Types/WebAPI/WebAPISimplifiedAlbumObject.swift index 100087a..47b6088 100644 --- a/Spottie/Backend/Types/WebAPI/WebAPISimplifiedAlbumObject.swift +++ b/Spottie/Backend/Types/WebAPI/WebAPISimplifiedAlbumObject.swift @@ -19,7 +19,7 @@ enum ReleaseDatePrecision: String, Codable { case day } -struct WebAPISimplifiedAlbumObject: Codable { +struct WebAPISimplifiedAlbumObject: Decodable, WebAPIImageCollection { var id: String var uri: String var albumType: AlbumType @@ -29,12 +29,4 @@ struct WebAPISimplifiedAlbumObject: Codable { var releaseDate: String var releaseDatePrecision: ReleaseDatePrecision var totalTracks: Int - - func getArtworkURL() -> URL { - if (self.images.isEmpty) { - return URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")! - } - - return URL(string: self.images[0].url)! - } } diff --git a/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift b/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift index 0c3c048..4c8e497 100644 --- a/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift +++ b/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift @@ -5,7 +5,7 @@ // Created by Lee Jun Kit on 24/5/21. // -struct WebAPITrackObject: Codable { +struct WebAPITrackObject: Decodable { var id: String var uri: String var album: WebAPISimplifiedAlbumObject diff --git a/Spottie/ViewModels/PlayerViewModel.swift b/Spottie/ViewModels/PlayerViewModel.swift index e92a0a3..9e88e12 100644 --- a/Spottie/ViewModels/PlayerViewModel.swift +++ b/Spottie/ViewModels/PlayerViewModel.swift @@ -53,7 +53,7 @@ class PlayerViewModel: PlayerStateProtocol { self.isPlaying = ctx.isPlaying self.trackName = ctx.item.name self.artistName = ctx.item.artists[0].name - self.artworkURL = ctx.item.album.getArtworkURL() + self.artworkURL = ctx.item.album.getImageURL(.medium) self.durationMs = ctx.item.durationMs self.progressMs = ctx.progressMs } diff --git a/Spottie/Views/Components/CarouselRow.swift b/Spottie/Views/Components/CarouselRow.swift index 3accb22..ef28056 100644 --- a/Spottie/Views/Components/CarouselRow.swift +++ b/Spottie/Views/Components/CarouselRow.swift @@ -9,8 +9,9 @@ import SwiftUI struct CarouselRow: View { let viewModel: ViewModel - let onItemPressed: (String) -> Void let numItemsToShow: Int + let onItemTapped: (String) -> Void + let onItemPlayButtonTapped: (String) -> Void var body: some View { VStack(alignment: .leading) { @@ -24,27 +25,62 @@ struct CarouselRow: View { .padding(.leading) .padding(.bottom, 8) }) - HStack(alignment: .top, spacing: 40) { - ForEach(viewModel.getItemsToShow(requestedNumToShow: numItemsToShow)) { item in - CarouselRowItem( - vm: item, - onPlayButtonPressed: { - onItemPressed(item.id) - }) - .onTapGesture { - print("unimplemented") - } + + if viewModel.items.isEmpty { + HStack { + Text("No items to show") + .foregroundColor(.secondary) } + .padding() + } + + switch viewModel.type { + case .grid: + HStack(alignment: .top, spacing: 40) { + ForEach(viewModel.getItemsToShow(requestedNumToShow: numItemsToShow)) { item in + CarouselRowItem( + vm: item, + onPlayButtonPressed: { + onItemPlayButtonTapped(item.uri) + }) + .onTapGesture { + onItemTapped(item.uri) + } + } + } + .padding([.leading, .trailing]) + case .shortcuts: + ShortcutGrid( + items: viewModel.items, + onItemPlayButtonTapped: onItemPlayButtonTapped, + onItemTapped: onItemTapped + ) + .padding([.leading, .trailing]) + case .trackList: + LazyVStack { + ForEach(viewModel.getItemsToShow(requestedNumToShow: 8)) { item in + TrackListItem(viewModel: item) + .onTapGesture(count: 2) { + onItemPlayButtonTapped(item.uri) + } + } + } + .padding([.leading, .trailing]) } - .padding([.leading, .trailing]) } - } } extension CarouselRow { + enum RowType { + case shortcuts + case trackList + case grid + } + struct ViewModel: Identifiable { var id: String + var type: RowType var title: String var subtitle: String? var items: [CarouselRowItem.ViewModel] diff --git a/Spottie/Views/Components/CarouselRowItem.swift b/Spottie/Views/Components/CarouselRowItem.swift index 97147c5..c499b9a 100644 --- a/Spottie/Views/Components/CarouselRowItem.swift +++ b/Spottie/Views/Components/CarouselRowItem.swift @@ -42,9 +42,11 @@ struct CarouselRowItem: View { .foregroundColor(.primary) .font(.headline) .padding(.vertical, 4) - Text(vm.subtitle) - .lineLimit(2) - .foregroundColor(.secondary) + vm.subtitle.map { + Text($0) + .lineLimit(2) + .foregroundColor(.secondary) + } } .frame(maxWidth: 200) .padding() @@ -62,10 +64,12 @@ struct CarouselRowItem: View { extension CarouselRowItem { struct ViewModel: Identifiable { var id: String + var uri: String var title: String - var subtitle: String + var subtitle: String? var artworkURL: URL var artworkIsCircle = false + var duration: Int? // for track list items only: is there a better way? } } diff --git a/Spottie/Views/Components/ShortcutGrid.swift b/Spottie/Views/Components/ShortcutGrid.swift index 30f4433..0fd6aea 100644 --- a/Spottie/Views/Components/ShortcutGrid.swift +++ b/Spottie/Views/Components/ShortcutGrid.swift @@ -10,29 +10,26 @@ import SDWebImageSwiftUI struct ShortcutGrid: View { let items: [CarouselRowItem.ViewModel] - let onItemPressed: (String) -> Void + let onItemPlayButtonTapped: (String) -> Void + let onItemTapped: (String) -> Void var gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 20), count: 3) var body: some View { - VStack(alignment: .leading) { - Text("Good Morning") - .font(.largeTitle).bold() - .padding(.leading) - LazyVGrid(columns: gridItemLayout, spacing: 20) { - ForEach(items) { item in - ShortcutItem( - itemHeight: 80, - viewModel: item, - onPlayButtonPressed: { - onItemPressed(item.id) - } - ) - .frame(height: 80) + LazyVGrid(columns: gridItemLayout, spacing: 20) { + ForEach(items) { item in + ShortcutItem( + itemHeight: 80, + viewModel: item, + onPlayButtonPressed: { + onItemPlayButtonTapped(item.uri) + } + ) + .frame(height: 80) + .onTapGesture { + onItemTapped(item.uri) } } - .padding([.leading, .trailing]) } - } } diff --git a/Spottie/Views/Components/TrackListItem.swift b/Spottie/Views/Components/TrackListItem.swift new file mode 100644 index 0000000..a2bcb23 --- /dev/null +++ b/Spottie/Views/Components/TrackListItem.swift @@ -0,0 +1,77 @@ +// +// TrackListItem.swift +// Spottie +// +// Created by Lee Jun Kit on 24/6/21. +// + +import SwiftUI +import SDWebImageSwiftUI + +struct TrackListItem: View { + let viewModel: CarouselRowItem.ViewModel + @ObservedObject private var hoverState = HoverState() + + var body: some View { + HStack { + WebImage(url: viewModel.artworkURL) + .resizable() + .aspectRatio(1.0, contentMode: .fit) + VStack(alignment: .leading) { + Text(viewModel.title) + viewModel.subtitle.map { subtitle in + Text(subtitle) + .foregroundColor(.secondary) + } + } + Spacer() + viewModel.duration.map { + Text(DurationFormatter.shared.format(TimeInterval($0))) + .foregroundColor(.secondary) + } + } + .frame(height: 44) + .padding([.leading, .trailing]) + .padding([.top, .bottom], 4) + .background( + Color(NSColor.alternatingContentBackgroundColors[1]) + .opacity(hoverState.isHovering ? 1.0 : 0.3) + .animation(.linear(duration: 0.15)) + ) + .onHover { isHovered in + hoverState.setIsHovering(isHovered) + } + } +} + +extension TrackListItem { + class DurationFormatter { + static let shared = DurationFormatter() + + private let formatter: DateComponentsFormatter + private init() { + formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .positional + } + + func format(_ from: TimeInterval) -> String { + return formatter.string(from: from)! + } + } +} + +struct TrackListItem_Previews: PreviewProvider { + static let viewModel = CarouselRowItem.ViewModel( + id: "abc", + uri: "abc", + title: "Hello World", + subtitle: "Artist 1, Artist 2", + artworkURL: URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")!, + duration: 240 + ) + static var previews: some View { + TrackListItem(viewModel: viewModel) + .frame(height: 44) + } +} diff --git a/Spottie/Views/Home.swift b/Spottie/Views/Home.swift index 7230ccb..b122b83 100644 --- a/Spottie/Views/Home.swift +++ b/Spottie/Views/Home.swift @@ -22,6 +22,14 @@ struct Home: View { .eraseToAnyPublisher() } + func onItemTapped(id: String) -> Void { + + } + + func onItemPlayButtonTapped(id: String) -> Void { + viewModel.load(id) + } + var body: some View { GeometryReader { reader in let numItemsToShow = numberOfItemsToShowInRow(reader) @@ -29,27 +37,27 @@ struct Home: View { ScrollView(.vertical) { if searchText.isEmpty { LazyVStack(alignment: .leading) { + Text("Good Morning") + .font(.largeTitle).bold() + .padding(.leading) ForEach(viewModel.rowViewModels) { vm in - if vm.id == "shortcuts" { - ShortcutGrid( - items: vm.items, - onItemPressed: viewModel.load - ) - .padding() - } else if vm.items.count > 0 { + if !vm.items.isEmpty { CarouselRow( viewModel: vm, - onItemPressed: viewModel.load, - numItemsToShow: numItemsToShow + numItemsToShow: numItemsToShow, + onItemTapped: onItemTapped, + onItemPlayButtonTapped: onItemPlayButtonTapped ) - .padding() + .padding() } } } } else { Search( viewModel: Search.ViewModel(searchTermPublisher: debouncedPublisher), - numItemsPerRow: numItemsToShow + numItemsPerRow: numItemsToShow, + onItemTapped: onItemTapped, + onItemPlayButtonTapped: onItemPlayButtonTapped ) } } @@ -86,6 +94,7 @@ extension Home { if group.id.hasPrefix("podcast") { return CarouselRow.ViewModel( id: group.id, + type: .grid, title: group.name, subtitle: group.tagline ?? "", items: [] @@ -94,6 +103,7 @@ extension Home { let items = group.items.map { item -> CarouselRowItem.ViewModel in let id = item.id + var uri = "" var title = "" var subtitle = "" var artworkURL = URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")! @@ -102,18 +112,21 @@ extension Home { if let data = item.data { switch data { case let .album(album): + uri = album.uri title = album.name - subtitle = album.artists[0].name - artworkURL = album.getArtworkURL() + subtitle = album.artists.map({$0.name}).joined(separator: ", ") + artworkURL = album.getImageURL(.large) case let .artist(artist): + uri = artist.uri title = artist.name subtitle = "Artist" - artworkURL = artist.getArtworkURL() + artworkURL = artist.getImageURL(.large) artworkIsCircle = true case let .playlist(playlist): + uri = playlist.uri title = playlist.name - subtitle = playlist.description ?? "" - artworkURL = playlist.getArtworkURL() + subtitle = playlist.description ?? "\(playlist.tracks.total) tracks, by \(playlist.owner.displayName ?? "an unnamed user")" + artworkURL = playlist.getImageURL(.large) case let .link(link): title = link.name subtitle = "" @@ -123,6 +136,7 @@ extension Home { return CarouselRowItem.ViewModel( id: id, + uri: uri, title: title, subtitle: subtitle, artworkURL: artworkURL, @@ -132,6 +146,7 @@ extension Home { let vm = CarouselRow.ViewModel( id: group.id, + type: group.id == "shortcuts" ? .shortcuts : .grid, title: group.name, subtitle: group.tagline ?? "", items: items diff --git a/Spottie/Views/Search.swift b/Spottie/Views/Search.swift index c9692e5..578864d 100644 --- a/Spottie/Views/Search.swift +++ b/Spottie/Views/Search.swift @@ -11,13 +11,18 @@ import Combine struct Search: View { @StateObject var viewModel: ViewModel var numItemsPerRow: Int + let onItemTapped: (String) -> Void + let onItemPlayButtonTapped: (String) -> Void var body: some View { LazyVStack(alignment: .leading) { ForEach(viewModel.results) { vm in - CarouselRow(viewModel: vm, onItemPressed: { id in - - }, numItemsToShow: numItemsPerRow) + CarouselRow( + viewModel: vm, + numItemsToShow: numItemsPerRow, + onItemTapped: onItemTapped, + onItemPlayButtonTapped: onItemPlayButtonTapped + ) } } } @@ -33,7 +38,7 @@ extension Search { init(searchTermPublisher: AnyPublisher) { searchTermPublisher - .debounce(for: 0.5, scheduler: DispatchQueue.main) + .debounce(for: 0.3, scheduler: DispatchQueue.main) .map { SpotifyAPI.search($0) .replaceError(with: nil) @@ -42,16 +47,38 @@ extension Search { .receive(on: DispatchQueue.main) .sink { response in if let r = response { + let tracksRow = CarouselRow.ViewModel( + id: "tracks", + type: .trackList, + title: "Tracks", + subtitle: nil, + items: r.tracks.items.map { track in + let id = track.id + let title = track.name + let subtitle = track.artists.map({$0.name}).joined(separator: ", ") + let artworkURL = track.album.getImageURL(.small) + return CarouselRowItem.ViewModel( + id: id, + uri: track.uri, + title: title, + subtitle: subtitle, + artworkURL: artworkURL, + duration: track.durationMs / 1000 + ) + }) + let artistsRow = CarouselRow.ViewModel( id: "artists", + type: .grid, title: "Artists", subtitle: nil, items: r.artists.items.map { artist in let id = artist.id let title = artist.name - let artworkURL = artist.getArtworkURL() + let artworkURL = artist.getImageURL(.large) return CarouselRowItem.ViewModel( id: id, + uri: artist.uri, title: title, subtitle: "Artist", artworkURL: artworkURL, @@ -61,15 +88,17 @@ extension Search { let albumsRow = CarouselRow.ViewModel( id: "albums", + type: .grid, title: "Albums", subtitle: nil, items: r.albums.items.map { album in let id = album.id let title = album.name - let subtitle = album.artists[0].name - let artworkURL = album.getArtworkURL() + let subtitle = album.artists.map({$0.name}).joined(separator: ", ") + let artworkURL = album.getImageURL(.large) return CarouselRowItem.ViewModel( id: id, + uri: album.uri, title: title, subtitle: subtitle, artworkURL: artworkURL @@ -78,22 +107,24 @@ extension Search { let playlistsRow = CarouselRow.ViewModel( id: "playlists", + type: .grid, title: "Playlists", subtitle: nil, items: r.playlists.items.map { playlist in let id = playlist.id let title = playlist.name let subtitle = "\(playlist.tracks.total) tracks, by \(playlist.owner.displayName ?? "an unnamed user")" - let artworkURL = playlist.getArtworkURL() + let artworkURL = playlist.getImageURL(.large) return CarouselRowItem.ViewModel( id: id, + uri: playlist.uri, title: title, subtitle: subtitle, artworkURL: artworkURL ) }) - self.results = [artistsRow, albumsRow, playlistsRow] + self.results = [tracksRow, artistsRow, albumsRow, playlistsRow] } } .store(in: &cancellables) @@ -104,6 +135,11 @@ extension Search { struct Search_Previews: PreviewProvider { static let vm = Search.ViewModel(searchTermPublisher: Just("Hello").eraseToAnyPublisher()) static var previews: some View { - Search(viewModel: vm, numItemsPerRow: 4) + Search( + viewModel: vm, + numItemsPerRow: 4, + onItemTapped: { id in }, + onItemPlayButtonTapped: { id in } + ) } }