async I/O for images and local state

This commit is contained in:
2025-07-16 00:27:33 +02:00
parent 747fa39056
commit 15c448611e

View File

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