1814 lines
70 KiB
Swift
1814 lines
70 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
|
|
|
|
import FlatBuffers
|
|
import Foundation
|
|
import UIKit
|
|
import ZIPFoundation
|
|
|
|
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"
|
|
}
|
|
|
|
class ViewController: UIViewController, UIGestureRecognizerDelegate {
|
|
let metadataFilename = "metadata.fb"
|
|
|
|
var homeView = UIView()
|
|
var readerView = UIView()
|
|
|
|
@IBOutlet var scrollingCollectionView: UICollectionView!
|
|
var scrollPos: CGPoint!
|
|
var hasSetContentOffset = false
|
|
var pageCount = 0
|
|
|
|
var imageView = UIImageView()
|
|
var mode = PageTurnMode.leftToRight
|
|
var metadataList: [URL: Metadata] = [:]
|
|
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),
|
|
])
|
|
setupScrollingCollectionView()
|
|
loadComics()
|
|
setupImageView()
|
|
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(metadataFilename).path) {
|
|
getMetadataFromFilename(path: dir)
|
|
}
|
|
|
|
currentPath = dir
|
|
|
|
let data = try! Data(contentsOf: dir.appendingPathComponent(metadataFilename))
|
|
var byteBuffer = ByteBuffer(data: data)
|
|
metadata = try! getCheckedRoot(byteBuffer: &byteBuffer)
|
|
metadataList[dir] = metadata
|
|
loadLocalState()
|
|
let volCovP = ProgressIndices(v: progress.v, c: 0, i: 0)
|
|
let volumeDir = currentPath.appendingPathComponent("volumes")
|
|
let archive = try! Archive(url: volumeDir.appendingPathComponent(metadata.volumes[progress.v].archive), accessMode: .read)
|
|
let targetEntry = archive[metadata.volumes[volCovP.v].chapters[volCovP.c].images[volCovP.i].filename]!
|
|
var extractedData = Data()
|
|
_ = try archive.extract(targetEntry) { data in
|
|
extractedData.append(data)
|
|
}
|
|
comics.append(
|
|
Comic(
|
|
cover: UIImage(data: extractedData)!,
|
|
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 imageSize(at url: URL) -> CGSize? {
|
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
|
return nil
|
|
}
|
|
|
|
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
|
let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
|
|
let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else {
|
|
return nil
|
|
}
|
|
|
|
return CGSize(width: width, height: height)
|
|
}
|
|
|
|
func getMetadataFromFilename(path: URL) {
|
|
// Beautiful, is it not?
|
|
let patterns: [(NSRegularExpression, NSRegularExpression, Int, Int, Int)] = [
|
|
(try! NSRegularExpression(pattern: #"c([0-9]+(?:x[0-9]+)?) \(v([0-9]+)\) - p([0-9]+(?:-[0-9]+)?)"#), try! NSRegularExpression(pattern: #"^(.*) - c([0-9]+(?:x[0-9]+)?) \(v([0-9]+)\) - p([0-9]+(?:-[0-9]+)?)"#), 3, 1, 2),
|
|
(try! NSRegularExpression(pattern: "v([0-9]+) p([0-9]+(?:-[0-9]+)?) - c([0-9]+(?:x[0-9]+)?)"), try! NSRegularExpression(pattern: "^(.*) - v([0-9]+) p([0-9]+(?:-[0-9]+)?) - c([0-9]+(?:x[0-9]+)?)"), 2, 3, 1),
|
|
]
|
|
var pattern: NSRegularExpression! = nil
|
|
var titlePattern: NSRegularExpression! = nil
|
|
|
|
var pageRegexPosition: Int! = nil
|
|
var chapterRegexPosition: Int! = nil
|
|
var volumeRegexPosition: Int! = nil
|
|
|
|
var currentVolume: UInt32! = nil
|
|
var currentChapter: (UInt32, UInt32?)!
|
|
|
|
if fileManager.fileExists(
|
|
atPath: path.appendingPathComponent(metadataFilename).absoluteString)
|
|
{
|
|
print("skipped metadata file creation")
|
|
return
|
|
}
|
|
|
|
var builder = FlatBufferBuilder(initialSize: 1024)
|
|
|
|
var volumes: [Offset] = []
|
|
var currentChapters: [Offset] = []
|
|
var currentImages: [Offset] = []
|
|
|
|
var titleValue: String = ""
|
|
|
|
let volumeDir = path.appendingPathComponent("volumes")
|
|
|
|
var isDir: ObjCBool = false
|
|
if !fileManager.fileExists(atPath: volumeDir.path, isDirectory: &isDir) || !isDir.boolValue {
|
|
print("Subdirectory volumes not found in \"\(path)\", returning")
|
|
return
|
|
}
|
|
let volumeURLs = try! fileManager.contentsOfDirectory(
|
|
at: volumeDir,
|
|
includingPropertiesForKeys: [.isRegularFileKey],
|
|
options: [.skipsHiddenFiles]
|
|
).filter { $0.pathExtension == "cbz" }
|
|
|
|
if volumeURLs.isEmpty {
|
|
print("found no volumes in volume dir: \(volumeDir)")
|
|
return
|
|
}
|
|
|
|
var filenames: [(String, String)] = []
|
|
for volumeURL in volumeURLs {
|
|
do {
|
|
let volumeArchive = try Archive(url: volumeURL, accessMode: .read)
|
|
volumeArchive.map { URL(string: $0.path)!.lastPathComponent }.sorted().forEach{ filenames.append((volumeURL.lastPathComponent, $0)) }
|
|
} catch {
|
|
print("Failed to read archive \(volumeURL)")
|
|
continue
|
|
}
|
|
}
|
|
|
|
if filenames.isEmpty {
|
|
print("Found no files in archives")
|
|
return
|
|
}
|
|
|
|
do {
|
|
for (currentPattern, currentTitlePattern, currentPageRegexPosition, currentChapterRegexPosition, currentVolumeRegexPosition) in patterns {
|
|
let filename = filenames[0].1
|
|
let range = NSRange(filename.startIndex..., in: filename)
|
|
if let match = currentTitlePattern.firstMatch(in: filename, range: range) {
|
|
if (filename as NSString).substring(with: match.range(at: 1)) == "" {
|
|
continue
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
if let match = currentPattern.firstMatch(in: filename, range: range) {
|
|
if (filename as NSString).substring(with: match.range(at: currentPageRegexPosition)) == "" {
|
|
continue
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
if let match = currentPattern.firstMatch(in: filename, range: range) {
|
|
if (filename as NSString).substring(with: match.range(at: currentChapterRegexPosition)) == "" {
|
|
continue
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
if let match = currentPattern.firstMatch(in: filename, range: range) {
|
|
if (filename as NSString).substring(with: match.range(at: currentVolumeRegexPosition)) == "" {
|
|
continue
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
pattern = currentPattern
|
|
titlePattern = currentTitlePattern
|
|
|
|
pageRegexPosition = currentPageRegexPosition
|
|
chapterRegexPosition = currentChapterRegexPosition
|
|
volumeRegexPosition = currentVolumeRegexPosition
|
|
break
|
|
}
|
|
|
|
if pattern == nil || titlePattern == nil || pageRegexPosition == nil || chapterRegexPosition == nil || volumeRegexPosition == nil {
|
|
print("Could not find pattern, returning")
|
|
return
|
|
}
|
|
|
|
|
|
let regex = pattern!
|
|
let titleRegex = titlePattern!
|
|
|
|
for (i, (volumeURL, filename)) in filenames.enumerated() {
|
|
let range = NSRange(filename.startIndex..., in: filename)
|
|
if i == 0 {
|
|
if let match = titleRegex.firstMatch(in: filename, range: range) {
|
|
titleValue = (filename as NSString).substring(with: match.range(at: 1))
|
|
} else {
|
|
print("Did not find match for title, regex did not handle this case.")
|
|
return
|
|
}
|
|
}
|
|
if let match = regex.firstMatch(in: filename, range: range) {
|
|
let chapterStr = (filename as NSString).substring(with: match.range(at: chapterRegexPosition))
|
|
let chapterParts = chapterStr.split(separator: "x", maxSplits: 1)
|
|
let chapter: (UInt32, UInt32?) = (
|
|
UInt32(chapterParts[0])!, chapterParts.count > 1 ? UInt32(chapterParts[1])! : nil
|
|
)
|
|
|
|
let volume: UInt32 = UInt32((filename as NSString).substring(with: match.range(at: volumeRegexPosition)))!
|
|
|
|
let pageStr = (filename as NSString).substring(with: match.range(at: pageRegexPosition))
|
|
let pageParts = pageStr.split(separator: "-", maxSplits: 1)
|
|
let (page, doublePage) = (
|
|
UInt32(pageParts[0])!, pageParts.count > 1
|
|
)
|
|
|
|
let archive = try Archive(url: volumeDir.appendingPathComponent(volumeURL), accessMode: .read)
|
|
let entry = archive[filename]!
|
|
var size = ImageDimensions(width: 0, height: 0)
|
|
do {
|
|
size = try imageDimensions(of: entry, in: archive)
|
|
} catch {
|
|
print("Failed to get image dimensions of filename: \(filename)")
|
|
return
|
|
}
|
|
let m_filename = builder.create(string: filename)
|
|
let imageMetadata = ImageMetadata.createImageMetadata(&builder,
|
|
doublePage: doublePage,
|
|
filenameOffset: m_filename,
|
|
firstPage: page,
|
|
size: Size(width: UInt32(size.width), height: UInt32(size.height))
|
|
)
|
|
|
|
if currentVolume != nil {
|
|
if volume != currentVolume {
|
|
assert(volume > currentVolume)
|
|
|
|
let chapterName = builder.create(string: "")
|
|
let imagesOffset = builder.createVector(ofOffsets: currentImages)
|
|
let chapterMetadata = ChapterMetadata.createChapterMetadata(&builder,
|
|
chapter: MetaValue(
|
|
main: currentChapter.0, bonus: currentChapter.1 ?? 0
|
|
),
|
|
nameOffset: chapterName,
|
|
imagesVectorOffset: imagesOffset,
|
|
)
|
|
|
|
currentChapters.append(chapterMetadata)
|
|
|
|
let volumeTitle = builder.create(string: "")
|
|
let volumeURLOffset = builder.create(string: volumeURL)
|
|
let chaptersOffset = builder.createVector(ofOffsets: currentChapters)
|
|
let volumeMetadata = VolumeMetadata.createVolumeMetadata(
|
|
&builder,
|
|
volume: MetaValue(main: currentVolume, bonus: 0),
|
|
titleOffset: volumeTitle,
|
|
archiveOffset: volumeURLOffset,
|
|
chaptersVectorOffset: chaptersOffset,
|
|
)
|
|
|
|
volumes.append(volumeMetadata)
|
|
|
|
currentImages = [imageMetadata]
|
|
currentChapters = []
|
|
} else if chapter != currentChapter {
|
|
if chapter.0 == currentChapter.0 {
|
|
assert(chapter.1! == currentChapter.1 ?? 1)
|
|
} else {
|
|
assert(chapter.0 == currentChapter.0 + 1)
|
|
}
|
|
|
|
let chapterName = builder.create(string: "")
|
|
let imagesOffset = builder.createVector(ofOffsets: currentImages)
|
|
let chapterMetadata = ChapterMetadata.createChapterMetadata(&builder,
|
|
chapter: MetaValue(
|
|
main: currentChapter.0, bonus: currentChapter.1 ?? 0
|
|
),
|
|
nameOffset: chapterName,
|
|
imagesVectorOffset: imagesOffset,
|
|
)
|
|
|
|
currentChapters.append(chapterMetadata)
|
|
|
|
currentImages = [imageMetadata]
|
|
|
|
} else {
|
|
currentImages.append(imageMetadata)
|
|
}
|
|
} else {
|
|
currentImages.append(imageMetadata)
|
|
}
|
|
if (i == filenames.count - 1) {
|
|
let chapterName = builder.create(string: "")
|
|
let imagesOffset = builder.createVector(ofOffsets: currentImages)
|
|
let chapterMetadata = ChapterMetadata.createChapterMetadata(&builder,
|
|
chapter: MetaValue(
|
|
main: chapter.0, bonus: chapter.1 ?? 0
|
|
),
|
|
nameOffset: chapterName,
|
|
imagesVectorOffset: imagesOffset,
|
|
)
|
|
|
|
currentChapters.append(chapterMetadata)
|
|
|
|
let volumeTitle = builder.create(string: "")
|
|
let volumeURLOffset = builder.create(string: volumeURL)
|
|
let chaptersOffset = builder.createVector(ofOffsets: currentChapters)
|
|
let volumeMetadata = VolumeMetadata.createVolumeMetadata(
|
|
&builder,
|
|
volume: MetaValue(main: volume, bonus: 0),
|
|
titleOffset: volumeTitle,
|
|
archiveOffset: volumeURLOffset,
|
|
chaptersVectorOffset: chaptersOffset
|
|
)
|
|
|
|
volumes.append(volumeMetadata)
|
|
|
|
currentImages = []
|
|
currentChapters = []
|
|
}
|
|
currentVolume = volume
|
|
currentChapter = chapter
|
|
} else {
|
|
print("no regex match on \(filename), returning")
|
|
return
|
|
}
|
|
}
|
|
} catch {
|
|
print("failed reading image file names")
|
|
}
|
|
|
|
if volumes.count == 0 {
|
|
print("Why no metadata from file name?")
|
|
return
|
|
}
|
|
|
|
let title = builder.create(string: titleValue)
|
|
let originalLanguage = builder.create(string: "Japanese")
|
|
let countryOfOrigin = builder.create(string: "Japan")
|
|
let description = builder.create(string: "")
|
|
let tags = builder.createVector(ofOffsets: [])
|
|
let volumeOffsets = builder.createVector(ofOffsets: volumes)
|
|
let metadata = Metadata.createMetadata(
|
|
&builder,
|
|
titleOffset: title,
|
|
format: Format.manga,
|
|
originalLanguageOffset: originalLanguage,
|
|
countryOfOriginOffset: countryOfOrigin,
|
|
publicationDemographic: PublicationDemographic.shounen,
|
|
status: Status.finished,
|
|
contentRating: ContentRating.safe,
|
|
source: Source.original,
|
|
startReleaseDate: Date(year: 2000, month: 1, day: 1),
|
|
endReleaseDate: Date(year: 2000, month: 1, day: 1),
|
|
tagsVectorOffset: tags,
|
|
descriptionOffset: description,
|
|
volumesVectorOffset: volumeOffsets,
|
|
)
|
|
|
|
builder.finish(offset: metadata)
|
|
try! builder.data.write(to: path.appendingPathComponent(metadataFilename))
|
|
}
|
|
|
|
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 path = currentPath.appendingPathComponent("state.json").path
|
|
if !fileManager.fileExists(atPath: path) {
|
|
progress = ProgressIndices(v: 0, c: 0, i: 0)
|
|
mode = .leftToRight
|
|
return
|
|
}
|
|
let json = try Data(String(contentsOfFile: 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) {
|
|
var newProgress = ProgressIndices(v: 0, c: 0, i: 0)
|
|
for _ in 0 ..< indexPath.item {
|
|
newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
|
|
}
|
|
progress = newProgress
|
|
}
|
|
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 = metadataList[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
|
|
scrollingCollectionView.reloadData()
|
|
}
|
|
|
|
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 {
|
|
let archiveURL = currentPath.appendingPathComponent("volumes").appendingPathComponent(metadata.volumes[progress.v].archive)
|
|
imageLoader.loadImage(
|
|
archiveURL: archiveURL,
|
|
filename: getImagePath(progress: progress),
|
|
scaling: .scaleAspectFit,
|
|
screenSize: UIScreen.main.bounds.size
|
|
) { [weak self] image in
|
|
self?.imageView.image = image
|
|
}
|
|
}
|
|
}
|
|
scrollingCollectionView.isHidden = mode != .scroll
|
|
leftView.isHidden = mode == .scroll
|
|
rightView.isHidden = mode == .scroll
|
|
imageView.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
|
|
updateInfo()
|
|
let path = getImagePath(progress: progress)
|
|
|
|
imageLoader.setActiveWindow([path])
|
|
|
|
let archiveURL = currentPath.appendingPathComponent("volumes").appendingPathComponent(metadata.volumes[progress.v].archive)
|
|
imageLoader.loadImage(archiveURL: archiveURL, filename: getImagePath(progress: progress), scaling: scaling, screenSize: UIScreen.main.bounds.size) { [weak self] image in
|
|
self?.imageView.image = image
|
|
}
|
|
|
|
for _ in 0 ..< preloadCount {
|
|
newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
|
|
let archive = metadata.volumes[newProgress.v].archive!
|
|
let filename = getImagePath(progress: newProgress)
|
|
let archiveURL = currentPath.appendingPathComponent("volumes").appendingPathComponent(archive)
|
|
imageLoader.preloadImage(archiveURL: archiveURL, filename: filename, scaling: scaling, screenSize: UIScreen.main.bounds.size)
|
|
}
|
|
|
|
saveLocalState()
|
|
}
|
|
|
|
func metaValueToString(m: MetaValue) -> String {
|
|
var r = ""
|
|
if m.bonus != 0 {
|
|
r = String(m.main) + "." + String(m.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.volumes[metadata.volumes.count - 1].chapters[metadata.volumes[metadata.volumes.count - 1].chapters.count - 1].chapter!
|
|
let lastChapterString = metaValueToString(m: lastChapterValue)
|
|
|
|
let volumeValue = metadata.volumes[progress.v].volume!
|
|
let volumeString = metaValueToString(m: volumeValue)
|
|
|
|
let lastVolumeValue = metadata.volumes[metadata.volumes.count - 1].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)
|
|
"""
|
|
text += "\nImage size: \(metadata.volumes[progress.v].chapters[progress.c].images[progress.i].size!.width)x\(metadata.volumes[progress.v].chapters[progress.c].images[progress.i].size!.height)"
|
|
info.text = text
|
|
}
|
|
|
|
func getImagePath(progress: ProgressIndices) -> String {
|
|
let (v, c, i) = (progress.v, progress.c, progress.i)
|
|
let modernPath = metadata.volumes[v].chapters[c].images[i].filename!
|
|
// 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? {
|
|
let data = try! Data(contentsOf: path.appendingPathComponent(metadataFilename))
|
|
var byteBuffer = ByteBuffer(data: data)
|
|
let metadata: Metadata = try! getCheckedRoot(byteBuffer: &byteBuffer)
|
|
return metadata
|
|
}
|
|
|
|
func setImages(path _: URL) {
|
|
let scaling: UIView.ContentMode!
|
|
if mode == .scroll {
|
|
scaling = UIView.ContentMode.scaleAspectFill
|
|
} else { scaling = UIView.ContentMode.scaleAspectFit }
|
|
imageLoader.loadImage(
|
|
archiveURL: currentPath.appendingPathComponent("volumes").appendingPathComponent(metadata.volumes[progress.v].archive),
|
|
filename: getImagePath(progress: progress),
|
|
scaling: scaling,
|
|
screenSize: UIScreen.main.bounds.size
|
|
) { [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(archiveURL: currentPath.appendingPathComponent("volumes").appendingPathComponent(metadata.volumes[progress.v].archive), filename: getImagePath(progress: newProgress), scaling: scaling, screenSize: UIScreen.main.bounds.size)
|
|
}
|
|
}
|
|
|
|
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(
|
|
archiveURL: currentPath.appendingPathComponent("volumes").appendingPathComponent(metadata.volumes[progress.v].archive),
|
|
filename: getImagePath(progress: progress),
|
|
scaling: .scaleAspectFit,
|
|
screenSize: size
|
|
) { 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 {
|
|
let path = documentsURL.appendingPathComponent("state.json").path
|
|
if !fileManager.fileExists(atPath: path) {
|
|
return GlobalState(comicName: nil)
|
|
}
|
|
do {
|
|
let json = try Data(
|
|
String(
|
|
contentsOfFile: 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 if collectionView == scrollingCollectionView {
|
|
if metadata == nil {
|
|
return 0
|
|
}
|
|
var sum = 0
|
|
for volume in metadata.volumes {
|
|
for chapter in volume.chapters {
|
|
sum += chapter.images.count
|
|
}
|
|
}
|
|
return sum
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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 {
|
|
print("metadata is nil, should probably not be the case")
|
|
return cell
|
|
}
|
|
var newProgress = ProgressIndices(v: 0, c: 0, i: 0)
|
|
if indexPath.item != 0 {
|
|
for _ in 0 ..< indexPath.item {
|
|
newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
|
|
}
|
|
}
|
|
imageLoader.loadImage(
|
|
archiveURL: currentPath.appendingPathComponent("volumes").appendingPathComponent(metadata.volumes[progress.v].archive),
|
|
filename: getImagePath(progress: newProgress),
|
|
scaling: scaling, screenSize: UIScreen.main.bounds.size) { image in
|
|
cell.imageView.image = image
|
|
}
|
|
return cell
|
|
} else {
|
|
assertionFailure()
|
|
}
|
|
|
|
// Xcode profiling sucks:
|
|
return
|
|
collectionView.dequeueReusableCell(
|
|
withReuseIdentifier: "ScrollingImageCell", for: indexPath
|
|
)
|
|
as! ScrollingImageCell
|
|
}
|
|
|
|
func collectionView(
|
|
_ collectionView: UICollectionView,
|
|
layout _: UICollectionViewLayout,
|
|
sizeForItemAt indexPath: 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()
|
|
}
|
|
var newProgress = ProgressIndices(v: 0, c: 0, i: 0)
|
|
for _ in 0 ..< indexPath.item {
|
|
newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
|
|
}
|
|
let imageMetadata = metadata.volumes[newProgress.v].chapters[newProgress.c].images[newProgress.i]
|
|
return CGSize(
|
|
width: readerView.bounds.width,
|
|
height: CGFloat(imageMetadata.size!.height) * readerView.bounds.width / CGFloat(imageMetadata.size!.width)
|
|
)
|
|
} 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 portraitFitCache = NSCache<NSString, UIImage>()
|
|
private let landscapeFitCache = NSCache<NSString, UIImage>()
|
|
private let portraitFillCache = NSCache<NSString, UIImage>()
|
|
private let landscapeFillCache = NSCache<NSString, UIImage>()
|
|
private var loadingTasks: [String: (UIImage?) -> Void] = [:]
|
|
private var activeTokens: [String: CancellationToken] = [:]
|
|
private let archives = NSCache<NSString, Archive>()
|
|
|
|
init() {
|
|
// 128 MiB
|
|
portraitFitCache.totalCostLimit = 128 * 1024 * 1024
|
|
landscapeFitCache.totalCostLimit = 128 * 1024 * 1024
|
|
portraitFillCache.totalCostLimit = 128 * 1024 * 1024
|
|
landscapeFillCache.totalCostLimit = 128 * 1024 * 1024
|
|
// The reason archives are cached and have a strict limit of four, is because Archive supposedly opens a posix file pointer and keeps it until the object is destroyed.
|
|
archives.countLimit = 4
|
|
}
|
|
|
|
func preloadImage(archiveURL: URL, filename: String, scaling: UIView.ContentMode, screenSize: CGSize) {
|
|
loadImage(archiveURL: archiveURL, filename: filename, scaling: scaling, screenSize: screenSize, completion: nil)
|
|
}
|
|
|
|
func loadImage(
|
|
archiveURL: URL, filename: String,
|
|
scaling: UIView.ContentMode,
|
|
screenSize: CGSize,
|
|
completion: ((UIImage?) -> Void)?
|
|
) {
|
|
let rotation: Rotation = screenSize.width > screenSize.height ? .horizontal : .vertical
|
|
let key = filename
|
|
|
|
if scaling == .scaleAspectFit {
|
|
if rotation == .vertical, let cached = portraitFitCache.object(forKey: key as NSString) {
|
|
completion?(cached); return
|
|
}
|
|
if rotation == .horizontal, let cached = landscapeFitCache.object(forKey: key as NSString) {
|
|
completion?(cached); return
|
|
}
|
|
} else if scaling == .scaleAspectFill {
|
|
if rotation == .vertical, let cached = portraitFillCache.object(forKey: key as NSString) {
|
|
completion?(cached); return
|
|
}
|
|
if rotation == .horizontal, let cached = landscapeFillCache.object(forKey: key as NSString) {
|
|
completion?(cached); return
|
|
}
|
|
} else {
|
|
print("this should not be reachable")
|
|
return
|
|
}
|
|
|
|
if let existing = activeTokens[key], !existing.isCancelled {
|
|
if let completion = completion {
|
|
loadingTasks[key] = completion
|
|
}
|
|
return
|
|
}
|
|
|
|
if let completion = completion {
|
|
loadingTasks[key] = completion
|
|
}
|
|
|
|
let token = CancellationToken()
|
|
activeTokens[key] = token
|
|
|
|
queue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
func finish(_ image: UIImage?) {
|
|
DispatchQueue.main.async {
|
|
guard self.activeTokens[key] === token else { return }
|
|
self.activeTokens.removeValue(forKey: key)
|
|
if let image = image {
|
|
if let callback = self.loadingTasks.removeValue(forKey: key) {
|
|
callback(image)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if token.isCancelled { finish(nil); return }
|
|
|
|
var extractedData = Data()
|
|
if self.archives.object(forKey: archiveURL.lastPathComponent as NSString) == nil {
|
|
let archive = try! Archive(url: archiveURL, accessMode: .read)
|
|
self.archives.setObject(archive, forKey: archiveURL.lastPathComponent as NSString)
|
|
}
|
|
let archive = self.archives.object(forKey: archiveURL.lastPathComponent as NSString)!
|
|
_ = try! archive.extract(archive[filename]!) { data in
|
|
extractedData.append(data)
|
|
}
|
|
|
|
if token.isCancelled { finish(nil); return }
|
|
|
|
guard var image = UIImage(data: extractedData) else {
|
|
finish(nil)
|
|
return
|
|
}
|
|
|
|
if token.isCancelled { finish(nil); return }
|
|
|
|
// 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)
|
|
let cost = imageByteSize(vScaleImage)!
|
|
if scaling == .scaleAspectFit {
|
|
self.portraitFitCache.setObject(vScaleImage, forKey: filename as NSString, cost: cost)
|
|
} else if scaling == .scaleAspectFill {
|
|
self.portraitFillCache.setObject(vScaleImage, forKey: filename as NSString, cost: cost)
|
|
} else {
|
|
print("this should be unreachable")
|
|
return
|
|
}
|
|
if rotation == .vertical { image = vScaleImage }
|
|
}
|
|
|
|
if token.isCancelled { finish(nil); return }
|
|
|
|
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)
|
|
let cost = imageByteSize(hScaleImage)!
|
|
if scaling == .scaleAspectFit {
|
|
self.landscapeFitCache.setObject(hScaleImage, forKey: filename as NSString, cost: cost)
|
|
} else if scaling == .scaleAspectFill {
|
|
self.landscapeFillCache.setObject(hScaleImage, forKey: filename as NSString, cost: cost)
|
|
} else {
|
|
print("this should be unreachable")
|
|
return
|
|
}
|
|
if rotation == .horizontal { image = hScaleImage }
|
|
}
|
|
|
|
finish(image)
|
|
}
|
|
}
|
|
|
|
func setActiveWindow(_ paths: [String]) {
|
|
let desired = Set(paths)
|
|
for (key, token) in activeTokens where !desired.contains(key) {
|
|
token.cancel()
|
|
loadingTasks[key] = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
final class CancellationToken {
|
|
private var _isCancelled = false
|
|
private let lock = NSLock()
|
|
|
|
var isCancelled: Bool {
|
|
lock.lock(); defer { lock.unlock() }
|
|
return _isCancelled
|
|
}
|
|
|
|
func cancel() {
|
|
lock.lock(); defer { lock.unlock() }
|
|
_isCancelled = true
|
|
}
|
|
}
|
|
|
|
struct ImageDimensions {
|
|
let width: Int
|
|
let height: Int
|
|
}
|
|
|
|
enum ImageSizeResult: Error {
|
|
case found(ImageDimensions)
|
|
}
|
|
|
|
enum ImageSizeError: Error {
|
|
case couldNotDetermineSize
|
|
}
|
|
|
|
func imageDimensions(of entry: Entry, in archive: Archive) throws -> ImageDimensions {
|
|
let imageSource = CGImageSourceCreateIncremental(nil)
|
|
var accumulatedData = Data()
|
|
|
|
do {
|
|
// This runs until the closure throws, or the entry is fully consumed
|
|
_ = try archive.extract(entry) { chunk in
|
|
accumulatedData.append(chunk)
|
|
CGImageSourceUpdateData(imageSource, accumulatedData as CFData, false)
|
|
|
|
if let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
|
|
let width = properties[kCGImagePropertyPixelWidth as String] as? Int,
|
|
let height = properties[kCGImagePropertyPixelHeight as String] as? Int {
|
|
// We have what we need — abort the rest of the decompression
|
|
throw ImageSizeResult.found(ImageDimensions(width: width, height: height))
|
|
}
|
|
}
|
|
} catch ImageSizeResult.found(let dimensions) {
|
|
return dimensions
|
|
}
|
|
|
|
// Fallback: some formats only report size once fully loaded (rare, but be safe)
|
|
CGImageSourceUpdateData(imageSource, accumulatedData as CFData, true)
|
|
if let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
|
|
let width = properties[kCGImagePropertyPixelWidth as String] as? Int,
|
|
let height = properties[kCGImagePropertyPixelHeight as String] as? Int {
|
|
print("This fell back to loading the complete image for size metadata")
|
|
return ImageDimensions(width: width, height: height)
|
|
}
|
|
|
|
throw ImageSizeError.couldNotDetermineSize
|
|
}
|