diff --git a/Shared/Channels/ChannelPlaylistView.swift b/Shared/Channels/ChannelPlaylistView.swift index 1ca2d48e..4a76616e 100644 --- a/Shared/Channels/ChannelPlaylistView.swift +++ b/Shared/Channels/ChannelPlaylistView.swift @@ -15,15 +15,14 @@ struct ChannelPlaylistView: View { var player = PlayerModel.shared @ObservedObject private var recents = RecentsModel.shared + @State private var isLoading = false + private var items: [ContentItem] { ContentItem.array(of: store.item?.videos ?? []) } private var resource: Resource? { - let resource = accounts.api.channelPlaylist(playlist.id) - resource?.addObserver(store) - - return resource + accounts.api.channelPlaylist(playlist.id) } var body: some View { @@ -48,7 +47,7 @@ struct ChannelPlaylistView: View { .labelStyle(.iconOnly) } #endif - VerticalCells(items: items) + VerticalCells(items: items, isLoading: isLoading) .environment(\.inChannelPlaylistView, true) } .environment(\.listingStyle, channelPlaylistListingStyle) @@ -56,11 +55,16 @@ struct ChannelPlaylistView: View { if let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(playlist) { store.replace(cache) } - resource?.loadIfNeeded()?.onSuccess { response in - if let playlist: ChannelPlaylist = response.typedContent() { - ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist) + isLoading = true + resource? + .load() + .onSuccess { response in + if let playlist: ChannelPlaylist = response.typedContent() { + ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist) + store.replace(playlist) + } } - } + .onCompletion { _ in isLoading = false } } #if os(tvOS) .background(Color.background(scheme: colorScheme)) diff --git a/Shared/Channels/ChannelVideosView.swift b/Shared/Channels/ChannelVideosView.swift index fca43ac9..cbfa1199 100644 --- a/Shared/Channels/ChannelVideosView.swift +++ b/Shared/Channels/ChannelVideosView.swift @@ -65,7 +65,7 @@ struct ChannelVideosView: View { .frame(maxWidth: .infinity) #endif - VerticalCells(items: contentItems, edgesIgnoringSafeArea: verticalCellsEdgesIgnoringSafeArea) { + VerticalCells(items: contentItems, isLoading: resource?.isLoading ?? false, edgesIgnoringSafeArea: verticalCellsEdgesIgnoringSafeArea) { if let description = presentedChannel?.description, !description.isEmpty { Button { withAnimation(.spring()) { diff --git a/Shared/Home/FavoriteItemView.swift b/Shared/Home/FavoriteItemView.swift index 3f66ccd9..d29975cc 100644 --- a/Shared/Home/FavoriteItemView.swift +++ b/Shared/Home/FavoriteItemView.swift @@ -42,21 +42,9 @@ struct FavoriteItemView: View { .padding(.leading, 15) #endif - if limitedItems.isEmpty, !(resource?.isLoading ?? false) { - VStack(alignment: .leading) { - Text(emptyItemsText) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(.secondary) - - if hideShorts || hideWatched { - AccentButton(text: "Disable filters", maxWidth: nil, verticalPadding: 0, minHeight: 30) { - hideShorts = false - hideWatched = false - reloadVisibleWatches() - } - } - } - .padding(.vertical, 10) + if limitedItems.isEmpty { + EmptyItems(isLoading: resource?.isLoading ?? false) { reloadVisibleWatches() } + .padding(.vertical, 10) #if os(tvOS) .padding(.horizontal, 40) #else @@ -113,19 +101,6 @@ struct FavoriteItemView: View { } } - var emptyItemsText: String { - var filterText = "" - if hideShorts && hideWatched { - filterText = "(watched and shorts hidden)" - } else if hideShorts { - filterText = "(shorts hidden)" - } else if hideWatched { - filterText = "(watched hidden)" - } - - return "No videos to show".localized() + " " + filterText.localized() - } - var contextMenu: some View { Group { if item.section == .history { diff --git a/Shared/Playlists/PlaylistVideosView.swift b/Shared/Playlists/PlaylistVideosView.swift index dcf4c037..b9bb786c 100644 --- a/Shared/Playlists/PlaylistVideosView.swift +++ b/Shared/Playlists/PlaylistVideosView.swift @@ -70,7 +70,7 @@ struct PlaylistVideosView: View { } var body: some View { - VerticalCells(items: contentItems) + VerticalCells(items: contentItems, isLoading: resource?.isLoading ?? false) .onAppear { guard contentItems.isEmpty else { return } loadResource() diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index 9f927ee1..f56241b7 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -63,13 +63,11 @@ struct PlaylistsView: View { var body: some View { SignInRequiredView(title: "Playlists".localized()) { VStack { - VerticalCells(items: items, allowEmpty: true) { if shouldDisplayHeader { header } } + VerticalCells(items: items, isLoading: resource?.isLoading ?? false) { if shouldDisplayHeader { header } } .environment(\.currentPlaylistID, currentPlaylist?.id) .environment(\.listingStyle, playlistListingStyle) - if currentPlaylist != nil, items.isEmpty { - hintText("Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"".localized()) - } else if model.all.isEmpty { + if model.all.isEmpty { hintText("You have no playlists\n\nTap on \"New Playlist\" to create one".localized()) } } diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index c5d47a3b..68d047a8 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -244,22 +244,12 @@ struct SearchView: View { if showRecentQueries { recentQueries } else { - VerticalCells(items: state.store.collection, allowEmpty: state.query.isEmpty) { + VerticalCells(items: state.store.collection, isLoading: state.isLoading) { if shouldDisplayHeader { header } } .environment(\.loadMoreContentHandler) { state.loadNextPage() } - - if noResults { - Text("No results") - - if searchFiltersActive { - Button("Reset search filters", action: resetFilters) - } - - Spacer() - } } } } @@ -280,12 +270,6 @@ struct SearchView: View { searchDuration != .any || searchDate != .any } - private func resetFilters() { - searchSortOrder = .relevance - searchDate = .any - searchDuration = .any - } - private var noResults: Bool { state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty } diff --git a/Shared/Subscriptions/FeedView.swift b/Shared/Subscriptions/FeedView.swift index cb21a124..34c6ca06 100644 --- a/Shared/Subscriptions/FeedView.swift +++ b/Shared/Subscriptions/FeedView.swift @@ -16,7 +16,7 @@ struct FeedView: View { } var body: some View { - VerticalCells(items: videos) { if shouldDisplayHeader { header } } + VerticalCells(items: videos, isLoading: feed.isLoading) { if shouldDisplayHeader { header } } .environment(\.loadMoreContentHandler) { feed.loadNextPage() } .onAppear { feed.loadResources() diff --git a/Shared/Trending/TrendingView.swift b/Shared/Trending/TrendingView.swift index 73d15135..06d81620 100644 --- a/Shared/Trending/TrendingView.swift +++ b/Shared/Trending/TrendingView.swift @@ -22,62 +22,51 @@ struct TrendingView: View { } @State private var error: RequestError? + @State private var resource: Resource? + @State private var isLoading = false init(_ videos: [Video] = [Video]()) { self.videos = videos } - var resource: Resource { - let newResource: Resource - - newResource = accounts.api.trending(country: country, category: category) - newResource.addObserver(store) - - return newResource - } - var body: some View { - Section { - VerticalCells(items: trending) { if shouldDisplayHeader { header } } - .environment(\.listingStyle, trendingListingStyle) - } - - .toolbar { - ToolbarItem { - RequestErrorButton(error: error) - } - #if os(macOS) - ToolbarItemGroup { - if let favoriteItem { - FavoriteButton(item: favoriteItem) - .id(favoriteItem.id) - } - - categoryButton - countryButton + VerticalCells(items: trending, isLoading: isLoading) { if shouldDisplayHeader { header } } + .environment(\.listingStyle, trendingListingStyle) + .toolbar { + ToolbarItem { + RequestErrorButton(error: error) } - #endif - } - .onChange(of: resource) { _ in - resource.load() - .onFailure { self.error = $0 } - .onSuccess { _ in self.error = nil } - updateFavoriteItem() - } - .onAppear { - resource.loadIfNeeded()? - .onFailure { self.error = $0 } - .onSuccess { _ in self.error = nil } + #if os(macOS) + ToolbarItemGroup { + if let favoriteItem { + FavoriteButton(item: favoriteItem) + .id(favoriteItem.id) + } - updateFavoriteItem() - } + categoryButton + countryButton + } + #endif + } + .onChange(of: category) { _ in updateResource() } + .onChange(of: country) { _ in updateResource() } + .onChange(of: accounts.current) { _ in updateResource() } + .onChange(of: resource) { _ in + isLoading = true + resource?.load() + .onFailure { self.error = $0 } + .onSuccess { _ in self.error = nil } + .onCompletion { _ in self.isLoading = false } + } + .onAppear { updateResource() + } #if os(tvOS) - .fullScreenCover(isPresented: $presentingCountrySelection) { - TrendingCountry(selectedCountry: $country) - } + .fullScreenCover(isPresented: $presentingCountrySelection) { + TrendingCountry(selectedCountry: $country) + } #else - .sheet(isPresented: $presentingCountrySelection) { + .sheet(isPresented: $presentingCountrySelection) { TrendingCountry(selectedCountry: $country) #if os(macOS) .frame(minWidth: 400, minHeight: 400) @@ -85,9 +74,11 @@ struct TrendingView: View { } .background( Button("Refresh") { - resource.load() + isLoading = true + resource?.load() .onFailure { self.error = $0 } .onSuccess { _ in self.error = nil } + .onCompletion { _ in self.isLoading = false } } .keyboardShortcut("r") .opacity(0) @@ -96,16 +87,18 @@ struct TrendingView: View { #endif #if os(iOS) .refreshControl { refreshControl in - resource.load().onCompletion { _ in + resource?.load().onCompletion { _ in refreshControl.endRefreshing() } } .backport .refreshable { DispatchQueue.main.async { - resource.load() + isLoading = true + resource?.load() .onFailure { self.error = $0 } .onSuccess { _ in self.error = nil } + .onCompletion { _ in self.isLoading = false } } } .navigationBarTitleDisplayMode(.inline) @@ -131,9 +124,13 @@ struct TrendingView: View { } #else .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in - resource.loadIfNeeded()? - .onFailure { self.error = $0 } + let request = resource?.loadIfNeeded() + if request != nil { + isLoading = true + } + request?.onFailure { self.error = $0 } .onSuccess { _ in self.error = nil } + .onCompletion { _ in self.isLoading = false } } #endif } @@ -225,7 +222,7 @@ struct TrendingView: View { private var countryButton: some View { Button(action: { presentingCountrySelection.toggle() - resource.removeObservers(ownedBy: store) + resource?.removeObservers(ownedBy: store) }) { #if os(iOS) Label("Country", systemImage: "flag") @@ -236,6 +233,13 @@ struct TrendingView: View { } } + private func updateResource() { + let resource = accounts.api.trending(country: country, category: category) + resource.addObserver(store) + self.resource = resource + updateFavoriteItem() + } + private func updateFavoriteItem() { favoriteItem = FavoriteItem(section: .trending(country.rawValue, category.rawValue)) } @@ -254,7 +258,7 @@ struct TrendingView: View { HideShortsButtons() Button { - resource.load() + resource?.load() .onFailure { self.error = $0 } .onSuccess { _ in self.error = nil } } label: { diff --git a/Shared/Videos/VerticalCells.swift b/Shared/Videos/VerticalCells.swift index 9bf6f207..1579ded4 100644 --- a/Shared/Videos/VerticalCells.swift +++ b/Shared/Videos/VerticalCells.swift @@ -10,7 +10,7 @@ struct VerticalCells: View { @Environment(\.listingStyle) private var listingStyle var items = [ContentItem]() - var allowEmpty = false + var isLoading: Bool var edgesIgnoringSafeArea = Edge.Set.horizontal let header: Header? @@ -19,32 +19,48 @@ struct VerticalCells: View { init( items: [ContentItem], - allowEmpty: Bool = false, + isLoading: Bool, edgesIgnoringSafeArea: Edge.Set = .horizontal, @ViewBuilder header: @escaping () -> Header? = { nil } ) { self.items = items - self.allowEmpty = allowEmpty + self.isLoading = isLoading self.edgesIgnoringSafeArea = edgesIgnoringSafeArea self.header = header() } init( items: [ContentItem], - allowEmpty: Bool = false + isLoading: Bool ) where Header == EmptyView { - self.init(items: items, allowEmpty: allowEmpty) { EmptyView() } + self.init(items: items, isLoading: isLoading) { EmptyView() } } var body: some View { ScrollView(.vertical, showsIndicators: scrollViewShowsIndicators) { - LazyVGrid(columns: adaptiveItem, alignment: .center) { - Section(header: header) { - ForEach(contentItems) { item in - ContentItemView(item: item) - .onAppear { loadMoreContentItemsIfNeeded(current: item) } + Group { + LazyVGrid(columns: adaptiveItem, alignment: .center) { + Section(header: header) { + ForEach(contentItems) { item in + ContentItemView(item: item) + .onAppear { loadMoreContentItemsIfNeeded(current: item) } + } } } + .overlay( + GeometryReader { proxy in + Color.clear + .onAppear { + gridSize = proxy.size + } + .onChange(of: proxy.size) { newValue in + gridSize = newValue + } + } + ) + if !isLoading && gridSize.height < 50 { + EmptyItems() + } } .padding() } @@ -57,7 +73,7 @@ struct VerticalCells: View { } var contentItems: [ContentItem] { - items.isEmpty ? (allowEmpty ? items : ContentItem.placeholders) : items.sorted { $0 < $1 } + items.isEmpty && isLoading ? (ContentItem.placeholders) : items.sorted { $0 < $1 } } func loadMoreContentItemsIfNeeded(current item: ContentItem) { @@ -104,7 +120,7 @@ struct VerticalCells: View { struct VeticalCells_Previews: PreviewProvider { static var previews: some View { - VerticalCells(items: ContentItem.array(of: Array(repeating: Video.fixture, count: 30))) + VerticalCells(items: ContentItem.array(of: Array(repeating: Video.fixture, count: 30)), isLoading: false) .injectFixtureEnvironmentObjects() } } diff --git a/Shared/Views/EmptyItems.swift b/Shared/Views/EmptyItems.swift new file mode 100644 index 00000000..52b0df69 --- /dev/null +++ b/Shared/Views/EmptyItems.swift @@ -0,0 +1,62 @@ +import Defaults +import SwiftUI + +struct EmptyItems: View { + @Default(.hideShorts) private var hideShorts + @Default(.hideWatched) private var hideWatched + + var isLoading = false + var onDisableFilters: () -> Void = {} + + var body: some View { + VStack(alignment: .leading) { + Group { + if isLoading { + HStack(spacing: 10) { + ProgressView() + .progressViewStyle(.circular) + Text("Loading...") + } + } else { + Text(emptyItemsText) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(.secondary) + + if hideShorts || hideWatched { + AccentButton(text: "Disable filters", maxWidth: nil, verticalPadding: 0, minHeight: 30) { + hideShorts = false + hideWatched = false + onDisableFilters() + } + } + } + } + + var emptyItemsText: String { + var filterText = "" + if hideShorts && hideWatched { + filterText = "(watched and shorts hidden)" + } else if hideShorts { + filterText = "(shorts hidden)" + } else if hideWatched { + filterText = "(watched hidden)" + } + + return "No videos to show".localized() + " " + filterText.localized() + } +} + +struct EmptyItems_Previews: PreviewProvider { + static var previews: some View { + VStack { + Spacer() + EmptyItems() + Spacer() + EmptyItems(isLoading: true) + Spacer() + } + .padding() + } +} diff --git a/Shared/Views/PopularView.swift b/Shared/Views/PopularView.swift index 8ca31a6b..82f5d4a4 100644 --- a/Shared/Views/PopularView.swift +++ b/Shared/Views/PopularView.swift @@ -20,7 +20,7 @@ struct PopularView: View { } var body: some View { - VerticalCells(items: videos) { if shouldDisplayHeader { header } } + VerticalCells(items: videos, isLoading: resource?.isLoading ?? false) { if shouldDisplayHeader { header } } .onAppear { resource?.addObserver(store) resource?.loadIfNeeded()? diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 86a97ac4..ac0ad26f 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -690,6 +690,9 @@ 379DC3D128BA4EB400B09677 /* Seek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DC3D028BA4EB400B09677 /* Seek.swift */; }; 379DC3D228BA4EB400B09677 /* Seek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DC3D028BA4EB400B09677 /* Seek.swift */; }; 379DC3D328BA4EB400B09677 /* Seek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DC3D028BA4EB400B09677 /* Seek.swift */; }; + 379E7C2F2A20AF0A00AF8118 /* EmptyItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E7C2E2A20AF0A00AF8118 /* EmptyItems.swift */; }; + 379E7C302A20AF0A00AF8118 /* EmptyItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E7C2E2A20AF0A00AF8118 /* EmptyItems.swift */; }; + 379E7C312A20AF0A00AF8118 /* EmptyItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E7C2E2A20AF0A00AF8118 /* EmptyItems.swift */; }; 379E7C332A20FE3900AF8118 /* FocusableSearchTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E7C322A20FE3900AF8118 /* FocusableSearchTextField.swift */; }; 379E7C342A20FE3900AF8118 /* FocusableSearchTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E7C322A20FE3900AF8118 /* FocusableSearchTextField.swift */; }; 379E7C362A2105B900AF8118 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 379E7C352A2105B900AF8118 /* Introspect */; }; @@ -1394,6 +1397,7 @@ 379ACB502A1F8DB000E01914 /* HomeSettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSettingsButton.swift; sourceTree = ""; }; 379B0252287A1CDF001015B5 /* OrientationTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrientationTracker.swift; sourceTree = ""; }; 379DC3D028BA4EB400B09677 /* Seek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Seek.swift; sourceTree = ""; }; + 379E7C2E2A20AF0A00AF8118 /* EmptyItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyItems.swift; sourceTree = ""; }; 379E7C322A20FE3900AF8118 /* FocusableSearchTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableSearchTextField.swift; sourceTree = ""; }; 379EF9DF29AA585F009FE6C6 /* HideShortsButtons.swift */ = {isa = PBXFileReference; indentWidth = 3; lastKnownFileType = sourcecode.swift; path = HideShortsButtons.swift; sourceTree = ""; }; 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualitySettings.swift; sourceTree = ""; }; @@ -1907,6 +1911,7 @@ 37FB285D272225E800A57617 /* ContentItemView.swift */, 372CFD14285F2E2A00B0B54B /* ControlsBar.swift */, 3748186D26A769D60084E870 /* DetailBadge.swift */, + 379E7C2E2A20AF0A00AF8118 /* EmptyItems.swift */, 37599F37272B4D740087F250 /* FavoriteButton.swift */, 379EF9DF29AA585F009FE6C6 /* HideShortsButtons.swift */, 37758C0A2A1D1C8B001FD900 /* HideWatchedButtons.swift */, @@ -3185,6 +3190,7 @@ 374AB3DB28BCAF7E00DF56FB /* SeekType.swift in Sources */, 37192D5728B179D60012EEDD /* ChaptersView.swift in Sources */, 37D836BC294927E700005E5E /* ChannelsCacheModel.swift in Sources */, + 379E7C2F2A20AF0A00AF8118 /* EmptyItems.swift in Sources */, 37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */, 37C0698227260B2100F7F6CB /* ThumbnailsModel.swift in Sources */, 37BC50A82778A84700510953 /* HistorySettings.swift in Sources */, @@ -3495,6 +3501,7 @@ 37599F35272B44000087F250 /* FavoritesModel.swift in Sources */, 376527BC285F60F700102284 /* PlayerTimeModel.swift in Sources */, 37F64FE526FE70A60081B69E /* RedrawOnModifier.swift in Sources */, + 379E7C302A20AF0A00AF8118 /* EmptyItems.swift in Sources */, 377ABC45286E4B74009C986F /* ManifestedInstance.swift in Sources */, 3795593727B08538007FF8F4 /* StreamControl.swift in Sources */, 37772E0E2A216F8600608BD9 /* String+ReplacingHTMLEntities.swift in Sources */, @@ -3789,6 +3796,7 @@ 37C3A24727235DA70087A57A /* ChannelPlaylist.swift in Sources */, 3788AC2926F6840700F6BAA9 /* FavoriteItemView.swift in Sources */, 37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */, + 379E7C312A20AF0A00AF8118 /* EmptyItems.swift in Sources */, 3718B9A52921A97F0003DB2E /* InspectorView.swift in Sources */, 37E70925271CD43000D34DDE /* WelcomeScreen.swift in Sources */, 376BE50D27349108009AD608 /* BrowsingSettings.swift in Sources */,