diff --git a/Spottie.xcodeproj/project.pbxproj b/Spottie.xcodeproj/project.pbxproj index 0b879d2..9b080a5 100644 --- a/Spottie.xcodeproj/project.pbxproj +++ b/Spottie.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ 475EE24A267DA451007BEBDC /* GreenPlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475EE249267DA451007BEBDC /* GreenPlayButton.swift */; }; 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 */; }; 47C56F602679A789003EA20A /* PlayerCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C56F5F2679A789003EA20A /* PlayerCommands.swift */; }; /* End PBXBuildFile section */ @@ -147,6 +148,7 @@ 475EE249267DA451007BEBDC /* GreenPlayButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GreenPlayButton.swift; sourceTree = ""; }; 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 = ""; }; 47C56F5F2679A789003EA20A /* PlayerCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCommands.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -320,6 +322,7 @@ 475EE243267D6659007BEBDC /* ShortcutItem.swift */, 475EE247267D7784007BEBDC /* SearchField.swift */, 475EE249267DA451007BEBDC /* GreenPlayButton.swift */, + 4764B39C26833B9900AE471E /* HoverState.swift */, ); path = Components; sourceTree = ""; @@ -483,6 +486,7 @@ 4730616526565841001E3A1F /* Library.swift in Sources */, 4730619926591F92001E3A1F /* VolumeChangedEvent.swift in Sources */, 4730619726591F14001E3A1F /* MetadataAvailableEvent.swift in Sources */, + 4764B39D26833B9900AE471E /* HoverState.swift in Sources */, 4730617B265903A6001E3A1F /* EventBroker.swift in Sources */, 470201CA265CF9380030ECA9 /* ShuffleButton.swift in Sources */, 4730619326591EE1001E3A1F /* TrackChangedEvent.swift in Sources */, diff --git a/Spottie/Views/Components/CarouselRow.swift b/Spottie/Views/Components/CarouselRow.swift index a5316fb..3accb22 100644 --- a/Spottie/Views/Components/CarouselRow.swift +++ b/Spottie/Views/Components/CarouselRow.swift @@ -10,63 +10,51 @@ import SwiftUI struct CarouselRow: View { let viewModel: ViewModel let onItemPressed: (String) -> Void + let numItemsToShow: Int var body: some View { - if viewModel.groupID.hasPrefix("podcast") { - EmptyView() - } else { - VStack(alignment: .leading) { - Text(viewModel.title) - .font(.title).bold() + VStack(alignment: .leading) { + Text(viewModel.title) + .font(.title).bold() + .padding(.leading) + viewModel.subtitle.map({ + Text($0) + .font(.body) + .foregroundColor(.secondary) .padding(.leading) - viewModel.subtitle.map({ - Text($0) - .font(.body) - .foregroundColor(.secondary) - .padding(.leading) - .padding(.bottom, 8) - }) - HStack(alignment: .top, spacing: 40) { - ForEach(viewModel.items) { item in - let vm = CarouselRowItem.ViewModel.init(item) - CarouselRowItem(viewModel: vm, onPlayButtonPressed: { + .padding(.bottom, 8) + }) + HStack(alignment: .top, spacing: 40) { + ForEach(viewModel.getItemsToShow(requestedNumToShow: numItemsToShow)) { item in + CarouselRowItem( + vm: item, + onPlayButtonPressed: { onItemPressed(item.id) }) - .onTapGesture { - - } - } + .onTapGesture { + print("unimplemented") + } } - .padding([.leading, .trailing]) } + .padding([.leading, .trailing]) } } } extension CarouselRow { - enum RenderMode { - case circular - case normal - } - - class ViewModel: ObservableObject { - var groupID: String + struct ViewModel: Identifiable { + var id: String var title: String var subtitle: String? - var items: [RecommendationItem] + var items: [CarouselRowItem.ViewModel] - init(_ recommendationGroup: RecommendationGroup, numberOfItemsToShow: Int) { - self.groupID = recommendationGroup.id - - title = recommendationGroup.name - subtitle = recommendationGroup.tagline - - let numItems = recommendationGroup.items.count - if (numItems < numberOfItemsToShow) { - items = recommendationGroup.items + func getItemsToShow(requestedNumToShow: Int) -> [CarouselRowItem.ViewModel] { + let numItems = items.count + if (numItems < requestedNumToShow) { + return items } else { - items = Array(recommendationGroup.items[0...numberOfItemsToShow - 1]) + return Array(items[0...requestedNumToShow - 1]) } } } diff --git a/Spottie/Views/Components/CarouselRowItem.swift b/Spottie/Views/Components/CarouselRowItem.swift index 9a9a751..97147c5 100644 --- a/Spottie/Views/Components/CarouselRowItem.swift +++ b/Spottie/Views/Components/CarouselRowItem.swift @@ -9,21 +9,22 @@ import SwiftUI import SDWebImageSwiftUI struct CarouselRowItem: View { - @ObservedObject var viewModel: ViewModel + var vm: ViewModel var onPlayButtonPressed: () -> Void + @ObservedObject private var hoverState = HoverState() var body: some View { VStack(alignment: .leading) { ZStack(alignment: .bottomTrailing) { - if viewModel.artworkIsCircle { - WebImage(url: viewModel.artworkURL) + if vm.artworkIsCircle { + WebImage(url: vm.artworkURL) .resizable() .aspectRatio(1.0, contentMode: .fill) .clipShape(Circle()) .overlay(Circle().stroke(Color.white, lineWidth: 4.0)) .shadow(radius: 7) } else { - WebImage(url: viewModel.artworkURL) + WebImage(url: vm.artworkURL) .resizable() .aspectRatio(1.0, contentMode: .fill) .cornerRadius(5) @@ -32,16 +33,16 @@ struct CarouselRowItem: View { onPlayButtonPressed() } .offset(x: -16, y: -16) - .opacity(viewModel.isHovering ? 1.0 : 0.0) + .opacity(hoverState.isHovering ? 1.0 : 0.0) .animation(.linear(duration: 0.1)) } - Text(viewModel.title) + Text(vm.title) .lineLimit(1) .foregroundColor(.primary) .font(.headline) .padding(.vertical, 4) - Text(viewModel.subtitle) + Text(vm.subtitle) .lineLimit(2) .foregroundColor(.secondary) } @@ -49,50 +50,22 @@ struct CarouselRowItem: View { .padding() .background( Color(NSColor.alternatingContentBackgroundColors[1]) - .opacity(viewModel.isHovering ? 1.0 : 0.3) + .opacity(hoverState.isHovering ? 1.0 : 0.3) .animation(.linear(duration: 0.15)) ) .onHover { isHovered in - viewModel.isHovering = isHovered + hoverState.setIsHovering(isHovered) } } } extension CarouselRowItem { - class ViewModel: ObservableObject { - @Published var title: String - @Published var subtitle: String - @Published var artworkURL: URL - @Published var isHovering = false - @Published var artworkIsCircle = 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" - self.artworkURL = URL(string: artist.images[0].url)! - self.artworkIsCircle = true - 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://misc.scdn.co/liked-songs/liked-songs-640.png")! - } - } else { - self.title = "" - self.subtitle = "" - self.artworkURL = URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")! - } - } + struct ViewModel: Identifiable { + var id: String + var title: String + var subtitle: String + var artworkURL: URL + var artworkIsCircle = false } } diff --git a/Spottie/Views/Components/HoverState.swift b/Spottie/Views/Components/HoverState.swift new file mode 100644 index 0000000..bcb948f --- /dev/null +++ b/Spottie/Views/Components/HoverState.swift @@ -0,0 +1,15 @@ +// +// HoverState.swift +// Spottie +// +// Created by Lee Jun Kit on 23/6/21. +// + +import SwiftUI + +class HoverState: ObservableObject { + @Published var isHovering = false + func setIsHovering(_ isHovering: Bool) { + self.isHovering = isHovering + } +} diff --git a/Spottie/Views/Components/ShortcutGrid.swift b/Spottie/Views/Components/ShortcutGrid.swift index 9625605..30f4433 100644 --- a/Spottie/Views/Components/ShortcutGrid.swift +++ b/Spottie/Views/Components/ShortcutGrid.swift @@ -9,7 +9,7 @@ import SwiftUI import SDWebImageSwiftUI struct ShortcutGrid: View { - let items: [RecommendationItem] + let items: [CarouselRowItem.ViewModel] let onItemPressed: (String) -> Void var gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 20), count: 3) @@ -22,7 +22,7 @@ struct ShortcutGrid: View { ForEach(items) { item in ShortcutItem( itemHeight: 80, - viewModel: ShortcutItem.ViewModel(item), + viewModel: item, onPlayButtonPressed: { onItemPressed(item.id) } diff --git a/Spottie/Views/Components/ShortcutItem.swift b/Spottie/Views/Components/ShortcutItem.swift index c7cf911..090598c 100644 --- a/Spottie/Views/Components/ShortcutItem.swift +++ b/Spottie/Views/Components/ShortcutItem.swift @@ -10,10 +10,9 @@ import SDWebImageSwiftUI struct ShortcutItem: View { var itemHeight: CGFloat - var viewModel: ViewModel + var viewModel: CarouselRowItem.ViewModel var onPlayButtonPressed: () -> Void - - @State private var isHovering = false + @ObservedObject private var hoverState = HoverState() var body: some View { HStack { @@ -29,45 +28,16 @@ struct ShortcutItem: View { onPress: onPlayButtonPressed ) .padding(.trailing) - .opacity(isHovering ? 1.0 : 0.0) + .opacity(hoverState.isHovering ? 1.0 : 0.0) .animation(.linear(duration: 0.1)) } .background( Color(NSColor.alternatingContentBackgroundColors[1]) - .opacity(isHovering ? 1.0 : 0.3) + .opacity(hoverState.isHovering ? 1.0 : 0.3) .animation(.linear(duration: 0.1)) ) .onHover { hovering in - isHovering = hovering - } - } -} - -extension ShortcutItem { - class ViewModel { - var title: String - var artworkURL: URL - - init(_ item: RecommendationItem) { - if let data = item.data { - switch data { - case let .album(album): - self.title = album.name - self.artworkURL = album.getArtworkURL()! - case let .artist(artist): - self.title = artist.name - self.artworkURL = URL(string: artist.images[0].url)! - case let.playlist(playlist): - self.title = playlist.name - self.artworkURL = URL(string: playlist.images[0].url)! - case let .link(link): - self.title = link.name - self.artworkURL = URL(string: link.images[0].url)! - } - } else { - self.title = "Unknown" - self.artworkURL = URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")! - } + hoverState.setIsHovering(hovering) } } } diff --git a/Spottie/Views/Home.swift b/Spottie/Views/Home.swift index ad89ae6..ef80b84 100644 --- a/Spottie/Views/Home.swift +++ b/Spottie/Views/Home.swift @@ -27,21 +27,19 @@ struct Home: View { ScrollView(.vertical) { if searchText.isEmpty { LazyVStack(alignment: .leading) { - ForEach(viewModel.recommendationGroups) { group in - if group.id == "shortcuts" { + ForEach(viewModel.rowViewModels) { vm in + if vm.id == "shortcuts" { ShortcutGrid( - items: group.items, + items: vm.items, onItemPressed: viewModel.load ) .padding() - } else if group.items.count > 0 { - let vm = CarouselRow.ViewModel.init(group, - numberOfItemsToShow: numberOfItemsToShowInRow(reader) - ) - + } else if vm.items.count > 0 { + let numItemsToShow = numberOfItemsToShowInRow(reader) CarouselRow( viewModel: vm, - onItemPressed: viewModel.load + onItemPressed: viewModel.load, + numItemsToShow: numItemsToShow ) .padding() } @@ -74,11 +72,69 @@ struct Home: View { extension Home { class ViewModel: ObservableObject { - @Published var recommendationGroups: [RecommendationGroup] = [] + @Published var rowViewModels: [CarouselRow.ViewModel] = [] private var cancellables = [AnyCancellable]() + init() { SpotifyAPI.getPersonalizedRecommendations().sink { _ in } receiveValue: { response in - self.recommendationGroups = response!.content.items + let recommendationGroups = response!.content.items + self.rowViewModels = recommendationGroups.map { group in + if group.id.hasPrefix("podcast") { + return CarouselRow.ViewModel( + id: group.id, + title: group.name, + subtitle: group.tagline ?? "", + items: [] + ) + } + + let items = group.items.map { item -> CarouselRowItem.ViewModel in + let id = item.id + var title = "" + var subtitle = "" + var artworkURL = URL(string: "https://misc.scdn.co/liked-songs/liked-songs-640.png")! + var artworkIsCircle = false + + if let data = item.data { + switch data { + case let .album(album): + title = album.name + subtitle = album.artists[0].name + artworkURL = album.getArtworkURL()! + case let .artist(artist): + title = artist.name + subtitle = "Artist" + artworkURL = URL(string: artist.images[0].url)! + artworkIsCircle = true + case let .playlist(playlist): + title = playlist.name + subtitle = playlist.description ?? "" + artworkURL = URL(string: playlist.images[0].url)! + case let .link(link): + title = link.name + subtitle = "" + artworkURL = URL(string: link.images[0].url)! + } + } + + return CarouselRowItem.ViewModel( + id: id, + title: title, + subtitle: subtitle, + artworkURL: artworkURL, + artworkIsCircle: artworkIsCircle + ) + } + + let vm = CarouselRow.ViewModel( + id: group.id, + title: group.name, + subtitle: group.tagline ?? "", + items: items + ) + + return vm + } }.store(in: &cancellables) } diff --git a/Spottie/Views/Search.swift b/Spottie/Views/Search.swift index 721129f..15e808b 100644 --- a/Spottie/Views/Search.swift +++ b/Spottie/Views/Search.swift @@ -35,6 +35,7 @@ extension Search { } .switchToLatest() .receive(on: DispatchQueue.main) + .assign(to: &$results) } }