// // SearchView.swift // Yattee // // Search tab with source filtering and results. // import SwiftUI struct SearchView: View { @Environment(\.appEnvironment) private var appEnvironment @Namespace private var sheetTransition /// Initial query for deep linking. When set, auto-executes search on appear. var initialQuery: String? = nil /// External search text binding (from CompactTabView iOS 18+ integration) private var externalSearchText: Binding? /// Internal search text state (for standalone usage) @State private var internalSearchText = "" /// Computed binding to use external or internal search text private var searchTextBinding: Binding { externalSearchText ?? $internalSearchText } @State private var showFilterSheet = false @State private var selectedSearchInstance: Instance? @State private var searchViewModel: SearchViewModel? @State private var searchHistory: [SearchHistory] = [] @State private var recentChannels: [RecentChannel] = [] @State private var recentPlaylists: [RecentPlaylist] = [] @State private var showingClearAllRecentsConfirmation = false @State private var showViewOptions = false @State private var isSearchHistoryExpanded = false @AppStorage("searchFilters") private var savedFiltersData: Data? // Persisted search instance selection @AppStorage("searchInstanceID") private var savedSearchInstanceID: String? // View options (persisted, separate from subscriptions) @AppStorage("searchLayout") private var layout: VideoListLayout = .list @AppStorage("searchRowStyle") private var rowStyle: VideoRowStyle = .regular @AppStorage("searchGridColumns") private var gridColumns = 2 @AppStorage("searchHideWatched") private var hideWatched = false /// List style from centralized settings. private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset } // Privacy toggle tracking (establishes @Observable observation) private var saveRecentSearches: Bool { appEnvironment?.settingsManager.saveRecentSearches ?? true } private var saveRecentChannels: Bool { appEnvironment?.settingsManager.saveRecentChannels ?? true } private var saveRecentPlaylists: Bool { appEnvironment?.settingsManager.saveRecentPlaylists ?? true } // Grid layout configuration @State private var viewWidth: CGFloat = 0 private var gridConfig: GridLayoutConfiguration { GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns) } /// The instance to use for searching. /// Defaults to the active instance if none is explicitly selected. private var searchInstance: Instance? { selectedSearchInstance ?? appEnvironment?.instancesManager.activeInstance } /// All enabled instances available for searching. private var availableInstances: [Instance] { appEnvironment?.instancesManager.enabledInstances ?? [] } private var hasResults: Bool { searchViewModel?.hasResults ?? false } private var hasSearched: Bool { searchViewModel?.hasSearched ?? false } /// Initialize SearchView with optional external search text binding init(searchText: Binding? = nil, initialQuery: String? = nil) { self.externalSearchText = searchText self.initialQuery = initialQuery } var body: some View { Group { if searchTextBinding.wrappedValue.isEmpty { emptySearchView } else if let vm = searchViewModel { if !vm.hasSearched { // Not yet submitted - show suggestions, loading, or empty if !vm.suggestions.isEmpty && !hasResults { suggestionsView } else if vm.isFetchingSuggestions && !hasResults { // Show spinner while loading first suggestions ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // No suggestions yet and not loading - show empty spacer to keep layout stable Spacer() .frame(maxWidth: .infinity, maxHeight: .infinity) } } else if vm.isSearching && !hasResults { // First search loading - show filter strip with loading indicator resultsViewWithLoading } else if let error = vm.errorMessage, !hasResults { errorView(error) } else if hasResults { // Show results even if searching - keeps filter strip visible resultsView } else { noResultsView } } else { Spacer() .frame(maxWidth: .infinity, maxHeight: .infinity) } } .navigationTitle(String(localized: "tabs.search")) #if !os(tvOS) .toolbarTitleDisplayMode(.inlineLarge) #endif .toolbar { // View options button - always visible ToolbarItem(placement: .primaryAction) { Button { showViewOptions = true } label: { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "searchViewOptions", in: sheetTransition) } } .searchable(text: searchTextBinding, prompt: Text(String(localized: "search.placeholder"))) .sheet(isPresented: $showFilterSheet) { SearchFiltersSheet(onApply: { if hasResults { Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } } }, filters: Binding( get: { searchViewModel?.filters ?? .defaults }, set: { newFilters in searchViewModel?.filters = newFilters saveFilters(newFilters) } )) .presentationDetents([.medium, .large]) } .sheet(isPresented: $showViewOptions) { ViewOptionsSheet( layout: $layout, rowStyle: $rowStyle, gridColumns: $gridColumns, hideWatched: $hideWatched, maxGridColumns: gridConfig.maxColumns ) .liquidGlassSheetContent(sourceID: "searchViewOptions", in: sheetTransition) } .onSubmit(of: .search) { searchViewModel?.cancelSuggestions() searchViewModel?.filters.type = .video if let filters = searchViewModel?.filters { saveFilters(filters) } Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } } .onChange(of: searchTextBinding.wrappedValue) { _, newValue in if newValue.isEmpty { searchViewModel?.clearResults() // Clear everything when empty searchViewModel?.filters = .defaults saveFilters(.defaults) } else { // Clear results but keep suggestions visible until new ones load searchViewModel?.clearSearchResults() searchViewModel?.fetchSuggestions(for: newValue) } } .task { initializeViewModel() } .task(id: initialQuery) { // Auto-execute search when opened with an initial query if let query = initialQuery, !query.isEmpty, searchTextBinding.wrappedValue.isEmpty { searchTextBinding.wrappedValue = query searchViewModel?.cancelSuggestions() searchViewModel?.filters.type = .video if let filters = searchViewModel?.filters { saveFilters(filters) } Task { await searchViewModel?.search(query: query) } } } .onReceive(NotificationCenter.default.publisher(for: .searchHistoryDidChange)) { _ in loadSearchHistory() } .onReceive(NotificationCenter.default.publisher(for: .recentChannelsDidChange)) { _ in loadRecentChannels() } .onReceive(NotificationCenter.default.publisher(for: .recentPlaylistsDidChange)) { _ in loadRecentPlaylists() } .onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in handleInstancesChanged() } .onChange(of: hideWatched) { _, newValue in searchViewModel?.hideWatchedVideos = newValue } .onChange(of: saveRecentSearches) { _, _ in loadSearchHistory() } .onChange(of: saveRecentChannels) { _, _ in loadRecentChannels() } .onChange(of: saveRecentPlaylists) { _, _ in loadRecentPlaylists() } } private func initializeViewModel() { guard let appEnvironment, searchViewModel == nil else { return } // Restore persisted instance selection if available if selectedSearchInstance == nil, let savedID = savedSearchInstanceID, let savedUUID = UUID(uuidString: savedID), let restoredInstance = availableInstances.first(where: { $0.id == savedUUID }) { selectedSearchInstance = restoredInstance } else if selectedSearchInstance == nil, savedSearchInstanceID != nil { // Saved instance no longer exists, clear the saved ID savedSearchInstanceID = nil } guard let instance = searchInstance else { return } searchViewModel = SearchViewModel( instance: instance, contentService: appEnvironment.contentService, deArrowProvider: appEnvironment.deArrowBrandingProvider, dataManager: appEnvironment.dataManager, settingsManager: appEnvironment.settingsManager ) searchViewModel?.hideWatchedVideos = hideWatched loadFilters() loadSearchHistory() loadRecentChannels() loadRecentPlaylists() } private func switchToInstance(_ instance: Instance) { guard let appEnvironment else { return } selectedSearchInstance = instance savedSearchInstanceID = instance.id.uuidString // Recreate ViewModel for new instance let hadResults = hasResults searchViewModel = SearchViewModel( instance: instance, contentService: appEnvironment.contentService, deArrowProvider: appEnvironment.deArrowBrandingProvider, dataManager: appEnvironment.dataManager, settingsManager: appEnvironment.settingsManager ) searchViewModel?.hideWatchedVideos = hideWatched loadFilters() // Re-run search if we had results if hadResults { Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } } } /// Handles instance list changes by invalidating stale references. private func handleInstancesChanged() { // Check if selected instance was removed if let selected = selectedSearchInstance, !availableInstances.contains(where: { $0.id == selected.id }) { selectedSearchInstance = nil savedSearchInstanceID = nil } // Check if the current VM's instance was removed if let vm = searchViewModel, !availableInstances.contains(where: { $0.id == vm.instance.id }) { searchViewModel = nil // Re-initialize with a valid instance initializeViewModel() } } private func loadFilters() { guard let data = savedFiltersData, let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else { return } searchViewModel?.filters = decoded } private func saveFilters(_ filters: SearchFilters) { savedFiltersData = try? JSONEncoder().encode(filters) } // MARK: - Views /// Instance picker for selecting search source when multiple instances are available @ViewBuilder private var instancePickerView: some View { if availableInstances.count > 1 { HStack { Text(String(localized: "search.source.title")) .font(.headline) .padding(.leading, listStyle == .inset ? 8 : 0) Spacer() Menu { ForEach(availableInstances, id: \.id) { instance in Button { switchToInstance(instance) } label: { if instance.id == searchInstance?.id { Label(instance.displayName, systemImage: "checkmark") } else { Text(instance.displayName) } } } } label: { Text(searchInstance?.displayName ?? "") .lineLimit(1) .truncationMode(.tail) } } .padding(listStyle == .inset ? 4 : 0) .background(listStyle == .inset ? ListBackgroundStyle.card.color : .clear) .clipShape(.rect(cornerRadius: 10)) .padding(.horizontal) } } private var searchFiltersStrip: some View { HStack(spacing: 12) { // Filter button Button { showFilterSheet = true } label: { Image(systemName: (searchViewModel?.filters.isDefault ?? true) ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") .font(.title2) } // Content type segmented picker Picker("", selection: Binding( get: { searchViewModel?.filters.type ?? .video }, set: { searchViewModel?.filters.type = $0 } )) { ForEach(SearchContentType.allCases) { type in Text(type.title).tag(type) } } .pickerStyle(.segmented) } .padding(.horizontal) .padding(.vertical, 8) .onChange(of: searchViewModel?.filters.type) { _, _ in if let filters = searchViewModel?.filters { saveFilters(filters) } Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } } } // MARK: - Search History Helpers private var displayedSearchHistory: [SearchHistory] { if isSearchHistoryExpanded || searchHistory.count <= 5 { return searchHistory } else { return Array(searchHistory.prefix(5)) } } private var hasMoreSearchHistory: Bool { searchHistory.count > 5 } private var emptySearchView: some View { Group { if searchHistory.isEmpty && recentChannels.isEmpty && recentPlaylists.isEmpty { // Empty state with icon VStack(spacing: 24) { Spacer(minLength: 60) Image(systemName: "magnifyingglass") .font(.system(size: 48)) .foregroundStyle(.tertiary) Text(String(localized: "search.empty.description")) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Spacer() } .padding() .accessibilityLabel("search.empty") } else { // Recent searches, channels, and playlists in scrollable view ScrollView { LazyVStack(alignment: .leading, spacing: 24, pinnedViews: []) { // Instance picker at top when multiple instances available instancePickerView // Recent search queries section if !searchHistory.isEmpty { VStack(alignment: .leading, spacing: 0) { // Header - tappable to expand/collapse when more than 5 items Button { if hasMoreSearchHistory { withAnimation(.easeInOut(duration: 0.2)) { isSearchHistoryExpanded.toggle() } } } label: { HStack { Text(String(localized: "search.recentSearches.title")) .font(.headline) .foregroundStyle(.primary) Spacer() if hasMoreSearchHistory { Image(systemName: isSearchHistoryExpanded ? "chevron.up" : "chevron.down") .font(.caption) .foregroundStyle(.secondary) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!hasMoreSearchHistory) .padding(.horizontal) .padding(.bottom, 8) // Items VStack(spacing: 0) { ForEach(displayedSearchHistory) { history in Button { executeSearch(history.query) } label: { HStack(spacing: 12) { Image(systemName: "clock.arrow.circlepath") .foregroundStyle(.secondary) Text(history.query) .foregroundStyle(.primary) Spacer() } .padding(.horizontal, listStyle == .inset ? 16 : 0) .padding(.vertical, 12) .contentShape(Rectangle()) } .buttonStyle(.plain) .swipeActions { SwipeAction( symbolImage: "trash", tint: .white, background: .red, font: .body, size: CGSize(width: 38, height: 38) ) { reset in deleteHistory(history) reset() } } .contextMenu { Button(role: .destructive) { deleteHistory(history) } label: { Label(String(localized: "common.delete"), systemImage: "trash") } } if history.id != displayedSearchHistory.last?.id { Divider() .padding(.leading, 52) } } } .background(listStyle == .inset ? ListBackgroundStyle.card.color : Color.clear) .clipShape(.rect(cornerRadius: listStyle == .inset ? 10 : 0)) .padding(.horizontal) } } // Recent channels section if !recentChannels.isEmpty { VStack(alignment: .leading, spacing: 12) { // Header HStack { Text(String(localized: "search.recentChannels.title")) .font(.headline) Spacer() } .padding(.horizontal) // Horizontal scroll ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 16) { ForEach(recentChannels) { recentChannel in NavigationLink( value: NavigationDestination.channel( recentChannel.channelID, sourceFromRawValue( recentChannel.sourceRawValue, instanceURL: recentChannel.instanceURLString ) ) ) { ChannelCardGridView( channel: channelFromRecent(recentChannel), isCompact: false ) .frame(width: 160) } .zoomTransitionSource(id: recentChannel.channelID) .buttonStyle(.plain) .contextMenu { Button(role: .destructive) { deleteRecentChannel(recentChannel) } label: { Label(String(localized: "common.delete"), systemImage: "trash") } } } } .padding(.horizontal) } } } // Recent playlists section if !recentPlaylists.isEmpty { VStack(alignment: .leading, spacing: 12) { // Header HStack { Text(String(localized: "search.recentPlaylists.title")) .font(.headline) Spacer() } .padding(.horizontal) // Horizontal scroll ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 16) { ForEach(recentPlaylists) { recentPlaylist in NavigationLink(value: NavigationDestination.playlist(.remote(playlistIDFromRecent(recentPlaylist), instance: nil, title: recentPlaylist.title))) { PlaylistCardView( playlist: playlistFromRecent(recentPlaylist), isCompact: false ) .frame(width: 200) } .zoomTransitionSource(id: playlistIDFromRecent(recentPlaylist)) .buttonStyle(.plain) .contextMenu { Button(role: .destructive) { deleteRecentPlaylist(recentPlaylist) } label: { Label(String(localized: "common.delete"), systemImage: "trash") } } } } .padding(.horizontal) } } } // Clear All Recents button at bottom Button { showingClearAllRecentsConfirmation = true } label: { HStack(spacing: 6) { Spacer() Image(systemName: "trash") Text(String(localized: "search.clearAllRecents")) .fontWeight(.semibold) Spacer() } } .foregroundStyle(.secondary) .padding(.top, 16) .padding(.bottom, 32) } .padding(.vertical) } .background(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color) .accessibilityLabel("search.recents") .confirmationDialog( String(localized: "search.clearAllRecents.confirm"), isPresented: $showingClearAllRecentsConfirmation, titleVisibility: .visible ) { Button(String(localized: "search.clearAllRecents"), role: .destructive) { clearAllRecents() } Button(String(localized: "common.cancel"), role: .cancel) {} } } } } @ViewBuilder private var suggestionsView: some View { if let vm = searchViewModel { List { ForEach(vm.suggestions, id: \.self) { suggestion in Button { dismissKeyboard() searchTextBinding.wrappedValue = suggestion vm.cancelSuggestions() vm.filters.type = .video saveFilters(vm.filters) Task { await vm.search(query: suggestion) } } label: { HStack(spacing: 12) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) Text(suggestion) .foregroundStyle(.primary) Spacer() Image(systemName: "arrow.up.left") .font(.caption) .foregroundStyle(.tertiary) } .contentShape(Rectangle()) } .buttonStyle(.plain) } } .listStyle(.plain) } } private var noResultsView: some View { ContentUnavailableView { Label(String(localized: "search.noResults.title"), systemImage: "magnifyingglass") } description: { Text(String(localized: "search.noResults.description")) } .accessibilityIdentifier("search.noResults") } private func errorView(_ error: String) -> some View { ContentUnavailableView { Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle") } description: { Text(error) } actions: { Button(String(localized: "common.retry")) { Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } } } } /// Queue source for search results private var searchQueueSource: QueueSource { let page = searchViewModel?.page ?? 1 let continuationString = page > 1 ? String(page) : nil return .search(query: searchTextBinding.wrappedValue, continuation: continuationString) } /// Unified results combining all content types for display. /// Uses the API's original ordering when type is .all, otherwise prioritizes the selected type. private var unifiedResults: [SearchResultItem] { guard let vm = searchViewModel else { return [] } // For .all type, use resultItems which preserves API order if vm.filters.type == .all { return vm.resultItems } // For specific types, prioritize that type first var results: [SearchResultItem] = [] switch vm.filters.type { case .video: // Show videos first, but also include any playlists/channels server returned for (index, video) in vm.videos.enumerated() { results.append(.video(video, index: index)) } for playlist in vm.playlists { results.append(.playlist(playlist)) } for channel in vm.channels { results.append(.channel(channel)) } case .playlist: // Show playlists first, then any videos/channels server returned for playlist in vm.playlists { results.append(.playlist(playlist)) } for (index, video) in vm.videos.enumerated() { results.append(.video(video, index: index)) } for channel in vm.channels { results.append(.channel(channel)) } case .channel: // Show channels first, then any videos/playlists server returned for channel in vm.channels { results.append(.channel(channel)) } for (index, video) in vm.videos.enumerated() { results.append(.video(video, index: index)) } for playlist in vm.playlists { results.append(.playlist(playlist)) } case .all: // Already handled above break } return results } /// Background style based on layout and list style. private var resultsBackgroundStyle: ListBackgroundStyle { if layout == .list && listStyle == .inset { return .grouped } else { return .plain } } @ViewBuilder private var resultsViewWithLoading: some View { if searchViewModel != nil { resultsBackgroundStyle.color .ignoresSafeArea() .overlay( ScrollView { VStack(spacing: 16) { // Filter strip at top (only for instances that support search filters) if searchInstance?.supportsSearchFilters == true { searchFiltersStrip } // Loading indicator ProgressView() .accessibilityIdentifier("search.loading") .frame(maxWidth: .infinity) .padding(.top, 40) } } ) } } @ViewBuilder private var resultsView: some View { if searchViewModel != nil { resultsBackgroundStyle.color .ignoresSafeArea() .overlay( ScrollView { if layout == .list { listResultsContent } else { gridResultsContent } } .accessibilityLabel("search.results") .background( GeometryReader { geometry in Color.clear .onAppear { viewWidth = geometry.size.width } .onChange(of: geometry.size.width) { _, newWidth in viewWidth = newWidth } } ) ) } } @ViewBuilder private var listResultsContent: some View { if searchViewModel != nil { if listStyle == .inset { insetListContent } else { plainListContent } } } @ViewBuilder private var insetListContent: some View { if let vm = searchViewModel { VStack(spacing: 0) { // Filter strip at top (only for instances that support search filters) if searchInstance?.supportsSearchFilters == true { searchFiltersStrip } // Card container VideoListContent(listStyle: .inset) { ForEach(Array(unifiedResults.enumerated()), id: \.element.id) { resultIndex, item in searchResultRow(item: item, resultIndex: resultIndex, vm: vm) } LoadMoreTrigger( isLoading: vm.isLoadingMore || (vm.isSearching && vm.page == 1), hasMore: vm.hasMoreResults ) { Task { await vm.loadMore() } } } } } } @ViewBuilder private var plainListContent: some View { if let vm = searchViewModel { VStack(spacing: 0) { // Filter strip at top (only for instances that support search filters) if searchInstance?.supportsSearchFilters == true { searchFiltersStrip } VideoListContent(listStyle: .plain) { ForEach(Array(unifiedResults.enumerated()), id: \.element.id) { resultIndex, item in searchResultRow(item: item, resultIndex: resultIndex, vm: vm) } LoadMoreTrigger( isLoading: vm.isLoadingMore || (vm.isSearching && vm.page == 1), hasMore: vm.hasMoreResults ) { Task { await vm.loadMore() } } } } } } /// Single row for a search result item (video, playlist, or channel). @ViewBuilder private func searchResultRow(item: SearchResultItem, resultIndex: Int, vm: SearchViewModel) -> some View { VideoListRow( isLast: resultIndex == unifiedResults.count - 1, rowStyle: rowStyle, listStyle: listStyle, contentWidth: item.isChannel ? rowStyle.thumbnailHeight : nil ) { switch item { case .video(let video, let videoIndex): VideoRowView(video: video, style: rowStyle) .tappableVideo( video, queueSource: searchQueueSource, sourceLabel: String(localized: "queue.source.search \(searchTextBinding.wrappedValue)"), videoList: vm.videos, videoIndex: videoIndex, loadMoreVideos: loadMoreSearchResultsCallback ) #if !os(tvOS) .videoSwipeActions(video: video) #endif .onAppear { if resultIndex == unifiedResults.count - 3 { Task { await vm.loadMore() } } } case .playlist(let playlist): NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) { SearchPlaylistRowView(playlist: playlist, style: rowStyle) .contentShape(Rectangle()) } .zoomTransitionSource(id: playlist.id) .buttonStyle(.plain) .onAppear { if resultIndex == unifiedResults.count - 3 { Task { await vm.loadMore() } } } case .channel(let channel): NavigationLink( value: NavigationDestination.channel( channel.id.channelID, channel.id.source ) ) { ChannelRowView(channel: channel, style: rowStyle) .contentShape(Rectangle()) } .zoomTransitionSource(id: channel.id.channelID) .buttonStyle(.plain) .onAppear { if resultIndex == unifiedResults.count - 3 { Task { await vm.loadMore() } } } } } } @ViewBuilder private var gridResultsContent: some View { if let vm = searchViewModel { LazyVStack(spacing: 16) { // Filter strip at top (only for instances that support search filters) if searchInstance?.supportsSearchFilters == true { searchFiltersStrip .padding(.bottom, 8) } // Single grid with mixed content preserving order VideoGridContent(columns: gridConfig.effectiveColumns) { ForEach(Array(unifiedResults.enumerated()), id: \.element.id) { resultIndex, item in switch item { case .video(let video, let videoIndex): VideoCardView( video: video, isCompact: gridConfig.isCompactCards ) .frame(maxHeight: .infinity, alignment: .top) .tappableVideo( video, queueSource: searchQueueSource, sourceLabel: String(localized: "queue.source.search \(searchTextBinding.wrappedValue)"), videoList: vm.videos, videoIndex: videoIndex, loadMoreVideos: loadMoreSearchResultsCallback ) .onAppear { if resultIndex == unifiedResults.count - 3 { Task { await vm.loadMore() } } } case .playlist(let playlist): NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) { PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards) .frame(maxHeight: .infinity, alignment: .top) .contentShape(Rectangle()) } .zoomTransitionSource(id: playlist.id) .buttonStyle(.plain) .onAppear { if resultIndex == unifiedResults.count - 3 { Task { await vm.loadMore() } } } case .channel(let channel): NavigationLink( value: NavigationDestination.channel( channel.id.channelID, channel.id.source ) ) { ChannelCardGridView(channel: channel, isCompact: gridConfig.isCompactCards) .frame(maxHeight: .infinity, alignment: .top) .contentShape(Rectangle()) } .zoomTransitionSource(id: channel.id.channelID) .buttonStyle(.plain) .onAppear { if resultIndex == unifiedResults.count - 3 { Task { await vm.loadMore() } } } } } } // Loading indicator at bottom if vm.isLoadingMore || (vm.isSearching && vm.page == 1) { ProgressView() .frame(maxWidth: .infinity) .padding() } } } } private func dismissKeyboard() { #if os(iOS) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) #endif } // MARK: - Search History Helpers private func loadSearchHistory() { guard appEnvironment?.settingsManager.saveRecentSearches != false else { searchHistory = [] return } guard let limit = appEnvironment?.settingsManager.searchHistoryLimit else { return } searchHistory = appEnvironment?.dataManager.fetchSearchHistory(limit: limit) ?? [] } private func deleteHistory(_ history: SearchHistory) { appEnvironment?.dataManager.deleteSearchQuery(history) } private func clearAllHistory() { appEnvironment?.dataManager.clearSearchHistory() isSearchHistoryExpanded = false } private func executeSearch(_ query: String) { dismissKeyboard() searchTextBinding.wrappedValue = query searchViewModel?.cancelSuggestions() searchViewModel?.filters.type = .video if let filters = searchViewModel?.filters { saveFilters(filters) } Task { await searchViewModel?.search(query: query) } } // MARK: - Recent Channels/Playlists Helpers private func loadRecentChannels() { guard appEnvironment?.settingsManager.saveRecentChannels != false else { recentChannels = [] return } guard let limit = appEnvironment?.settingsManager.searchHistoryLimit else { return } recentChannels = appEnvironment?.dataManager.fetchRecentChannels(limit: limit) ?? [] } private func loadRecentPlaylists() { guard appEnvironment?.settingsManager.saveRecentPlaylists != false else { recentPlaylists = [] return } guard let limit = appEnvironment?.settingsManager.searchHistoryLimit else { return } recentPlaylists = appEnvironment?.dataManager.fetchRecentPlaylists(limit: limit) ?? [] } private func deleteRecentChannel(_ channel: RecentChannel) { appEnvironment?.dataManager.deleteRecentChannel(channel) } private func deleteRecentPlaylist(_ playlist: RecentPlaylist) { appEnvironment?.dataManager.deleteRecentPlaylist(playlist) } private func clearAllRecentChannels() { appEnvironment?.dataManager.clearRecentChannels() } private func clearAllRecentPlaylists() { appEnvironment?.dataManager.clearRecentPlaylists() } private func clearAllRecents() { appEnvironment?.dataManager.clearSearchHistory() appEnvironment?.dataManager.clearRecentChannels() appEnvironment?.dataManager.clearRecentPlaylists() isSearchHistoryExpanded = false } /// Converts RecentChannel to Channel for display private func channelFromRecent(_ recent: RecentChannel) -> Channel { Channel( id: ChannelID( source: sourceFromRawValue(recent.sourceRawValue, instanceURL: recent.instanceURLString), channelID: recent.channelID ), name: recent.name, subscriberCount: recent.subscriberCount, thumbnailURL: recent.thumbnailURLString.flatMap { URL(string: $0) }, isVerified: recent.isVerified ) } /// Converts RecentPlaylist to Playlist for display private func playlistFromRecent(_ recent: RecentPlaylist) -> Playlist { Playlist( id: playlistIDFromRecent(recent), title: recent.title, author: Author(id: "", name: recent.authorName), videoCount: recent.videoCount, thumbnailURL: recent.thumbnailURLString.flatMap { URL(string: $0) } ) } /// Reconstructs PlaylistID from RecentPlaylist private func playlistIDFromRecent(_ recent: RecentPlaylist) -> PlaylistID { PlaylistID( source: sourceFromRawValue(recent.sourceRawValue, instanceURL: recent.instanceURLString), playlistID: recent.playlistID ) } /// Reconstructs ContentSource from raw values private func sourceFromRawValue(_ rawValue: String, instanceURL: String?) -> ContentSource { switch rawValue { case "global": return .global(provider: ContentSource.youtubeProvider) case "federated": if let urlString = instanceURL, let url = URL(string: urlString) { return .federated(provider: ContentSource.peertubeProvider, instance: url) } return .global(provider: ContentSource.youtubeProvider) case "extracted": if let urlString = instanceURL, let url = URL(string: urlString) { return .extracted(extractor: "generic", originalURL: url) } return .global(provider: ContentSource.youtubeProvider) default: return .global(provider: ContentSource.youtubeProvider) } } // MARK: - Video Queue Continuation @Sendable private func loadMoreSearchResultsCallback() async throws -> ([Video], String?) { guard let searchViewModel else { throw NSError( domain: "SearchView", code: -1, userInfo: [NSLocalizedDescriptionKey: "Search view model not initialized"] ) } // Check if there are more results to load guard searchViewModel.hasMoreResults else { return ([], nil) } // Load more results using SearchViewModel's pagination await searchViewModel.loadMore() // Return newly loaded videos and next page as continuation let videos = searchViewModel.videos let nextPage = searchViewModel.page let hasMore = searchViewModel.hasMoreResults // Convert next page to continuation string (only if there are more results) let continuation = hasMore ? String(nextPage) : nil return (videos, continuation) } } // MARK: - Search Filters Sheet struct SearchFiltersSheet: View { @Environment(\.dismiss) private var dismiss let onApply: () -> Void @Binding var filters: SearchFilters var body: some View { NavigationStack { Form { // Sort, Upload Date, Duration in one section Section { Picker(String(localized: "search.sort"), selection: $filters.sort) { ForEach(SearchSortOption.allCases) { option in Text(option.title).tag(option) } } Picker(String(localized: "search.uploadDate"), selection: $filters.date) { ForEach(SearchDateFilter.allCases) { option in Text(option.title).tag(option) } } Picker(String(localized: "search.duration"), selection: $filters.duration) { ForEach(SearchDurationFilter.allCases) { option in Text(option.title).tag(option) } } } // Reset Button Section { Button(role: .destructive) { let currentType = filters.type filters = .defaults filters.type = currentType } label: { HStack { Spacer() Text(String(localized: "search.filters.reset")) Spacer() } } .disabled(filters.isDefault) } } .navigationTitle(String(localized: "search.filters")) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button(String(localized: "common.cancel")) { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button(String(localized: "common.apply")) { onApply() dismiss() } } } } } } // MARK: - Preview #Preview { NavigationStack { SearchView() } .appEnvironment(.preview) }