diff --git a/Extensions/Array+Next.swift b/Extensions/Array+Next.swift index 767929f1..2879596b 100644 --- a/Extensions/Array+Next.swift +++ b/Extensions/Array+Next.swift @@ -2,7 +2,7 @@ extension Array where Element: Equatable { func next(after element: Element) -> Element? { let idx = firstIndex(of: element) - if idx == nil { + if idx.isNil { return first } diff --git a/Model/InvidiousAPI.swift b/Model/InvidiousAPI.swift index fc74ed2a..756e921e 100644 --- a/Model/InvidiousAPI.swift +++ b/Model/InvidiousAPI.swift @@ -225,12 +225,12 @@ final class InvidiousAPI: Service, ObservableObject { .withParam("q", searchQuery(query.query)) .withParam("sort_by", query.sortBy.parameter) - if let date = query.date?.rawValue { - resource = resource.withParam("date", date) + if let date = query.date, date != .any { + resource = resource.withParam("date", date.rawValue) } - if let duration = query.duration?.rawValue { - resource = resource.withParam("duration", duration) + if let duration = query.duration, duration != .any { + resource = resource.withParam("duration", duration.rawValue) } return resource diff --git a/Model/PlayerModel.swift b/Model/PlayerModel.swift index a2d0f950..5bb42fd3 100644 --- a/Model/PlayerModel.swift +++ b/Model/PlayerModel.swift @@ -116,7 +116,7 @@ final class PlayerModel: ObservableObject { self.saveTime() self.player?.replaceCurrentItem(with: self.playerItemWithMetadata(for: stream)) self.playback.stream = stream - if self.timeObserver == nil { + if self.timeObserver.isNil { self.addTimeObserver() } self.player?.play() @@ -201,7 +201,7 @@ final class PlayerModel: ObservableObject { } fileprivate func composition(for stream: Stream) -> AVMutableComposition { - if compositions[stream] == nil { + if compositions[stream].isNil { compositions[stream] = AVMutableComposition() } diff --git a/Model/RecentsModel.swift b/Model/RecentsModel.swift index 69910588..caf738bf 100644 --- a/Model/RecentsModel.swift +++ b/Model/RecentsModel.swift @@ -29,7 +29,9 @@ final class RecentsModel: ObservableObject { } func addQuery(_ query: String) { - open(.init(from: query)) + if !query.isEmpty { + open(.init(from: query)) + } } var presentedChannel: Channel? { diff --git a/Model/SearchModel.swift b/Model/SearchModel.swift index c49cf5ac..40b35f91 100644 --- a/Model/SearchModel.swift +++ b/Model/SearchModel.swift @@ -5,7 +5,7 @@ import SwiftUI final class SearchModel: ObservableObject { @Published var store = Store<[Video]>() - @Published var api: InvidiousAPI! + @Published var api = InvidiousAPI() @Published var query = SearchQuery() @Published var queryText = "" @Published var querySuggestions = Store<[String]>() @@ -30,10 +30,13 @@ final class SearchModel: ObservableObject { resource = newResource resource.addObserver(store) - loadResourceIfNeededAndReplaceStore() + + if !query.isEmpty { + loadResourceIfNeededAndReplaceStore() + } } - func resetQuery(_ query: SearchQuery) { + func resetQuery(_ query: SearchQuery = SearchQuery()) { self.query = query let newResource = api.search(query) @@ -48,7 +51,10 @@ final class SearchModel: ObservableObject { resource = newResource resource.addObserver(store) - loadResourceIfNeededAndReplaceStore() + + if !query.isEmpty { + loadResourceIfNeededAndReplaceStore() + } } func loadResourceIfNeededAndReplaceStore() { diff --git a/Model/SearchQuery.swift b/Model/SearchQuery.swift index 2bcfda7e..003803ae 100644 --- a/Model/SearchQuery.swift +++ b/Model/SearchQuery.swift @@ -3,7 +3,7 @@ import Foundation final class SearchQuery: ObservableObject { enum Date: String, CaseIterable, Identifiable, DefaultsSerializable { - case hour, today, week, month, year + case any, hour, today, week, month, year var id: SearchQuery.Date.RawValue { rawValue @@ -15,7 +15,7 @@ final class SearchQuery: ObservableObject { } enum Duration: String, CaseIterable, Identifiable, DefaultsSerializable { - case short, long + case any, short, long var id: SearchQuery.Duration.RawValue { rawValue diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index dfa865e4..1d821a32 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -36,7 +36,6 @@ 373CFAC226966159003CB2C6 /* CoverSectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAC126966159003CB2C6 /* CoverSectionRowView.swift */; }; 373CFAC32696616C003CB2C6 /* CoverSectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAC126966159003CB2C6 /* CoverSectionRowView.swift */; }; 373CFAC42696616C003CB2C6 /* CoverSectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAC126966159003CB2C6 /* CoverSectionRowView.swift */; }; - 373CFAC926966188003CB2C6 /* SearchOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAC52696617C003CB2C6 /* SearchOptionsView.swift */; }; 373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; }; 373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; }; 373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; }; @@ -312,7 +311,6 @@ 372915E52687E3B900F5A35B /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; 373CFABD26966115003CB2C6 /* CoverSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverSectionView.swift; sourceTree = ""; }; 373CFAC126966159003CB2C6 /* CoverSectionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverSectionRowView.swift; sourceTree = ""; }; - 373CFAC52696617C003CB2C6 /* SearchOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOptionsView.swift; sourceTree = ""; }; 373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = ""; }; 373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = ""; }; 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFormView.swift; sourceTree = ""; }; @@ -552,15 +550,6 @@ path = Views; sourceTree = ""; }; - 371AAE2926CF143200901972 /* Options */ = { - isa = PBXGroup; - children = ( - 37B76E95268747C900CE5671 /* OptionsView.swift */, - 373CFAC52696617C003CB2C6 /* SearchOptionsView.swift */, - ); - path = Options; - sourceTree = ""; - }; 3748186426A762300084E870 /* Fixtures */ = { isa = PBXGroup; children = ( @@ -729,8 +718,8 @@ 37D4B159267164AE00C925CA /* tvOS */ = { isa = PBXGroup; children = ( - 371AAE2926CF143200901972 /* Options */, 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */, + 37B76E95268747C900CE5671 /* OptionsView.swift */, 37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */, 37D4B15E267164AF00C925CA /* Assets.xcassets */, 37D4B1AE26729DEB00C925CA /* Info.plist */, @@ -1269,7 +1258,6 @@ 376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */, 3748187026A769D60084E870 /* DetailBadge.swift in Sources */, - 373CFAC926966188003CB2C6 /* SearchOptionsView.swift in Sources */, 37A9965C26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */, 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, 376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */, diff --git a/Shared/PearvidiousApp.swift b/Shared/PearvidiousApp.swift index 4190ad6b..4a565a41 100644 --- a/Shared/PearvidiousApp.swift +++ b/Shared/PearvidiousApp.swift @@ -40,7 +40,7 @@ struct PearvidiousApp: App { search.api = api subscriptions.api = api - guard api.account == nil, instances.defaultAccount != nil else { + guard api.account.isNil, instances.defaultAccount != nil else { return } diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index a7686e6b..9aaefff0 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -159,7 +159,7 @@ struct PlaylistsView: View { } func selectEditedPlaylist() { - if editedPlaylist == nil { + if editedPlaylist.isNil { selectPlaylist(nil) } diff --git a/Shared/Trending/TrendingView.swift b/Shared/Trending/TrendingView.swift index 936a2ded..bda63215 100644 --- a/Shared/Trending/TrendingView.swift +++ b/Shared/Trending/TrendingView.swift @@ -61,6 +61,9 @@ struct TrendingView: View { .foregroundColor(.secondary) categoryButton + // only way to disable Menu animation is to + // force redraw of the view when it changes + .id(UUID()) } HStack { @@ -70,7 +73,6 @@ struct TrendingView: View { countryButton } } - .transaction { t in t.animation = .none } } } #endif @@ -117,15 +119,9 @@ struct TrendingView: View { } #else - Menu(category.name) { + Picker("Category", selection: $category) { ForEach(TrendingCategory.allCases) { category in - Button(action: { self.category = category }) { - if category == self.category { - Label(category.name, systemImage: "checkmark") - } else { - Text(category.name) - } - } + Text(category.name).tag(category) } } #endif diff --git a/Shared/Views/SearchView.swift b/Shared/Views/SearchView.swift index da48304c..8537be96 100644 --- a/Shared/Views/SearchView.swift +++ b/Shared/Views/SearchView.swift @@ -5,9 +5,9 @@ import SwiftUI struct SearchView: View { private var query: SearchQuery? - @State private var searchSortOrder: SearchQuery.SortOrder = .relevance - @State private var searchDate: SearchQuery.Date? - @State private var searchDuration: SearchQuery.Duration? + @State private var searchSortOrder = SearchQuery.SortOrder.relevance + @State private var searchDate = SearchQuery.Date.any + @State private var searchDuration = SearchQuery.Duration.any @State private var presentingClearConfirmation = false @State private var recentsChanged = false @@ -17,20 +17,28 @@ struct SearchView: View { @EnvironmentObject private var recents @EnvironmentObject private var state + @State private var searchDebounceTimer: Timer? + @State private var recentSearchDebounceTimer: Timer? + init(_ query: SearchQuery? = nil) { self.query = query } var body: some View { - Group { + VStack { if navigationStyle == .tab && state.queryText.isEmpty { - VStack { - if !recentItems.isEmpty { - recentQueries - } - } + recentQueries } else { - VideosView(videos: state.store.collection) + #if os(tvOS) + ScrollView(.vertical, showsIndicators: false) { + filtersHorizontalStack + + VideosCellsHorizontal(videos: state.store.collection) + } + .edgesIgnoringSafeArea(.horizontal) + #else + VideosView(videos: state.store.collection) + #endif if state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty { Text("No results") @@ -38,8 +46,8 @@ struct SearchView: View { if searchFiltersActive { Button("Reset search filters") { self.searchSortOrder = .relevance - self.searchDate = nil - self.searchDuration = nil + self.searchDate = .any + self.searchDuration = .any } } @@ -48,53 +56,29 @@ struct SearchView: View { } } .toolbar { - #if os(iOS) - ToolbarItemGroup(placement: .bottomBar) { + #if !os(tvOS) + ToolbarItemGroup(placement: toolbarPlacement) { Section { - if !state.queryText.isEmpty { - Text("Sort:") - .foregroundColor(.secondary) + #if os(macOS) + HStack { + Text("Sort:") + .foregroundColor(.secondary) - Menu(searchSortOrder.name) { - ForEach(SearchQuery.SortOrder.allCases) { sortOrder in - Button(sortOrder.name) { - searchSortOrder = sortOrder - } - } + searchSortOrderPicker } - - Spacer() - - Text("Filter:") - .foregroundColor(.secondary) - - Menu(searchDuration?.name ?? "Duration") { - Button("All") { - searchDuration = nil - } - ForEach(SearchQuery.Duration.allCases) { duration in - Button(duration.name) { - searchDuration = duration - } - } + #else + Menu("Sort: \(searchSortOrder.name)") { + searchSortOrderPicker } - .foregroundColor(searchDuration.isNil ? .secondary : .accentColor) - - Menu(searchDate?.name ?? "Date") { - Button("All") { - searchDate = nil - } - ForEach(SearchQuery.Date.allCases) { date in - Button(date.name) { - searchDate = date - } - } - } - .foregroundColor(searchDate.isNil ? .secondary : .accentColor) - } + #endif } .transaction { t in t.animation = .none } + + Spacer() + + filtersMenu } + #endif } .onAppear { @@ -109,8 +93,25 @@ struct SearchView: View { .searchCompletion(suggestion) } } - .onChange(of: state.queryText) { query in - state.loadSuggestions(query) + .onChange(of: state.queryText) { newQuery in + if newQuery.isEmpty { + state.resetQuery() + } + + state.loadSuggestions(newQuery) + + #if os(tvOS) + searchDebounceTimer?.invalidate() + recentSearchDebounceTimer?.invalidate() + + searchDebounceTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in + state.changeQuery { query in query.query = newQuery } + } + + recentSearchDebounceTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in + recents.addQuery(newQuery) + } + #endif } .onSubmit(of: .search) { state.changeQuery { query in query.query = state.queryText } @@ -138,24 +139,48 @@ struct SearchView: View { #endif } + var toolbarPlacement: ToolbarItemPlacement { + #if os(iOS) + .bottomBar + #else + .automatic + #endif + } + + var filtersActive: Bool { + searchDuration != .any || searchDate != .any + } + var recentQueries: some View { - List { - Section(header: Text("Recents")) { - ForEach(recentItems) { item in - Button(item.title) { - state.queryText = item.title - state.changeQuery { query in query.query = item.title } + VStack { + List { + Section(header: Text("Recents")) { + if recentItems.isEmpty { + Text("Search history is empty") + .foregroundColor(.secondary) } - #if os(iOS) - .swipeActions(edge: .trailing) { - clearButton(item) + ForEach(recentItems) { item in + Button(item.title) { + state.queryText = item.title + state.changeQuery { query in query.query = item.title } } - #endif + #if os(iOS) + .swipeActions(edge: .trailing) { + clearButton(item) + } + #elseif os(tvOS) + .contextMenu { + clearButton(item) + } + #endif + } + } + .redrawOn(change: recentsChanged) + + if !recentItems.isEmpty { + clearAllButton } } - .redrawOn(change: recentsChanged) - - clearAllButton } #if os(iOS) .listStyle(.insetGrouped) @@ -183,10 +208,125 @@ struct SearchView: View { } var searchFiltersActive: Bool { - searchDate != nil || searchDuration != nil + searchDate != .any || searchDuration != .any } var recentItems: [RecentItem] { Defaults[.recentlyOpened].filter { $0.type == .query }.reversed() } + + var searchSortOrderPicker: some View { + Picker("Sort", selection: $searchSortOrder) { + ForEach(SearchQuery.SortOrder.allCases) { sortOrder in + Text(sortOrder.name).tag(sortOrder) + } + } + } + + #if os(tvOS) + var searchSortOrderButton: some View { + Button(action: { self.searchSortOrder = self.searchSortOrder.next() }) { Text(self.searchSortOrder.name) + .font(.system(size: 30)) + .padding(.horizontal) + .padding(.vertical, 2) + } + .buttonStyle(.card) + .contextMenu { + ForEach(SearchQuery.SortOrder.allCases) { sortOrder in + Button(sortOrder.name) { + self.searchSortOrder = sortOrder + } + } + } + } + + var searchDateButton: some View { + Button(action: { self.searchDate = self.searchDate.next() }) { + Text(self.searchDate.name) + .font(.system(size: 30)) + .padding(.horizontal) + .padding(.vertical, 2) + } + .buttonStyle(.card) + .contextMenu { + ForEach(SearchQuery.Date.allCases) { searchDate in + Button(searchDate.name) { + self.searchDate = searchDate + } + } + } + } + + var searchDurationButton: some View { + Button(action: { self.searchDuration = self.searchDuration.next() }) { + Text(self.searchDate.name) + .font(.system(size: 30)) + .padding(.horizontal) + .padding(.vertical, 2) + } + .buttonStyle(.card) + .contextMenu { + ForEach(SearchQuery.Duration.allCases) { searchDuration in + Button(searchDuration.name) { + self.searchDuration = searchDuration + } + } + } + } + + var filtersHorizontalStack: some View { + HStack { + HStack(spacing: 30) { + Text("Sort") + .foregroundColor(.secondary) + searchSortOrderButton + } + .frame(maxWidth: .infinity, alignment: .trailing) + HStack(spacing: 30) { + Text("Duration") + .foregroundColor(.secondary) + searchDurationButton + } + .frame(maxWidth: .infinity) + + HStack(spacing: 30) { + Text("Date") + .foregroundColor(.secondary) + searchDateButton + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.system(size: 20)) + } + #else + var filtersMenu: some View { + Menu(filtersActive ? "Filter: active" : "Filter") { + Picker(selection: $searchDuration, label: Text("Duration")) { + ForEach(SearchQuery.Duration.allCases) { duration in + Text(duration.name).tag(duration) + } + } + + Picker("Upload date", selection: $searchDate) { + ForEach(SearchQuery.Date.allCases) { date in + Text(date.name).tag(date) + } + } + } + .foregroundColor(filtersActive ? .accentColor : .secondary) + .transaction { t in t.animation = .none } + } + + #endif +} + +struct SearchView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + SearchView(SearchQuery(query: "Is Google Evil")) + .environmentObject(NavigationModel()) + .environmentObject(SearchModel()) + .environmentObject(SubscriptionsModel()) + } + } } diff --git a/macOS/PlayerViewController.swift b/macOS/PlayerViewController.swift index 3bb9673f..78ecb09a 100644 --- a/macOS/PlayerViewController.swift +++ b/macOS/PlayerViewController.swift @@ -24,7 +24,7 @@ final class PlayerViewController: NSViewController { override func loadView() { playerModel = PlayerModel(playback: playback, api: api, resolution: resolution) - guard playerModel.player == nil else { + guard playerModel.player.isNil else { return } diff --git a/tvOS/AddToPlaylistView.swift b/tvOS/AddToPlaylistView.swift index dc22cf5e..1816871a 100644 --- a/tvOS/AddToPlaylistView.swift +++ b/tvOS/AddToPlaylistView.swift @@ -43,7 +43,7 @@ struct AddToPlaylistView: View { CoverSectionRowView { Button("Add", action: addToPlaylist) - .disabled(currentPlaylist == nil) + .disabled(currentPlaylist.isNil) } } diff --git a/tvOS/Options/SearchOptionsView.swift b/tvOS/Options/SearchOptionsView.swift deleted file mode 100644 index cc56deb5..00000000 --- a/tvOS/Options/SearchOptionsView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Defaults -import SwiftUI - -struct SearchOptionsView: View { - @Default(.searchSortOrder) private var searchSortOrder - @Default(.searchDate) private var searchDate - @Default(.searchDuration) private var searchDuration - - var body: some View { - CoverSectionView("Search Options") { - CoverSectionRowView("Sort By") { searchSortOrderButton } - CoverSectionRowView("Upload date") { searchDateButton } - CoverSectionRowView("Duration") { searchDurationButton } - } - } - - var searchSortOrderButton: some View { - Button(self.searchSortOrder.name) { - self.searchSortOrder = self.searchSortOrder.next() - } - .contextMenu { - ForEach(SearchQuery.SortOrder.allCases) { sortOrder in - Button(sortOrder.name) { - self.searchSortOrder = sortOrder - } - } - } - } - - var searchDateButton: some View { - Button(self.searchDate?.name ?? "All") { - self.searchDate = self.searchDate == nil ? SearchQuery.Date.allCases.first : self.searchDate!.next(nilAtEnd: true) - } - - .contextMenu { - ForEach(SearchQuery.Date.allCases) { searchDate in - Button(searchDate.name) { - self.searchDate = searchDate - } - } - - Button("Reset") { - self.searchDate = nil - } - } - } - - var searchDurationButton: some View { - Button(self.searchDuration?.name ?? "All") { - self.searchDuration = self.searchDuration == nil ? SearchQuery.Duration.allCases.first : self.searchDuration!.next(nilAtEnd: true) - } - .contextMenu { - ForEach(SearchQuery.Duration.allCases) { searchDuration in - Button(searchDuration.name) { - self.searchDuration = searchDuration - } - } - - Button("Reset") { - self.searchDuration = nil - } - } - } -} diff --git a/tvOS/Options/OptionsView.swift b/tvOS/OptionsView.swift similarity index 83% rename from tvOS/Options/OptionsView.swift rename to tvOS/OptionsView.swift index acf8a494..02e4d5f7 100644 --- a/tvOS/Options/OptionsView.swift +++ b/tvOS/OptionsView.swift @@ -17,8 +17,6 @@ struct OptionsView: View { VStack(alignment: .leading) { Spacer() - tabSelectionOptions - CoverSectionView("View Options") { CoverSectionRowView("Show videos as") { nextLayoutButton } } @@ -42,18 +40,6 @@ struct OptionsView: View { .background(.thinMaterial) } - var tabSelectionOptions: some View { - VStack { - switch navigation.tabSelection { - case .search: - SearchOptionsView() - - default: - EmptyView() - } - } - } - var nextLayoutButton: some View { Button(layout.name) { self.layout = layout.next() diff --git a/tvOS/TVNavigationView.swift b/tvOS/TVNavigationView.swift index e8c5fd38..b1de6686 100644 --- a/tvOS/TVNavigationView.swift +++ b/tvOS/TVNavigationView.swift @@ -4,7 +4,7 @@ import SwiftUI struct TVNavigationView: View { @EnvironmentObject private var navigation @EnvironmentObject private var playback - @EnvironmentObject private var recents + @EnvironmentObject private var recents @EnvironmentObject private var search @State private var showingOptions = false @@ -34,16 +34,6 @@ struct TVNavigationView: View { .tag(TabSelection.playlists) SearchView() - .searchable(text: $search.queryText) { - ForEach(search.querySuggestions.collection, id: \.self) { suggestion in - Text(suggestion) - .searchCompletion(suggestion) - } - } - .onChange(of: search.queryText) { newQuery in - search.loadSuggestions(newQuery) - search.changeQuery { query in query.query = newQuery } - } .tabItem { Image(systemName: "magnifyingglass") } .tag(TabSelection.search) }