mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
355 lines
12 KiB
Swift
355 lines
12 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|