Consolidate tvOS search header: search, type, filters, view options

Reorganize the tvOS search UI into a single header row with search
field, type filter menu, combined filters menu (sort/date/duration
with reset), and view options button. Removes the separate filter
strip between search and results on tvOS.
This commit is contained in:
Arkadiusz Fal
2026-04-13 22:00:50 +02:00
parent debfdef26f
commit 5417374275
2 changed files with 207 additions and 126 deletions

View File

@@ -117,7 +117,7 @@ struct InstanceBrowseView: View {
GeometryReader { geometry in GeometryReader { geometry in
#if os(tvOS) #if os(tvOS)
VStack(spacing: 0) { VStack(spacing: 0) {
// tvOS: Inline search field and view options button // tvOS: Search field, type filter, search filters, view options
HStack(spacing: 24) { HStack(spacing: 24) {
TextField("instance.browse.search.placeholder", text: $searchText) TextField("instance.browse.search.placeholder", text: $searchText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
@@ -126,6 +126,20 @@ struct InstanceBrowseView: View {
Task { await searchViewModel?.search(query: searchText) } Task { await searchViewModel?.search(query: searchText) }
} }
// Type filter
filterMenu(
title: String(localized: "search.filters.type"),
selection: Binding(
get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 }
),
options: SearchContentType.allCases,
labelForOption: { $0.title }
)
// Combined search filters menu
tvOSFiltersMenu
Button { Button {
showViewOptions = true showViewOptions = true
} label: { } label: {
@@ -156,11 +170,6 @@ struct InstanceBrowseView: View {
feedChannelFilterStrip feedChannelFilterStrip
} }
// Search filter strip (shown persistently after search submitted)
if isInSearchMode && (searchViewModel?.hasSearched ?? false) && instance.supportsSearchFilters {
searchFiltersStrip
}
// Content // Content
Group { Group {
if isInSearchMode, let vm = searchViewModel { if isInSearchMode, let vm = searchViewModel {
@@ -510,81 +519,6 @@ struct InstanceBrowseView: View {
// MARK: - Search Filters Strip // MARK: - Search Filters Strip
private var searchFiltersStrip: some View {
HStack(spacing: 12) {
#if os(tvOS)
// tvOS: Inline filter menus instead of sheet
filterMenu(
title: String(localized: "search.sort"),
selection: Binding(
get: { searchViewModel?.filters.sort ?? .relevance },
set: { searchViewModel?.filters.sort = $0 }
),
options: SearchSortOption.allCases,
labelForOption: { $0.title }
)
filterMenu(
title: String(localized: "search.uploadDate"),
selection: Binding(
get: { searchViewModel?.filters.date ?? .any },
set: { searchViewModel?.filters.date = $0 }
),
options: SearchDateFilter.allCases,
labelForOption: { $0.title }
)
filterMenu(
title: String(localized: "search.duration"),
selection: Binding(
get: { searchViewModel?.filters.duration ?? .any },
set: { searchViewModel?.filters.duration = $0 }
),
options: SearchDurationFilter.allCases,
labelForOption: { $0.title }
)
#else
// 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)
}
#endif
// Content type picker
#if os(tvOS)
filterMenu(
title: String(localized: "search.filters.type"),
selection: Binding(
get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 }
),
options: SearchContentType.allCases,
labelForOption: { $0.title }
)
#else
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
}
.padding(.horizontal)
.padding(.vertical, 8)
.onChange(of: searchViewModel?.filters.type) { _, _ in
Task {
await searchViewModel?.search(query: searchText)
}
}
}
#if os(tvOS) #if os(tvOS)
private func filterMenu<T: Hashable & Identifiable & CaseIterable>( private func filterMenu<T: Hashable & Identifiable & CaseIterable>(
title: String, title: String,
@@ -610,6 +544,100 @@ struct InstanceBrowseView: View {
.font(.caption) .font(.caption)
} }
} }
private var tvOSFiltersMenu: some View {
Menu {
Menu(String(localized: "search.sort")) {
ForEach(SearchSortOption.allCases) { option in
Button {
searchViewModel?.filters.sort = option
Task { await searchViewModel?.search(query: searchText) }
} label: {
if searchViewModel?.filters.sort == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
Menu(String(localized: "search.uploadDate")) {
ForEach(SearchDateFilter.allCases) { option in
Button {
searchViewModel?.filters.date = option
Task { await searchViewModel?.search(query: searchText) }
} label: {
if searchViewModel?.filters.date == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
Menu(String(localized: "search.duration")) {
ForEach(SearchDurationFilter.allCases) { option in
Button {
searchViewModel?.filters.duration = option
Task { await searchViewModel?.search(query: searchText) }
} label: {
if searchViewModel?.filters.duration == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
Divider()
Button(role: .destructive) {
let currentType = searchViewModel?.filters.type ?? .video
searchViewModel?.filters = .defaults
searchViewModel?.filters.type = currentType
Task { await searchViewModel?.search(query: searchText) }
} 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")
.font(.caption)
}
}
#else
private var searchFiltersStrip: some View {
HStack(spacing: 12) {
Button {
showFilterSheet = true
} label: {
Image(systemName: (searchViewModel?.filters.isDefault ?? true)
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
.font(.title2)
}
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
Task {
await searchViewModel?.search(query: searchText)
}
}
}
#endif #endif
// MARK: - Feed Channel Filter Strip // MARK: - Feed Channel Filter Strip

View File

@@ -179,7 +179,7 @@ struct SearchView: View {
private var tvOSOrDefaultContent: some View { private var tvOSOrDefaultContent: some View {
#if os(tvOS) #if os(tvOS)
VStack(spacing: 0) { VStack(spacing: 0) {
// tvOS: Inline search field and view options button // tvOS: Search field, type filter, search filters, view options
HStack(spacing: 24) { HStack(spacing: 24) {
TextField("search.placeholder", text: searchTextBinding) TextField("search.placeholder", text: searchTextBinding)
.textFieldStyle(.plain) .textFieldStyle(.plain)
@@ -192,6 +192,20 @@ struct SearchView: View {
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
} }
// Type filter
filterMenu(
title: String(localized: "search.filters.type"),
selection: Binding(
get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 }
),
options: SearchContentType.allCases,
labelForOption: { $0.title }
)
// Combined search filters menu
tvOSFiltersMenu
Button { Button {
showViewOptions = true showViewOptions = true
} label: { } label: {
@@ -382,38 +396,9 @@ struct SearchView: View {
} }
} }
#if !os(tvOS)
private var searchFiltersStrip: some View { private var searchFiltersStrip: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
#if os(tvOS)
// tvOS: Inline filter menus instead of sheet
filterMenu(
title: String(localized: "search.sort"),
selection: Binding(
get: { searchViewModel?.filters.sort ?? .relevance },
set: { searchViewModel?.filters.sort = $0 }
),
options: SearchSortOption.allCases,
labelForOption: { $0.title }
)
filterMenu(
title: String(localized: "search.uploadDate"),
selection: Binding(
get: { searchViewModel?.filters.date ?? .any },
set: { searchViewModel?.filters.date = $0 }
),
options: SearchDateFilter.allCases,
labelForOption: { $0.title }
)
filterMenu(
title: String(localized: "search.duration"),
selection: Binding(
get: { searchViewModel?.filters.duration ?? .any },
set: { searchViewModel?.filters.duration = $0 }
),
options: SearchDurationFilter.allCases,
labelForOption: { $0.title }
)
#else
// Filter button // Filter button
Button { Button {
showFilterSheet = true showFilterSheet = true
@@ -423,20 +408,8 @@ struct SearchView: View {
: "line.3.horizontal.decrease.circle.fill") : "line.3.horizontal.decrease.circle.fill")
.font(.title2) .font(.title2)
} }
#endif
// Content type picker // Content type segmented picker
#if os(tvOS)
filterMenu(
title: String(localized: "search.filters.type"),
selection: Binding(
get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 }
),
options: SearchContentType.allCases,
labelForOption: { $0.title }
)
#else
Picker("", selection: Binding( Picker("", selection: Binding(
get: { searchViewModel?.filters.type ?? .video }, get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 } set: { searchViewModel?.filters.type = $0 }
@@ -446,7 +419,6 @@ struct SearchView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
#endif
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -459,6 +431,7 @@ struct SearchView: View {
} }
} }
} }
#endif
#if os(tvOS) #if os(tvOS)
private func filterMenu<T: Hashable & Identifiable & CaseIterable>( private func filterMenu<T: Hashable & Identifiable & CaseIterable>(
@@ -488,6 +461,78 @@ struct SearchView: View {
.font(.caption) .font(.caption)
} }
} }
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")
.font(.caption)
}
}
#endif #endif
// MARK: - Search History Helpers // MARK: - Search History Helpers
@@ -867,10 +912,12 @@ struct SearchView: View {
.overlay( .overlay(
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
#if !os(tvOS)
// Filter strip at top (only for instances that support search filters) // Filter strip at top (only for instances that support search filters)
if searchInstance?.supportsSearchFilters == true { if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip searchFiltersStrip
} }
#endif
// Loading indicator // Loading indicator
ProgressView() ProgressView()
@@ -928,9 +975,11 @@ struct SearchView: View {
if let vm = searchViewModel { if let vm = searchViewModel {
VStack(spacing: 0) { VStack(spacing: 0) {
// Filter strip at top (only for instances that support search filters) // Filter strip at top (only for instances that support search filters)
#if !os(tvOS)
if searchInstance?.supportsSearchFilters == true { if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip searchFiltersStrip
} }
#endif
// Card container // Card container
VideoListContent(listStyle: .inset) { VideoListContent(listStyle: .inset) {
@@ -954,9 +1003,11 @@ struct SearchView: View {
if let vm = searchViewModel { if let vm = searchViewModel {
VStack(spacing: 0) { VStack(spacing: 0) {
// Filter strip at top (only for instances that support search filters) // Filter strip at top (only for instances that support search filters)
#if !os(tvOS)
if searchInstance?.supportsSearchFilters == true { if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip searchFiltersStrip
} }
#endif
VideoListContent(listStyle: .plain) { VideoListContent(listStyle: .plain) {
ForEach(Array(unifiedResults.enumerated()), id: \.element.id) { resultIndex, item in ForEach(Array(unifiedResults.enumerated()), id: \.element.id) { resultIndex, item in
@@ -1042,10 +1093,12 @@ struct SearchView: View {
if let vm = searchViewModel { if let vm = searchViewModel {
LazyVStack(spacing: 16) { LazyVStack(spacing: 16) {
// Filter strip at top (only for instances that support search filters) // Filter strip at top (only for instances that support search filters)
#if !os(tvOS)
if searchInstance?.supportsSearchFilters == true { if searchInstance?.supportsSearchFilters == true {
searchFiltersStrip searchFiltersStrip
.padding(.bottom, 8) .padding(.bottom, 8)
} }
#endif
// Single grid with mixed content preserving order // Single grid with mixed content preserving order
VideoGridContent(columns: gridConfig.effectiveColumns) { VideoGridContent(columns: gridConfig.effectiveColumns) {