1
0
mirror of https://github.com/yattee/yattee.git synced 2024-12-13 13:50:32 +05:30

Add infinite scroll for search (fixes #5)

This commit is contained in:
Arkadiusz Fal 2022-01-05 00:18:01 +01:00
parent 3326088081
commit ea6363ba65
14 changed files with 145 additions and 47 deletions

View File

@ -91,17 +91,19 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
content.json.arrayValue.map {
let type = $0.dictionaryValue["type"]?.stringValue
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
let results = content.json.arrayValue.compactMap { json -> ContentItem in
let type = json.dictionaryValue["type"]?.stringValue
if type == "channel" {
return ContentItem(channel: self.extractChannel(from: $0))
return ContentItem(channel: self.extractChannel(from: json))
} else if type == "playlist" {
return ContentItem(playlist: self.extractChannelPlaylist(from: $0))
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
}
return ContentItem(video: self.extractVideo(from: $0))
return ContentItem(video: self.extractVideo(from: json))
}
return SearchPage(results: results, last: results.isEmpty)
}
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
@ -238,7 +240,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
}
func search(_ query: SearchQuery) -> Resource {
func search(_ query: SearchQuery, page: String?) -> Resource {
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
@ -252,6 +254,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource = resource.withParam("duration", duration.rawValue)
}
if let page = page {
resource = resource.withParam("page", page)
}
return resource
}

View File

