Files
yattee/Yattee/ViewModels/SearchViewModel.swift
Arkadiusz Fal 8ade5a54d0 Add a Hide Shorts toggle to the view options
Videos carry isShort from Yattee Server and Piped, and Invidious feed
entries are taken as Shorts when they arrive with no length and aren't
live or upcoming: the feed comes from RSS, which lists Shorts, while
lengths come from the Videos tab, which doesn't. Subscriptions, search
and instance browse hide videos known to be Shorts when the toggle is
on; videos whose source can't tell are always shown.
2026-10-11 14:06:15 +02:00

369 lines
13 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
/// Hide Shorts (controlled by view options, not filters)
var hideShorts: 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 = filterHiddenVideos(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 and Shorts if those filters are enabled
if isVideoHidden(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 = filterHiddenVideos(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 and Shorts if those filters are enabled
if isVideoHidden(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 and Shorts when those filters are enabled.
private func filterHiddenVideos(_ videos: [Video]) -> [Video] {
var videos = videos
if hideShorts {
videos = videos.filter { !$0.isKnownShort }
}
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
}
}
/// Whether the view options hide this video from results.
private func isVideoHidden(_ video: Video) -> Bool {
(hideShorts && video.isKnownShort) || (hideWatchedVideos && isVideoWatched(video))
}
/// 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
}
}