mirror of
https://github.com/yattee/yattee.git
synced 2026-05-11 18:05:03 +00:00
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:
@@ -117,7 +117,7 @@ struct InstanceBrowseView: View {
|
||||
GeometryReader { geometry in
|
||||
#if os(tvOS)
|
||||
VStack(spacing: 0) {
|
||||
// tvOS: Inline search field and view options button
|
||||
// tvOS: Search field, type filter, search filters, view options
|
||||
HStack(spacing: 24) {
|
||||
TextField("instance.browse.search.placeholder", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
@@ -126,6 +126,20 @@ struct InstanceBrowseView: View {
|
||||
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 {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
@@ -156,11 +170,6 @@ struct InstanceBrowseView: View {
|
||||
feedChannelFilterStrip
|
||||
}
|
||||
|
||||
// Search filter strip (shown persistently after search submitted)
|
||||
if isInSearchMode && (searchViewModel?.hasSearched ?? false) && instance.supportsSearchFilters {
|
||||
searchFiltersStrip
|
||||
}
|
||||
|
||||
// Content
|
||||
Group {
|
||||
if isInSearchMode, let vm = searchViewModel {
|
||||
@@ -510,81 +519,6 @@ struct InstanceBrowseView: View {
|
||||
|
||||
// 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)
|
||||
private func filterMenu<T: Hashable & Identifiable & CaseIterable>(
|
||||
title: String,
|
||||
@@ -610,6 +544,100 @@ struct InstanceBrowseView: View {
|
||||
.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
|
||||
|
||||
// MARK: - Feed Channel Filter Strip
|
||||
|
||||
@@ -179,7 +179,7 @@ struct SearchView: View {
|
||||
private var tvOSOrDefaultContent: some View {
|
||||
#if os(tvOS)
|
||||
VStack(spacing: 0) {
|
||||
// tvOS: Inline search field and view options button
|
||||
// tvOS: Search field, type filter, search filters, view options
|
||||
HStack(spacing: 24) {
|
||||
TextField("search.placeholder", text: searchTextBinding)
|
||||
.textFieldStyle(.plain)
|
||||
@@ -192,6 +192,20 @@ struct SearchView: View {
|
||||
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 {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
@@ -382,38 +396,9 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
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
|
||||
@@ -423,20 +408,8 @@ struct SearchView: View {
|
||||
: "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
|
||||
// Content type segmented picker
|
||||
Picker("", selection: Binding(
|
||||
get: { searchViewModel?.filters.type ?? .video },
|
||||
set: { searchViewModel?.filters.type = $0 }
|
||||
@@ -446,7 +419,6 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
@@ -459,6 +431,7 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
private func filterMenu<T: Hashable & Identifiable & CaseIterable>(
|
||||
@@ -488,6 +461,78 @@ struct SearchView: View {
|
||||
.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
|
||||
|
||||
// MARK: - Search History Helpers
|
||||
@@ -867,10 +912,12 @@ struct SearchView: View {
|
||||
.overlay(
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
#if !os(tvOS)
|
||||
// Filter strip at top (only for instances that support search filters)
|
||||
if searchInstance?.supportsSearchFilters == true {
|
||||
searchFiltersStrip
|
||||
}
|
||||
#endif
|
||||
|
||||
// Loading indicator
|
||||
ProgressView()
|
||||
@@ -928,9 +975,11 @@ struct SearchView: View {
|
||||
if let vm = searchViewModel {
|
||||
VStack(spacing: 0) {
|
||||
// Filter strip at top (only for instances that support search filters)
|
||||
#if !os(tvOS)
|
||||
if searchInstance?.supportsSearchFilters == true {
|
||||
searchFiltersStrip
|
||||
}
|
||||
#endif
|
||||
|
||||
// Card container
|
||||
VideoListContent(listStyle: .inset) {
|
||||
@@ -954,9 +1003,11 @@ struct SearchView: View {
|
||||
if let vm = searchViewModel {
|
||||
VStack(spacing: 0) {
|
||||
// Filter strip at top (only for instances that support search filters)
|
||||
#if !os(tvOS)
|
||||
if searchInstance?.supportsSearchFilters == true {
|
||||
searchFiltersStrip
|
||||
}
|
||||
#endif
|
||||
|
||||
VideoListContent(listStyle: .plain) {
|
||||
ForEach(Array(unifiedResults.enumerated()), id: \.element.id) { resultIndex, item in
|
||||
@@ -1042,10 +1093,12 @@ struct SearchView: View {
|
||||
if let vm = searchViewModel {
|
||||
LazyVStack(spacing: 16) {
|
||||
// Filter strip at top (only for instances that support search filters)
|
||||
#if !os(tvOS)
|
||||
if searchInstance?.supportsSearchFilters == true {
|
||||
searchFiltersStrip
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Single grid with mixed content preserving order
|
||||
VideoGridContent(columns: gridConfig.effectiveColumns) {
|
||||
|
||||
Reference in New Issue
Block a user