Files
yattee/Yattee/Views/Search/SearchView.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

1602 lines
63 KiB
Swift

//
// 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<String>?
/// Internal search text state (for standalone usage)
@State private var internalSearchText = ""
/// Computed binding to use external or internal search text
private var searchTextBinding: Binding<String> {
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
/// Suppresses the next onChange suggestion-fetch when the search text is set
/// programmatically (recent search / suggestion tap / deep link), so it goes
/// straight to results instead of re-showing suggestions.
@State private var suppressNextTextChangeSearch = 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
@AppStorage("searchHideShorts") private var hideShorts = 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
@State private var watchEntriesMap: [String: WatchEntry] = [:]
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
}
/// Initialize SearchView with optional external search text binding
init(searchText: Binding<String>? = nil, initialQuery: String? = nil) {
self.externalSearchText = searchText
self.initialQuery = initialQuery
}
/// View options button lives on the leading edge on macOS, trailing elsewhere.
private var viewOptionsPlacement: ToolbarItemPlacement {
#if os(macOS)
.navigation
#else
.primaryAction
#endif
}
var body: some View {
tvOSOrDefaultContent
#if !os(macOS)
.sheet(isPresented: $showFilterSheet) {
searchFiltersSheetContent
#if !os(tvOS)
.presentationDetents([.medium, .large])
#endif
}
.sheet(isPresented: $showViewOptions) {
viewOptionsSheetContent
#if !os(tvOS)
.liquidGlassSheetContent(sourceID: "searchViewOptions", in: sheetTransition)
#endif
}
#endif
.onChange(of: searchTextBinding.wrappedValue) { _, newValue in
if suppressNextTextChangeSearch {
suppressNextTextChangeSearch = false
return
}
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 {
suppressNextTextChangeSearch = true
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: .watchHistoryDidChange)) { _ in
loadWatchEntries()
}
.task {
loadWatchEntries()
}
.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: hideShorts) { _, newValue in
searchViewModel?.hideShorts = newValue
}
.onChange(of: saveRecentSearches) { _, _ in
loadSearchHistory()
}
.onChange(of: saveRecentChannels) { _, _ in
loadRecentChannels()
}
.onChange(of: saveRecentPlaylists) { _, _ in
loadRecentPlaylists()
}
}
private var searchFiltersSheetContent: some View {
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)
}
))
}
private var viewOptionsSheetContent: some View {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumns,
hideWatched: $hideWatched,
hideShorts: $hideShorts,
maxGridColumns: gridConfig.maxColumns
)
}
#if os(macOS)
private var searchFiltersToolbarButton: some View {
Button {
showFilterSheet = true
} label: {
Label(String(localized: "search.filters"), systemImage: filtersIconName)
}
.popover(isPresented: $showFilterSheet, arrowEdge: .bottom) {
searchFiltersSheetContent
}
}
#endif
@ViewBuilder
private var tvOSOrDefaultContent: some View {
#if os(tvOS)
VStack(spacing: 0) {
// tvOS: Search field, type filter, search filters, view options
HStack(spacing: 24) {
TextField("search.placeholder", text: searchTextBinding)
.textFieldStyle(.plain)
.onSubmit {
searchViewModel?.cancelSuggestions()
searchViewModel?.filters.type = .video
if let filters = searchViewModel?.filters {
saveFilters(filters)
}
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
}
if !searchTextBinding.wrappedValue.isEmpty {
// Type filter
filterMenu(
title: (searchViewModel?.filters.type ?? .video).title,
selection: Binding(
get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 }
),
options: SearchContentType.allCases,
labelForOption: { $0.title }
)
// Combined search filters menu
tvOSFiltersMenu
}
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 20)
searchContent
.padding(.top, 20)
.focusSection()
}
#else
searchContent
.navigationTitle(String(localized: "tabs.search"))
.toolbarTitleDisplayMode(.inlineLarge)
.toolbar {
// View options first so it is the leftmost navigation item on macOS.
ToolbarItem(placement: viewOptionsPlacement) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "searchViewOptions", in: sheetTransition)
#if os(macOS)
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
viewOptionsSheetContent
}
#endif
}
#if os(macOS)
if searchInstance?.supportsSearchFilters == true {
// Filters button + content type picker are always visible on macOS.
// The picker is rendered bare (no opacity/disabled/fixedSize wrappers):
// wrapping a `.segmented` Picker in those modifiers inserts a hosting
// layer that makes the toolbar fall back to a Liquid-Glass pill instead
// of the native segmented control.
ToolbarItem(placement: .navigation) {
searchFiltersToolbarButton
}
ToolbarItem(placement: .principal) {
if #available(macOS 26, *) {
contentTypePicker
} else {
contentTypePicker
.fixedSize()
}
}
}
#endif
#if os(macOS)
// Pin the trailing group (search field) to the right edge
// so it doesn't shift when the filter items appear/disappear.
if #available(macOS 26, *) {
ToolbarSpacer(.flexible, placement: .primaryAction)
}
#endif
}
.searchable(text: searchTextBinding, prompt: Text(String(localized: "search.placeholder")))
.onSubmit(of: .search) {
searchViewModel?.cancelSuggestions()
#if !os(macOS)
// On macOS the content type picker is always visible, so preserve the
// user's chosen type instead of resetting it to .video on each submit.
searchViewModel?.filters.type = .video
#endif
if let filters = searchViewModel?.filters {
saveFilters(filters)
}
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
}
.onChange(of: searchViewModel?.filters.type) { _, _ in
if let filters = searchViewModel?.filters {
saveFilters(filters)
}
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
}
#endif
}
@ViewBuilder
private var searchContent: some View {
Group {
if searchTextBinding.wrappedValue.isEmpty {
emptySearchView
} else if let vm = searchViewModel {
if !vm.hasSearched {
if !vm.suggestions.isEmpty && !hasResults {
suggestionsView
} else if vm.isFetchingSuggestions && !hasResults {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Spacer()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else if vm.isSearching && !hasResults {
resultsViewWithLoading
} else if let error = vm.errorMessage, !hasResults {
errorView(error)
} else if hasResults {
resultsView
} else {
noResultsView
}
} else {
Spacer()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
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
searchViewModel?.hideShorts = hideShorts
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
searchViewModel?.hideShorts = hideShorts
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
private var recentItemsSpacing: CGFloat {
#if os(tvOS)
16
#else
0
#endif
}
private var horizontalCardSpacing: CGFloat {
#if os(tvOS)
48
#else
16
#endif
}
/// 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)
#if !os(tvOS)
.clipShape(.rect(cornerRadius: 10))
#endif
.padding(.horizontal)
}
}
#if !os(tvOS)
private var filtersIconName: String {
(searchViewModel?.filters.isDefault ?? true)
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill"
}
// Content type segmented picker
private var contentTypePicker: some View {
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)
}
#endif
#if os(iOS)
private var searchFiltersStrip: some View {
HStack(spacing: 12) {
// Filter button
Button {
showFilterSheet = true
} label: {
Image(systemName: filtersIconName)
.font(.title2)
}
contentTypePicker
}
.padding(.horizontal)
.padding(.vertical, 8)
}
#endif
#if os(tvOS)
private func filterMenu<T: Hashable & Identifiable & CaseIterable>(
title: String,
selection: Binding<T>,
options: [T],
labelForOption: @escaping (T) -> String
) -> some View {
Menu {
ForEach(options) { option in
Button {
selection.wrappedValue = option
if let filters = searchViewModel?.filters {
saveFilters(filters)
}
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
} label: {
if option == selection.wrappedValue {
Label(labelForOption(option), systemImage: "checkmark")
} else {
Text(labelForOption(option))
}
}
}
} label: {
Text(title)
}
}
private var tvOSFiltersMenu: some View {
Menu {
// Sort By
Menu(String(localized: "search.sort")) {
ForEach(SearchSortOption.allCases) { option in
Button {
searchViewModel?.filters.sort = option
if let filters = searchViewModel?.filters { saveFilters(filters) }
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
} label: {
if searchViewModel?.filters.sort == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
// Upload Date
Menu(String(localized: "search.uploadDate")) {
ForEach(SearchDateFilter.allCases) { option in
Button {
searchViewModel?.filters.date = option
if let filters = searchViewModel?.filters { saveFilters(filters) }
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
} label: {
if searchViewModel?.filters.date == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
// Duration
Menu(String(localized: "search.duration")) {
ForEach(SearchDurationFilter.allCases) { option in
Button {
searchViewModel?.filters.duration = option
if let filters = searchViewModel?.filters { saveFilters(filters) }
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
} label: {
if searchViewModel?.filters.duration == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
Divider()
// Reset
Button(role: .destructive) {
let currentType = searchViewModel?.filters.type ?? .video
searchViewModel?.filters = .defaults
searchViewModel?.filters.type = currentType
if let filters = searchViewModel?.filters { saveFilters(filters) }
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
} label: {
Label(String(localized: "search.filters.reset"), systemImage: "arrow.counterclockwise")
}
.disabled(searchViewModel?.filters.isDefault ?? true)
} label: {
Label(String(localized: "search.filters"), systemImage: "line.3.horizontal.decrease")
}
}
#endif
// 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
#if os(tvOS)
HStack {
Text(String(localized: "search.recentSearches.title"))
.font(.headline)
.foregroundStyle(.primary)
Spacer()
}
.padding(.horizontal)
.padding(.bottom, 8)
#else
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)
#endif
// Items
VStack(spacing: recentItemsSpacing) {
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 !os(tvOS)
if history.id != displayedSearchHistory.last?.id {
Divider()
.padding(.leading, 52)
}
#endif
}
}
#if os(tvOS)
.background(.clear)
#else
.background(listStyle == .inset ? ListBackgroundStyle.card.color : Color.clear)
.clipShape(.rect(cornerRadius: listStyle == .inset ? 10 : 0))
#endif
.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: horizontalCardSpacing) {
ForEach(recentChannels) { recentChannel in
NavigationLink(
value: NavigationDestination.channel(
recentChannel.channelID,
sourceFromRawValue(
recentChannel.sourceRawValue,
instanceURL: recentChannel.instanceURLString
)
)
) {
ChannelCardGridView(
channel: channelFromRecent(recentChannel),
isCompact: false
)
#if os(tvOS)
.frame(width: 240)
#else
.frame(width: 160)
#endif
}
.zoomTransitionSource(id: recentChannel.channelID)
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
deleteRecentChannel(recentChannel)
} label: {
Label(String(localized: "common.delete"), systemImage: "trash")
}
}
}
}
.padding(.horizontal)
}
#if os(tvOS)
.scrollClipDisabled()
#endif
}
}
// 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: horizontalCardSpacing) {
ForEach(recentPlaylists) { recentPlaylist in
NavigationLink(value: NavigationDestination.playlist(.remote(playlistIDFromRecent(recentPlaylist), instance: nil, title: recentPlaylist.title))) {
PlaylistCardView(
playlist: playlistFromRecent(recentPlaylist),
isCompact: false
)
#if os(tvOS)
.frame(width: 320)
#else
.frame(width: 200)
#endif
}
.zoomTransitionSource(id: playlistIDFromRecent(recentPlaylist))
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
deleteRecentPlaylist(recentPlaylist)
} label: {
Label(String(localized: "common.delete"), systemImage: "trash")
}
}
}
}
.padding(.horizontal)
}
#if os(tvOS)
.scrollClipDisabled()
#endif
}
}
// Clear All Recents button at bottom
Button {
showingClearAllRecentsConfirmation = true
} label: {
HStack(spacing: 6) {
Image(systemName: "trash")
Text(String(localized: "search.clearAllRecents"))
.fontWeight(.semibold)
}
}
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.top, 16)
.padding(.bottom, 32)
}
.padding(.vertical)
}
#if os(tvOS)
.scrollClipDisabled()
#endif
.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) {}
}
.presentationCompactAdaptation(.sheet)
}
}
}
@ViewBuilder
private var suggestionsView: some View {
if let vm = searchViewModel {
List {
ForEach(vm.suggestions, id: \.self) { suggestion in
Button {
dismissKeyboard()
suppressNextTextChangeSearch = (suggestion != searchTextBinding.wrappedValue)
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"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.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) }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// 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 {
#if os(tvOS)
ProgressView()
.accessibilityIdentifier("search.loading")
.frame(maxWidth: .infinity)
.padding(.top, 40)
#else
resultsBackgroundStyle.color
.ignoresSafeArea()
.overlay(
ScrollView {
VStack(spacing: 16) {
#if os(iOS)
if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip
}
#endif
ProgressView()
.accessibilityIdentifier("search.loading")
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}
)
#endif
}
}
@ViewBuilder
private var resultsView: some View {
if searchViewModel != nil {
#if os(tvOS)
ScrollView {
if layout == .list {
listResultsContent
} else {
gridResultsContent
}
}
.scrollClipDisabled()
.accessibilityLabel("search.results")
.background(
GeometryReader { geometry in
Color.clear
.onAppear {
viewWidth = geometry.size.width
}
.onChange(of: geometry.size.width) { _, newWidth in
viewWidth = newWidth
}
}
)
#else
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
}
}
)
)
#endif
}
}
@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 os(iOS)
if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip
}
#endif
// 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 os(iOS)
if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip
}
#endif
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, watchProgress: watchProgress(for: video))
.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 os(iOS)
if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip
.padding(.bottom, 8)
}
#endif
// 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,
watchProgress: watchProgress(for: 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 loadWatchEntries() {
watchEntriesMap = appEnvironment?.dataManager.watchEntriesMap() ?? [:]
}
private func watchProgress(for video: Video) -> Double? {
guard let entry = watchEntriesMap[video.id.videoID] else { return nil }
let progress = entry.progress
return progress > 0 && progress < 1 ? progress : nil
}
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 executeSearch(_ query: String) {
dismissKeyboard()
suppressNextTextChangeSearch = (query != searchTextBinding.wrappedValue)
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 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 {
#if os(macOS)
// Popover content: filters apply live, click-outside dismisses.
filtersForm
.onChange(of: filters) { _, _ in onApply() }
.padding()
.frame(width: 300)
#else
NavigationStack {
filtersForm
.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()
}
}
}
}
#endif
}
private var filtersForm: some View {
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)
}
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
SearchView()
}
.appEnvironment(.preview)
}