Files
yattee/Yattee/Views/Search/SearchView.swift
Arkadiusz Fal 612dce6b9f Refactor views
2026-02-09 01:13:02 +01:00

1249 lines
51 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
@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<String>? = 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)
}