// 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 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 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), ]) 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) ) metadataList[dir] = metadata 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 = 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 // 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, 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 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, screenSize: UIScreen.main.bounds.size) { [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, screenSize: UIScreen.main.bounds.size) } 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, 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(at: 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( at: 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 { 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() private let horCache = NSCache() 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, screenSize: CGSize) { loadImage(at: path, scaling: scaling, screenSize: screenSize, completion: nil) } func loadImage( at path: URL, scaling: UIView.ContentMode, screenSize: CGSize, completion: ((UIImage?) -> Void)? ) { 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 ) }