import Defaults import Siesta import SwiftUI struct SearchView: View { private var query: SearchQuery? @State private var searchSortOrder = SearchQuery.SortOrder.relevance @State private var searchDate = SearchQuery.Date.any @State private var searchDuration = SearchQuery.Duration.any @State private var recentsChanged = false #if os(tvOS) @State private var searchDebounce = Debounce() @State private var recentsDebounce = Debounce() #endif @State private var favoriteItem: FavoriteItem? @Environment(\.navigationStyle) private var navigationStyle @EnvironmentObject private var accounts @EnvironmentObject private var recents @EnvironmentObject private var state private var videos = [Video]() var items: [ContentItem] { state.store.collection.sorted { $0 < $1 } } init(_ query: SearchQuery? = nil, videos: [Video] = []) { self.query = query self.videos = videos } var body: some View { PlayerControlsView { #if os(iOS) VStack { SearchTextField() if state.query.query != state.queryText, !state.queryText.isEmpty, !state.querySuggestions.collection.isEmpty { SearchSuggestions() } else { results } } #else ZStack { results if state.query.query != state.queryText, !state.queryText.isEmpty, !state.querySuggestions.collection.isEmpty { HStack { Spacer() SearchSuggestions() .borderLeading(width: 1, color: Color("ControlsBorderColor")) .frame(maxWidth: 280) } } } #endif } .toolbar { #if !os(tvOS) ToolbarItemGroup(placement: toolbarPlacement) { #if os(macOS) if let favoriteItem = favoriteItem { FavoriteButton(item: favoriteItem) .id(favoriteItem.id) } #endif if accounts.app.supportsSearchFilters { Section { #if os(macOS) HStack { Text("Sort:") .foregroundColor(.secondary) searchSortOrderPicker } #else Menu("Sort: \(searchSortOrder.name)") { searchSortOrderPicker } #endif } .transaction { t in t.animation = .none } } #if os(iOS) Spacer() if let favoriteItem = favoriteItem { FavoriteButton(item: favoriteItem) .id(favoriteItem.id) } Spacer() #endif if accounts.app.supportsSearchFilters { filtersMenu } #if os(macOS) SearchTextField() #endif } #endif } .onAppear { if query != nil { state.queryText = query!.query state.resetQuery(query!) updateFavoriteItem() } if !videos.isEmpty { state.store.replace(ContentItem.array(of: videos)) } } .onChange(of: state.query.query) { newQuery in if newQuery.isEmpty { favoriteItem = nil } else { updateFavoriteItem() } } .onChange(of: state.queryText) { newQuery in if newQuery.isEmpty { state.resetQuery() } state.loadSuggestions(newQuery) #if os(tvOS) searchDebounce.invalidate() recentsDebounce.invalidate() searchDebounce.debouncing(2) { state.changeQuery { query in query.query = newQuery updateFavoriteItem() } } recentsDebounce.debouncing(10) { recents.addQuery(newQuery) } #endif } .onChange(of: searchSortOrder) { order in state.changeQuery { query in query.sortBy = order updateFavoriteItem() } } .onChange(of: searchDate) { date in state.changeQuery { query in query.date = date updateFavoriteItem() } } .onChange(of: searchDuration) { duration in state.changeQuery { query in query.duration = duration updateFavoriteItem() } } #if !os(tvOS) .ignoresSafeArea(.keyboard, edges: .bottom) .navigationTitle("Search") #endif #if os(iOS) .navigationBarHidden(true) #endif } private var results: some View { VStack { if showRecentQueries { recentQueries } else { #if os(tvOS) ScrollView(.vertical, showsIndicators: false) { HStack(spacing: 0) { if accounts.app.supportsSearchFilters { filtersHorizontalStack } if let favoriteItem = favoriteItem { FavoriteButton(item: favoriteItem) .id(favoriteItem.id) .labelStyle(.iconOnly) .font(.system(size: 25)) } } HorizontalCells(items: items) } .edgesIgnoringSafeArea(.horizontal) #else VerticalCells(items: items) #endif if noResults { Text("No results") if searchFiltersActive { Button("Reset search filters", action: resetFilters) } Spacer() } } } } private var toolbarPlacement: ToolbarItemPlacement { #if os(iOS) .bottomBar #else .automatic #endif } private var showRecentQueries: Bool { navigationStyle == .tab && state.queryText.isEmpty } private var filtersActive: Bool { searchDuration != .any || searchDate != .any } private func resetFilters() { searchSortOrder = .relevance searchDate = .any searchDuration = .any } private var noResults: Bool { items.isEmpty && !state.isLoading && !state.query.isEmpty } private var recentQueries: some View { VStack { List { Section(header: Text("Recents")) { if recentItems.isEmpty { Text("Search history is empty") .foregroundColor(.secondary) } ForEach(recentItems) { item in Button(item.title) { state.queryText = item.title state.changeQuery { query in query.query = item.title } updateFavoriteItem() } .contextMenu { deleteButton(item) deleteAllButton } } } .redrawOn(change: recentsChanged) } } #if os(iOS) .listStyle(.insetGrouped) #endif } private func deleteButton(_ item: RecentItem) -> some View { Button { recents.close(item) recentsChanged.toggle() } label: { Label("Delete", systemImage: "trash") } } private var deleteAllButton: some View { Button { recents.clearQueries() recentsChanged.toggle() } label: { Label("Delete All", systemImage: "trash.fill") } } private var searchFiltersActive: Bool { searchDate != .any || searchDuration != .any } private var recentItems: [RecentItem] { Defaults[.recentlyOpened].filter { $0.type == .query }.reversed() } private var searchSortOrderPicker: some View { Picker("Sort", selection: $searchSortOrder) { ForEach(SearchQuery.SortOrder.allCases) { sortOrder in Text(sortOrder.name).tag(sortOrder) } } } #if os(tvOS) private var searchSortOrderButton: some View { Button(action: { self.searchSortOrder = self.searchSortOrder.next() }) { Text(self.searchSortOrder.name) .font(.system(size: 30)) .padding(.horizontal) .padding(.vertical, 2) } .buttonStyle(.card) .contextMenu { ForEach(SearchQuery.SortOrder.allCases) { sortOrder in Button(sortOrder.name) { self.searchSortOrder = sortOrder } } } } private var searchDateButton: some View { Button(action: { self.searchDate = self.searchDate.next() }) { Text(self.searchDate.name) .font(.system(size: 30)) .padding(.horizontal) .padding(.vertical, 2) } .buttonStyle(.card) .contextMenu { ForEach(SearchQuery.Date.allCases) { searchDate in Button(searchDate.name) { self.searchDate = searchDate } } } } private var searchDurationButton: some View { Button(action: { self.searchDuration = self.searchDuration.next() }) { Text(self.searchDuration.name) .font(.system(size: 30)) .padding(.horizontal) .padding(.vertical, 2) } .buttonStyle(.card) .contextMenu { ForEach(SearchQuery.Duration.allCases) { searchDuration in Button(searchDuration.name) { self.searchDuration = searchDuration } } } } private var filtersHorizontalStack: some View { HStack { HStack(spacing: 30) { Text("Sort") .foregroundColor(.secondary) searchSortOrderButton } .frame(maxWidth: 300, alignment: .trailing) HStack(spacing: 30) { Text("Duration") .foregroundColor(.secondary) searchDurationButton } .frame(maxWidth: 300) HStack(spacing: 30) { Text("Date") .foregroundColor(.secondary) searchDateButton } .frame(maxWidth: 300, alignment: .leading) } .font(.system(size: 30)) } #else private var filtersMenu: some View { Menu(filtersActive ? "Filter: active" : "Filter") { Picker(selection: $searchDuration, label: Text("Duration")) { ForEach(SearchQuery.Duration.allCases) { duration in Text(duration.name).tag(duration) } } Picker("Upload date", selection: $searchDate) { ForEach(SearchQuery.Date.allCases) { date in Text(date.name).tag(date) } } } .foregroundColor(filtersActive ? .accentColor : .secondary) .transaction { t in t.animation = .none } } #endif private func updateFavoriteItem() { favoriteItem = FavoriteItem(section: .searchQuery( state.query.query, state.query.date?.rawValue ?? "", state.query.duration?.rawValue ?? "", state.query.sortBy.rawValue )) } } struct SearchView_Previews: PreviewProvider { static var previews: some View { NavigationView { SearchView(SearchQuery(query: "Is Google Evil"), videos: Video.fixtures(30)) .injectFixtureEnvironmentObjects() } } }