From 7dfe7af9f4e4c66ae21ded0bcd6d4b525ff14c6f Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 7 Mar 2024 13:31:03 -0700 Subject: [PATCH] wip --- Shared/Components/ImageView.swift | 3 + Shared/Components/InitialFailureView.swift | 1 + Shared/Components/TruncatedText.swift | 4 + .../{Wrapped View.swift => WrappedView.swift} | 0 .../ViewExtensions/ViewExtensions.swift | 2 + Shared/ViewModels/HomeViewModel.swift | 3 +- .../PagingLibraryViewModel.swift | 21 ++-- .../RecentlyAddedViewModel.swift | 6 + Shared/ViewModels/MediaItemViewModel.swift | 119 ------------------ Shared/ViewModels/MediaViewModel.swift | 63 ++++++++-- Swiftfin.xcodeproj/project.pbxproj | 18 +-- Swiftfin/Views/HomeView/HomeView.swift | 3 +- Swiftfin/Views/MediaView.swift | 113 +++++++++-------- .../Components/LibraryRow.swift | 2 + .../PagingLibraryView/PagingLibraryView.swift | 11 +- 15 files changed, 166 insertions(+), 203 deletions(-) rename Shared/Components/{Wrapped View.swift => WrappedView.swift} (100%) delete mode 100644 Shared/ViewModels/MediaItemViewModel.swift diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index 31462cd7b..717db2e2d 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -13,6 +13,7 @@ import NukeUI import SwiftUI import UIKit +// TODO: move to separate file struct ImageSource: Hashable { let url: URL? @@ -26,6 +27,8 @@ struct ImageSource: Hashable { private let imagePipeline = ImagePipeline(configuration: .withDataCache) +// TODO: Binding inits? +// - instead of removing first source on failure, just safe index into sources struct ImageView: View { @State diff --git a/Shared/Components/InitialFailureView.swift b/Shared/Components/InitialFailureView.swift index 8f7b8d876..e74d47d54 100644 --- a/Shared/Components/InitialFailureView.swift +++ b/Shared/Components/InitialFailureView.swift @@ -8,6 +8,7 @@ import SwiftUI +// TODO: remove and replace with icon of item type instead struct InitialFailureView: View { let initials: String diff --git a/Shared/Components/TruncatedText.swift b/Shared/Components/TruncatedText.swift index ff470112b..919e72aa2 100644 --- a/Shared/Components/TruncatedText.swift +++ b/Shared/Components/TruncatedText.swift @@ -10,6 +10,9 @@ import Defaults import SwiftUI // TODO: allow `isTruncated` to be communicated externally via modifier +// TODO: `seeMoreBehavior` for iOS +// - `entireView`: entire view becomes a button +// - `seeMore`: can only select see more struct TruncatedText: View { @@ -109,6 +112,7 @@ extension TruncatedText { ) } + // TODO: rename `onSeeMore` func seeMoreAction(_ action: @escaping () -> Void) -> Self { copy(modifying: \.seeMoreAction, with: action) } diff --git a/Shared/Components/Wrapped View.swift b/Shared/Components/WrappedView.swift similarity index 100% rename from Shared/Components/Wrapped View.swift rename to Shared/Components/WrappedView.swift diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 19cefd9d7..432733d9b 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -19,6 +19,7 @@ extension View { AnyView(self) } + // TODO: rename `invertedMask`? func inverseMask(alignment: Alignment = .center, _ content: @escaping () -> some View) -> some View { mask(alignment: alignment) { content() @@ -217,6 +218,7 @@ extension View { modifier(AttributeViewModifier(style: style)) } + // TODO: rename `blurredFullScreenCover` func blurFullScreenCover( isPresented: Binding, onDismiss: (() -> Void)? = nil, diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 1cde2fcac..f70b9db91 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -91,13 +91,12 @@ final class HomeViewModel: ViewModel, Stateful { let resumeItems = try await getResumeItems() let libraries = try await getLibraries() - // should probably in a task group, but fast enough for library in libraries { await library.send(.refresh) } await MainActor.run { - self.resumeItems.append(contentsOf: resumeItems) + self.resumeItems.elements = resumeItems self.libraries = libraries } } diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift index a20a4224e..289ed4abd 100644 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -40,7 +40,7 @@ class PagingLibraryViewModel: LibraryViewModel, Eventf enum State: Equatable { case error(LibraryError) case gettingNextPage - case items + case content case refreshing } @@ -69,9 +69,10 @@ class PagingLibraryViewModel: LibraryViewModel, Eventf @Published final var state: State = .refreshing - private var currentPage = 0 + private(set) final var currentPage = 0 + private(set) final var hasNextPage = true + private var eventSubject: PassthroughSubject = .init() - private var hasNextPage = true private var isStatic: Bool // tasks @@ -96,7 +97,7 @@ class PagingLibraryViewModel: LibraryViewModel, Eventf func respond(to action: Action) -> State { if action == .refresh, isStatic { - return .items + return .content } switch action { @@ -133,7 +134,7 @@ class PagingLibraryViewModel: LibraryViewModel, Eventf guard !Task.isCancelled else { return } await MainActor.run { - self?.state = .items + self?.state = .content } } catch { guard !Task.isCancelled else { return } @@ -148,7 +149,7 @@ class PagingLibraryViewModel: LibraryViewModel, Eventf return .refreshing case .getNextPage: - guard hasNextPage else { return .items } + guard hasNextPage else { return .content } pagingTask = Task { [weak self] in do { @@ -157,7 +158,7 @@ class PagingLibraryViewModel: LibraryViewModel, Eventf guard !Task.isCancelled else { return } await MainActor.run { - self?.state = .items + self?.state = .content } } catch { guard !Task.isCancelled else { return } @@ -216,9 +217,15 @@ class PagingLibraryViewModel: LibraryViewModel, Eventf hasNextPage = !(pageItems.count < DefaultPageSize) + // Sometimes, a subclass may return a page even if it's contextually + // "out of pages". Check explicitly if items were duplicated. + let preItemCount = items.count + await MainActor.run { items.append(contentsOf: pageItems) } + + print("increased item size by: \(items.count - preItemCount)") } /// Gets the items at the given page. If the number of items diff --git a/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift b/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift index 7bb8fabc3..00e3bd6e7 100644 --- a/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift @@ -10,6 +10,7 @@ import Combine import Foundation import JellyfinAPI +// TODO: verify this properly returns pages of items in correct date-added order final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel { init() { @@ -37,6 +38,11 @@ final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel { parameters.sortOrder = [.descending] parameters.startIndex = page + // Ncessary to get an actual "next page" with this endpoint. + // Could be a performance issue for lots of items, but there's + // nothing we can do about it. + parameters.excludeItemIDs = items.compactMap(\.id) + return parameters } } diff --git a/Shared/ViewModels/MediaItemViewModel.swift b/Shared/ViewModels/MediaItemViewModel.swift deleted file mode 100644 index 59e0b7e9d..000000000 --- a/Shared/ViewModels/MediaItemViewModel.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import Foundation -import JellyfinAPI - -final class MediaItemViewModel: ViewModel { - - enum MediaType: Hashable { - case downloads - case favorites - case liveTV - case userView(BaseItemDto) - } - - @Published - var imageSources: [ImageSource] = [] - - let mediaType: MediaType - - init(type: MediaType) { - self.mediaType = type - super.init() - -// if item.collectionType == "favorites" { -// randomItemTask = Task { [weak self] in -// -// guard let sources = try? await self?.getRandomItemImageSource(traits: [.isFavorite]) else { return } -// guard let self else { return } -// -// await MainActor.run { -// self.imageSources = sources -// } -// } -// .asAnyCancellable() -// } else if item.collectionType == "downloads" { -// imageSources = [] -// } else if !Defaults[.Customization.Library.randomImage] || item.collectionType == "liveTV" { -// imageSources = [item.imageSource(.primary, maxWidth: 500)] -// } else { -// randomItemTask = Task { [weak self] in -// -// guard let sources = try? await self?.getRandomItemImageSource() else { return } -// guard let self else { return } -// -// await MainActor.run { -// self.imageSources = sources -// } -// } -// .asAnyCancellable() -// } - } - - func setImageSources(randomImage: Bool) { - switch mediaType { - case .downloads: - () - case .favorites: - Task { [weak self] in - guard let self else { return } - - let sources = try await randomItemImageSources(traits: [.isFavorite]) - - await MainActor.run { - self.imageSources = sources - } - } - .store(in: &cancellables) - case .liveTV: - () - case let .userView(item): - Task { [weak self] in - guard let self else { return } - - let sources = try await randomItemImageSources(parent: item) - - await MainActor.run { - self.imageSources = sources - } - } - .store(in: &cancellables) - } - } - - private func randomItemImageSources(parent: BaseItemDto? = nil, traits: [ItemTrait]? = nil) async throws -> [ImageSource] { - - var parameters = Paths.GetItemsByUserIDParameters() - parameters.limit = 3 - parameters.isRecursive = true - parameters.parentID = parent?.id - parameters.includeItemTypes = [.movie, .series, .boxSet] - parameters.filters = traits - parameters.sortBy = [ItemSortBy.random.rawValue] - - let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) - let response = try await userSession.client.send(request) - - return (response.value.items ?? []) - .map { $0.imageSource(.backdrop, maxWidth: 500) } - } -} - -extension MediaItemViewModel: Hashable { - - static func == (lhs: MediaItemViewModel, rhs: MediaItemViewModel) -> Bool { - lhs.mediaType == rhs.mediaType - } - - func hash(into hasher: inout Hasher) { - hasher.combine(mediaType) - } -} diff --git a/Shared/ViewModels/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel.swift index 2b8173298..16e2f92e9 100644 --- a/Shared/ViewModels/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel.swift @@ -18,6 +18,26 @@ import OrderedCollections // TODO: excluded userviews final class MediaViewModel: ViewModel, Stateful { + enum MediaType: Displayable, Hashable { + case downloads + case favorites + case liveTV + case userView(BaseItemDto) + + var displayTitle: String { + switch self { + case .downloads: + return L10n.downloads + case .favorites: + return L10n.favorites + case .liveTV: + return L10n.liveTV + case let .userView(item): + return item.displayTitle + } + } + } + // TODO: remove once collection types become an enum static let supportedCollectionTypes: [String] = ["boxsets", "folders", "movies", "tvshows", "livetv"] @@ -38,7 +58,7 @@ final class MediaViewModel: ViewModel, Stateful { } @Published - var mediaItems: OrderedSet = [] + var mediaItems: OrderedSet = [] @Published var state: State = .initial @@ -75,14 +95,12 @@ final class MediaViewModel: ViewModel, Stateful { mediaItems.removeAll() } - let userViews = try await getUserViews() - .map { MediaItemViewModel(type: .userView($0)) } - - let allMediaItems = userViews - .prepending(.init(type: .favorites), if: Defaults[.Customization.Library.showFavorites]) + let media = try await getUserViews() + .map(MediaType.userView) + .prepending(.favorites, if: Defaults[.Customization.Library.showFavorites]) await MainActor.run { - mediaItems.append(contentsOf: allMediaItems) + mediaItems.elements = media } } @@ -114,6 +132,35 @@ final class MediaViewModel: ViewModel, Stateful { let currentUserPath = Paths.getCurrentUser let response = try await userSession.client.send(currentUserPath) - return response.value.configuration?.latestItemsExcludes ?? [] + return response.value.configuration?.myMediaExcludes ?? [] + } + + func randomItemImageSources(for mediaType: MediaType) async throws -> [ImageSource] { + + var parentID: String? + + if case let MediaType.userView(item) = mediaType { + parentID = item.id + } + + var filters: [ItemTrait]? + + if mediaType == .favorites { + filters = [.isFavorite] + } + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.limit = 3 + parameters.isRecursive = true + parameters.parentID = parentID + parameters.includeItemTypes = [.movie, .series, .boxSet] + parameters.filters = filters + parameters.sortBy = [ItemSortBy.random.rawValue] + + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return (response.value.items ?? []) + .map { $0.imageSource(.backdrop, maxWidth: 500) } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 34cca4a6b..5da6b1251 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -320,7 +320,6 @@ E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA82938140700E8B599 /* DarkAppIcon.swift */; }; E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CAA2938140A00E8B599 /* LightAppIcon.swift */; }; E1401CB129386C9200E8B599 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CB029386C9200E8B599 /* UIColor.swift */; }; - E1401D45293A952300E8B599 /* MediaItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401D44293A952300E8B599 /* MediaItemViewModel.swift */; }; E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */; }; E148128528C15472003B8787 /* SortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder.swift */; }; E148128628C15475003B8787 /* SortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder.swift */; }; @@ -398,7 +397,6 @@ E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */; }; E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; - E1575E83293E784A001665B1 /* MediaItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401D44293A952300E8B599 /* MediaItemViewModel.swift */; }; E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA62938140300E8B599 /* PrimaryAppIcon.swift */; }; E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA82938140700E8B599 /* DarkAppIcon.swift */; }; E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA12938122C00E8B599 /* AppIcons.swift */; }; @@ -595,8 +593,8 @@ E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */; }; E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; - E1B5784128F8AFCB00D42911 /* Wrapped View.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* Wrapped View.swift */; }; - E1B5784228F8AFCB00D42911 /* Wrapped View.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* Wrapped View.swift */; }; + E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* WrappedView.swift */; }; + E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* WrappedView.swift */; }; E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Sequence.swift */; }; E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Sequence.swift */; }; E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A629577BCE004B26CF /* Pulse */; }; @@ -1017,7 +1015,6 @@ E1401CA82938140700E8B599 /* DarkAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkAppIcon.swift; sourceTree = ""; }; E1401CAA2938140A00E8B599 /* LightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightAppIcon.swift; sourceTree = ""; }; E1401CB029386C9200E8B599 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; - E1401D44293A952300E8B599 /* MediaItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItemViewModel.swift; sourceTree = ""; }; E148128428C15472003B8787 /* SortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortOrder.swift; sourceTree = ""; }; E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemFilter+ItemTrait.swift"; sourceTree = ""; }; E148128A28C15526003B8787 /* ItemSortBy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSortBy.swift; sourceTree = ""; }; @@ -1166,7 +1163,7 @@ E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargePlaybackButtons.swift; sourceTree = ""; }; E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentLogHandler.swift; sourceTree = ""; }; E1B490462967E2E500D3EDCE /* CoreStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreStore.swift; sourceTree = ""; }; - E1B5784028F8AFCB00D42911 /* Wrapped View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Wrapped View.swift"; sourceTree = ""; }; + E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = ""; }; E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = ""; }; E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = ""; }; @@ -1415,7 +1412,6 @@ E1EDA8D52B924CA500F9A57E /* LibraryViewModel */, C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, - E1401D44293A952300E8B599 /* MediaItemViewModel.swift */, 625CB5742678C33500530A6E /* MediaViewModel.swift */, 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */, @@ -2565,7 +2561,7 @@ E1356E0129A7309D00382563 /* SeparatorHStack.swift */, E1A1528928FD22F600600579 /* TextPairView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */, - E1B5784028F8AFCB00D42911 /* Wrapped View.swift */, + E1B5784028F8AFCB00D42911 /* WrappedView.swift */, ); path = Components; sourceTree = ""; @@ -3218,7 +3214,7 @@ E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E1575E93293E7B1E001665B1 /* Float.swift in Sources */, - E1B5784228F8AFCB00D42911 /* Wrapped View.swift in Sources */, + E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */, E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */, E17AC96B2954D00E003D2BC2 /* URLResponse.swift in Sources */, @@ -3402,7 +3398,6 @@ E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */, C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */, E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */, - E1575E83293E784A001665B1 /* MediaItemViewModel.swift in Sources */, 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */, E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, E14EDECD2B8FB709000F00A4 /* ItemYear.swift in Sources */, @@ -3455,7 +3450,6 @@ 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E11895AF2893840F0042947B /* NavBarOffsetView.swift in Sources */, - E1401D45293A952300E8B599 /* MediaItemViewModel.swift in Sources */, E18E0208288749200022598C /* BlurView.swift in Sources */, E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */, @@ -3544,7 +3538,7 @@ E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E11245B728D97ED200D8A977 /* TopBarView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */, - E1B5784128F8AFCB00D42911 /* Wrapped View.swift in Sources */, + E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */, E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index edfb638e7..4fd5499a1 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -47,7 +47,6 @@ struct HomeView: View { } } - @ViewBuilder private func errorView(with error: some Error) -> some View { ErrorView(error: error) .onRetry { @@ -67,7 +66,7 @@ struct HomeView: View { ProgressView() } } - .transition(.opacity.animation(.linear(duration: 2))) + .transition(.opacity.animation(.linear(duration: 0.1))) } .onFirstAppear { viewModel.send(.refresh) diff --git a/Swiftfin/Views/MediaView.swift b/Swiftfin/Views/MediaView.swift index c09270ae6..3937f51b8 100644 --- a/Swiftfin/Views/MediaView.swift +++ b/Swiftfin/Views/MediaView.swift @@ -34,10 +34,10 @@ struct MediaView: View { CollectionVGrid( $viewModel.mediaItems, layout: UIDevice.isPhone ? phoneLayout : padLayout - ) { viewModel in - MediaItem(viewModel: viewModel) + ) { mediaType in + MediaItem(viewModel: viewModel, type: mediaType) .onSelect { - switch viewModel.mediaType { + switch mediaType { case .downloads: router.route(to: \.downloads) case .favorites: @@ -45,30 +45,21 @@ struct MediaView: View { router.route(to: \.library, viewModel) case .liveTV: router.route(to: \.liveTV) - case let .userView(item): () + case let .userView(item): let viewModel = ItemLibraryViewModel(parent: item) router.route(to: \.library, viewModel) } - -// switch viewModel.item.collectionType { -// case "downloads": -// router.route(to: \.downloads) -// case "favorites": -// let viewModel = ItemLibraryViewModel(parent: viewModel.item, filters: .favorites) -// router.route(to: \.library, viewModel) -// case "folders": -// let viewModel = ItemLibraryViewModel(parent: viewModel.item, filters: .default) -// router.route(to: \.library, viewModel) -// case "livetv": -// router.route(to: \.liveTV) -// default: -// let viewModel = ItemLibraryViewModel(parent: viewModel.item, filters: .default) -// router.route(to: \.library, viewModel) -// } } } } + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + var body: some View { WrappedView { Group { @@ -76,19 +67,18 @@ struct MediaView: View { case .content: contentView case let .error(error): - ErrorView(error: error) + errorView(with: error) case .initial, .refreshing: ProgressView() } } + .transition(.opacity.animation(.linear(duration: 0.1))) } .ignoresSafeArea() .navigationTitle(L10n.allMedia) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if viewModel.isLoading { - ProgressView() - } + .topBarTrailing { + if viewModel.isLoading { + ProgressView() } } .onFirstAppear { @@ -99,16 +89,38 @@ struct MediaView: View { extension MediaView { + // TODO: custom view for folders struct MediaItem: View { + @Default(.Customization.Library.randomImage) + private var useRandomImage + @ObservedObject - private var viewModel: MediaItemViewModel + var viewModel: MediaViewModel + + @State + private var imageSources: [ImageSource] = [] private var onSelect: () -> Void + private let mediaType: MediaViewModel.MediaType - init(viewModel: MediaItemViewModel) { + init(viewModel: MediaViewModel, type: MediaViewModel.MediaType) { self.viewModel = viewModel self.onSelect = {} + self.mediaType = type + } + + private func setImageSources() { + Task { @MainActor in + if useRandomImage { + self.imageSources = try await viewModel.randomItemImageSources(for: mediaType) + return + } + + if case let MediaViewModel.MediaType.userView(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } + } } var body: some View { @@ -118,30 +130,33 @@ extension MediaView { ZStack { Color.clear - ImageView(viewModel.imageSources) - .id(viewModel.imageSources.hashValue) + ImageView(imageSources) + .id(imageSources.hashValue) + + if useRandomImage || + mediaType == .favorites || + mediaType == .downloads + { + ZStack { + Color.black + .opacity(0.5) + + Text(mediaType.displayTitle) + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .multilineTextAlignment(.center) + .frame(alignment: .center) + } + } } -// .overlay { -// if Defaults[.Customization.Library.randomImage] || -// viewModel.item.collectionType == "favorites" || -// viewModel.item.collectionType == "downloads" -// { -// ZStack { -// Color.black -// .opacity(0.5) -// -// Text(viewModel.item.displayTitle) -// .foregroundColor(.white) -// .font(.title2) -// .fontWeight(.semibold) -// .lineLimit(1) -// .multilineTextAlignment(.center) -// .frame(alignment: .center) -// } -// } -// } .posterStyle(.landscape) } + .onFirstAppear(perform: setImageSources) + .onChange(of: useRandomImage) { _ in + setImageSources() + } } } } diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift index 0c4e50a44..1504e83c0 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift @@ -12,6 +12,8 @@ import SwiftUI // TODO: have `ImageView` failure view be an icon based on Element/BaseItemDto type // TODO: different text sized based on poster type +// TODO: for landscape, have thumbs come first +// TODO: implement so that the separator isn't influenced by tap effect extension PagingLibraryView { diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index 5d778c467..4152ba575 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -130,7 +130,6 @@ struct PagingLibraryView: View { } } - @ViewBuilder private func errorView(with error: some Error) -> some View { ErrorView(error: error) .onRetry { @@ -174,7 +173,7 @@ struct PagingLibraryView: View { errorView(with: error) case .refreshing: ProgressView() - case .gettingNextPage, .items: + case .gettingNextPage, .content: if viewModel.items.isEmpty { L10n.noResults.text } else { @@ -182,7 +181,8 @@ struct PagingLibraryView: View { } } } - .transition(.opacity.animation(.linear(duration: 0.1))) + // TODO: this causes the navigation bar to not refresh on .items, find fix +// .transition(.opacity.animation(.linear(duration: 0.1))) } .ignoresSafeArea() .navigationTitle(viewModel.parent?.displayTitle ?? "") @@ -228,7 +228,10 @@ struct PagingLibraryView: View { } } .onFirstAppear { - viewModel.send(.refresh) + // May have been passed a view model that already had a page of items + if viewModel.items.isEmpty { + viewModel.send(.refresh) + } } .topBarTrailing {