Files
yattee/Yattee/Views/Settings/PeerTubeInstancesExploreView.swift
Arkadiusz Fal 7ecd408f89 Pin search bar to right of macOS toolbar in list views
Match the global Search view by pinning the searchable field and
trailing buttons to the right edge on macOS 26 via a flexible
ToolbarSpacer at .primaryAction. Applied to History, Bookmarks,
Manage Channels, Downloads, Instance Browse, and PeerTube Explore.

In History, the clear-history item moves from .automatic to
.primaryAction so it groups with the other trailing controls.
2026-06-12 09:54:24 +02:00

319 lines
9.8 KiB
Swift

//
// PeerTubeInstancesExploreView.swift
// Yattee
//
// View for browsing and adding PeerTube instances from the public directory.
//
import SwiftUI
struct PeerTubeInstancesExploreView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
// All instances loaded from API
@State private var allInstances: [PeerTubeDirectoryInstance] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var searchText = ""
@State private var filters = PeerTubeDirectoryFilters()
@State private var showFiltersSheet = false
// Pagination for display (client-side)
@State private var displayLimit = 50
private let pageSize = 50
// Cache for filter options
@State private var availableLanguages: [String] = []
@State private var availableCountries: [String] = []
/// Filtered instances based on current filters and search
private var filteredInstances: [PeerTubeDirectoryInstance] {
var result = allInstances
// Filter by search text
let searchQuery = searchText.lowercased().trimmingCharacters(in: .whitespaces)
if !searchQuery.isEmpty {
result = result.filter { instance in
instance.name.lowercased().contains(searchQuery) ||
instance.host.lowercased().contains(searchQuery) ||
(instance.shortDescription?.lowercased().contains(searchQuery) ?? false)
}
}
// Filter by language
if let language = filters.language {
result = result.filter { $0.languages.contains(language) }
}
// Filter by country
if let country = filters.country {
result = result.filter { $0.country == country }
}
return result
}
/// Instances to display (with pagination)
private var displayedInstances: [PeerTubeDirectoryInstance] {
Array(filteredInstances.prefix(displayLimit))
}
/// Whether more instances can be loaded
private var hasMore: Bool {
displayLimit < filteredInstances.count
}
var body: some View {
Group {
#if os(tvOS)
content
.onChange(of: searchText) { _, _ in
displayLimit = pageSize
}
#else
NavigationStack {
content
.navigationTitle(String(localized: "peertube.explore.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar { toolbarContent }
.searchable(text: $searchText, prompt: Text(String(localized: "peertube.explore.search")))
.onChange(of: searchText) { _, _ in
// Reset display limit when search changes
displayLimit = pageSize
}
}
#endif
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 400)
#endif
.task {
await loadAllInstances()
}
#if !os(macOS)
.sheet(isPresented: $showFiltersSheet) {
filtersSheetContent
}
#endif
}
private var filtersSheetContent: some View {
PeerTubeFiltersSheet(
filters: $filters,
languages: availableLanguages,
countries: availableCountries,
onApply: {
// Reset display limit when filters change
displayLimit = pageSize
}
)
}
// MARK: - Content
@ViewBuilder
private var content: some View {
#if os(tvOS)
VStack(spacing: 0) {
if !allInstances.isEmpty {
tvOSSearchHeader
}
contentBody
}
#else
contentBody
#endif
}
@ViewBuilder
private var contentBody: some View {
if isLoading && allInstances.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage, allInstances.isEmpty {
ContentUnavailableView {
Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button(String(localized: "common.retry")) {
Task {
await loadAllInstances()
}
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if displayedInstances.isEmpty {
ContentUnavailableView {
Label(String(localized: "peertube.explore.noResults"), systemImage: "magnifyingglass")
} description: {
Text(String(localized: "peertube.explore.noResults.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
instancesList
}
}
#if os(tvOS)
@ViewBuilder
private var tvOSSearchHeader: some View {
HStack(spacing: 16) {
TextField(
"",
text: $searchText,
prompt: Text(String(localized: "peertube.explore.search"))
)
.textFieldStyle(.plain)
.focusSection()
Button {
showFiltersSheet = true
} label: {
Label(
String(localized: "peertube.explore.filters"),
systemImage: filters.isDefault ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill"
)
}
.focusSection()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
#endif
private var instancesList: some View {
List {
// Instances
ForEach(displayedInstances) { instance in
PeerTubeDirectoryRow(
instance: instance,
isAlreadyAdded: isInstanceAdded(instance)
) {
addInstance(instance)
}
}
// Load more indicator
if hasMore {
HStack {
Spacer()
Button(String(localized: "common.loadMore")) {
loadMore()
}
.buttonStyle(.borderless)
Spacer()
}
.onAppear {
loadMore()
}
}
}
.listStyle(.plain)
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
#if !os(tvOS)
sheetCloseToolbarItem { dismiss() }
#endif
#if os(macOS)
// Pin the trailing group (search field + toolbar buttons) to the right edge,
// matching the global Search view.
if #available(macOS 26, *) {
ToolbarSpacer(.flexible, placement: .primaryAction)
}
#endif
ToolbarItem(placement: .primaryAction) {
Button {
showFiltersSheet = true
} label: {
Label(
String(localized: "peertube.explore.filters"),
systemImage: filters.isDefault ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill"
)
}
#if os(macOS)
.popover(isPresented: $showFiltersSheet, arrowEdge: .bottom) {
filtersSheetContent
}
#endif
}
}
// MARK: - Data Loading
private func loadAllInstances() async {
guard let appEnvironment else { return }
let api = PeerTubeDirectoryAPI(httpClient: appEnvironment.httpClient)
isLoading = true
errorMessage = nil
do {
// Load all instances (API returns up to ~1700)
let response = try await api.fetchInstances(start: 0, count: 2000)
await MainActor.run {
allInstances = response.data
isLoading = false
// Extract available languages and countries from the data
var languages = Set<String>()
var countries = Set<String>()
for instance in response.data {
languages.formUnion(instance.languages)
if let country = instance.country, !country.isEmpty {
countries.insert(country)
}
}
availableLanguages = languages.sorted()
availableCountries = countries.sorted()
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func loadMore() {
displayLimit += pageSize
}
// MARK: - Instance Management
private func isInstanceAdded(_ directoryInstance: PeerTubeDirectoryInstance) -> Bool {
guard let url = directoryInstance.url,
let instancesManager = appEnvironment?.instancesManager else { return false }
return instancesManager.instances.contains { $0.url.host == url.host }
}
private func addInstance(_ directoryInstance: PeerTubeDirectoryInstance) {
guard let url = directoryInstance.url,
let instancesManager = appEnvironment?.instancesManager else { return }
let instance = Instance(
type: .peertube,
url: url,
name: directoryInstance.name
)
instancesManager.add(instance)
}
}
// MARK: - Preview
#Preview {
PeerTubeInstancesExploreView()
.appEnvironment(.preview)
}