mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
Add infinite scroll for search (fixes #5)
This commit is contained in:
parent
3326088081
commit
ea6363ba65
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -42,4 +42,8 @@ enum VideosApp: String, CaseIterable {
|
||||
var supportsComments: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
7
Model/Search/SearchPage.swift
Normal file
7
Model/Search/SearchPage.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct SearchPage {
|
||||
var results = [ContentItem]()
|
||||
var nextPage: String?
|
||||
var last = false
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 */,
|
||||
|
Loading…
Reference in New Issue
Block a user