@ -51,8 +51,13 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
self.extractVideos(from: content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
self.extractContentItems(from: content.json.dictionaryValue["items"]!)
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
return SearchPage(
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
nextPage: nextPage,
last: nextPage == "null"
)
}
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
@ -123,10 +128,18 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
.withParam("region", country.rawValue)
}
func search(_ query: SearchQuery) -> Resource {
resource(baseURL: account.instance.apiURL, path: "search")
func search(_ query: SearchQuery, page: String?) -> Resource {
let path = page.isNil ? "search" : "nextpage/search"
let resource = resource(baseURL: account.instance.apiURL, path: path)
.withParam("q", query.query)
.withParam("filter", "")
.withParam("filter", "all")
if page.isNil {
return resource
}
return resource.withParam("nextpage", page)
}
func searchSuggestions(query: String) -> Resource {

View File

@ -9,7 +9,7 @@ protocol VideosAPI {
func channel(_ id: String) -> Resource
func channelVideos(_ id: String) -> Resource
func trending(country: Country, category: TrendingCategory?) -> Resource
func search(_ query: SearchQuery) -> Resource
func search(_ query: SearchQuery, page: String?) -> Resource
func searchSuggestions(query: String) -> Resource
func video(_ id: Video.ID) -> Resource

View File

@ -42,4 +42,8 @@ enum VideosApp: String, CaseIterable {
var supportsComments: Bool {
self == .piped
}
var searchUsesIndexedPages: Bool {
self == .invidious
}
}

View File

@ -4,6 +4,7 @@ import SwiftUI
final class SearchModel: ObservableObject {
@Published var store = Store<[ContentItem]>()
@Published var page: SearchPage?
var accounts = AccountsModel()
@Published var query = SearchQuery()
@ -13,7 +14,6 @@ final class SearchModel: ObservableObject {
@Published var fieldIsFocused = false
private var previousResource: Resource?
private var resource: Resource!
var isLoading: Bool {
@ -23,60 +23,54 @@ final class SearchModel: ObservableObject {
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
let newResource = accounts.api.search(query, page: nil)
guard newResource != resource else {
return
}
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
page = nil
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
loadResource()
}
}
func resetQuery(_ query: SearchQuery = SearchQuery()) {
self.query = query
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
let newResource = accounts.api.search(query, page: nil)
guard newResource != resource else {
return
}
page = nil
store.replace([])
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
loadResource()
}
}
func loadResourceIfNeededAndReplaceStore() {
func loadResource() {
let currentResource = resource!
if let request = resource.loadIfNeeded() {
request.onSuccess { response in
if let results: [ContentItem] = response.typedContent() {
self.replace(results, for: currentResource)
}
resource.load().onSuccess { response in
if let page: SearchPage = response.typedContent() {
self.page = page
self.replace(page.results, for: currentResource)
}
} else {
replace(store.collection, for: currentResource)
}
}
func replace(_ videos: [ContentItem], for resource: Resource) {
func replace(_ items: [ContentItem], for resource: Resource) {
if self.resource == resource {
store = Store<[ContentItem]>(videos)
store = Store<[ContentItem]>(items)
}
}
@ -108,4 +102,38 @@ final class SearchModel: ObservableObject {
}
}
}
func loadNextPage() {
guard var pageToLoad = page, !pageToLoad.last else {
return
}
if pageToLoad.nextPage.isNil, accounts.app.searchUsesIndexedPages {
pageToLoad.nextPage = "2"
}
resource?.removeObservers(ownedBy: store)
resource = accounts.api.search(query, page: page?.nextPage)
resource.addObserver(store)
resource
.load()
.onSuccess { response in
if let page: SearchPage = response.typedContent() {
var nextPage: Int?
if self.accounts.app.searchUsesIndexedPages {
nextPage = Int(pageToLoad.nextPage ?? "0")
}
self.page = page
if self.accounts.app.searchUsesIndexedPages {
self.page?.nextPage = String((nextPage ?? 1) + 1)
}
self.replace(self.store.collection + page.results, for: self.resource)
}
}
}
}

View File

@ -0,0 +1,7 @@
import Foundation
struct SearchPage {
var results = [ContentItem]()
var nextPage: String?
var last = false
}

View File

@ -61,11 +61,8 @@ final class SearchQuery: ObservableObject {
@Published var date: SearchQuery.Date? = .month
@Published var duration: SearchQuery.Duration?
@Published var page = 1
init(query: String = "", page: Int = 1, sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) {
init(query: String = "", sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) {
self.query = query
self.page = page
self.sortBy = sortBy
self.date = date
self.duration = duration

View File

@ -30,8 +30,7 @@ extension Defaults.Keys {
.init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")),
.init(section: .channel("UCXuqSBlHAE6Xw-yeJA0Tunw", "Linus Tech Tips")),
.init(section: .channel("UCBJycsmduvYEL83R_U4JriQ", "Marques Brownlee")),
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple")),
.init(section: .searchQuery("Apple Pie Recipes", "", "", ""))
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple"))
])
#if !os(tvOS)

View File

@ -29,6 +29,12 @@ private struct CurrentPlaylistID: EnvironmentKey {
static let defaultValue: String? = nil
}
private struct LoadMoreContentHandler: EnvironmentKey {
static let defaultValue: LoadMoreContentHandlerClosure = { print("infinite load") }
}
typealias LoadMoreContentHandlerClosure = () -> Void
extension EnvironmentValues {
var inNavigationView: Bool {
get { self[InNavigationViewKey.self] }
@ -59,4 +65,9 @@ extension EnvironmentValues {
get { self[CurrentPlaylistID.self] }
set { self[CurrentPlaylistID.self] = newValue }
}
var loadMoreContentHandler: LoadMoreContentHandlerClosure {
get { self[LoadMoreContentHandler.self] }
set { self[LoadMoreContentHandler.self] = newValue }
}
}

View File

@ -113,12 +113,15 @@ struct FavoriteItemView: View {
return accounts.api.playlist(id)
case let .searchQuery(text, date, duration, order):
return accounts.api.search(.init(
query: text,
sortBy: SearchQuery.SortOrder(rawValue: order) ?? .uploadDate,
date: SearchQuery.Date(rawValue: date),
duration: SearchQuery.Duration(rawValue: duration)
))
return accounts.api.search(
.init(
query: text,
sortBy: SearchQuery.SortOrder(rawValue: order) ?? .uploadDate,
date: SearchQuery.Date(rawValue: date),
duration: SearchQuery.Duration(rawValue: duration)
),
page: nil
)
}
return nil

View File

@ -199,10 +199,12 @@ struct SearchView: View {
}
HorizontalCells(items: items)
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
}
.edgesIgnoringSafeArea(.horizontal)
#else
VerticalCells(items: items)
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
#endif
if noResults {

View File

@ -4,6 +4,8 @@ import SwiftUI
struct HorizontalCells: View {
var items = [ContentItem]()
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
@Default(.channelOnThumbnail) private var channelOnThumbnail
var body: some View {
@ -12,6 +14,7 @@ struct HorizontalCells: View {
ForEach(items) { item in
ContentItemView(item: item)
.environment(\.horizontalCells, true)
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
#if os(tvOS)
.frame(width: 580)
.padding(.trailing, 20)
@ -33,6 +36,13 @@ struct HorizontalCells: View {
.edgesIgnoringSafeArea(.horizontal)
}
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
loadMoreContentHandler()
}
}
var cellHeight: Double {
#if os(tvOS)
560

View File

@ -6,6 +6,8 @@ struct VerticalCells: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
var items = [ContentItem]()
var body: some View {
@ -13,6 +15,7 @@ struct VerticalCells: View {
LazyVGrid(columns: columns, alignment: .center) {
ForEach(items.sorted { $0 < $1 }) { item in
ContentItemView(item: item)
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
}
}
.padding()
@ -24,6 +27,13 @@ struct VerticalCells: View {
#endif
}
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
loadMoreContentHandler()
}
}
var columns: [GridItem] {
#if os(tvOS)
items.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [items.count, 1].max()!) : adaptiveItem

View File

@ -174,6 +174,9 @@
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3751B4B127836902000B7DF4 /* SearchPage.swift */; };
3751B4B327836902000B7DF4 /* SearchPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3751B4B127836902000B7DF4 /* SearchPage.swift */; };
3751B4B427836902000B7DF4 /* SearchPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3751B4B127836902000B7DF4 /* SearchPage.swift */; };
3758638A2721B0A9000CB14E /* ChannelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743B86727216D3600261544 /* ChannelCell.swift */; };
37599F30272B42810087F250 /* FavoriteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37599F2F272B42810087F250 /* FavoriteItem.swift */; };
37599F31272B42810087F250 /* FavoriteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37599F2F272B42810087F250 /* FavoriteItem.swift */; };
@ -648,6 +651,7 @@
374C0542272496E4009BDDBE /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = macOS/AppDelegate.swift; sourceTree = SOURCE_ROOT; };
374C0544272496FD009BDDBE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
375168D52700FAFF008F96A6 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = "<group>"; };
3751B4B127836902000B7DF4 /* SearchPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPage.swift; sourceTree = "<group>"; };
37599F2F272B42810087F250 /* FavoriteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItem.swift; sourceTree = "<group>"; };
37599F33272B44000087F250 /* FavoritesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesModel.swift; sourceTree = "<group>"; };
37599F37272B4D740087F250 /* FavoriteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteButton.swift; sourceTree = "<group>"; };
@ -1341,6 +1345,7 @@
isa = PBXGroup;
children = (
3711403E26B206A6005B3555 /* SearchModel.swift */,
3751B4B127836902000B7DF4 /* SearchPage.swift */,
373CFACA26966264003CB2C6 /* SearchQuery.swift */,
);
path = Search;
@ -1868,6 +1873,7 @@
37599F34272B44000087F250 /* FavoritesModel.swift in Sources */,
37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */,
377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */,
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */,
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
@ -2035,6 +2041,7 @@
37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */,
37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */,
3765788A2685471400D4EA09 /* Playlist.swift in Sources */,
3751B4B327836902000B7DF4 /* SearchPage.swift in Sources */,
3782B9532755667600990149 /* String+Format.swift in Sources */,
37E2EEAC270656EC00170416 /* PlayerControlsView.swift in Sources */,
37BF662027308884008CCFB0 /* DropFavoriteOutside.swift in Sources */,
@ -2262,6 +2269,7 @@
376BE50827347B57009AD608 /* SettingsHeader.swift in Sources */,
37A9966026D6F9B9006E3224 /* FavoritesView.swift in Sources */,
37001565271B1F250049C794 /* AccountsModel.swift in Sources */,
3751B4B427836902000B7DF4 /* SearchPage.swift in Sources */,
374C0541272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */,
37130A61277657300033018A /* PersistenceController.swift in Sources */,
37E70929271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */,