From 0dd592df02511a2df4a8def4b6522bcd098bdd91 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 13 Jan 2022 14:35:20 -0700 Subject: [PATCH] wip --- Shared/Coordinators/FilterCoordinator.swift | 11 +- Shared/Coordinators/LibraryCoordinator.swift | 8 +- .../MoviesLibrariesCoordinator.swift | 2 +- .../Coordinators/TVLibrariesCoordinator.swift | 2 +- .../BaseItemDtoExtensions.swift | 3 +- Shared/ViewModels/LibraryListViewModel.swift | 30 +++- Shared/ViewModels/LibraryViewModel.swift | 127 ++++++----------- Swiftfin tvOS/Views/LibraryView.swift | 4 - Swiftfin.xcodeproj/project.pbxproj | 24 ++-- .../Components/DetectBottomScrollView.swift | 99 ++++++++++++++ Swiftfin/Components/PortraitItemButton.swift | 69 ++++++++++ Swiftfin/Components/PortraitItemView.swift | 83 ----------- Swiftfin/Views/HomeView.swift | 6 +- Swiftfin/Views/LibraryListView.swift | 72 +++++----- Swiftfin/Views/LibrarySearchView.swift | 2 +- Swiftfin/Views/LibraryView.swift | 129 ++++++++---------- Swiftfin/Views/NextUpView.swift | 41 ------ 17 files changed, 359 insertions(+), 353 deletions(-) create mode 100644 Swiftfin/Components/DetectBottomScrollView.swift create mode 100644 Swiftfin/Components/PortraitItemButton.swift delete mode 100644 Swiftfin/Components/PortraitItemView.swift delete mode 100644 Swiftfin/Views/NextUpView.swift diff --git a/Shared/Coordinators/FilterCoordinator.swift b/Shared/Coordinators/FilterCoordinator.swift index 10a13f05..7a02e915 100644 --- a/Shared/Coordinators/FilterCoordinator.swift +++ b/Shared/Coordinators/FilterCoordinator.swift @@ -7,10 +7,11 @@ // import Foundation +import JellyfinAPI import Stinsen import SwiftUI -typealias FilterCoordinatorParams = (filters: Binding, enabledFilterType: [FilterType], parentId: String) +typealias FilterCoordinatorParams = (libraryItem: BaseItemDto, filters: Binding, enabledFilterType: [FilterType]) final class FilterCoordinator: NavigationCoordinatable { @@ -19,19 +20,19 @@ final class FilterCoordinator: NavigationCoordinatable { @Root var start = makeStart + let libraryItem: BaseItemDto @Binding var filters: LibraryFilters var enabledFilterType: [FilterType] - var parentId: String = "" - init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { + init(libraryItem: BaseItemDto, filters: Binding, enabledFilterType: [FilterType]) { + self.libraryItem = libraryItem _filters = filters self.enabledFilterType = enabledFilterType - self.parentId = parentId } @ViewBuilder func makeStart() -> some View { - LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: parentId) + LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: libraryItem.id!) } } diff --git a/Shared/Coordinators/LibraryCoordinator.swift b/Shared/Coordinators/LibraryCoordinator.swift index 1678e407..32449cf2 100644 --- a/Shared/Coordinators/LibraryCoordinator.swift +++ b/Shared/Coordinators/LibraryCoordinator.swift @@ -11,8 +11,6 @@ import JellyfinAPI import Stinsen import SwiftUI -typealias LibraryCoordinatorParams = (viewModel: LibraryViewModel, title: String) - final class LibraryCoordinator: NavigationCoordinatable { let stack = NavigationStack(initial: \LibraryCoordinator.start) @@ -29,16 +27,14 @@ final class LibraryCoordinator: NavigationCoordinatable { var modalItem = makeModalItem let viewModel: LibraryViewModel - let title: String - init(viewModel: LibraryViewModel, title: String) { + init(viewModel: LibraryViewModel) { self.viewModel = viewModel - self.title = title } @ViewBuilder func makeStart() -> some View { - LibraryView(viewModel: self.viewModel, title: title) + LibraryView(viewModel: self.viewModel) } func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { diff --git a/Shared/Coordinators/MoviesLibrariesCoordinator.swift b/Shared/Coordinators/MoviesLibrariesCoordinator.swift index 15e14888..142238d2 100644 --- a/Shared/Coordinators/MoviesLibrariesCoordinator.swift +++ b/Shared/Coordinators/MoviesLibrariesCoordinator.swift @@ -34,6 +34,6 @@ final class MovieLibrariesCoordinator: NavigationCoordinatable { } func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { - LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) +// LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) } } diff --git a/Shared/Coordinators/TVLibrariesCoordinator.swift b/Shared/Coordinators/TVLibrariesCoordinator.swift index b2ec1121..5335cd31 100644 --- a/Shared/Coordinators/TVLibrariesCoordinator.swift +++ b/Shared/Coordinators/TVLibrariesCoordinator.swift @@ -34,6 +34,6 @@ final class TVLibrariesCoordinator: NavigationCoordinatable { } func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { - LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) + LibraryCoordinator(viewModel: .init(libraryItem: <#T##BaseItemDto#>)) } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index a043de60..849dffe8 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -206,6 +206,7 @@ public extension BaseItemDto { case episode = "Episode" case series = "Series" case boxset = "BoxSet" + case collectionFolder = "CollectionFolder" case unknown @@ -228,7 +229,7 @@ public extension BaseItemDto { func portraitHeaderViewURL(maxWidth: Int) -> URL { switch itemType { - case .movie, .season, .series, .boxset: + case .movie, .season, .series, .boxset, .collectionFolder: return getPrimaryImage(maxWidth: maxWidth) case .episode: return getSeriesPrimaryImage(maxWidth: maxWidth) diff --git a/Shared/ViewModels/LibraryListViewModel.swift b/Shared/ViewModels/LibraryListViewModel.swift index 25ed0557..d4bc01e5 100644 --- a/Shared/ViewModels/LibraryListViewModel.swift +++ b/Shared/ViewModels/LibraryListViewModel.swift @@ -13,6 +13,8 @@ final class LibraryListViewModel: ViewModel { @Published var libraries: [BaseItemDto] = [] + @Published + var libraryRandomItems: [BaseItemDto: BaseItemDto] = [:] // temp var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) @@ -29,8 +31,34 @@ final class LibraryListViewModel: ViewModel { .sink(receiveCompletion: { completion in self.handleAPIRequestError(completion: completion) }, receiveValue: { response in - self.libraries = response.items ?? [] + if let libraries = response.items { + self.libraries = libraries + + for library in libraries { + self.getRandomLibraryItem(for: library) + } + } }) .store(in: &cancellables) } + + // MARK: Library random item + + func getRandomLibraryItem(for library: BaseItemDto) { + guard library.itemType == .collectionFolder else { return } + + ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, + limit: 1, + parentId: library.id) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { result in + if let item = result.items?.first { + self.libraryRandomItems[library] = item + } else { + self.libraryRandomItems[library] = library + } + } + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index 4f41a662..02cbe13b 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -10,6 +10,7 @@ import Combine import Foundation import JellyfinAPI import SwiftUICollection +import UIKit typealias LibraryRow = CollectionRow @@ -20,15 +21,11 @@ struct LibraryRowCell: Hashable { } final class LibraryViewModel: ViewModel { - var parentID: String? - var person: BaseItemPerson? - var genre: NameGuidPair? - var studio: NameGuidPair? @Published - var items = [BaseItemDto]() + var items: [BaseItemDto] = [] @Published - var rows = [LibraryRow]() + var rows: [LibraryRow] = [] @Published var totalPages = 0 @@ -36,15 +33,17 @@ final class LibraryViewModel: ViewModel { var currentPage = 0 @Published var hasNextPage = false - @Published - var hasPreviousPage = false // temp @Published var filters: LibraryFilters - + + let libraryItem: BaseItemDto + var person: BaseItemPerson? + var genre: NameGuidPair? + var studio: NameGuidPair? private let columns: Int - private var libraries = [BaseItemDto]() + private let pageItemSize: Int var enabledFilterType: [FilterType] { if genre == nil { @@ -54,27 +53,40 @@ final class LibraryViewModel: ViewModel { } } - init(parentID: String? = nil, + init(libraryItem: BaseItemDto, person: BaseItemPerson? = nil, genre: NameGuidPair? = nil, studio: NameGuidPair? = nil, filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]), columns: Int = 7) { - self.parentID = parentID + self.libraryItem = libraryItem self.person = person self.genre = genre self.studio = studio self.filters = filters self.columns = columns + + // Size is typical size of portrait items + self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185) + + print("Page item size: \(pageItemSize)") + super.init() $filters - .sink(receiveValue: requestItems(with:)) + .sink(receiveValue: { newFilters in + self.requestItemsAsync(with: newFilters, replaceCurrentItems: true) + }) .store(in: &cancellables) } - func requestItems(with filters: LibraryFilters) { + func requestItemsAsync(with filters: LibraryFilters, replaceCurrentItems: Bool = false) { + + if replaceCurrentItems { + self.items = [] + } + let personIDs: [String] = [person].compactMap(\.?.id) let studioIDs: [String] = [studio].compactMap(\.?.id) let genreIDs: [String] @@ -85,65 +97,12 @@ final class LibraryViewModel: ViewModel { } let sortBy = filters.sortBy.map(\.rawValue) - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, - startIndex: currentPage * 100, - limit: 100, + ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * pageItemSize, + limit: pageItemSize, recursive: true, searchTerm: nil, sortOrder: filters.sortOrder, - parentId: parentID, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], - includeItemTypes: filters.filters - .contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series", "BoxSet"], - filters: filters.filters, - sortBy: sortBy, - tags: filters.tags, - enableUserData: true, - personIds: personIDs, - studioIds: studioIDs, - genreIds: genreIDs, - enableImages: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.log.debug("Received \(String(response.items?.count ?? 0)) items in library \(self?.parentID ?? "nil")") - guard let self = self else { return } - let totalPages = ceil(Double(response.totalRecordCount ?? 0) / 100.0) - self.totalPages = Int(totalPages) - self.hasPreviousPage = self.currentPage > 0 - self.hasNextPage = self.currentPage < self.totalPages - 1 - self.items = response.items ?? [] - self.rows = self.calculateRows(for: self.items) - }) - .store(in: &cancellables) - } - - func requestItemsAsync(with filters: LibraryFilters) { - let personIDs: [String] = [person].compactMap(\.?.id) - let studioIDs: [String] = [studio].compactMap(\.?.id) - let genreIDs: [String] - if filters.withGenres.isEmpty { - genreIDs = [genre].compactMap(\.?.id) - } else { - genreIDs = filters.withGenres.compactMap(\.id) - } - let sortBy = filters.sortBy.map(\.rawValue) - - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, - limit: 100, - recursive: true, - searchTerm: nil, - sortOrder: filters.sortOrder, - parentId: parentID, + parentId: libraryItem.id, fields: [ .primaryImageAspectRatio, .seriesPrimaryImage, @@ -163,13 +122,15 @@ final class LibraryViewModel: ViewModel { studioIds: studioIDs, genreIds: genreIDs, enableImages: true) + .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in + guard let self = self else { return } - let totalPages = ceil(Double(response.totalRecordCount ?? 0) / 100.0) + let totalPages = ceil(Double(response.totalRecordCount ?? 0) / Double(self.pageItemSize)) + self.totalPages = Int(totalPages) - self.hasPreviousPage = self.currentPage > 0 self.hasNextPage = self.currentPage < self.totalPages - 1 self.items.append(contentsOf: response.items ?? []) self.rows = self.calculateRows(for: self.items) @@ -177,21 +138,12 @@ final class LibraryViewModel: ViewModel { .store(in: &cancellables) } - func requestNextPage() { - currentPage += 1 - requestItems(with: filters) - } - func requestNextPageAsync() { currentPage += 1 requestItemsAsync(with: filters) } - func requestPreviousPage() { - currentPage -= 1 - requestItems(with: filters) - } - + // tvOS calculations for collection view private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] { guard !itemList.isEmpty else { return [] } let rowCount = itemList.count / columns @@ -220,3 +172,14 @@ final class LibraryViewModel: ViewModel { return calculatedRows } } + +extension UIScreen { + + static func itemsFillableOnScreen(width: CGFloat, height: CGFloat) -> Int { + + let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width + let itemSize = width * height + + return Int(screenSize / itemSize) + } +} diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index 1344730e..02df6a86 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.swift @@ -15,7 +15,6 @@ struct LibraryView: View { var libraryRouter: LibraryCoordinator.Router @StateObject var viewModel: LibraryViewModel - var title: String // MARK: tracks for grid @@ -91,6 +90,3 @@ struct LibraryView: View { } } } - -// stream BM^S by nicki! -// diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index e91d1ec5..4e1b381e 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -75,7 +75,6 @@ 5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; }; 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276D263C25100035E14B /* ContinueWatchingView.swift */; }; - 53892770263C25230035E14B /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276F263C25230035E14B /* NextUpView.swift */; }; 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; 53913BEF26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; @@ -136,7 +135,7 @@ 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; }; - 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */; }; + 53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitItemButton.swift */; }; 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; }; 5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; @@ -276,6 +275,7 @@ E10EAA51277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; + E111DE222790BB46008118A3 /* DetectBottomScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; @@ -549,7 +549,6 @@ 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5389276D263C25100035E14B /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; - 5389276F263C25230035E14B /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; 53913BCA26D323FE00EB3286 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; 53913BCD26D323FE00EB3286 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Localizable.strings; sourceTree = ""; }; @@ -577,7 +576,7 @@ 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = ""; }; 53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; - 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemView.swift; sourceTree = ""; }; + 53F866432687A45F00DCD1D7 /* PortraitItemButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemButton.swift; sourceTree = ""; }; 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSize.swift; sourceTree = ""; }; 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VLCPlayer+subtitles.swift"; sourceTree = ""; }; @@ -672,6 +671,7 @@ E10D87E127852FD000BD264C /* EpisodesRowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowManager.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; + E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectBottomScrollView.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStreamExtension.swift; sourceTree = ""; }; @@ -1214,11 +1214,12 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( + E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */, E176DE6E278E3522001EFD8D /* EpisodesRowView */, E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, C4BE076D2720FEA8003F4AD1 /* PortraitItemElement.swift */, - 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, + 53F866432687A45F00DCD1D7 /* PortraitItemButton.swift */, E1AA331C2782541500F6439C /* PrimaryButtonView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */, ); @@ -1454,7 +1455,6 @@ 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */, C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */, - 5389276F263C25230035E14B /* NextUpView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, E13DD3E427177D15009D4DAF /* ServerListView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, @@ -2294,7 +2294,7 @@ 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, - 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */, + 53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */, E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, @@ -2309,6 +2309,7 @@ E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, + E111DE222790BB46008118A3 /* DetectBottomScrollView.swift in Sources */, 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, C4BE078B272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */, @@ -2328,7 +2329,6 @@ E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, - 53892770263C25230035E14B /* NextUpView.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, C4BE0766271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, @@ -2804,7 +2804,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2841,7 +2841,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2872,7 +2872,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2899,7 +2899,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Swiftfin/Components/DetectBottomScrollView.swift b/Swiftfin/Components/DetectBottomScrollView.swift new file mode 100644 index 00000000..5d275f6c --- /dev/null +++ b/Swiftfin/Components/DetectBottomScrollView.swift @@ -0,0 +1,99 @@ +// +// 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) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// https://stackoverflow.com/questions/56573373/swiftui-get-size-of-child + +struct ChildSizeReader: View { + @Binding var size: CGSize + let content: () -> Content + var body: some View { + ZStack { + content() + .background( + GeometryReader { proxy in + Color.clear + .preference(key: SizePreferenceKey.self, value: proxy.size) + } + ) + } + .onPreferenceChange(SizePreferenceKey.self) { preferences in + self.size = preferences + } + } +} + +struct SizePreferenceKey: PreferenceKey { + typealias Value = CGSize + static var defaultValue: Value = .zero + + static func reduce(value _: inout Value, nextValue: () -> Value) { + _ = nextValue() + } +} + +struct ViewOffsetKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} + +struct DetectBottomScrollView: View { + private let spaceName = "scroll" + + @State private var wholeSize: CGSize = .zero + @State private var scrollViewSize: CGSize = .zero + @State private var previousDidReachBottom = false + let content: () -> Content + let didReachBottom: (Bool) -> Void + + init(content: @escaping () -> Content, + didReachBottom: @escaping (Bool) -> Void) { + self.content = content + self.didReachBottom = didReachBottom + } + + var body: some View { + ChildSizeReader(size: $wholeSize) { + ScrollView { + ChildSizeReader(size: $scrollViewSize) { + content() + .background( + GeometryReader { proxy in + Color.clear.preference( + key: ViewOffsetKey.self, + value: -1 * proxy.frame(in: .named(spaceName)).origin.y + ) + } + ) + .onPreferenceChange( + ViewOffsetKey.self, + perform: { value in + + if value >= scrollViewSize.height - wholeSize.height { + if !previousDidReachBottom { + previousDidReachBottom = true + didReachBottom(true) + } + } else { + if previousDidReachBottom { + previousDidReachBottom = false + didReachBottom(false) + } + } + } + ) + } + } + .coordinateSpace(name: spaceName) + } + } +} diff --git a/Swiftfin/Components/PortraitItemButton.swift b/Swiftfin/Components/PortraitItemButton.swift new file mode 100644 index 00000000..c6251064 --- /dev/null +++ b/Swiftfin/Components/PortraitItemButton.swift @@ -0,0 +1,69 @@ +// +// 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) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct PortraitItemButton: View { + + let item: ItemType + let maxWidth: CGFloat + let horizontalAlignment: HorizontalAlignment + let textAlignment: TextAlignment + let selectedAction: (ItemType) -> Void + + init(item: ItemType, + maxWidth: CGFloat = 110, + horizontalAlignment: HorizontalAlignment = .leading, + textAlignment: TextAlignment = .leading, + selectedAction: @escaping (ItemType) -> Void) + { + self.item = item + self.maxWidth = maxWidth + self.horizontalAlignment = horizontalAlignment + self.textAlignment = textAlignment + self.selectedAction = selectedAction + } + + var body: some View { + Button { + selectedAction(item) + } label: { + VStack(alignment: horizontalAlignment) { + ImageView(src: item.imageURLContsructor(maxWidth: Int(maxWidth)), + bh: item.blurHash, + failureInitials: item.failureInitials) + .portraitPoster(width: maxWidth) + .shadow(radius: 4, y: 2) + + if item.showTitle { + Text(item.title) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } + + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } + } + .frame(width: maxWidth) + } + .frame(alignment: .top) + .padding(.bottom) + } +} diff --git a/Swiftfin/Components/PortraitItemView.swift b/Swiftfin/Components/PortraitItemView.swift deleted file mode 100644 index 0f67464c..00000000 --- a/Swiftfin/Components/PortraitItemView.swift +++ /dev/null @@ -1,83 +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) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct PortraitItemView: View { - var item: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100), - bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash()) - .frame(width: 100, height: 150) - .cornerRadius(10) - .shadow(radius: 4, y: 2) - .shadow(radius: 4, y: 2) - .overlay(Rectangle() - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7) - .padding(0), alignment: .bottomLeading) - .overlay(ZStack { - if item.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(.leading, 2) - .padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9) - .opacity(1), alignment: .bottomLeading) - .overlay(ZStack { - if item.userData?.played ?? false { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.accentColor) - .background(Color(.white)) - .clipShape(Circle().scale(0.8)) - } else { - if item.userData?.unplayedItemCount != nil { - Capsule() - .fill(Color.accentColor) - .frame(minWidth: 20, minHeight: 20, maxHeight: 20) - Text(String(item.userData!.unplayedItemCount ?? 0)) - .foregroundColor(.white) - .font(.caption2) - .padding(2) - } - } - }.padding(2) - .fixedSize() - .opacity(1), alignment: .topTrailing).opacity(1) - Text(item.seriesName ?? item.name ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - if item.type == "Movie" || item.type == "Series" { - Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else if item.type == "Season" { - Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else { - Text(L10n.seasonAndEpisode(String(item.parentIndexNumber ?? 0), String(item.indexNumber ?? 0))) - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } - }.frame(width: 100) - } -} diff --git a/Swiftfin/Views/HomeView.swift b/Swiftfin/Views/HomeView.swift index e6e8f764..a4fa04cf 100644 --- a/Swiftfin/Views/HomeView.swift +++ b/Swiftfin/Views/HomeView.swift @@ -90,9 +90,9 @@ struct HomeView: View { Button { homeRouter - .route(to: \.library, (viewModel: .init(parentID: library.id!, - filters: viewModel.recentFilterSet), - title: library.name ?? "")) +// .route(to: \.library, (viewModel: .init(parentID: library.id!, +// filters: viewModel.recentFilterSet), +// title: library.name ?? "")) } label: { HStack { L10n.seeAll.text.font(.subheadline).fontWeight(.bold) diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index 2438e35c..607c4a6d 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -23,23 +23,20 @@ struct LibraryListView: View { libraryListRouter.route(to: \.library, (viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: L10n.favorites)) } label: { - ZStack { - HStack { - Spacer() - L10n.yourFavorites.text - .foregroundColor(.black) - .font(.subheadline) - .fontWeight(.semibold) - Spacer() - } - } - .padding(16) + HStack { + Spacer() + L10n.yourFavorites.text + .foregroundColor(.black) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + } + .frame(height: 100) .background(Color.white) - .frame(minWidth: 100, maxWidth: .infinity) } .cornerRadius(10) .shadow(radius: 5) - .padding(.bottom, 5) + .padding() if !viewModel.isLoading { @@ -62,17 +59,17 @@ struct LibraryListView: View { .fontWeight(.semibold) } Spacer() - }.padding(32) - }.background(Color.black) - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) + } + } + .background(Color.black) + .frame(height: 100) } .cornerRadius(10) .shadow(radius: 5) - .padding(.bottom, 5) + .padding() } - ForEach(viewModel.libraries, id: \.id) { library in + ForEach(Array(viewModel.libraryRandomItems.keys), id: \.id) { library in if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" { Button { libraryListRouter.route(to: \.library, @@ -80,25 +77,24 @@ struct LibraryListView: View { title: library.name ?? "")) } label: { ZStack { - ImageView(src: library.getPrimaryImage(maxWidth: 500), bh: library.getPrimaryImageBlurHash()) - .opacity(0.4) - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - }.background(Color.black) - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) +// ImageView(src: library.getPrimaryImage(maxWidth: 500), bh: library.getPrimaryImageBlurHash()) +// .opacity(0.4) + + ImageView(src: viewModel.libraryRandomItems[library]!.getBackdropImage(maxWidth: 500)) + + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + } + .background(Color.black) + .frame(height: 100) } .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) + .shadow(radius: 5) + .padding() } else { EmptyView() } @@ -106,9 +102,7 @@ struct LibraryListView: View { } else { ProgressView() } - }.padding(.leading, 16) - .padding(.trailing, 16) - .padding(.top, 8) + } } .navigationTitle(L10n.allMedia) .toolbar { diff --git a/Swiftfin/Views/LibrarySearchView.swift b/Swiftfin/Views/LibrarySearchView.swift index c1b431a3..d4ae3958 100644 --- a/Swiftfin/Views/LibrarySearchView.swift +++ b/Swiftfin/Views/LibrarySearchView.swift @@ -88,7 +88,7 @@ struct LibrarySearchView: View { Button { searchRouter.route(to: \.item, item) } label: { - PortraitItemView(item: item) + PortraitItemElement(item: item) } } } diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift index c5c37a92..d015d8d7 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView.swift @@ -10,100 +10,86 @@ import Stinsen import SwiftUI struct LibraryView: View { + @EnvironmentObject var libraryRouter: LibraryCoordinator.Router @StateObject var viewModel: LibraryViewModel - var title: String // MARK: tracks for grid var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) @State - private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + private var tracks: [GridItem] = Array(repeating: .init(.flexible(), alignment: .top), count: Int(UIScreen.main.bounds.size.width) / 125) func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + tracks = Array(repeating: .init(.flexible(), alignment: .top), count: Int(UIScreen.main.bounds.size.width) / 125) } + + @ViewBuilder + private var loadingView: some View { + ProgressView() + } + + @ViewBuilder + private var noResultsView: some View { + L10n.noResults.text + } + + @ViewBuilder + private var libraryItemsView: some View { + DetectBottomScrollView { + VStack { + LazyVGrid(columns: tracks) { + ForEach(viewModel.items, id: \.id) { item in + if item.type != "Folder" { + PortraitItemButton(item: item) { item in + libraryRouter.route(to: \.item, item) + } + } + } + } + .ignoresSafeArea() + .listRowSeparator(.hidden) + .onRotate { _ in + recalcTracks() + } + + Spacer() + .frame(height: 30) + } + } didReachBottom: { newValue in + if newValue && viewModel.hasNextPage { + viewModel.requestNextPageAsync() + } + } + } var body: some View { Group { - if viewModel.isLoading == true { + if viewModel.isLoading && viewModel.items.isEmpty { ProgressView() } else if !viewModel.items.isEmpty { - VStack { - ScrollView(.vertical) { - Spacer().frame(height: 16) - LazyVGrid(columns: tracks) { - ForEach(viewModel.items, id: \.id) { item in - if item.type != "Folder" { - Button { - libraryRouter.route(to: \.item, item) - } label: { - PortraitItemView(item: item) - } - } - } - }.onRotate { _ in - recalcTracks() - } - if viewModel.hasNextPage || viewModel.hasPreviousPage { - HStack { - Spacer() - HStack { - Button { - viewModel.requestPreviousPage() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 25)) - }.disabled(!viewModel.hasPreviousPage) - Text(L10n.pageOfWithNumbers(String(viewModel.currentPage + 1), String(viewModel.totalPages))) - .font(.subheadline) - .fontWeight(.medium) - Button { - viewModel.requestNextPage() - } label: { - Image(systemName: "chevron.right") - .font(.system(size: 25)) - }.disabled(!viewModel.hasNextPage) - } - Spacer() - } - } - Spacer().frame(height: 16) - } - } + libraryItemsView } else { - L10n.noResults.text + noResultsView } } - .navigationBarTitle(title, displayMode: .inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - if viewModel.hasPreviousPage { - Button { - viewModel.requestPreviousPage() - } label: { - Image(systemName: "chevron.left") - }.disabled(viewModel.isLoading) - } - if viewModel.hasNextPage { - Button { - viewModel.requestNextPage() - } label: { - Image(systemName: "chevron.right") - }.disabled(viewModel.isLoading) - } - Label("Icon One", systemImage: "line.horizontal.3.decrease.circle") - .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) - .onTapGesture { - libraryRouter - .route(to: \.filter, (filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, - parentId: viewModel.parentID ?? "")) - } + + Button { +// libraryRouter +// .route(to: \.filter, (filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, +// parentId: viewModel.parentID ?? "")) + } label: { + Image(systemName: "line.horizontal.3.decrease.circle") + } + .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) + Button { - libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID)) +// libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID)) } label: { Image(systemName: "magnifyingglass") } @@ -111,6 +97,3 @@ struct LibraryView: View { } } } - -// stream BM^S by nicki! -// diff --git a/Swiftfin/Views/NextUpView.swift b/Swiftfin/Views/NextUpView.swift deleted file mode 100644 index 01496746..00000000 --- a/Swiftfin/Views/NextUpView.swift +++ /dev/null @@ -1,41 +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) 2022 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import Stinsen -import SwiftUI - -struct NextUpView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - - var items: [BaseItemDto] - - var body: some View { - VStack(alignment: .leading) { - L10n.nextUp.text - .font(.title2) - .fontWeight(.bold) - .padding(.leading, 16) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(items, id: \.id) { item in - Button { - homeRouter.route(to: \.item, item) - } label: { - PortraitItemView(item: item) - } - }.padding(.trailing, 16) - } - .padding(.leading, 20) - } - .frame(height: 200) - } - } -}