diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 30c332ba8..aa2c7e5de 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,7 +4731,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 398; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4753,7 +4753,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.2; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PRODUCT_MODULE_NAME = Palace; PRODUCT_NAME = "Palace-noDRM"; @@ -4790,7 +4790,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 398; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4812,7 +4812,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.2; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PRODUCT_MODULE_NAME = Palace; PRODUCT_NAME = "Palace-noDRM"; @@ -4974,7 +4974,7 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 398; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -5000,7 +5000,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.2; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PROVISIONING_PROFILE_SPECIFIER = ""; RUN_CLANG_STATIC_ANALYZER = YES; @@ -5035,7 +5035,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 398; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -5062,7 +5062,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.2; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "App Store"; diff --git a/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme b/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme index f4d92e394..d46ee12b1 100644 --- a/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme +++ b/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme @@ -143,12 +143,12 @@ diff --git a/Palace/Accounts/Library/AccountsManager.swift b/Palace/Accounts/Library/AccountsManager.swift index 58d08ca1b..0e52ebee4 100644 --- a/Palace/Accounts/Library/AccountsManager.swift +++ b/Palace/Accounts/Library/AccountsManager.swift @@ -257,14 +257,6 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } } - for acct in newAccounts { - group.enter() - DispatchQueue.global(qos: .background).async { - acct.loadLogo() - group.leave() - } - } - group.notify(queue: .main) { var mainFeed = URL(string: self.currentAccount?.catalogUrl ?? "") if let cur = self.currentAccount, cur.details?.needsAgeCheck ?? false { diff --git a/Palace/AppInfrastructure/NavigationCoordinator.swift b/Palace/AppInfrastructure/NavigationCoordinator.swift index 044cdabf9..9bc8a1797 100644 --- a/Palace/AppInfrastructure/NavigationCoordinator.swift +++ b/Palace/AppInfrastructure/NavigationCoordinator.swift @@ -190,7 +190,6 @@ final class NavigationCoordinator: ObservableObject { struct CatalogLaneFilterState { let appliedSelections: Set - let currentSort: String // Store as string to avoid enum duplication let facetGroups: [CatalogFilterGroup] } diff --git a/Palace/AppInfrastructure/TPPAppDelegate.swift b/Palace/AppInfrastructure/TPPAppDelegate.swift index ce96cd5ed..74d5a5f76 100644 --- a/Palace/AppInfrastructure/TPPAppDelegate.swift +++ b/Palace/AppInfrastructure/TPPAppDelegate.swift @@ -223,14 +223,16 @@ extension TPPAppDelegate { if needsAccount { var nav: UINavigationController! let accountList = TPPAccountList { account in - // Match CatalogView's Add Library flow: persist, switch account, update feed URL, notify, dismiss if !TPPSettings.shared.settingsAccountIdsList.contains(account.uuid) { TPPSettings.shared.settingsAccountIdsList.append(account.uuid) } - AccountsManager.shared.currentAccount = account if let urlString = account.catalogUrl, let url = URL(string: urlString) { TPPSettings.shared.accountMainFeedURL = url } + AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) nav?.dismiss(animated: true) } @@ -306,9 +308,6 @@ final class MemoryPressureMonitor { URLCache.shared.removeAllCachedResponses() TPPNetworkExecutor.shared.clearCache() - ImageCache.shared.clear() - GeneralCache.clearAllCaches() - MyBooksDownloadCenter.shared.pauseAllDownloads() self.reclaimDiskSpaceIfNeeded(minimumFreeMegabytes: 256) diff --git a/Palace/Book/Models/TPPBook.swift b/Palace/Book/Models/TPPBook.swift index 5aa9bbd6b..b3b98fe29 100644 --- a/Palace/Book/Models/TPPBook.swift +++ b/Palace/Book/Models/TPPBook.swift @@ -557,10 +557,9 @@ extension TPPBook { TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(self) { [weak self] image in guard let self = self else { return } - let final = image ?? UIImage(systemName: "book") - self.thumbnailImage = final - if let img = final { + self.thumbnailImage = image + if let img = image { self.imageCache.set(img, for: self.identifier) self.imageCache.set(img, for: thumbnailKey) if self.coverImage == nil { @@ -595,38 +594,69 @@ extension TPPBook { // MARK: - Dominant Color (async, off main thread) private extension TPPBook { + private static let colorProcessingQueue = DispatchQueue(label: "org.thepalaceproject.dominantcolor", qos: .utility) + private static let sharedCIContext: CIContext = { + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { + return CIContext() + } + return CIContext(options: [ + .workingColorSpace: colorSpace, + .outputColorSpace: colorSpace, + .useSoftwareRenderer: false + ]) + }() + func updateDominantColor(using image: UIImage) { let inputImage = image - DispatchQueue.global(qos: .userInitiated).async { [weak self] in + Self.colorProcessingQueue.async { [weak self] in guard let self = self else { return } - let ciImage = CIImage(image: inputImage) - let filter = CIFilter.areaAverage() - filter.inputImage = ciImage - filter.extent = ciImage?.extent ?? .zero - - guard let outputImage = filter.outputImage else { return } - - var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [CIContextOption.useSoftwareRenderer: false]) - context.render( - outputImage, - toBitmap: &bitmap, - rowBytes: 4, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .RGBA8, - colorSpace: nil - ) + autoreleasepool { + guard let ciImage = CIImage(image: inputImage) else { + Log.debug(#file, "Failed to create CIImage from UIImage for book: \(self.identifier)") + return + } - let color = UIColor( - red: CGFloat(bitmap[0]) / 255.0, - green: CGFloat(bitmap[1]) / 255.0, - blue: CGFloat(bitmap[2]) / 255.0, - alpha: CGFloat(bitmap[3]) / 255.0 - ) + guard !ciImage.extent.isEmpty else { + Log.debug(#file, "CIImage has empty extent for book: \(self.identifier)") + return + } - DispatchQueue.main.async { - self.dominantUIColor = color + let filter = CIFilter.areaAverage() + filter.inputImage = ciImage + filter.extent = ciImage.extent + + guard let outputImage = filter.outputImage else { + Log.debug(#file, "Failed to generate output image from filter for book: \(self.identifier)") + return + } + + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { + Log.debug(#file, "Failed to create sRGB color space for book: \(self.identifier)") + return + } + + var bitmap = [UInt8](repeating: 0, count: 4) + + Self.sharedCIContext.render( + outputImage, + toBitmap: &bitmap, + rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, + colorSpace: colorSpace + ) + + let color = UIColor( + red: CGFloat(bitmap[0]) / 255.0, + green: CGFloat(bitmap[1]) / 255.0, + blue: CGFloat(bitmap[2]) / 255.0, + alpha: CGFloat(bitmap[3]) / 255.0 + ) + + DispatchQueue.main.async { + self.dominantUIColor = color + } } } } diff --git a/Palace/Book/Models/TPPBookCoverRegistry.swift b/Palace/Book/Models/TPPBookCoverRegistry.swift index 23a504329..c565ff65e 100644 --- a/Palace/Book/Models/TPPBookCoverRegistry.swift +++ b/Palace/Book/Models/TPPBookCoverRegistry.swift @@ -13,16 +13,19 @@ actor TPPBookCoverRegistry { } func coverImage(for book: TPPBook) async -> UIImage? { - guard let url = book.imageURL else { return await thumbnailImage(for: book) } - return await fetchImage(from: url, for: book, isCover: true) + if let url = book.imageURL, let image = await fetchImage(from: url, for: book, isCover: true) { + return image + } + + return await thumbnailImage(for: book) } func thumbnailImage(for book: TPPBook) async -> UIImage? { - guard let url = book.imageThumbnailURL else { - return await placeholder(for: book) + if let url = book.imageThumbnailURL, let image = await fetchImage(from: url, for: book, isCover: false) { + return image } - return await fetchImage(from: url, for: book, isCover: false) + return await placeholder(for: book) } private func fetchImage(from url: URL, for book: TPPBook, isCover: Bool) async -> UIImage? { @@ -36,16 +39,19 @@ actor TPPBookCoverRegistry { } let task = Task { [weak self] in - guard let self else { return UIImage() } + guard let self else { return nil } do { let (data, _) = try await URLSession.shared.data(from: url) - guard let image = UIImage(data: data) else { return nil } + guard let image = UIImage(data: data) else { + Log.error(#file, "Failed to decode image data from URL: \(url)") + return nil + } self.imageCache.set(image, for: key as String, expiresIn: nil) return image } catch { - Log.error(#file, "Failed to fetch image: \(error.localizedDescription)") + Log.error(#file, "Failed to fetch image from \(url): \(error.localizedDescription)") return nil } } diff --git a/Palace/Book/UI/AudiobookSampleToolbar.swift b/Palace/Book/UI/AudiobookSampleToolbar.swift index e0bdee709..99d89b3bf 100644 --- a/Palace/Book/UI/AudiobookSampleToolbar.swift +++ b/Palace/Book/UI/AudiobookSampleToolbar.swift @@ -14,7 +14,7 @@ struct AudiobookSampleToolbar: View { @ObservedObject var player: AudiobookSamplePlayer private var book: TPPBook - private let imageLoader = AsyncImage(image: UIImage(systemName: "book.closed.fill") ?? UIImage()) + private let imageLoader: AsyncImage private let toolbarHeight: CGFloat = 70 private let toolbarPadding: CGFloat = 5 private let imageViewHeight: CGFloat = 70 @@ -25,10 +25,31 @@ struct AudiobookSampleToolbar: View { self.book = book guard let sample = book.sample as? AudiobookSample else { return nil } player = AudiobookSamplePlayer(sample: sample) + + let placeholderImage = Self.generatePlaceholder(for: book) + imageLoader = AsyncImage(image: placeholderImage) + if let imageURL = book.imageThumbnailURL ?? book.imageURL { imageLoader.loadImage(url: imageURL) } } + + private static func generatePlaceholder(for book: TPPBook) -> UIImage { + let size = CGSize(width: 80, height: 120) + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: size, format: format) + .image { ctx in + if let view = NYPLTenPrintCoverView( + frame: CGRect(origin: .zero, size: size), + withTitle: book.title, + withAuthor: book.authors ?? "Unknown Author", + withScale: 0.4 + ) { + view.layer.render(in: ctx.cgContext) + } + } + } var body: some View { HStack { diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index be0c5efce..536f7b01f 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -345,20 +345,46 @@ final class BookDetailViewModel: ObservableObject { processingButtons.contains(button) } + // MARK: - Authentication Helper + + /// Ensures authentication document is loaded and handles sign-in if needed. + private func ensureAuthAndExecute(_ action: @escaping () -> Void) { + let businessLogic = TPPSignInBusinessLogic( + libraryAccountID: AccountsManager.shared.currentAccount?.uuid ?? "", + libraryAccountsProvider: AccountsManager.shared, + urlSettingsProvider: TPPSettings.shared, + bookRegistry: TPPBookRegistry.shared, + bookDownloadsCenter: MyBooksDownloadCenter.shared, + userAccountProvider: TPPUserAccount.self, + uiDelegate: nil, + drmAuthorizer: nil + ) + + businessLogic.ensureAuthenticationDocumentIsLoaded { [weak self] (success: Bool) in + DispatchQueue.main.async { + guard let self = self else { return } + + let account = TPPUserAccount.sharedAccount() + if account.needsAuth && !account.hasCredentials() { + self.showHalfSheet = false + TPPAccountSignInViewController.requestCredentials { [weak self] in + guard let self else { return } + action() + } + return + } + action() + } + } + } + // MARK: - Download/Return/Cancel func didSelectDownload(for book: TPPBook) { self.downloadProgress = 0 - let account = TPPUserAccount.sharedAccount() - if account.needsAuth && !account.hasCredentials() { - showHalfSheet = false - TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - self.startDownloadAfterAuth(book: book) - } - return + ensureAuthAndExecute { [weak self] in + self?.startDownloadAfterAuth(book: book) } - startDownloadAfterAuth(book: book) } private func startDownloadAfterAuth(book: TPPBook) { @@ -368,16 +394,9 @@ final class BookDetailViewModel: ObservableObject { } func didSelectReserve(for book: TPPBook) { - let account = TPPUserAccount.sharedAccount() - if account.needsAuth && !account.hasCredentials() { - showHalfSheet = false - TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - self.downloadCenter.startBorrow(for: book, attemptDownload: false) - } - return + ensureAuthAndExecute { [weak self] in + self?.downloadCenter.startBorrow(for: book, attemptDownload: false) } - downloadCenter.startBorrow(for: book, attemptDownload: false) } func didSelectCancel() { @@ -386,7 +405,6 @@ final class BookDetailViewModel: ObservableObject { } func didSelectReturn(for book: TPPBook, completion: (() -> Void)?) { - // Prevent multiple return requests and UI loops processingButtons.insert(.returning) downloadCenter.returnBook(withIdentifier: book.identifier) { [weak self] in guard let self else { return } @@ -400,35 +418,31 @@ final class BookDetailViewModel: ObservableObject { @MainActor func didSelectRead(for book: TPPBook, completion: (() -> Void)?) { - let account = TPPUserAccount.sharedAccount() - if account.needsAuth && !account.hasCredentials() { - TPPAccountSignInViewController.requestCredentials { [weak self] in - Task { @MainActor in - self?.openBook(book, completion: completion) - } - } - return - } + ensureAuthAndExecute { [weak self] in + Task { @MainActor in + guard let self = self else { return } #if FEATURE_DRM_CONNECTOR - let user = TPPUserAccount.sharedAccount() - - if user.hasCredentials() { - if user.hasAuthToken() { - openBook(book, completion: completion) - return - } else if !(AdobeCertificate.defaultCertificate?.hasExpired ?? false) && - !NYPLADEPT.sharedInstance().isUserAuthorized(user.userID, withDevice: user.deviceID) { - let reauthenticator = TPPReauthenticator() - reauthenticator.authenticateIfNeeded(user, usingExistingCredentials: true) { - Task { @MainActor in + let user = TPPUserAccount.sharedAccount() + + if user.hasCredentials() { + if user.hasAuthToken() { self.openBook(book, completion: completion) + return + } else if !(AdobeCertificate.defaultCertificate?.hasExpired ?? false) && + !NYPLADEPT.sharedInstance().isUserAuthorized(user.userID, withDevice: user.deviceID) { + let reauthenticator = TPPReauthenticator() + reauthenticator.authenticateIfNeeded(user, usingExistingCredentials: true) { + Task { @MainActor in + self.openBook(book, completion: completion) + } + } + return } } - return +#endif + self.openBook(book, completion: completion) } } -#endif - openBook(book, completion: completion) } @MainActor diff --git a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift index fab0dfd18..4355aff52 100644 --- a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift +++ b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift @@ -14,6 +14,9 @@ class CatalogLaneMoreViewModel: ObservableObject { @Published var isLoading = true @Published var error: String? + @Published var nextPageURL: URL? + @Published var isLoadingMore = false + // UI State @Published var showingSortSheet = false @Published var showingFiltersSheet = false @@ -24,14 +27,12 @@ class CatalogLaneMoreViewModel: ObservableObject { @Published var pendingSelections: Set = [] @Published var appliedSelections: Set = [] @Published var isApplyingFilters = false - @Published var currentSort: CatalogSortService.SortOption = .titleAZ // MARK: - Properties let title: String let url: URL private let filterService = CatalogFilterService.self - private let sortService = CatalogSortService.self private let api: DefaultCatalogAPI private var cancellables = Set() @@ -49,6 +50,10 @@ class CatalogLaneMoreViewModel: ObservableObject { return ungroupedBooks } + var shouldShowPagination: Bool { + return nextPageURL != nil + } + // MARK: - Initialization init(title: String, url: URL, api: DefaultCatalogAPI? = nil) { @@ -63,14 +68,6 @@ class CatalogLaneMoreViewModel: ObservableObject { } private func setupObservers() { - // Observe sort changes - $currentSort - .dropFirst() // Skip initial value - .sink { [weak self] _ in - self?.sortBooksInPlace() - } - .store(in: &cancellables) - // Setup pending selections when filter sheet opens $showingFiltersSheet .filter { $0 } // Only when opening @@ -99,8 +96,7 @@ class CatalogLaneMoreViewModel: ObservableObject { // Just restore the filter state if it exists, but don't refetch if let savedState = coordinator.resolveCatalogFilterState(for: url) { // Only restore if current state doesn't match saved state - if appliedSelections != savedState.appliedSelections || - currentSort.localizedString != savedState.currentSort { + if appliedSelections != savedState.appliedSelections { restoreFilterState(savedState) } } @@ -132,8 +128,11 @@ class CatalogLaneMoreViewModel: ObservableObject { lanes.removeAll() ungroupedBooks.removeAll() facetGroups.removeAll() + nextPageURL = nil let feedObjc = feed.opdsFeed + extractNextPageURL(from: feedObjc) + if let entries = feedObjc.entries as? [TPPOPDSEntry] { switch feedObjc.type { case .acquisitionGrouped: @@ -180,7 +179,39 @@ class CatalogLaneMoreViewModel: ObservableObject { .compactMap(CatalogFilterService.parseKey) .map { CatalogFilterService.makeGroupTitleKey(group: $0.group, title: $0.title) } ) - sortBooksInPlace() + } + + // MARK: - Pagination + + private func extractNextPageURL(from feed: TPPOPDSFeed) { + guard let links = feed.links as? [TPPOPDSLink] else { return } + for link in links { + if link.rel == "next" { + nextPageURL = link.href + break + } + } + } + + func loadNextPage() async { + guard let nextURL = nextPageURL, !isLoadingMore else { return } + + isLoadingMore = true + defer { isLoadingMore = false } + + do { + if let feed = try await api.fetchFeed(at: nextURL) { + let feedObjc = feed.opdsFeed + extractNextPageURL(from: feedObjc) + + if let entries = feedObjc.entries as? [TPPOPDSEntry] { + let newBooks = entries.compactMap { CatalogViewModel.makeBook(from: $0) } + ungroupedBooks.append(contentsOf: newBooks) + } + } + } catch { + Log.error(#file, "Failed to load next page: \(error.localizedDescription)") + } } // MARK: - Registry Sync @@ -270,7 +301,6 @@ class CatalogLaneMoreViewModel: ObservableObject { if let feed = try await api.fetchFeed(at: filterURL) { if let entries = feed.opdsFeed.entries as? [TPPOPDSEntry] { ungroupedBooks = entries.compactMap { CatalogViewModel.makeBook(from: $0) } - sortBooksInPlace() } if feed.opdsFeed.type == TPPOPDSFeedType.acquisitionUngrouped { @@ -308,19 +338,24 @@ class CatalogLaneMoreViewModel: ObservableObject { } } - // MARK: - Sorting + // MARK: - OPDS Facet Selection - func sortBooksInPlace() { - CatalogSortService.sort(books: &ungroupedBooks, by: currentSort) + func applyOPDSFacet(_ facet: CatalogFilter, coordinator: NavigationCoordinator) async { + guard let href = facet.href else { return } + + isLoading = true + error = nil + defer { isLoading = false } + + await fetchAndApplyFeed(at: href) + saveFilterState(coordinator: coordinator) } // MARK: - State Persistence func saveFilterState(coordinator: NavigationCoordinator) { - let sortString = currentSort.localizedString let state = CatalogLaneFilterState( appliedSelections: appliedSelections, - currentSort: sortString, facetGroups: facetGroups ) coordinator.storeCatalogFilterState(state, for: url) @@ -329,9 +364,16 @@ class CatalogLaneMoreViewModel: ObservableObject { func restoreFilterState(_ state: CatalogLaneFilterState) { appliedSelections = state.appliedSelections facetGroups = state.facetGroups - - if let restoredSort = CatalogSortService.SortOption.from(localizedString: state.currentSort) { - currentSort = restoredSort - } + } + + var sortFacets: [CatalogFilter] { + return facetGroups + .first { $0.name.lowercased().contains("sort") }? + .filters ?? [] + } + + /// Get the currently active sort facet title (for display) + var activeSortTitle: String? { + return sortFacets.first { $0.active }?.title } } diff --git a/Palace/CatalogUI/Views/CatalogContentView.swift b/Palace/CatalogUI/Views/CatalogContentView.swift index cd120aea3..77d6585fc 100644 --- a/Palace/CatalogUI/Views/CatalogContentView.swift +++ b/Palace/CatalogUI/Views/CatalogContentView.swift @@ -20,7 +20,6 @@ struct CatalogContentView: View { } } .padding(.vertical, 17) - .padding(.bottom, 100) } .refreshable { await viewModel.refresh() } .onReceive(viewModel.$shouldScrollToTop) { shouldScroll in diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift deleted file mode 100644 index a919fc82f..000000000 --- a/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift +++ /dev/null @@ -1,67 +0,0 @@ -import SwiftUI - -// MARK: - CatalogLaneMoreContentView -struct CatalogLaneMoreContentView: View { - @ObservedObject var viewModel: CatalogLaneMoreViewModel - let onBookSelected: (TPPBook) -> Void - let onLaneMoreTapped: (String, URL) -> Void - - var body: some View { - if viewModel.isLoading { - loadingView - } else if let error = viewModel.error { - errorView(error) - } else if !viewModel.lanes.isEmpty { - lanesView - } else { - booksListView - } - } -} - -// MARK: - Private Views -private extension CatalogLaneMoreContentView { - var loadingView: some View { - ScrollView { - BookListSkeletonView() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - func errorView(_ error: String) -> some View { - Text(error) - .padding() - } - - var lanesView: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 24) { - ForEach(viewModel.lanes) { lane in - CatalogLaneRowView( - title: lane.title, - books: lane.books.map { TPPBookRegistry.shared.updatedBookMetadata($0) ?? $0 }, - moreURL: lane.moreURL, - onSelect: onBookSelected, - onMoreTapped: onLaneMoreTapped, - showHeader: true - ) - .dismissKeyboardOnTap() - } - } - .padding(.vertical, 20) - } - .refreshable { await viewModel.refresh() } - } - - var booksListView: some View { - ScrollView { - BookListView( - books: viewModel.sortedBooks, - isLoading: .constant(false), - onSelect: onBookSelected - ) - } - .refreshable { await viewModel.refresh() } - } -} - diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift index 2c8440a52..31673891d 100644 --- a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift @@ -68,9 +68,6 @@ struct CatalogLaneMoreView: View { .onReceive(downloadProgressPublisher) { changedId in viewModel.applyRegistryUpdates(changedIdentifier: changedId) } - .onChange(of: viewModel.currentSort) { _ in - viewModel.saveFilterState(coordinator: coordinator) - } .sheet(isPresented: $viewModel.showingSortSheet) { SortOptionsSheet .presentationDetents([.medium, .large]) @@ -129,6 +126,10 @@ struct CatalogLaneMoreView: View { } private func handleAccountChange() { + if viewModel.showSearch { + dismissSearch() + } + setupAccount() viewModel.appliedSelections.removeAll() viewModel.pendingSelections.removeAll() @@ -202,12 +203,17 @@ struct CatalogLaneMoreView: View { .padding(.top, 12) ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(CatalogSortService.SortOption.allCases, id: \.self) { sort in - Button(action: { viewModel.currentSort = sort }) { + ForEach(viewModel.sortFacets, id: \.id) { facet in + Button(action: { + Task { + await viewModel.applyOPDSFacet(facet, coordinator: coordinator) + viewModel.showingSortSheet = false + } + }) { HStack { - Image(systemName: viewModel.currentSort == sort ? "largecircle.fill.circle" : "circle") + Image(systemName: facet.active ? "largecircle.fill.circle" : "circle") .foregroundColor(.primary) - Text(sort.localizedString) + Text(facet.title) .foregroundColor(.primary) Spacer() } @@ -263,9 +269,9 @@ private extension CatalogLaneMoreView { FacetToolbarView( title: viewModel.title, showFilter: true, - onSort: { viewModel.showingSortSheet = true }, + onSort: viewModel.sortFacets.isEmpty ? nil : { viewModel.showingSortSheet = true }, onFilter: { viewModel.showingFiltersSheet = true }, - currentSortTitle: viewModel.currentSort.localizedString, + currentSortTitle: viewModel.activeSortTitle, appliedFiltersCount: viewModel.activeFiltersCount ) .padding(.bottom, 5) @@ -346,9 +352,13 @@ private extension CatalogLaneMoreView { @ViewBuilder var booksView: some View { ScrollView { - BookListView(books: viewModel.ungroupedBooks, isLoading: $viewModel.isLoading) { book in - presentBookDetail(book) - } + BookListView( + books: viewModel.ungroupedBooks, + isLoading: $viewModel.isLoading, + onSelect: { book in presentBookDetail(book) }, + onLoadMore: viewModel.shouldShowPagination ? { @MainActor in await viewModel.loadNextPage() } : nil, + isLoadingMore: viewModel.isLoadingMore + ) } .refreshable { await viewModel.fetchAndApplyFeed(at: viewModel.url) } } diff --git a/Palace/CatalogUI/Views/CatalogView.swift b/Palace/CatalogUI/Views/CatalogView.swift index 2887a4b8a..82f294149 100644 --- a/Palace/CatalogUI/Views/CatalogView.swift +++ b/Palace/CatalogUI/Views/CatalogView.swift @@ -187,6 +187,10 @@ private extension CatalogView { } func handleAccountChange() { + if showSearch { + dismissSearch() + } + let account = AccountsManager.shared.currentAccount account?.logoDelegate = logoObserver account?.loadLogo() @@ -198,10 +202,13 @@ private extension CatalogView { } func switchToAccount(_ account: Account) { - AccountsManager.shared.currentAccount = account if let urlString = account.catalogUrl, let url = URL(string: urlString) { TPPSettings.shared.accountMainFeedURL = url } + AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) Task { await viewModel.refresh() } } diff --git a/Palace/CatalogUI/Views/FacetToolbarView.swift b/Palace/CatalogUI/Views/FacetToolbarView.swift index 6240c7fe1..3a3296f10 100644 --- a/Palace/CatalogUI/Views/FacetToolbarView.swift +++ b/Palace/CatalogUI/Views/FacetToolbarView.swift @@ -3,9 +3,9 @@ import SwiftUI struct FacetToolbarView: View { let title: String? let showFilter: Bool - let onSort: () -> Void + let onSort: (() -> Void)? let onFilter: () -> Void - let currentSortTitle: String + let currentSortTitle: String? var appliedFiltersCount: Int = 0 var body: some View { @@ -16,14 +16,16 @@ struct FacetToolbarView: View { .bold() } Spacer() - Button(action: onSort) { - HStack(spacing: 2) { - ImageProviders.MyBooksView.sort - Text(currentSortTitle) + if let onSort = onSort, let currentSortTitle = currentSortTitle { + Button(action: onSort) { + HStack(spacing: 2) { + ImageProviders.MyBooksView.sort + Text(currentSortTitle) + } + .palaceFont(size: 14) } - .palaceFont(size: 14) + .buttonStyle(.plain) } - .buttonStyle(.plain) if showFilter { Button(action: onFilter) { HStack(spacing: 3) { diff --git a/Palace/Holds/HoldsView.swift b/Palace/Holds/HoldsView.swift index 89c5861fc..cda5fc8b2 100644 --- a/Palace/Holds/HoldsView.swift +++ b/Palace/Holds/HoldsView.swift @@ -58,7 +58,9 @@ struct HoldsView: View { .sheet(isPresented: $model.showLibraryAccountView) { UIViewControllerWrapper( TPPAccountList { account in - model.loadAccount(account) + DispatchQueue.main.async { + model.loadAccount(account) + } }, updater: { _ in } ) diff --git a/Palace/Holds/HoldsViewModel.swift b/Palace/Holds/HoldsViewModel.swift index adc5b2c07..e18208e3e 100644 --- a/Palace/Holds/HoldsViewModel.swift +++ b/Palace/Holds/HoldsViewModel.swift @@ -119,7 +119,13 @@ final class HoldsViewModel: ObservableObject { } private func updateFeed(_ account: Account) { + if let urlString = account.catalogUrl, let url = URL(string: urlString) { + TPPSettings.shared.accountMainFeedURL = url + } AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) } diff --git a/Palace/Logging/AudiobookFileLogger.swift b/Palace/Logging/AudiobookFileLogger.swift index 4c44d08cc..efd62795b 100644 --- a/Palace/Logging/AudiobookFileLogger.swift +++ b/Palace/Logging/AudiobookFileLogger.swift @@ -29,14 +29,20 @@ class AudiobookFileLogger: TPPErrorLogger { print("New event logged: \(event.description)") let logFileUrl = logsDirectoryUrl.appendingPathComponent("\(bookId).log") let logMessage = "\(Date()): \(event)\n" + let maxLogFileSize: Int64 = 2_000_000 if FileManager.default.fileExists(atPath: logFileUrl.path) { - if let fileHandle = try? FileHandle(forWritingTo: logFileUrl) { - fileHandle.seekToEndOfFile() + let fileSize = (try? FileManager.default.attributesOfItem(atPath: logFileUrl.path)[.size] as? Int64) ?? 0 + + if fileSize > maxLogFileSize { + try? FileManager.default.removeItem(at: logFileUrl) + try? "...[previous log truncated due to size]...\n\(logMessage)".write(to: logFileUrl, atomically: true, encoding: .utf8) + } else if let fileHandle = try? FileHandle(forWritingTo: logFileUrl) { + defer { try? fileHandle.close() } + try? fileHandle.seekToEnd() if let logData = logMessage.data(using: .utf8) { fileHandle.write(logData) } - fileHandle.closeFile() } } else { try? logMessage.write(to: logFileUrl, atomically: true, encoding: .utf8) @@ -46,6 +52,25 @@ class AudiobookFileLogger: TPPErrorLogger { func retrieveLog(forBookId bookId: String) -> String? { guard let logsDirectoryUrl = logsDirectoryUrl else { return nil } let logFileUrl = logsDirectoryUrl.appendingPathComponent("\(bookId).log") + + guard let fileSize = try? FileManager.default.attributesOfItem(atPath: logFileUrl.path)[.size] as? Int64 else { + return try? String(contentsOf: logFileUrl) + } + + let maxLogSize: Int64 = 1_000_000 + if fileSize > maxLogSize { + Log.warn(#file, "Log file for \(bookId) is \(fileSize) bytes, truncating to last \(maxLogSize) bytes") + guard let fileHandle = try? FileHandle(forReadingFrom: logFileUrl) else { return nil } + defer { try? fileHandle.close() } + + let offset = max(0, fileSize - maxLogSize) + try? fileHandle.seek(toOffset: UInt64(offset)) + + if let data = try? fileHandle.readToEnd(), let truncatedLog = String(data: data, encoding: .utf8) { + return "...[truncated \(offset) bytes]...\n" + truncatedLog + } + } + return try? String(contentsOf: logFileUrl) } diff --git a/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift b/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift index afd755877..b942391a6 100644 --- a/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift +++ b/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift @@ -43,7 +43,7 @@ extension BookCellState { class BookCellModel: ObservableObject { typealias DisplayStrings = Strings.BookCell - @Published var image = ImageProviders.MyBooksView.bookPlaceholder ?? UIImage() + @Published var image = UIImage() @Published var showAlert: AlertModel? @Published var isLoading: Bool = false { didSet { @@ -105,6 +105,7 @@ class BookCellModel: ObservableObject { self.imageCache = imageCache self.registryState = TPPBookRegistry.shared.state(for: book.identifier) self.stableButtonState = self.computeButtonState(book: book, registryState: self.registryState, isManagingHold: self.isManagingHold) + self.image = generatePlaceholder(for: book) registerForNotifications() loadBookCoverImage() bindRegistryState() @@ -120,6 +121,23 @@ class BookCellModel: ObservableObject { // MARK: - Image Loading + private func generatePlaceholder(for book: TPPBook) -> UIImage { + let size = CGSize(width: 80, height: 120) + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: size, format: format) + .image { ctx in + if let view = NYPLTenPrintCoverView( + frame: CGRect(origin: .zero, size: size), + withTitle: book.title, + withAuthor: book.authors ?? "Unknown Author", + withScale: 0.4 + ) { + view.layer.render(in: ctx.cgContext) + } + } + } + func loadBookCoverImage() { let simpleKey = book.identifier let thumbnailKey = "\(book.identifier)_thumbnail" diff --git a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift index a679e2274..d4a5e4697 100644 --- a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift +++ b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift @@ -39,10 +39,16 @@ extension BookButtonState { buttons.append(book.isAudiobook ? .audiobookSample : .sample) } case .holding, .holdingFrontOfQueue: - buttons.append(.manageHold) - if book.hasSample && previewEnabled { - buttons.append(book.isAudiobook ? .audiobookSample : .sample) + if isHoldReady(book: book) { + buttons = [.get, .cancelHold] + } else { + buttons.append(.manageHold) + + if book.hasSample && previewEnabled { + buttons.append(book.isAudiobook ? .audiobookSample : .sample) + } } + case .managingHold: if isHoldReady(book: book) { buttons = [.get, .cancelHold] diff --git a/Palace/MyBooks/MyBooks/BookListView.swift b/Palace/MyBooks/MyBooks/BookListView.swift index 6d6b6a761..cd781de03 100644 --- a/Palace/MyBooks/MyBooks/BookListView.swift +++ b/Palace/MyBooks/MyBooks/BookListView.swift @@ -4,6 +4,8 @@ struct BookListView: View { let books: [TPPBook] @Binding var isLoading: Bool let onSelect: (TPPBook) -> Void + var onLoadMore: (() async -> Void)? = nil + var isLoadingMore: Bool = false @State private var containerWidth: CGFloat = UIScreen.main.bounds.width var body: some View { @@ -14,6 +16,15 @@ struct BookListView: View { } .buttonStyle(.plain) .applyBorderStyle() + .onAppear { + if let onLoadMore = onLoadMore, book.identifier == books.last?.identifier { + Task { await onLoadMore() } + } + } + } + + if isLoadingMore { + paginationLoadingIndicator } } .padding(.horizontal, 12) @@ -30,6 +41,13 @@ struct BookListView: View { } ) } + + private var paginationLoadingIndicator: some View { + PulsatingDotsLoader() + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .gridCellColumns(gridLayout.count) + } private var gridLayout: [GridItem] { if UIDevice.current.userInterfaceIdiom == .pad { @@ -52,3 +70,40 @@ extension View { } } +// MARK: - Pulsating Dots Loader +struct PulsatingDotsLoader: View { + @State private var pulse1: Bool = false + @State private var pulse2: Bool = false + @State private var pulse3: Bool = false + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(Color.gray.opacity(0.25)) + .frame(width: 12, height: 12) + .opacity(pulse1 ? 0.6 : 1.0) + + Circle() + .fill(Color.gray.opacity(0.25)) + .frame(width: 12, height: 12) + .opacity(pulse2 ? 0.6 : 1.0) + + Circle() + .fill(Color.gray.opacity(0.25)) + .frame(width: 12, height: 12) + .opacity(pulse3 ? 0.6 : 1.0) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + pulse1 = true + } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true).delay(0.3)) { + pulse2 = true + } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true).delay(0.6)) { + pulse3 = true + } + } + } +} + diff --git a/Palace/MyBooks/MyBooks/MyBooksViewModel.swift b/Palace/MyBooks/MyBooks/MyBooksViewModel.swift index c104527dc..13f4611bb 100644 --- a/Palace/MyBooks/MyBooks/MyBooksViewModel.swift +++ b/Palace/MyBooks/MyBooks/MyBooksViewModel.swift @@ -140,8 +140,18 @@ enum Group: Int { } private func updateFeed(_ account: Account) { + if !TPPSettings.shared.settingsAccountIdsList.contains(account.uuid) { + TPPSettings.shared.settingsAccountIdsList.append(account.uuid) + } + + if let urlString = account.catalogUrl, let url = URL(string: urlString) { + TPPSettings.shared.accountMainFeedURL = url + } + AccountsManager.shared.currentAccount = account - // Notify the app that the account changed so Catalog and UI refresh appropriately + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) } diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index f5e7304e4..b3308aedf 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -133,6 +133,7 @@ import OverdriveProcessor } private var hasAttemptedAuthentication = false + private var isRequestingCredentials = false private func process(error: [String: Any]?, for book: TPPBook) { guard let errorType = error?["type"] as? String else { @@ -156,7 +157,19 @@ import OverdriveProcessor return } + guard !isRequestingCredentials else { + NSLog("Already requesting credentials, skipping re-authentication for: \(book.title)") + return + } + hasAttemptedAuthentication = true + isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + NSLog("Invalid credentials problem when borrowing a book, present sign in VC") reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: false) { [weak self] in @@ -165,8 +178,15 @@ import OverdriveProcessor return } - DispatchQueue.main.async { - self.startDownload(for: book) + self.isRequestingCredentials = false + + // Only retry if user now has credentials to prevent infinite recursion + if self.userAccount.hasCredentials() { + DispatchQueue.main.async { + self.startDownload(for: book) + } + } else { + NSLog("Authentication completed but no credentials present, user may have cancelled") } } @@ -239,18 +259,49 @@ import OverdriveProcessor } private func requestCredentialsAndStartDownload(for book: TPPBook) { + guard !isRequestingCredentials else { + NSLog("Already requesting credentials for authentication, skipping duplicate request for: \(book.title)") + return + } + + isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + #if FEATURE_DRM_CONNECTOR if AdobeCertificate.defaultCertificate?.hasExpired ?? false { + isRequestingCredentials = false // ADEPT crashes the app with expired certificate. TPPAlertUtils.presentFromViewControllerOrNil(alertController: TPPAlertUtils.expiredAdobeDRMAlert(), viewController: nil, animated: true, completion: nil) } else { TPPAccountSignInViewController.requestCredentials { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry download if user now has credentials + // This prevents infinite recursion when refreshAuthIfNeeded returns false immediately + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Sign-in completed but no credentials present, user may have cancelled") + } } } #else TPPAccountSignInViewController.requestCredentials { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry download if user now has credentials + // This prevents infinite recursion when refreshAuthIfNeeded returns false immediately + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Sign-in completed but no credentials present, user may have cancelled") + } } #endif } @@ -392,8 +443,28 @@ import OverdriveProcessor guard let self = self else { return } self.bookRegistry.setState(.downloadNeeded, for: book.identifier) + guard !self.isRequestingCredentials else { + NSLog("Already requesting credentials, skipping re-authentication in problemFoundHandler for: \(book.title)") + return + } + + self.isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + self.reauthenticator.authenticateIfNeeded(self.userAccount, usingExistingCredentials: false) { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry if user now has credentials to prevent infinite recursion + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Authentication completed but no credentials present, user may have cancelled") + } } } @@ -445,8 +516,29 @@ import OverdriveProcessor private func handleProblem(for book: TPPBook, problemDocument: TPPProblemDocument?) { bookRegistry.setState(.downloadNeeded, for: book.identifier) + + guard !isRequestingCredentials else { + NSLog("Already requesting credentials, skipping re-authentication in handleProblem for: \(book.title)") + return + } + + isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: false) { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry if user now has credentials to prevent infinite recursion + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Authentication completed but no credentials present, user may have cancelled") + } } } @@ -1178,6 +1270,13 @@ extension MyBooksDownloadCenter { if book.defaultBookContentType == .audiobook { Log.info(#file, "LCP audiobook license fulfilled, ready for streaming: \(book.identifier)") + + if let license = TPPLCPLicense(url: licenseUrl) { + self.bookRegistry.setFulfillmentId(license.identifier, for: book.identifier) + } else { + Log.error(#file, "🔑 ❌ Failed to read license for fulfillment ID") + } + self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) self.bookRegistry.setState(.downloadSuccessful, for: book.identifier) diff --git a/Palace/PDF/Model/TPPEncryptedPDFDocument.swift b/Palace/PDF/Model/TPPEncryptedPDFDocument.swift index 9cd905f3e..91c70d4bb 100644 --- a/Palace/PDF/Model/TPPEncryptedPDFDocument.swift +++ b/Palace/PDF/Model/TPPEncryptedPDFDocument.swift @@ -13,6 +13,7 @@ import UIKit @objcMembers class TPPEncryptedPDFDocument: NSObject { private var thumbnailsCache = NSCache() + private var memoryWarningObserver: NSObjectProtocol? /// PDF document data. let data: Data @@ -34,10 +35,51 @@ import UIKit self.document = CGPDFDocument(dataProvider) super.init() + configureCacheLimits() + setupMemoryWarningHandler() + setPageCount() setTitle() setCover() } + + deinit { + if let observer = memoryWarningObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + private func configureCacheLimits() { + let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) + let cacheMemoryMB: Int + let countLimit: Int + + if deviceMemoryMB < 2048 { + cacheMemoryMB = 30 + countLimit = 50 + } else if deviceMemoryMB < 4096 { + cacheMemoryMB = 50 + countLimit = 100 + } else { + cacheMemoryMB = 80 + countLimit = 150 + } + + thumbnailsCache.totalCostLimit = cacheMemoryMB * 1024 * 1024 + thumbnailsCache.countLimit = countLimit + } + + private func setupMemoryWarningHandler() { + memoryWarningObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + DispatchQueue.global(qos: .utility).async { + self?.thumbnailsCache.removeAllObjects() + } + } + } func setPageCount() { Task { @@ -74,7 +116,7 @@ import UIKit } if let thumbnail = self.thumbnail(for: page), let thumbnailData = thumbnail.jpegData(compressionQuality: 0.5) { DispatchQueue.main.async { - self.thumbnailsCache.setObject(thumbnailData as NSData, forKey: pageNumber) + self.thumbnailsCache.setObject(thumbnailData as NSData, forKey: pageNumber, cost: thumbnailData.count) } } } @@ -143,7 +185,7 @@ extension TPPEncryptedPDFDocument { return cachedImage } else { if let image = self.page(at: page)?.thumbnail, let data = image.jpegData(compressionQuality: 0.5) { - thumbnailsCache.setObject(data as NSData, forKey: pageNumber) + thumbnailsCache.setObject(data as NSData, forKey: pageNumber, cost: data.count) return image } else { return nil diff --git a/Palace/PDF/Model/TPPPDFTextExtractor.swift b/Palace/PDF/Model/TPPPDFTextExtractor.swift index 6371d31fe..563c4d1ad 100644 --- a/Palace/PDF/Model/TPPPDFTextExtractor.swift +++ b/Palace/PDF/Model/TPPPDFTextExtractor.swift @@ -64,9 +64,14 @@ class TPPPDFTextExtractor { var array: CGPDFArrayRef? guard CGPDFScannerPopArray(scanner, &array), let array else { return } - var blockValue = "" + var blockComponents: [String] = [] + blockComponents.reserveCapacity(Int(CGPDFArrayGetCount(array))) + // Iterate through the array elements let count = CGPDFArrayGetCount(array) + let maxBlockSize = 50_000 + var currentSize = 0 + for index in 0.. 100 { - blockValue += " " + currentSize += 1 + guard currentSize < maxBlockSize else { break } + blockComponents.append(" ") } } case .integer: @@ -99,13 +111,18 @@ class TPPPDFTextExtractor { if CGPDFObjectGetValue(obj, .integer, &intValue) { // The same as realValue above if abs(intValue) > 100 { - blockValue += " " + currentSize += 1 + guard currentSize < maxBlockSize else { break } + blockComponents.append(" ") } } default: break } } - textBlocks.append(blockValue) + + if !blockComponents.isEmpty { + textBlocks.append(blockComponents.joined()) + } } } diff --git a/Palace/Reader2/UI/TPPEPUBViewController.swift b/Palace/Reader2/UI/TPPEPUBViewController.swift index a8033acd7..3accd265d 100644 --- a/Palace/Reader2/UI/TPPEPUBViewController.swift +++ b/Palace/Reader2/UI/TPPEPUBViewController.swift @@ -76,21 +76,31 @@ class TPPEPUBViewController: TPPBaseReaderViewController { setUIColor(for: preferences) } + override func updateNavigationBar(animated: Bool = true) { + super.updateNavigationBar(animated: animated) + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) setUIColor(for: preferences) log(.info, "TPPEPUBViewController will appear. UI color set based on preferences.") epubNavigator.submitPreferences(preferences) - + tabBarController?.tabBar.isHidden = true + if navigationItem.leftBarButtonItem == nil { let backItem = UIBarButtonItem(image: UIImage(systemName: "chevron.left"), style: .plain, target: self, action: #selector(closeEPUB)) navigationItem.leftBarButtonItem = backItem } navigationController?.navigationBar.isTranslucent = true + + navigationController?.setNavigationBarHidden(true, animated: false) + navigationController?.setToolbarHidden(true, animated: false) } @objc private func closeEPUB() { + tabBarController?.tabBar.isHidden = false + NavigationCoordinatorHub.shared.coordinator?.pop() } diff --git a/Palace/Settings/AccountList/TPPAccountList.swift b/Palace/Settings/AccountList/TPPAccountList.swift index f54c163d1..1087175b1 100644 --- a/Palace/Settings/AccountList/TPPAccountList.swift +++ b/Palace/Settings/AccountList/TPPAccountList.swift @@ -17,6 +17,8 @@ import Foundation private var sectionHeaderSize: CGFloat = 20 var requiresSelectionBeforeDismiss: Bool = false + + private var accountsLoadingLogos = Set() @objc required init(completion: @escaping (Account) -> ()) { self.completion = completion @@ -80,10 +82,6 @@ import Foundation private func finishConfiguration() { datasource.delegate = self - AccountsManager.shared.accounts().forEach { account in - account.logoDelegate = self - account.loadLogo() - } tableView.reloadData() } @@ -122,7 +120,11 @@ extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - completion(datasource.account(at: indexPath)) + let selectedAccount = datasource.account(at: indexPath) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.completion(selectedAccount) + } } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { @@ -141,9 +143,36 @@ extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { guard let cell = tableView.dequeueReusableCell(withIdentifier: TPPAccountListCell.reuseIdentifier, for: indexPath) as? TPPAccountListCell else { return UITableViewCell() } - cell.configure(for: datasource.account(at: indexPath)) + let account = datasource.account(at: indexPath) + + // Check cache synchronously and set image directly on cell to prevent gaps + if let cachedImage = account.imageCache.get(for: account.uuid) { + // Update account's logo if needed for consistency + if account.logo.size != cachedImage.size { + account.logo = cachedImage + } + cell.customImageView.image = cachedImage + } else { + // Set default logo while loading + cell.customImageView.image = account.logo + // Trigger async loading if not already in progress + loadLogoIfNeeded(for: account, at: indexPath) + } + + cell.customTextlabel.text = account.name + cell.customDetailLabel.text = account.subtitle + return cell } + + private func loadLogoIfNeeded(for account: Account, at indexPath: IndexPath) { + guard !accountsLoadingLogos.contains(account.uuid) else { return } + guard account.logoUrl != nil else { return } + + accountsLoadingLogos.insert(account.uuid) + account.logoDelegate = self + account.loadLogo() + } } // MARK: - DataSourceDelegate @@ -155,9 +184,15 @@ extension TPPAccountList: DataSourceDelegate { extension TPPAccountList: AccountLogoDelegate { func logoDidUpdate(in account: Account, to newLogo: UIImage) { - if let indexPath = datasource.indexPath(for: account) { - DispatchQueue.main.async { - self.tableView.reloadRows(at: [indexPath], with: .automatic) + accountsLoadingLogos.remove(account.uuid) + if let indexPath = datasource.indexPath(for: account), + tableView.indexPathsForVisibleRows?.contains(indexPath) == true { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + // Only update if cell is still visible + if let cell = self.tableView.cellForRow(at: indexPath) as? TPPAccountListCell { + cell.customImageView.image = newLogo + } } } } diff --git a/Palace/Settings/AccountList/TPPAccountListCell.swift b/Palace/Settings/AccountList/TPPAccountListCell.swift index 175f7320f..51441141f 100644 --- a/Palace/Settings/AccountList/TPPAccountListCell.swift +++ b/Palace/Settings/AccountList/TPPAccountListCell.swift @@ -63,6 +63,11 @@ class TPPAccountListCell: UITableViewCell { } + override func prepareForReuse() { + super.prepareForReuse() + customImageView.image = nil + } + func configure(for account: Account) { customImageView.image = account.logo customTextlabel.text = account.name diff --git a/Palace/Settings/NewSettings/TPPSettingsView.swift b/Palace/Settings/NewSettings/TPPSettingsView.swift index 8b70af43b..083482a4c 100644 --- a/Palace/Settings/NewSettings/TPPSettingsView.swift +++ b/Palace/Settings/NewSettings/TPPSettingsView.swift @@ -78,8 +78,10 @@ struct TPPSettingsView: View { .sheet(isPresented: $showAddLibrarySheet) { UIViewControllerWrapper( TPPAccountList { account in - MyBooksViewModel().loadAccount(account) - showAddLibrarySheet = false + DispatchQueue.main.async { + MyBooksViewModel().loadAccount(account) + showAddLibrarySheet = false + } }, updater: { _ in } ) diff --git a/Palace/Settings/TPPSettingsAccountsList.swift b/Palace/Settings/TPPSettingsAccountsList.swift index 69727e6eb..ec6d1126b 100644 --- a/Palace/Settings/TPPSettingsAccountsList.swift +++ b/Palace/Settings/TPPSettingsAccountsList.swift @@ -164,12 +164,17 @@ } updateSettingsAccountList() - // Return from search screen to the list of libraries navigationController?.popViewController(animated: false) - // Switch to the selected library + + if let urlString = account.catalogUrl, let url = URL(string: urlString) { + TPPSettings.shared.accountMainFeedURL = url + } + AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + self.tableView.reloadData() - NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) self.tabBarController?.selectedIndex = 0 (navigationController?.parent as? UINavigationController)?.popToRootViewController(animated: false) diff --git a/Palace/SignInLogic/TPPAccountSignInViewController.m b/Palace/SignInLogic/TPPAccountSignInViewController.m index 910c2b0e8..aaa2e2f05 100644 --- a/Palace/SignInLogic/TPPAccountSignInViewController.m +++ b/Palace/SignInLogic/TPPAccountSignInViewController.m @@ -56,6 +56,9 @@ @interface TPPAccountSignInViewController () Void)? = nil + @objc var refreshAuthCompletion: (() -> Void)? = nil // MARK:- OAuth / SAML / Clever Info diff --git a/Palace/Utilities/ImageCache/GeneralCache.swift b/Palace/Utilities/ImageCache/GeneralCache.swift index 3bfc4bda5..1cd5c6b32 100644 --- a/Palace/Utilities/ImageCache/GeneralCache.swift +++ b/Palace/Utilities/ImageCache/GeneralCache.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit import CryptoKit public enum CachingMode { @@ -22,6 +23,7 @@ public final class GeneralCache { private let cacheDirectory: URL private let queue = DispatchQueue(label: "com.Palace.GeneralCache", attributes: .concurrent) private let mode: CachingMode + private var memoryWarningObserver: NSObjectProtocol? private final class Entry: Codable { let value: Value @@ -51,6 +53,52 @@ public final class GeneralCache { let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! cacheDirectory = cachesDir.appendingPathComponent(cacheName, isDirectory: true) try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + + configureCacheLimits() + setupMemoryWarningHandler() + } + + deinit { + if let observer = memoryWarningObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + private func configureCacheLimits() { + let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) + let cacheMemoryMB: Int + let itemCountLimit: Int + + if deviceMemoryMB < 2048 { + cacheMemoryMB = 50 + itemCountLimit = 200 + } else if deviceMemoryMB < 4096 { + cacheMemoryMB = 100 + itemCountLimit = 400 + } else { + cacheMemoryMB = 150 + itemCountLimit = 600 + } + + memoryCache.totalCostLimit = cacheMemoryMB * 1024 * 1024 + memoryCache.countLimit = itemCountLimit + } + + private func setupMemoryWarningHandler() { + memoryWarningObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleMemoryWarning() + } + } + + private func handleMemoryWarning() { + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.memoryCache.removeAllObjects() + } } public func set(_ value: Value, for key: Key, expiresIn interval: TimeInterval? = nil) { @@ -59,7 +107,8 @@ public final class GeneralCache { let wrappedKey = WrappedKey(key) queue.sync(flags: .barrier) { if mode == .memoryOnly || mode == .memoryAndDisk { - memoryCache.setObject(entry, forKey: wrappedKey) + let cost = estimatedCost(for: value) + memoryCache.setObject(entry, forKey: wrappedKey, cost: cost) } if mode == .diskOnly || mode == .memoryAndDisk { saveToDisk(entry, for: key) @@ -67,6 +116,13 @@ public final class GeneralCache { } } + private func estimatedCost(for value: Value) -> Int { + if Value.self == Data.self, let data = value as? Data { + return data.count + } + return 4096 + } + public func get(for key: Key) -> Value? { return queue.sync { let wrappedKey = WrappedKey(key) @@ -97,7 +153,8 @@ public final class GeneralCache { if mode == .memoryAndDisk { let exp = attrs[.modificationDate] as? Date let reentry = Entry(value: value, expiration: exp) - memoryCache.setObject(reentry, forKey: wrappedKey) + let cost = estimatedCost(for: value) + memoryCache.setObject(reentry, forKey: wrappedKey, cost: cost) } return value } catch { diff --git a/Palace/Utilities/ImageCache/ImageCacheType.swift b/Palace/Utilities/ImageCache/ImageCacheType.swift index d074911dd..274e211d2 100644 --- a/Palace/Utilities/ImageCache/ImageCacheType.swift +++ b/Palace/Utilities/ImageCache/ImageCacheType.swift @@ -20,24 +20,38 @@ public final class ImageCache: ImageCacheType { private let dataCache = GeneralCache(cacheName: "ImageCache", mode: .memoryAndDisk) private let memoryImages = NSCache() private let defaultTTL: TimeInterval = 14 * 24 * 60 * 60 - private let maxDimension: CGFloat = 1024 + private let maxDimension: CGFloat private let compressionQuality: CGFloat = 0.7 + private let processingQueue: OperationQueue = { + let queue = OperationQueue() + queue.qualityOfService = .utility + queue.name = "org.thepalaceproject.imageprocessing" + return queue + }() private init() { let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) let cacheMemoryMB: Int + let maxConcurrentProcessing: Int if deviceMemoryMB < 2048 { cacheMemoryMB = 25 memoryImages.countLimit = 100 + maxDimension = 512 + maxConcurrentProcessing = 2 } else if deviceMemoryMB < 4096 { cacheMemoryMB = 40 memoryImages.countLimit = 150 + maxDimension = 768 + maxConcurrentProcessing = 3 } else { cacheMemoryMB = 60 memoryImages.countLimit = 200 + maxDimension = 1024 + maxConcurrentProcessing = 4 } + processingQueue.maxConcurrentOperationCount = maxConcurrentProcessing memoryImages.totalCostLimit = cacheMemoryMB * 1024 * 1024 NotificationCenter.default.addObserver( @@ -46,38 +60,49 @@ public final class ImageCache: ImageCacheType { name: UIApplication.didReceiveMemoryWarningNotification, object: nil ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleMemoryPressure), - name: UIApplication.didReceiveMemoryWarningNotification, - object: nil - ) - } - - @objc private func handleMemoryPressure() { - let currentCount = memoryImages.countLimit - memoryImages.countLimit = max(50, currentCount / 2) - memoryImages.totalCostLimit = memoryImages.totalCostLimit / 2 - - DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in - self?.memoryImages.countLimit = currentCount - } } @objc private func handleMemoryWarning() { + processingQueue.cancelAllOperations() + processingQueue.maxConcurrentOperationCount = 1 + memoryImages.removeAllObjects() dataCache.clearMemory() + + DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in + guard let self = self else { return } + let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) + if deviceMemoryMB < 2048 { + self.processingQueue.maxConcurrentOperationCount = 2 + } else if deviceMemoryMB < 4096 { + self.processingQueue.maxConcurrentOperationCount = 3 + } else { + self.processingQueue.maxConcurrentOperationCount = 4 + } + } } public func set(_ image: UIImage, for key: String, expiresIn: TimeInterval? = nil) { let ttl = expiresIn ?? defaultTTL - let processed = resize(image, maxDimension: maxDimension) - let cost = imageCost(processed) - memoryImages.setObject(processed, forKey: key as NSString, cost: cost) - DispatchQueue.global(qos: .utility).async { - guard let data = processed.jpegData(compressionQuality: self.compressionQuality) else { return } - self.dataCache.set(data, for: key, expiresIn: ttl) + + processingQueue.addOperation { [weak self] in + guard let self = self else { return } + + autoreleasepool { + guard let processed = self.resize(image, maxDimension: self.maxDimension) else { + Log.error(#file, "Failed to resize image for key: \(key). Skipping cache.") + return + } + + let cost = self.imageCost(processed) + self.memoryImages.setObject(processed, forKey: key as NSString, cost: cost) + + guard let data = processed.jpegData(compressionQuality: self.compressionQuality) else { + Log.error(#file, "Failed to compress image for key: \(key)") + return + } + self.dataCache.set(data, for: key, expiresIn: ttl) + } } } @@ -107,19 +132,55 @@ public final class ImageCache: ImageCacheType { dataCache.clear() } - private func resize(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + private func resize(_ image: UIImage, maxDimension: CGFloat) -> UIImage? { let size = image.size guard size.width > 0 && size.height > 0 else { return image } let maxSide = max(size.width, size.height) if maxSide <= maxDimension { return image } + let scale = maxDimension / maxSide let newSize = CGSize(width: size.width * scale, height: size.height * scale) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - format.opaque = false - let renderer = UIGraphicsImageRenderer(size: newSize, format: format) - return renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: newSize)) + + guard newSize.width > 0 && newSize.height > 0 else { + Log.error(#file, "Invalid resize dimensions: \(newSize)") + return image + } + + return autoreleasepool { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + + guard let cgImage = image.cgImage else { + Log.error(#file, "Failed to get CGImage from UIImage") + return image + } + + let colorSpace = cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB() + let bitmapInfo = cgImage.bitmapInfo + + guard let context = CGContext( + data: nil, + width: Int(newSize.width), + height: Int(newSize.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + Log.error(#file, "Failed to create CGContext for resize. Returning original image.") + return image + } + + context.interpolationQuality = .medium + context.draw(cgImage, in: CGRect(origin: .zero, size: newSize)) + + guard let resizedCGImage = context.makeImage() else { + Log.error(#file, "Failed to create resized CGImage") + return image + } + + return UIImage(cgImage: resizedCGImage, scale: 1.0, orientation: image.imageOrientation) } } diff --git a/ios-audiobooktoolkit b/ios-audiobooktoolkit index f16ff8941..cf5035d5d 160000 --- a/ios-audiobooktoolkit +++ b/ios-audiobooktoolkit @@ -1 +1 @@ -Subproject commit f16ff8941f19d2bc777bb204a06ac6ee0068d316 +Subproject commit cf5035d5d08f60fb0808c4d94e4803c94219c2c6