diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index 19dabb5..837610b 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -1,9 +1,10 @@ //TODO: Implement support for bonus chapters //TODO: Implement scrolling support -//TODO: Actually implement caching and do I/O on background thread import UIKit +let preloadCount = 10 + enum PageTurn { case next case previous @@ -59,9 +60,6 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { var readerView = UIView() var imageView = UIImageView() - var previousImage: UIImage? - var currentImage: UIImage? - var nextImage: UIImage? var mode = PageTurnMode.leftToRight var metadata: Metadata! var currentPage: Int! = nil @@ -92,6 +90,10 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { var comics: [Comic] = [] var collectionView: UICollectionView! + let imageLoader = ImageLoader.init() + + let localStateQueue = DispatchQueue(label: "state.local.queue", qos: .background) + override func viewDidLoad() { super.viewDidLoad() @@ -223,16 +225,19 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { } func saveLocalState() { - do { - try JSONEncoder().encode( - LocalState( - chapter: currentChapter, page: currentPage, turnMode: mode, - backgroundColor: convertColorToString( - color: readerView.backgroundColor ?? .white)) - ).write( - to: currentPath.appendingPathComponent("state.json")) - } catch { - print("failed to save local state") + let color = self.readerView.backgroundColor ?? .white + localStateQueue.async { + do { + try JSONEncoder().encode( + LocalState( + chapter: self.currentChapter, page: self.currentPage, turnMode: self.mode, + backgroundColor: self.convertColorToString( + color: color)) + ).write( + to: self.currentPath.appendingPathComponent("state.json")) + } catch { + print("failed to save local state") + } } } @@ -275,10 +280,10 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { saveGlobalState() loadLocalState() setImages(path: path, metadata: metadata) - imageView.image = currentImage info.text = """ + \(metadata.title) \(metadata.chapters[currentChapter - 1].title) - Volume \(Int(metadata.chapters[currentChapter - 1].volume)) + Volume \(Int(metadata.chapters[currentChapter - 1].volume)) of \(Int(metadata.chapters.last!.volume)) Chapter \(currentChapter!) of \(Int(metadata.chapters.last!.chapter)) Page \(currentPage!) of \(metadata.chapters[currentChapter - 1].pages) """ @@ -363,8 +368,7 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { func setupBottomBarInfo() { info.textAlignment = .center - info.numberOfLines = 0 - info.text = "" + info.numberOfLines = 5 info.translatesAutoresizingMaskIntoConstraints = false info.textColor = .white bottomBarView.addSubview(info) @@ -399,7 +403,7 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { readerView.isHidden = true homeView.isHidden = false globalState.comicName = nil - hideTopBar() + hideBar() setNeedsStatusBarAppearanceUpdate() saveGlobalState() @@ -585,7 +589,7 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { return false } - func toggleTopBar() { + func toggleBar() { topBarView.isHidden.toggle() bottomBarView.isHidden.toggle() @@ -595,14 +599,15 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { } } - func hideTopBar() { + func hideBar() { topBarView.isHidden = true + bottomBarView.isHidden = true pageTurnDropdownView.isHidden = true backgroundColorDropdownView.isHidden = true } @objc func handleTopTap() { - toggleTopBar() + toggleBar() } @objc func handleLeftTap() { @@ -655,52 +660,61 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { } func changeImage(turn: PageTurn) { - let (chapter, page) = getChapterAndPageFromTurn( + var (chapter, page) = getChapterAndPageFromTurn( chapter: currentChapter, page: currentPage, turn: turn) if (chapter, page) == (currentChapter, currentPage) { return } - let (c, p) = getChapterAndPageFromTurn(chapter: chapter, page: page, turn: turn) - let vol = Int(metadata.chapters[currentChapter - 1].volume) - switch turn { - case .next: - if nextImage == nil { return } - previousImage = currentImage - currentImage = nextImage - nextImage = getImage(chapter: c, volume: vol, page: p, path: self.currentPath) - case .previous: - if previousImage == nil { return } - nextImage = currentImage - currentImage = previousImage - previousImage = getImage(chapter: c, volume: vol, page: p, path: self.currentPath) + var vol = Int(metadata.chapters[chapter - 1].volume) + if let path = getImagePath(chapter: chapter, volume: vol, page: page, path: currentPath) { + imageLoader.loadImage(at: path) { + [weak self] image in + self?.imageView.image = image + } + } else { + return } currentPage = page currentChapter = chapter - imageView.image = currentImage + + for _ in 0...preloadCount { + (chapter, page) = getChapterAndPageFromTurn( + chapter: chapter, page: page, turn: .next) + vol = Int(metadata.chapters[chapter - 1].volume) + if let path = getImagePath( + chapter: chapter, volume: vol, page: page, path: currentPath) + { + imageLoader.preloadImage(at: path) + } else { + print("could not preload image") + } + } + saveLocalState() info.text = """ + \(metadata.title) \(metadata.chapters[currentChapter - 1].title) - Volume \(Int(metadata.chapters[currentChapter - 1].volume)) + Volume \(Int(metadata.chapters[currentChapter - 1].volume)) of \(Int(metadata.chapters.last!.volume)) Chapter \(currentChapter!) of \(Int(metadata.chapters.last!.chapter)) Page \(currentPage!) of \(metadata.chapters[currentChapter - 1].pages) """ } - func getImage(chapter: Int, volume: Int, page: Int, path: URL) -> UIImage! { + func getImagePath(chapter: Int, volume: Int, page: Int, path: URL) -> URL! { let newPath = path.appendingPathComponent(String(format: "volume_%04d", volume)) .appendingPathComponent( // TODO: it should not be necessary to sub 1 String(format: "chapter_%04d_image_%04d.png", chapter, page - 1)) - if !fileManager.fileExists(atPath: newPath.path) { - //TODO: Remove this - let alternatePath = path.appendingPathComponent(String(format: "volume_%04d", volume)) - .appendingPathComponent( - String(format: "chapter%03d_image_%03d.png", chapter, page - 1)) - if !fileManager.fileExists(atPath: alternatePath.path) { - print("Did not find image at path") - return nil - } - return UIImage(contentsOfFile: alternatePath.path)! + if fileManager.fileExists(atPath: newPath.path) { + return newPath } - return UIImage(contentsOfFile: newPath.path)! + //TODO: Remove this + let alternatePath = path.appendingPathComponent(String(format: "volume_%04d", volume)) + .appendingPathComponent( + String(format: "chapter%03d_image_%03d.png", chapter, page - 1)) + if !fileManager.fileExists(atPath: alternatePath.path) { + print("Did not find image at path") + return nil + } + return alternatePath } func getChapterAndPageFromTurn(chapter: Int, page: Int, turn: PageTurn) -> (Int, Int) { @@ -748,21 +762,31 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { func setImages(path: URL, metadata: Metadata) { var directories: [URL] = [] if currentPage != nil && currentChapter != nil { - currentImage = getImage( - chapter: currentChapter, volume: Int(metadata.chapters[currentChapter - 1].volume), - page: currentPage, path: currentPath) + var vol = Int(metadata.chapters[currentChapter - 1].volume) + imageLoader.loadImage( + at: getImagePath( + chapter: currentChapter, volume: vol, page: currentPage, path: currentPath + ) + ) { + [weak self] image in + self?.imageView.image = image + } - let (nc, np) = getChapterAndPageFromTurn( + var (chapter, page) = getChapterAndPageFromTurn( chapter: currentChapter, page: currentPage, turn: .next) - nextImage = getImage( - chapter: nc, volume: Int(metadata.chapters[nc - 1].volume), - page: np, path: currentPath) + for _ in 0...preloadCount { + vol = Int(metadata.chapters[chapter - 1].volume) + if let path = getImagePath( + chapter: chapter, volume: vol, page: page, path: currentPath) + { + imageLoader.preloadImage(at: path) + } else { + print("could not preload image") + } + (chapter, page) = getChapterAndPageFromTurn( + chapter: chapter, page: page, turn: .next) + } - let (pc, pp) = getChapterAndPageFromTurn( - chapter: currentChapter, page: currentPage, turn: .previous) - previousImage = getImage( - chapter: pc, volume: Int(metadata.chapters[pc - 1].volume), - page: pp, path: currentPath) return } @@ -781,9 +805,8 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { let dir: URL = directories[0] let ps: [String] = try fileManager.contentsOfDirectory(atPath: dir.path).sorted() let current = dir.appendingPathComponent(ps[0]) - let next = dir.appendingPathComponent(ps[1]) - currentImage = UIImage(contentsOfFile: current.path) - nextImage = UIImage(contentsOfFile: next.path) + imageView.image = UIImage(contentsOfFile: current.path) + let path_meta = current.lastPathComponent.components(separatedBy: "_") assert(path_meta[0] == "chapter") currentChapter = Int(path_meta[1])! @@ -888,3 +911,54 @@ class ImageCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } } + +class ImageLoader { + private let cache = NSCache() + private var loadingTasks: [String: [((UIImage?) -> Void)]] = [:] + private let queue = DispatchQueue(label: "image.loading.queue", qos: .userInitiated) + + init() { + cache.totalCostLimit = 50 * 1024 * 1024 + } + + func preloadImage(at path: URL) { + loadImage(at: path, completion: nil) + } + + func loadImage(at path: URL, completion: ((UIImage?) -> Void)?) { + if let cached = cache.object(forKey: path.path as NSString) { + completion?(cached) + return + } + + if loadingTasks[path.path] != nil { + if let completion = completion { + loadingTasks[path.path]?.append(completion) + } + return + } + + loadingTasks[path.path] = completion != nil ? [completion!] : [] + + queue.async { [weak self] in + guard let self = self else { return } + + let image = UIImage(contentsOfFile: path.path) + + DispatchQueue.main.async { + if let image = image, let data = image.pngData() { + self.cache.setObject(image, forKey: path.path as NSString, cost: data.count) + } else { + print("no image to cache found") + } + + self.loadingTasks[path.path]?.forEach { $0(image) } + self.loadingTasks.removeValue(forKey: path.path) + } + } + } + + func imageIfLoaded(at path: String) -> UIImage? { + return cache.object(forKey: path as NSString) + } +}