diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 9b6322cae..6f7b92ddc 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,10 +4731,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4793,10 +4790,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4980,10 +4974,7 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -5044,10 +5035,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; 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/TPPAppDelegate.swift b/Palace/AppInfrastructure/TPPAppDelegate.swift index ce96cd5ed..f704254f4 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) } diff --git a/Palace/Book/Models/TPPBook.swift b/Palace/Book/Models/TPPBook.swift index 33865e13b..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,60 +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 } - guard let ciImage = CIImage(image: inputImage) else { - Log.debug(#file, "Failed to create CIImage from UIImage for book: \(self.identifier)") - return - } - - guard !ciImage.extent.isEmpty else { - Log.debug(#file, "CIImage has empty extent for book: \(self.identifier)") - return - } - - let filter = CIFilter.areaAverage() - filter.inputImage = ciImage - filter.extent = ciImage.extent + autoreleasepool { + guard let ciImage = CIImage(image: inputImage) else { + Log.debug(#file, "Failed to create CIImage from UIImage for book: \(self.identifier)") + return + } - guard let outputImage = filter.outputImage else { - Log.debug(#file, "Failed to generate output image from filter for book: \(self.identifier)") - return - } + guard !ciImage.extent.isEmpty else { + Log.debug(#file, "CIImage has empty extent 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 - } + let filter = CIFilter.areaAverage() + filter.inputImage = ciImage + filter.extent = ciImage.extent - var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [ - .workingColorSpace: colorSpace, - .outputColorSpace: colorSpace, - .useSoftwareRenderer: false - ]) - - context.render( - outputImage, - toBitmap: &bitmap, - rowBytes: 4, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .RGBA8, - colorSpace: colorSpace - ) + guard let outputImage = filter.outputImage else { + Log.debug(#file, "Failed to generate output image from filter 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 let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { + Log.debug(#file, "Failed to create sRGB color space for book: \(self.identifier)") + return + } - DispatchQueue.main.async { - self.dominantUIColor = color + 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..5ac1e0ddf 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 @@ -63,14 +66,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 @@ -132,8 +127,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: @@ -183,6 +181,40 @@ class CatalogLaneMoreViewModel: ObservableObject { 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) + sortBooksInPlace() + } + } + } catch { + Log.error(#file, "Failed to load next page: \(error.localizedDescription)") + } + } + // MARK: - Registry Sync /// Refresh visible books with updated metadata @@ -311,7 +343,7 @@ class CatalogLaneMoreViewModel: ObservableObject { // MARK: - Sorting func sortBooksInPlace() { - CatalogSortService.sort(books: &ungroupedBooks, by: currentSort) + ungroupedBooks = CatalogSortService.sorted(books: ungroupedBooks, by: currentSort) } // MARK: - State Persistence 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..8e733f65a 100644 --- a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift @@ -129,6 +129,10 @@ struct CatalogLaneMoreView: View { } private func handleAccountChange() { + if viewModel.showSearch { + dismissSearch() + } + setupAccount() viewModel.appliedSelections.removeAll() viewModel.pendingSelections.removeAll() @@ -203,7 +207,11 @@ struct CatalogLaneMoreView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(CatalogSortService.SortOption.allCases, id: \.self) { sort in - Button(action: { viewModel.currentSort = sort }) { + Button(action: { + viewModel.currentSort = sort + viewModel.sortBooksInPlace() + viewModel.showingSortSheet = false + }) { HStack { Image(systemName: viewModel.currentSort == sort ? "largecircle.fill.circle" : "circle") .foregroundColor(.primary) @@ -346,9 +354,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: { await viewModel.loadNextPage() }, + 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/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/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/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/Reader2/UI/TPPEPUBViewController.swift b/Palace/Reader2/UI/TPPEPUBViewController.swift index b6c17e0c8..ae0a56001 100644 --- a/Palace/Reader2/UI/TPPEPUBViewController.swift +++ b/Palace/Reader2/UI/TPPEPUBViewController.swift @@ -110,6 +110,10 @@ class TPPEPUBViewController: TPPBaseReaderViewController { } navigationController?.navigationBar.isTranslucent = true + + navigationController?.setNavigationBarHidden(true, animated: false) + navigationController?.setToolbarHidden(true, animated: false) + tabBarController?.tabBar.isHidden = true } @objc private func closeEPUB() { 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/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