mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +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
|
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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user