Files
ImageViewer/ImageViewer/ViewController.swift
2026-02-27 02:48:47 +01:00

1552 lines
56 KiB
Swift

// TODO: Anilist support?
// TODO: Properly avoid swallowing of input from UICollectionView used for scrolling
// TODO: Convert between state for normal and scrolling page turn
// TODO: Support reading with scrolling and landscape mode
// FIXME: Update comicCollectionView when switching between landscape and portrait mode
import Foundation
import UIKit
let preloadCount = 2
// With a whopping 2 cores, and 2 threads because no hyperthreading, it makes sense to only have a queue as an extra thread to do work with to allow the main thread for ui.
let queue = DispatchQueue(label: "queue", qos: .utility)
enum PageTurn {
case next
case previous
}
enum ReadProgress: Codable {
case leftToRight(volumeIndex: Int, chapterIndex: Int, imageIndex: Int)
case rightToLeft(volumeIndex: Int, chapterIndex: Int, imageIndex: Int)
case scroll(CGPoint)
}
struct ProgressIndices {
var v: Int
var c: Int
var i: Int
}
enum PageTurnMode: Codable {
case leftToRight
case rightToLeft
case scroll
}
struct Comic {
var cover: UIImage
var metadata: Metadata
var path: URL
}
struct GlobalState: Codable {
var comicName: String? = nil
}
struct LocalState: Codable {
var progress: ReadProgress
var backgroundColor: String = "black"
}
struct Metadata: Codable {
var title: String
var original_language: String
var last_volume: MetaValue
var last_chapter: MetaValue
var chapter_count: Int
var publication_demographic: String
var status: String
var year: Int?
var content_rating: String
var state: String
var created_at: String
var updated_at: String
var volumes: [VolumeMetadata]
}
struct VolumeMetadata: Codable {
var volume: MetaValue
var name: String?
var chapters: [ChapterMetadata]
}
struct ChapterMetadata: Codable {
var chapter: MetaValue
var name: String?
var images: [ImageMetadata]
}
struct ImageMetadata: Codable {
var doublePage: Bool
var fileName: String
var firstPage: Int
}
struct MetaValue: Codable {
var main: Int
var bonus: Int?
}
class ViewController: UIViewController, UIGestureRecognizerDelegate {
var homeView = UIView()
var readerView = UIView()
@IBOutlet var scrollingCollectionView: UICollectionView!
var scrollPos: CGPoint!
var hasSetContentOffset = false
var pageCount = 0
var pagesAvailable = 0
var sizeList: [CGSize] = []
var chapterAndPages: [(Int, Int)] = []
var imageView = UIImageView()
var mode = PageTurnMode.leftToRight
var metadata: Metadata!
var currentPage: Int!
var progress = ProgressIndices(v: 0, c: 0, i: 0)
var currentPath: URL!
var changedOrientation = false
var leftTap: UITapGestureRecognizer!
var rightTap: UITapGestureRecognizer!
var topTap: UITapGestureRecognizer!
let leftView = UIView()
let rightView = UIView()
let topView = UIView()
let topBarView = UIView()
let bottomBarView = UIView()
let info = UILabel()
let pageTurnDropdownView = UIView()
let pageTurnDropdownButton = UIButton()
let backgroundColorDropdownView = UIView()
let backgroundColorDropdownButton = UIButton()
let homeButton = UIButton()
let fileManager = FileManager.default
var globalState = getGlobalState()
let documentsURL = getDocumentsURL().unsafelyUnwrapped
var comics: [Comic] = []
@IBOutlet var comicCollectionView: UICollectionView!
let imageLoader = ImageLoader()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setup()
}
func setup() {
do {
if try fileManager.contentsOfDirectory(atPath: documentsURL.path).isEmpty {
saveGlobalState()
}
} catch {
saveGlobalState()
}
homeView.translatesAutoresizingMaskIntoConstraints = false
readerView.translatesAutoresizingMaskIntoConstraints = false
readerView.isHidden = true
readerView.backgroundColor = .black
homeView.isHidden = false
view.addSubview(homeView)
view.addSubview(readerView)
NSLayoutConstraint.activate([
homeView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
homeView.topAnchor.constraint(equalTo: view.topAnchor),
homeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
homeView.widthAnchor.constraint(equalTo: view.widthAnchor),
readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
readerView.topAnchor.constraint(equalTo: view.topAnchor),
readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
readerView.widthAnchor.constraint(equalTo: view.widthAnchor),
])
loadComics()
setupImageView()
setupScrollingCollectionView()
setupGestures()
setupBar()
setupHomeView()
if let name = globalState.comicName {
readComic(name: name)
} else {
print("no comic?")
}
}
func setupHomeView() {
let layout = UICollectionViewFlowLayout()
let spacing: CGFloat = 8
layout.minimumInteritemSpacing = spacing
layout.minimumLineSpacing = spacing
comicCollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
comicCollectionView.translatesAutoresizingMaskIntoConstraints = false
comicCollectionView.dataSource = self
comicCollectionView.delegate = self
comicCollectionView.backgroundColor = .white
comicCollectionView.register(
ComicImageCell.self, forCellWithReuseIdentifier: "ComicImageCell"
)
homeView.addSubview(comicCollectionView)
}
func loadComics() {
do {
var directories: [URL] = []
let contents = try fileManager.contentsOfDirectory(
at: documentsURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)
directories = contents.filter { url in
var isDirectory: ObjCBool = false
return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
&& isDirectory.boolValue
}.sorted { $0.path < $1.path }
for dir in directories {
if !fileManager.fileExists(
atPath: dir.appendingPathComponent(
"metadata.json"
).path)
{
getMetadataFromFileName(path: dir)
}
currentPath = dir
metadata = try JSONDecoder().decode(
Metadata.self,
from:
Data(
String(
contentsOfFile: dir.appendingPathComponent(
"metadata.json"
)
.path
).utf8)
)
loadLocalState()
comics.append(
Comic(
cover: UIImage(contentsOfFile: getImagePath(progress: ProgressIndices(v: progress.v, c: 0, i: 0)).path)!,
metadata: metadata,
path: dir
))
}
} catch {
print("Failed to read directories")
}
}
func saveGlobalState() {
do {
try JSONEncoder().encode(globalState).write(
to: documentsURL.appendingPathComponent("state.json"))
} catch {
print("failed to save global state")
}
}
func convertColorToString(color: UIColor) -> String {
let r: String!
switch color {
case .white: r = "white"
case .gray: r = "gray"
case .black: r = "black"
case .red: r = "red"
case .blue: r = "blue"
default: r = "black"
}
return r
}
func convertStringToColor(str: String) -> UIColor {
let r: UIColor!
switch str {
case "white": r = .white
case "gray": r = .gray
case "black": r = .black
case "red": r = .red
case "blue": r = .blue
default: r = .black
}
return r
}
func getMetadataFromFileName(path: URL) {
// Beautiful, is it not?
let pattern = "c([0-9]+(?:x[0-9]+)?) \\(v([0-9]+)\\) - p([0-9]+(?:-[0-9]+)?)"
var currentVolume: Int! = nil
var currentChapter: (Int, Int?)!
let outFileName = "metadata.json"
if fileManager.fileExists(
atPath: path.appendingPathComponent(outFileName).absoluteString)
{
print("skipped metadata file creation")
return
}
var newMetadata = Metadata(
title: "",
original_language: "",
last_volume: MetaValue(main: 0, bonus: nil),
last_chapter: MetaValue(main: 0, bonus: nil),
chapter_count: 0,
publication_demographic: "",
status: "",
year: nil,
content_rating: "",
state: "",
created_at: "",
updated_at: "",
volumes: []
)
do {
let regex = try NSRegularExpression(pattern: pattern)
let dir = path.appendingPathComponent("images")
let fileURLs = try fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
)
let fileNames = fileURLs.map { $0.lastPathComponent }.sorted()
for fileName in fileNames {
let range = NSRange(fileName.startIndex..., in: fileName)
if let match = regex.firstMatch(in: fileName, range: range) {
let chapterStr = (fileName as NSString).substring(with: match.range(at: 1))
let chapterParts = chapterStr.split(separator: "x", maxSplits: 1)
let chapter = (
Int(chapterParts[0])!, chapterParts.count > 1 ? Int(chapterParts[1])! : nil
)
let volume = Int((fileName as NSString).substring(with: match.range(at: 2)))!
let pageStr = (fileName as NSString).substring(with: match.range(at: 3))
let pageParts = pageStr.split(separator: "-", maxSplits: 1)
let (page, doublePage) = (
Int(pageParts[0])!, pageParts.count > 1
)
if currentVolume != nil {
if volume != currentVolume {
newMetadata.chapter_count += 1
assert(volume > currentVolume)
newMetadata.volumes.append(
VolumeMetadata(
volume: MetaValue(main: volume, bonus: nil), name: nil,
chapters: [
ChapterMetadata(
chapter: MetaValue(
main: chapter.0, bonus: chapter.1
),
name: "",
images: [
ImageMetadata(
doublePage: doublePage, fileName: fileName,
firstPage: page
),
]
),
]
))
} else if chapter != currentChapter {
newMetadata.chapter_count += 1
if chapter.0 == currentChapter.0 {
assert(chapter.1! == currentChapter.1 ?? 1)
} else {
assert(chapter.0 == currentChapter.0 + 1)
}
newMetadata.volumes[newMetadata.volumes.count - 1].chapters.append(
ChapterMetadata(
chapter: MetaValue(main: chapter.0, bonus: chapter.1),
name: "",
images: [
ImageMetadata(
doublePage: doublePage, fileName: fileName,
firstPage: page
),
]
))
} else {
newMetadata.volumes[newMetadata.volumes.count - 1].chapters[
newMetadata.volumes[newMetadata.volumes.count - 1].chapters.count - 1
].images.append(
ImageMetadata(
doublePage: doublePage, fileName: fileName, firstPage: page
)
)
}
} else {
newMetadata.chapter_count += 1
newMetadata.volumes.append(
VolumeMetadata(
volume: MetaValue(main: volume, bonus: nil), name: nil,
chapters: [
ChapterMetadata(
chapter: MetaValue(main: chapter.0, bonus: chapter.1),
name: "",
images: [
ImageMetadata(
doublePage: doublePage, fileName: fileName,
firstPage: page
),
]
),
]
))
}
currentVolume = volume
currentChapter = chapter
}
}
} catch {
print("failed reading image file names")
}
metadata = newMetadata
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted]
encoder.keyEncodingStrategy = .useDefaultKeys
try encoder.encode(
newMetadata
).write(
to: path.appendingPathComponent(outFileName))
} catch {
print("failed to save generated metadata")
}
}
func saveLocalState() {
let color = readerView.backgroundColor ?? .black
let screenSize = UIScreen.main.bounds.size
var scrollOffset = scrollingCollectionView.contentOffset
if screenSize.width > screenSize.height {
scrollOffset.y *= (screenSize.height / screenSize.width)
}
var newProgress =
ReadProgress.leftToRight(
volumeIndex: progress.v, chapterIndex: progress.c,
imageIndex: progress.i
)
switch mode {
case .leftToRight:
newProgress = ReadProgress.leftToRight(
volumeIndex: progress.v, chapterIndex: progress.c,
imageIndex: progress.i
)
case .rightToLeft: newProgress = ReadProgress.rightToLeft(
volumeIndex: progress.v, chapterIndex: progress.c,
imageIndex: progress.i
)
case .scroll: newProgress = ReadProgress.scroll(scrollOffset)
}
queue.async {
do {
try JSONEncoder().encode(
LocalState(
progress: newProgress, backgroundColor: self.convertColorToString(color: color)
)
).write(
to: self.currentPath.appendingPathComponent("state.json"))
} catch {
print("failed to save local state")
}
}
}
func loadLocalState() {
do {
let json = try Data(
String(
contentsOfFile: currentPath.appendingPathComponent("state.json").path
).utf8)
let local = try JSONDecoder().decode(LocalState.self, from: json)
switch local.progress {
case let .leftToRight(volumeIndex, chapterIndex, imageIndex):
progress.v = volumeIndex
progress.c = chapterIndex
progress.i = imageIndex
mode = .leftToRight
case let .rightToLeft(volumeIndex, chapterIndex, imageIndex):
progress.v = volumeIndex
progress.c = chapterIndex
progress.i = imageIndex
mode = .rightToLeft
case let .scroll(point):
if scrollPos == nil {
scrollPos = point
let screenSize = UIScreen.main.bounds.size
var scrollOffset = point
if screenSize.width > screenSize.height {
scrollOffset.y *= (screenSize.width / screenSize.height)
}
if let indexPath = scrollingCollectionView.indexPathForItem(at: scrollOffset) {
var theProgress = ProgressIndices(v: 0, c: 0, i: 0)
for _ in 0 ... indexPath.item {
theProgress = getProgressIndicesFromTurn(
turn: .next, progress: theProgress
)
}
progress = theProgress
}
}
mode = .scroll
}
readerView.backgroundColor = convertStringToColor(str: local.backgroundColor)
} catch let decodingError as DecodingError {
print(decodingError.errorDescription!)
} catch {
print("Unexpected error: \(error)")
}
}
func setupGestures() {
let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
swipeLeft.direction = .left
readerView.addGestureRecognizer(swipeLeft)
let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
swipeRight.direction = .right
readerView.addGestureRecognizer(swipeRight)
setupTapZones()
}
func scrollViewDidScroll(_: UIScrollView) {
if scrollingCollectionView.isHidden { return }
let centerPoint = CGPoint(
x: scrollingCollectionView.bounds.midX + scrollingCollectionView.contentOffset.x,
y: scrollingCollectionView.bounds.midY + scrollingCollectionView.contentOffset.y
)
if let indexPath = scrollingCollectionView.indexPathForItem(at: centerPoint) {
let (chapter, page) = chapterAndPages[indexPath.item]
progress.c = chapter
progress.i = page
}
if scrollingCollectionView.isHidden == false {
saveLocalState()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if scrollingCollectionView.isHidden == true || mode != .scroll
|| scrollingCollectionView.contentSize == .zero
{
return
}
if !hasSetContentOffset, scrollPos != nil {
let screenSize = UIScreen.main.bounds.size
var offset = scrollPos!
if screenSize.width > screenSize.height {
offset.y *= (screenSize.width / screenSize.height)
}
scrollingCollectionView.setContentOffset(offset, animated: false)
hasSetContentOffset = true
} else if changedOrientation {
changedOrientation = false
let screenSize = UIScreen.main.bounds.size
var offset = scrollingCollectionView.contentOffset
offset.y *= (screenSize.width / screenSize.height)
scrollingCollectionView.setContentOffset(offset, animated: false)
}
}
func readComic(name: String) {
readerView.isHidden = false
homeView.isHidden = true
setNeedsStatusBarAppearanceUpdate()
if let path = getPathFromComicName(name: name) {
currentPath = path
metadata = getMetadata(path: path)!
globalState.comicName = metadata.title
saveGlobalState()
loadLocalState()
if mode != .scroll {
scrollingCollectionView.isHidden = true
leftView.isHidden = false
rightView.isHidden = false
setImages(path: path)
} else {
setupScrolling()
}
} else {
print("path not found")
}
}
func setupScrolling() {
leftView.isHidden = true
rightView.isHidden = true
scrollingCollectionView.isHidden = false
// TODO: Fix scrolling again
}
func countFiles() -> Int {
var count = 0
if let enumerator = fileManager.enumerator(
at: currentPath, includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) {
for case let dir as URL in enumerator {
do {
if let enumerator = fileManager.enumerator(
at: dir, includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) {
for case let file as URL in enumerator {
let resourceValues = try file.resourceValues(forKeys: [
.isRegularFileKey,
])
if resourceValues.isRegularFile == true {
count += 1
}
}
}
} catch {
print("Error reading file attributes for \(dir):", error)
}
}
}
return count
}
func setupScrollingCollectionView() {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 0
scrollingCollectionView = UICollectionView(
frame: view.bounds, collectionViewLayout: layout
)
scrollingCollectionView.translatesAutoresizingMaskIntoConstraints = false
scrollingCollectionView.dataSource = self
scrollingCollectionView.delegate = self
scrollingCollectionView.backgroundColor = .white
scrollingCollectionView.isHidden = true
scrollingCollectionView.register(
ScrollingImageCell.self, forCellWithReuseIdentifier: "ScrollingImageCell"
)
readerView.addSubview(scrollingCollectionView)
NSLayoutConstraint.activate([
scrollingCollectionView.topAnchor.constraint(equalTo: readerView.topAnchor),
scrollingCollectionView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
scrollingCollectionView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
scrollingCollectionView.heightAnchor.constraint(equalTo: readerView.heightAnchor),
])
}
func setupTapZones() {
leftView.translatesAutoresizingMaskIntoConstraints = false
rightView.translatesAutoresizingMaskIntoConstraints = false
topView.translatesAutoresizingMaskIntoConstraints = false
readerView.addSubview(leftView)
readerView.addSubview(rightView)
readerView.addSubview(topView)
NSLayoutConstraint.activate([
leftView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
leftView.topAnchor.constraint(equalTo: readerView.topAnchor),
leftView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
leftView.widthAnchor.constraint(equalTo: readerView.widthAnchor, multiplier: 0.5),
rightView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
rightView.topAnchor.constraint(equalTo: readerView.topAnchor),
rightView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
rightView.widthAnchor.constraint(equalTo: readerView.widthAnchor, multiplier: 0.5),
topView.topAnchor.constraint(equalTo: readerView.topAnchor),
topView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
topView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
topView.heightAnchor.constraint(equalTo: readerView.heightAnchor, multiplier: 0.2),
])
leftView.backgroundColor = .clear
rightView.backgroundColor = .clear
topView.backgroundColor = .clear
leftTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftTap))
rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap))
topTap = UITapGestureRecognizer(target: self, action: #selector(handleTopTap))
leftTap.delegate = self
rightTap.delegate = self
topTap.delegate = self
leftView.addGestureRecognizer(leftTap)
rightView.addGestureRecognizer(rightTap)
topView.addGestureRecognizer(topTap)
}
func setupBar() {
topBarView.translatesAutoresizingMaskIntoConstraints = false
topBarView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
topBarView.isHidden = true
readerView.addSubview(topBarView)
bottomBarView.translatesAutoresizingMaskIntoConstraints = false
bottomBarView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
bottomBarView.isHidden = true
readerView.addSubview(bottomBarView)
NSLayoutConstraint.activate([
topBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
topBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
topBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
topBarView.heightAnchor.constraint(equalToConstant: 64),
bottomBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
bottomBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
bottomBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
bottomBarView.heightAnchor.constraint(equalToConstant: 128),
])
setupBackgroundColorDropdown()
setupPageTurnDropdown()
setupHomeButton()
setupBottomBarInfo()
}
func setupBottomBarInfo() {
info.textAlignment = .center
info.numberOfLines = 6
info.translatesAutoresizingMaskIntoConstraints = false
info.textColor = .white
bottomBarView.addSubview(info)
NSLayoutConstraint.activate([
info.topAnchor.constraint(equalTo: bottomBarView.topAnchor),
info.bottomAnchor.constraint(equalTo: bottomBarView.bottomAnchor),
info.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor),
info.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor),
info.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
info.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
])
}
func setupHomeButton() {
homeButton.setTitle("Home", for: .normal)
homeButton.setTitleColor(.white, for: .normal)
homeButton.translatesAutoresizingMaskIntoConstraints = false
topBarView.addSubview(homeButton)
homeButton.addTarget(
self, action: #selector(goHome), for: .touchDown
)
NSLayoutConstraint.activate([
homeButton.trailingAnchor.constraint(equalTo: topBarView.trailingAnchor, constant: -32),
homeButton.centerYAnchor.constraint(equalTo: topBarView.centerYAnchor),
homeButton.topAnchor.constraint(equalTo: topBarView.topAnchor),
homeButton.bottomAnchor.constraint(equalTo: topBarView.bottomAnchor),
])
}
override var prefersStatusBarHidden: Bool {
return homeView.isHidden
}
@objc func goHome() {
readerView.isHidden = true
homeView.isHidden = false
globalState.comicName = nil
hideBar()
setNeedsStatusBarAppearanceUpdate()
saveGlobalState()
}
func setupBackgroundColorDropdown() {
backgroundColorDropdownButton.setTitle("Background color ▼", for: .normal)
backgroundColorDropdownButton.setTitleColor(.white, for: .normal)
backgroundColorDropdownButton.translatesAutoresizingMaskIntoConstraints = false
topBarView.addSubview(backgroundColorDropdownButton)
backgroundColorDropdownButton.addTarget(
self, action: #selector(toggleBackgroundColorDropdown), for: .touchDown
)
backgroundColorDropdownView.backgroundColor = UIColor.darkGray
backgroundColorDropdownView.translatesAutoresizingMaskIntoConstraints = false
backgroundColorDropdownView.isHidden = true
readerView.addSubview(backgroundColorDropdownView)
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 8
stackView.distribution = .fillEqually
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
backgroundColorDropdownView.addSubview(stackView)
let colorOptions = ["White", "Gray", "Black", "Red", "Blue"]
for title in colorOptions {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.contentHorizontalAlignment = .left
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
button.backgroundColor = .clear
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(
self, action: #selector(handleBackgroundColorOption), for: .touchDown
)
stackView.addArrangedSubview(button)
}
NSLayoutConstraint.activate([
backgroundColorDropdownButton.leadingAnchor.constraint(
equalTo: topBarView.leadingAnchor, constant: 32
),
backgroundColorDropdownButton.centerYAnchor.constraint(
equalTo: topBarView.centerYAnchor),
backgroundColorDropdownButton.topAnchor.constraint(
equalTo: topBarView.topAnchor),
backgroundColorDropdownButton.bottomAnchor.constraint(
equalTo: topBarView.bottomAnchor),
backgroundColorDropdownView.topAnchor.constraint(
equalTo: backgroundColorDropdownButton.bottomAnchor),
backgroundColorDropdownView.leadingAnchor.constraint(
equalTo: backgroundColorDropdownButton.leadingAnchor),
backgroundColorDropdownView.trailingAnchor.constraint(
equalTo: backgroundColorDropdownButton.trailingAnchor),
stackView.topAnchor.constraint(
equalTo: backgroundColorDropdownView.topAnchor),
stackView.bottomAnchor.constraint(equalTo: backgroundColorDropdownView.bottomAnchor),
stackView.leadingAnchor.constraint(equalTo: backgroundColorDropdownView.leadingAnchor),
stackView.trailingAnchor.constraint(
equalTo: backgroundColorDropdownView.trailingAnchor),
])
}
func setupPageTurnDropdown() {
pageTurnDropdownButton.setTitle("Page Turn Mode ▼", for: .normal)
pageTurnDropdownButton.setTitleColor(.white, for: .normal)
pageTurnDropdownButton.translatesAutoresizingMaskIntoConstraints = false
topBarView.addSubview(pageTurnDropdownButton)
pageTurnDropdownButton.addTarget(
self, action: #selector(togglePageTurnDropdown), for: .touchDown
)
pageTurnDropdownView.backgroundColor = UIColor.darkGray
pageTurnDropdownView.translatesAutoresizingMaskIntoConstraints = false
pageTurnDropdownView.isHidden = true
readerView.addSubview(pageTurnDropdownView)
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 8
stackView.distribution = .fillEqually
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
pageTurnDropdownView.addSubview(stackView)
let pageTurnOptions = ["Left to right", "Right to left", "Scroll"]
for title in pageTurnOptions {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.contentHorizontalAlignment = .left
button.backgroundColor = .clear
button.translatesAutoresizingMaskIntoConstraints = false
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
button.addTarget(
self, action: #selector(handlePageTurnOption), for: .touchDown
)
stackView.addArrangedSubview(button)
}
NSLayoutConstraint.activate([
pageTurnDropdownButton.leadingAnchor.constraint(
equalTo: backgroundColorDropdownButton.trailingAnchor, constant: 32
),
pageTurnDropdownButton.centerYAnchor.constraint(
equalTo: topBarView.centerYAnchor),
pageTurnDropdownButton.topAnchor.constraint(
equalTo: topBarView.topAnchor),
pageTurnDropdownButton.bottomAnchor.constraint(
equalTo: topBarView.bottomAnchor),
pageTurnDropdownView.topAnchor.constraint(
equalTo: pageTurnDropdownButton.bottomAnchor),
pageTurnDropdownView.leadingAnchor.constraint(
equalTo: pageTurnDropdownButton.leadingAnchor),
pageTurnDropdownView.trailingAnchor.constraint(
equalTo: pageTurnDropdownButton.trailingAnchor),
stackView.topAnchor.constraint(
equalTo: pageTurnDropdownView.topAnchor),
stackView.bottomAnchor.constraint(equalTo: pageTurnDropdownView.bottomAnchor),
stackView.leadingAnchor.constraint(equalTo: pageTurnDropdownView.leadingAnchor),
stackView.trailingAnchor.constraint(
equalTo: pageTurnDropdownView.trailingAnchor),
])
}
@objc func handlePageTurnOption(_ sender: UIButton) {
if let title = sender.currentTitle {
let prev = mode
switch title.lowercased() {
case "left to right": mode = .leftToRight
case "right to left": mode = .rightToLeft
case "scroll": mode = .scroll
default: break
}
if prev == mode { return }
if mode == .scroll {
setupScrolling()
} else {
imageLoader.loadImage(
at: getImagePath(progress: progress),
scaling: .scaleAspectFit
) { [weak self] image in
self?.imageView.image = image
}
}
}
scrollingCollectionView.isHidden = mode != .scroll
leftView.isHidden = mode == .scroll
rightView.isHidden = mode == .scroll
togglePageTurnDropdown()
saveLocalState()
}
@objc func handleBackgroundColorOption(_ sender: UIButton) {
if let title = sender.currentTitle {
switch title.lowercased() {
case "white": readerView.backgroundColor = .white
case "gray": readerView.backgroundColor = .gray
case "black": readerView.backgroundColor = .black
case "red": readerView.backgroundColor = .red
case "blue": readerView.backgroundColor = .blue
default: break
}
}
toggleBackgroundColorDropdown()
saveLocalState()
}
@objc func togglePageTurnDropdown() {
pageTurnDropdownView.isHidden.toggle()
}
@objc func toggleBackgroundColorDropdown() {
backgroundColorDropdownView.isHidden.toggle()
}
func gestureRecognizer(
_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer
) -> Bool {
return true
}
func toggleBar() {
topBarView.isHidden.toggle()
bottomBarView.isHidden.toggle()
if topBarView.isHidden {
pageTurnDropdownView.isHidden = true
backgroundColorDropdownView.isHidden = true
}
}
func scrollViewDidEndDecelerating(_: UIScrollView) {
if scrollingCollectionView.isHidden { return }
updateInfo()
saveLocalState()
}
func scrollViewDidEndDragging(_: UIScrollView, willDecelerate decelerate: Bool) {
if scrollingCollectionView.isHidden { return }
if !decelerate {
updateInfo()
}
saveLocalState()
}
func hideBar() {
topBarView.isHidden = true
bottomBarView.isHidden = true
pageTurnDropdownView.isHidden = true
backgroundColorDropdownView.isHidden = true
}
@objc func handleTopTap() {
toggleBar()
}
@objc func handleLeftTap() {
switch mode {
case .rightToLeft: changeImage(turn: .next)
case .leftToRight: changeImage(turn: .previous)
case .scroll: break
}
}
@objc func handleRightTap() {
switch mode {
case .rightToLeft: changeImage(turn: .previous)
case .leftToRight: changeImage(turn: .next)
case .scroll: break
}
}
@objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) {
switch gesture.direction {
case .left:
switch mode {
case .rightToLeft: changeImage(turn: .previous)
case .leftToRight: changeImage(turn: .next)
case .scroll: break
}
case .right:
switch mode {
case .rightToLeft: changeImage(turn: .next)
case .leftToRight: changeImage(turn: .previous)
case .scroll: break
}
default: break
}
}
func setupImageView() {
imageView.translatesAutoresizingMaskIntoConstraints = false
// Scaling is done when the image is loaded to avoid scaling on main thread
imageView.contentMode = .center
imageView.clipsToBounds = true
readerView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: readerView.topAnchor),
imageView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
])
}
func changeImage(turn: PageTurn) {
let scaling = UIView.ContentMode.scaleAspectFit
var newProgress = progress
newProgress = getProgressIndicesFromTurn(turn: turn, progress: newProgress)
if newProgress.v == progress.v, newProgress.c == progress.c, newProgress.i == progress.i {
return
}
progress = newProgress
if let path = getImagePath(progress: progress) {
imageLoader.loadImage(at: path, scaling: scaling) { [weak self] image in
self?.imageView.image = image
self?.updateInfo()
}
} else {
return
}
for _ in 0 ... preloadCount {
newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
if let path = getImagePath(progress: newProgress) {
imageLoader.preloadImage(at: path, scaling: scaling)
} else {
print("could not preload image")
}
}
saveLocalState()
}
func metaValueToString(m: MetaValue) -> String {
var r = ""
if let bonus = m.bonus {
r = String(m.main) + "." + String(bonus)
} else {
r = String(m.main)
}
return r
}
func updateInfo() {
if metadata == nil { return }
var text = "\(metadata.title)\n"
if let chapterTitle = metadata.volumes[progress.v].chapters[progress.c].name {
text +=
"\(chapterTitle)\n"
}
let chapterValue = metadata.volumes[progress.v].chapters[progress.c].chapter
let chapterString = metaValueToString(m: chapterValue)
let lastChapterValue = metadata.last_chapter
let lastChapterString = metaValueToString(m: lastChapterValue)
let volumeValue = metadata.volumes[progress.v].volume
let volumeString = metaValueToString(m: volumeValue)
let lastVolumeValue = metadata.last_volume
let lastVolumeString = metaValueToString(m: lastVolumeValue)
let lastChapterIndex = metadata.volumes[progress.v].chapters.count - 1
let lastImageIndex =
metadata.volumes[progress.v].chapters[lastChapterIndex].images.count - 1
let lastImage = metadata.volumes[progress.v].chapters[lastChapterIndex].images[
lastImageIndex
]
var lastPageString = ""
if lastImage.doublePage {
lastPageString = String(lastImage.firstPage + 1)
} else {
lastPageString = String(lastImage.firstPage)
}
let currentImage = metadata.volumes[progress.v].chapters[progress.c].images[progress.i]
var pageString = ""
if currentImage.doublePage {
pageString = String(currentImage.firstPage) + "-" + String(currentImage.firstPage + 1)
} else {
pageString = String(currentImage.firstPage)
}
text +=
"""
Volume \(volumeString) of \(lastVolumeString)
Chapter \(chapterString) of \(lastChapterString)
Page \(pageString) of \(lastPageString)
"""
if let size = imageLoader.size[getImagePath(progress: progress).path] {
text += "\nImage size: \(Int(size.width))x\(Int(size.height))"
}
info.text = text
}
func getImagePath(progress: ProgressIndices) -> URL! {
let (v, c, i) = (progress.v, progress.c, progress.i)
let modernPath = currentPath.appendingPathComponent("images").appendingPathComponent(
metadata.volumes[v].chapters[c].images[i].fileName)
// print("trying to get path \(modernPath)")
// if fileManager.fileExists(atPath: modernPath.path) {
return modernPath
// }
// return nil
}
func getProgressIndicesFromTurn(turn: PageTurn, progress: ProgressIndices) -> ProgressIndices {
let (v, c, i) = (progress.v, progress.c, progress.i)
switch turn {
case .next:
if metadata.volumes[v].chapters[c].images.count > i + 1 {
return ProgressIndices(v: v, c: c, i: i + 1)
}
if metadata.volumes[v].chapters.count > c + 1 {
return ProgressIndices(v: v, c: c + 1, i: 0)
}
if metadata.volumes.count > v + 1 {
return ProgressIndices(v: v + 1, c: 0, i: 0)
}
case .previous:
if i > 0 {
return ProgressIndices(v: v, c: c, i: i - 1)
}
if c > 0 {
return ProgressIndices(
v: v, c: c - 1, i: metadata.volumes[v].chapters[c - 1].images.count - 1
)
}
if v > 0 {
return ProgressIndices(
v: v - 1, c: metadata.volumes[v - 1].chapters.count - 1,
i: metadata.volumes[v - 1].chapters[
metadata.volumes[v - 1].chapters.count - 1
].images.count - 1
)
}
}
return ProgressIndices(v: v, c: c, i: i)
}
func getMetadata(path: URL) -> Metadata? {
do {
let json = try Data(
String(contentsOfFile: path.appendingPathComponent("metadata.json").path)
.utf8)
let metadata = try JSONDecoder().decode(Metadata.self, from: json)
return metadata
} catch let decodingError as DecodingError {
print(decodingError.errorDescription!)
} catch {
print("Unexpected error: \(error)")
}
return nil
}
func setImages(path _: URL) {
let scaling: UIView.ContentMode!
if mode == .scroll {
scaling = UIView.ContentMode.scaleAspectFill
} else { scaling = UIView.ContentMode.scaleAspectFit }
imageLoader.loadImage(
at:
getImagePath(progress: progress),
scaling: scaling
) { [weak self] image in
self?.imageView.image = image
self?.updateInfo()
}
var newProgress = progress
for _ in 0 ... preloadCount {
newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
imageLoader.preloadImage(at: getImagePath(progress: newProgress), scaling: scaling)
}
}
func getPathFromComicName(name: String) -> URL? {
for comic in comics {
if comic.metadata.title == name {
return comic.path
}
}
return nil
}
override func viewWillTransition(
to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
if mode != .scroll {
imageLoader.loadImage(
at: getImagePath(progress: progress),
scaling: .scaleAspectFit
) { image in
self.imageView.image = image
}
} else {
scrollingCollectionView.reloadData()
changedOrientation = true
}
}
}
func getGlobalState() -> GlobalState {
let fileManager = FileManager.default
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
do {
let json = try Data(
String(
contentsOfFile: documentsURL.appendingPathComponent("state.json").path
).utf8)
return try JSONDecoder().decode(GlobalState.self, from: json)
} catch {
print("Error reading directory contents: \(error)")
}
}
return GlobalState(comicName: nil)
}
func getDocumentsURL() -> URL? {
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
.first
{
return documentsURL
}
print("failed to get documents dir")
return nil
}
extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(
_ collectionView: UICollectionView, numberOfItemsInSection _: Int
)
-> Int
{
if collectionView == comicCollectionView {
return comics.count
} else {
return pagesAvailable
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath)
-> UICollectionViewCell
{
if collectionView == comicCollectionView {
let cell =
collectionView.dequeueReusableCell(
withReuseIdentifier: "ComicImageCell", for: indexPath
)
as! ComicImageCell
cell.imageView.image = comics[indexPath.item].cover
return cell
} else if collectionView == scrollingCollectionView {
// let scaling = UIView.ContentMode.scaleAspectFill
let cell =
collectionView.dequeueReusableCell(
withReuseIdentifier: "ScrollingImageCell", for: indexPath
)
as! ScrollingImageCell
if metadata == nil {
return cell
}
// TODO: Fix scrolling again
return cell
} else {
assertionFailure()
}
// Xcode profiling sucks:
return
collectionView.dequeueReusableCell(
withReuseIdentifier: "ScrollingImageCell", for: indexPath
)
as! ScrollingImageCell
}
func collectionView(
_ collectionView: UICollectionView,
layout _: UICollectionViewLayout,
sizeForItemAt _: IndexPath
) -> CGSize {
if collectionView == comicCollectionView {
let spacing: CGFloat = 8
let itemsPerRow: CGFloat = 3
let totalSpacing = (itemsPerRow - 1) * spacing
let width = (collectionView.bounds.width - totalSpacing) / itemsPerRow
return CGSize(width: width, height: width)
} else if collectionView == scrollingCollectionView {
if metadata == nil {
return CGSize()
}
// TODO: Fix scrolling again
return CGSize()
} else {
assertionFailure()
}
// Xcode profiling sucks:
return CGSize()
}
func collectionView(
_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath
) {
if collectionView == comicCollectionView {
let selectedComic = comics[indexPath.item]
readComic(name: selectedComic.metadata.title)
}
}
}
class ScrollingImageCell: UICollectionViewCell {
let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
// Scaling is done when the image is loaded to avoid scaling on main thread
imageView.contentMode = .center
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class ComicImageCell: UICollectionViewCell {
let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
enum Rotation {
case vertical
case horizontal
}
class ImageLoader {
private let cache = NSCache<NSString, UIImage>()
private let horCache = NSCache<NSString, UIImage>()
private var loadingTasks: [String: [(UIImage?) -> Void]] = [:]
var size: [String: CGSize] = [:]
init() {
// 128 MiB
cache.totalCostLimit = 128 * 1024 * 1024
}
func preloadImage(at path: URL, scaling: UIView.ContentMode) {
loadImage(at: path, scaling: scaling, completion: nil)
}
func loadImage(
at path: URL, scaling: UIView.ContentMode,
completion: ((UIImage?) -> Void)?
) {
let screenSize = UIScreen.main.bounds.size
let rotation: Rotation!
if screenSize.width > screenSize.height {
rotation = Rotation.horizontal
} else { rotation = Rotation.vertical }
if rotation == .vertical {
if let cached = cache.object(forKey: path.path as NSString) {
completion?(cached)
return
}
} else {
if let cached = horCache.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 }
guard var image = UIImage(contentsOfFile: path.path) else { return }
self.size[path.path] = image.size
// If you turn pages fast, completion will not be nil and as such only the needed scaled image should be prepared
if completion == nil || rotation == .vertical {
let vertical = CGSize(
width: min(screenSize.width, screenSize.height),
height: max(screenSize.height, screenSize.width)
)
let vScaledSize = aspectSize(
for: image.size, in: vertical, scaling: scaling
)
let vScaleImage = resizeImage(image, to: vScaledSize)
guard let cost = imageByteSize(vScaleImage) else { return }
self.cache.setObject(vScaleImage, forKey: path.path as NSString, cost: cost)
if rotation == .vertical {
image = vScaleImage
}
}
if completion == nil || rotation == .horizontal {
let horizontal = CGSize(
width: max(screenSize.width, screenSize.height),
height: min(screenSize.height, screenSize.width)
)
let hScaledSize = aspectSize(
for: image.size, in: horizontal, scaling: scaling
)
let hScaleImage = resizeImage(image, to: hScaledSize)
guard let cost = imageByteSize(hScaleImage) else { return }
self.horCache.setObject(hScaleImage, forKey: path.path as NSString, cost: cost)
if rotation == .horizontal {
image = hScaleImage
}
}
DispatchQueue.main.async {
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)
}
}
func imageByteSize(_ image: UIImage) -> Int? {
guard let cgImage = image.cgImage else { return nil }
return cgImage.bytesPerRow * cgImage.height
}
func resizeImage(_ image: UIImage, to size: CGSize) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, true, 0.0)
image.draw(in: CGRect(origin: .zero, size: size))
let scaledImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return scaledImage!
}
func aspectSize(for imageSize: CGSize, in boundingSize: CGSize, scaling: UIView.ContentMode)
-> CGSize
{
let widthRatio = boundingSize.width / imageSize.width
let heightRatio = boundingSize.height / imageSize.height
var scale = CGFloat()
switch scaling {
case .scaleAspectFit: scale = min(widthRatio, heightRatio)
case .scaleAspectFill: scale = max(widthRatio, heightRatio)
default: scale = 1
}
return CGSize(
width: imageSize.width * scale,
height: imageSize.height * scale
)
}