mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
354
Yattee/ViewModels/SearchViewModel.swift
Normal file
354
Yattee/ViewModels/SearchViewModel.swift
Normal file
@@ -0,0 +1,354 @@
|
||||
//
|
||||
// SearchViewModel.swift
|
||||
// Yattee
|
||||
//
|
||||
// Shared search logic for InstanceBrowseView and SearchView.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Unified search result item for displaying mixed content types.
|
||||
enum SearchResultItem: Identifiable, Sendable {
|
||||
case video(Video, index: Int)
|
||||
case playlist(Playlist)
|
||||
case channel(Channel)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .video(let video, _):
|
||||
return "video-\(video.id.id)"
|
||||
case .playlist(let playlist):
|
||||
return "playlist-\(playlist.id.id)"
|
||||
case .channel(let channel):
|
||||
return "channel-\(channel.id.id)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item is a channel (for divider alignment with circular avatar).
|
||||
var isChannel: Bool {
|
||||
if case .channel = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable view model for search functionality.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SearchViewModel {
|
||||
// MARK: - Configuration
|
||||
|
||||
let instance: Instance
|
||||
private let contentService: ContentService
|
||||
private let deArrowProvider: DeArrowBrandingProvider?
|
||||
private weak var dataManager: DataManager?
|
||||
private weak var settingsManager: SettingsManager?
|
||||
|
||||
// MARK: - Search State
|
||||
|
||||
var filters = SearchFilters()
|
||||
|
||||
/// Hide watched videos (controlled by view options, not filters)
|
||||
var hideWatchedVideos: Bool = false
|
||||
|
||||
/// Unified result items preserving API order (for InstanceBrowseView style display).
|
||||
private(set) var resultItems: [SearchResultItem] = []
|
||||
|
||||
/// Separate video array for video queue functionality.
|
||||
private(set) var videos: [Video] = []
|
||||
|
||||
/// Separate channel array (for SearchView style display).
|
||||
private(set) var channels: [Channel] = []
|
||||
|
||||
/// Separate playlist array (for SearchView style display).
|
||||
private(set) var playlists: [Playlist] = []
|
||||
|
||||
// MARK: - UI State
|
||||
|
||||
private(set) var isSearching = false
|
||||
private(set) var hasSearched = false
|
||||
private(set) var errorMessage: String?
|
||||
private(set) var suggestions: [String] = []
|
||||
private(set) var isFetchingSuggestions = false
|
||||
|
||||
// MARK: - Pagination
|
||||
|
||||
private(set) var page = 1
|
||||
private(set) var hasMoreResults = true
|
||||
private(set) var isLoadingMore = false
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var suggestionsTask: Task<Void, Never>?
|
||||
private var lastQuery: String = ""
|
||||
|
||||
/// Incremented each time filters change to detect stale results.
|
||||
private var filterVersion: Int = 0
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var hasResults: Bool {
|
||||
!resultItems.isEmpty || !videos.isEmpty || !channels.isEmpty || !playlists.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(
|
||||
instance: Instance,
|
||||
contentService: ContentService,
|
||||
deArrowProvider: DeArrowBrandingProvider? = nil,
|
||||
dataManager: DataManager? = nil,
|
||||
settingsManager: SettingsManager? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.contentService = contentService
|
||||
self.deArrowProvider = deArrowProvider
|
||||
self.dataManager = dataManager
|
||||
self.settingsManager = settingsManager
|
||||
}
|
||||
|
||||
// MARK: - Search Methods
|
||||
|
||||
/// Performs a search with the given query.
|
||||
/// - Parameters:
|
||||
/// - query: The search query
|
||||
/// - resetResults: Whether to reset pagination and clear existing results
|
||||
func search(query: String, resetResults: Bool = true) async {
|
||||
let trimmedQuery = query.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedQuery.isEmpty else { return }
|
||||
|
||||
// Cancel any in-flight search to prevent race conditions
|
||||
searchTask?.cancel()
|
||||
|
||||
// Increment filter version to invalidate any pending results
|
||||
filterVersion += 1
|
||||
let versionAtStart = filterVersion
|
||||
let filtersAtStart = filters
|
||||
|
||||
// Save to history if not in incognito mode and recent searches are enabled
|
||||
if settingsManager?.incognitoModeEnabled != true,
|
||||
settingsManager?.saveRecentSearches != false {
|
||||
dataManager?.addSearchQuery(trimmedQuery)
|
||||
}
|
||||
|
||||
if resetResults {
|
||||
page = 1
|
||||
hasMoreResults = true
|
||||
resultItems = []
|
||||
videos = []
|
||||
channels = []
|
||||
playlists = []
|
||||
}
|
||||
|
||||
lastQuery = trimmedQuery
|
||||
hasSearched = true
|
||||
isSearching = true
|
||||
errorMessage = nil
|
||||
|
||||
// Store the task so it can be cancelled if filters change
|
||||
searchTask = Task {
|
||||
do {
|
||||
let result = try await contentService.search(
|
||||
query: trimmedQuery,
|
||||
instance: instance,
|
||||
page: page,
|
||||
filters: filtersAtStart
|
||||
)
|
||||
|
||||
// Check if filters changed while we were waiting - discard stale results
|
||||
guard versionAtStart == filterVersion else { return }
|
||||
|
||||
if resetResults {
|
||||
// Fresh results
|
||||
videos = filterWatchedVideos(result.videos)
|
||||
channels = result.channels
|
||||
playlists = result.playlists
|
||||
|
||||
// Build unified result items from ordered items
|
||||
resultItems = result.orderedItems.enumerated().compactMap { _, item in
|
||||
switch item {
|
||||
case .video(let video):
|
||||
// Skip watched videos if filter is enabled
|
||||
if hideWatchedVideos && isVideoWatched(video) {
|
||||
return nil
|
||||
}
|
||||
if let index = videos.firstIndex(where: { $0.id == video.id }) {
|
||||
return .video(video, index: index)
|
||||
}
|
||||
return nil
|
||||
case .channel(let channel):
|
||||
return .channel(channel)
|
||||
case .playlist(let playlist):
|
||||
return .playlist(playlist)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Append with deduplication
|
||||
let existingVideoIDs = Set(videos.map(\.id))
|
||||
let existingChannelIDs = Set(channels.map(\.id))
|
||||
let existingPlaylistIDs = Set(playlists.map(\.id))
|
||||
|
||||
let filteredNewVideos = filterWatchedVideos(result.videos)
|
||||
let newVideos = filteredNewVideos.filter { !existingVideoIDs.contains($0.id) }
|
||||
let newChannels = result.channels.filter { !existingChannelIDs.contains($0.id) }
|
||||
let newPlaylists = result.playlists.filter { !existingPlaylistIDs.contains($0.id) }
|
||||
|
||||
// Append to separate arrays
|
||||
videos.append(contentsOf: newVideos)
|
||||
channels.append(contentsOf: newChannels)
|
||||
playlists.append(contentsOf: newPlaylists)
|
||||
|
||||
// Append to unified result items
|
||||
for item in result.orderedItems {
|
||||
switch item {
|
||||
case .video(let video):
|
||||
// Skip watched videos if filter is enabled
|
||||
if hideWatchedVideos && isVideoWatched(video) {
|
||||
continue
|
||||
}
|
||||
guard !existingVideoIDs.contains(video.id) else { continue }
|
||||
let videoIndex = videos.firstIndex(where: { $0.id == video.id }) ?? videos.count - 1
|
||||
resultItems.append(.video(video, index: videoIndex))
|
||||
case .channel(let channel):
|
||||
guard !existingChannelIDs.contains(channel.id) else { continue }
|
||||
resultItems.append(.channel(channel))
|
||||
case .playlist(let playlist):
|
||||
guard !existingPlaylistIDs.contains(playlist.id) else { continue }
|
||||
resultItems.append(.playlist(playlist))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasMoreResults = result.nextPage != nil
|
||||
prefetchBranding(for: result.videos)
|
||||
} catch {
|
||||
// Check if filters changed - don't show error for stale request
|
||||
guard versionAtStart == filterVersion else { return }
|
||||
// Don't report cancellation errors
|
||||
if !Task.isCancelled {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
isSearching = false
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
await searchTask?.value
|
||||
}
|
||||
|
||||
/// Loads more search results for the current query.
|
||||
func loadMore() async {
|
||||
guard hasMoreResults, !isLoadingMore, !isSearching, !lastQuery.isEmpty else { return }
|
||||
isLoadingMore = true
|
||||
page += 1
|
||||
await search(query: lastQuery, resetResults: false)
|
||||
}
|
||||
|
||||
/// Clears all search results and resets state.
|
||||
func clearResults() {
|
||||
searchTask?.cancel()
|
||||
resultItems = []
|
||||
videos = []
|
||||
channels = []
|
||||
playlists = []
|
||||
errorMessage = nil
|
||||
page = 1
|
||||
hasMoreResults = true
|
||||
hasSearched = false
|
||||
suggestions = []
|
||||
suggestionsTask?.cancel()
|
||||
lastQuery = ""
|
||||
}
|
||||
|
||||
/// Clears search results without clearing suggestions.
|
||||
/// Use when user is editing query but suggestions should persist.
|
||||
func clearSearchResults() {
|
||||
searchTask?.cancel()
|
||||
resultItems = []
|
||||
videos = []
|
||||
channels = []
|
||||
playlists = []
|
||||
errorMessage = nil
|
||||
page = 1
|
||||
hasMoreResults = true
|
||||
hasSearched = false
|
||||
lastQuery = ""
|
||||
}
|
||||
|
||||
// MARK: - Suggestions
|
||||
|
||||
/// Fetches search suggestions for the given query with debouncing.
|
||||
func fetchSuggestions(for query: String) {
|
||||
suggestionsTask?.cancel()
|
||||
|
||||
let trimmedQuery = query.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedQuery.isEmpty else {
|
||||
suggestions = []
|
||||
isFetchingSuggestions = false
|
||||
return
|
||||
}
|
||||
|
||||
isFetchingSuggestions = true
|
||||
|
||||
suggestionsTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms debounce
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
do {
|
||||
let results = try await contentService.searchSuggestions(
|
||||
query: trimmedQuery,
|
||||
instance: instance
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
suggestions = results
|
||||
isFetchingSuggestions = false
|
||||
} catch {
|
||||
// Only clear suggestions on actual errors, not cancellation
|
||||
guard !Task.isCancelled else { return }
|
||||
suggestions = []
|
||||
isFetchingSuggestions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels any pending suggestions fetch.
|
||||
func cancelSuggestions() {
|
||||
suggestionsTask?.cancel()
|
||||
suggestions = []
|
||||
isFetchingSuggestions = false
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func prefetchBranding(for videos: [Video]) {
|
||||
guard let deArrowProvider else { return }
|
||||
let youtubeIDs = videos.compactMap { video -> String? in
|
||||
if case .global = video.id.source { return video.id.videoID }
|
||||
return nil
|
||||
}
|
||||
deArrowProvider.prefetch(videoIDs: youtubeIDs)
|
||||
}
|
||||
|
||||
/// Filters out watched videos if hideWatchedVideos is enabled.
|
||||
private func filterWatchedVideos(_ videos: [Video]) -> [Video] {
|
||||
guard hideWatchedVideos, let dataManager else {
|
||||
return videos
|
||||
}
|
||||
|
||||
let watchMap = dataManager.watchEntriesMap()
|
||||
return videos.filter { video in
|
||||
guard let entry = watchMap[video.id.videoID] else { return true }
|
||||
return !entry.isFinished
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a video is watched (finished).
|
||||
private func isVideoWatched(_ video: Video) -> Bool {
|
||||
guard let dataManager else { return false }
|
||||
let watchMap = dataManager.watchEntriesMap()
|
||||
guard let entry = watchMap[video.id.videoID] else { return false }
|
||||
return entry.isFinished
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user