mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
1451 lines
64 KiB
Swift
1451 lines
64 KiB
Swift
//
|
||
// ReaderView.swift
|
||
// Sora
|
||
//
|
||
// Created by paul on 18/06/25.
|
||
//
|
||
|
||
import SwiftUI
|
||
import WebKit
|
||
|
||
class ChapterNavigator: ObservableObject {
|
||
static let shared = ChapterNavigator()
|
||
@Published var currentChapter: (moduleId: UUID, href: String, title: String, chapters: [[String: Any]], mediaTitle: String, chapterNumber: Int)? = nil
|
||
}
|
||
|
||
extension UserDefaults {
|
||
func cgFloat(forKey defaultName: String) -> CGFloat? {
|
||
if let value = object(forKey: defaultName) as? NSNumber {
|
||
return CGFloat(value.doubleValue)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func set(_ value: CGFloat, forKey defaultName: String) {
|
||
set(NSNumber(value: Double(value)), forKey: defaultName)
|
||
}
|
||
}
|
||
|
||
struct ReaderView: View {
|
||
let moduleId: UUID
|
||
let chapterHref: String
|
||
let chapterTitle: String
|
||
let chapters: [[String: Any]]
|
||
let mediaTitle: String
|
||
let chapterNumber: Int
|
||
|
||
@State private var htmlContent: String = ""
|
||
@State private var isLoading: Bool = true
|
||
@State private var error: Error?
|
||
@State private var isHeaderVisible: Bool = true
|
||
@State private var fontSize: CGFloat = 16
|
||
@State private var selectedFont: String = "-apple-system"
|
||
@State private var fontWeight: String = "normal"
|
||
@State private var isAutoScrolling: Bool = false
|
||
@State private var autoScrollSpeed: Double = 1.0
|
||
@State private var autoScrollTimer: Timer?
|
||
@State private var selectedColorPreset: Int = 0
|
||
@State private var isSettingsExpanded: Bool = false
|
||
@State private var textAlignment: String = "left"
|
||
@State private var lineSpacing: CGFloat = 1.6
|
||
@State private var margin: CGFloat = 4
|
||
@State private var readingProgress: Double = 0.0
|
||
@State private var lastProgressUpdate: Date = Date()
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@StateObject private var navigator = ChapterNavigator.shared
|
||
@State private var statusBarHidden = false
|
||
|
||
private let fontOptions = [
|
||
("-apple-system", "System"),
|
||
("Georgia", "Georgia"),
|
||
("Times New Roman", "Times"),
|
||
("Helvetica", "Helvetica"),
|
||
("Charter", "Charter"),
|
||
("New York", "New York")
|
||
]
|
||
private let weightOptions = [
|
||
("300", "Light"),
|
||
("normal", "Regular"),
|
||
("600", "Semibold"),
|
||
("bold", "Bold")
|
||
]
|
||
|
||
private let alignmentOptions = [
|
||
("left", "Left", "text.alignleft"),
|
||
("center", "Center", "text.aligncenter"),
|
||
("right", "Right", "text.alignright"),
|
||
("justify", "Justify", "text.justify")
|
||
]
|
||
|
||
private let colorPresets = [
|
||
(name: "Pure", background: "#ffffff", text: "#000000"),
|
||
(name: "Warm", background: "#f9f1e4", text: "#4f321c"),
|
||
(name: "Slate", background: "#49494d", text: "#d7d7d8"),
|
||
(name: "Off-Black", background: "#121212", text: "#EAEAEA"),
|
||
(name: "Dark", background: "#000000", text: "#ffffff")
|
||
]
|
||
|
||
private var currentTheme: (background: Color, text: Color) {
|
||
let preset = colorPresets[selectedColorPreset]
|
||
return (
|
||
background: Color(hex: preset.background),
|
||
text: Color(hex: preset.text)
|
||
)
|
||
}
|
||
|
||
init(moduleId: UUID, chapterHref: String, chapterTitle: String, chapters: [[String: Any]] = [], mediaTitle: String = "Unknown Novel", chapterNumber: Int = 1) {
|
||
self.moduleId = moduleId
|
||
self.chapterHref = chapterHref
|
||
self.chapterTitle = chapterTitle
|
||
self.chapters = chapters
|
||
self.mediaTitle = mediaTitle
|
||
self.chapterNumber = chapterNumber
|
||
|
||
_fontSize = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerFontSize") ?? 16)
|
||
_selectedFont = State(initialValue: UserDefaults.standard.string(forKey: "readerFontFamily") ?? "-apple-system")
|
||
_fontWeight = State(initialValue: UserDefaults.standard.string(forKey: "readerFontWeight") ?? "normal")
|
||
_selectedColorPreset = State(initialValue: UserDefaults.standard.integer(forKey: "readerColorPreset"))
|
||
_textAlignment = State(initialValue: UserDefaults.standard.string(forKey: "readerTextAlignment") ?? "left")
|
||
_lineSpacing = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerLineSpacing") ?? 1.6)
|
||
_margin = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerMargin") ?? 4)
|
||
}
|
||
|
||
private func ensureModuleLoaded() {
|
||
if let module = ModuleManager().modules.first(where: { $0.id == moduleId }) {
|
||
do {
|
||
let moduleContent = try ModuleManager().getModuleContent(module)
|
||
JSController.shared.loadScript(moduleContent)
|
||
Logger.shared.log("Loaded script for module \(moduleId)", type: "Debug")
|
||
} catch {
|
||
Logger.shared.log("Failed to load module script: \(error)", type: "Error")
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .bottom) {
|
||
currentTheme.background.ignoresSafeArea()
|
||
|
||
if isLoading {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: currentTheme.text))
|
||
.onDisappear {
|
||
stopAutoScroll()
|
||
}
|
||
} else if let error = error {
|
||
VStack {
|
||
Text("Error loading chapter")
|
||
.font(.headline)
|
||
.foregroundColor(currentTheme.text)
|
||
Text(error.localizedDescription)
|
||
.font(.subheadline)
|
||
.foregroundColor(currentTheme.text.opacity(0.7))
|
||
}
|
||
} else {
|
||
ZStack {
|
||
Color.clear
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
withAnimation(.easeInOut(duration: 0.6)) {
|
||
isHeaderVisible.toggle()
|
||
statusBarHidden = !isHeaderVisible
|
||
setStatusBarHidden(!isHeaderVisible)
|
||
if !isHeaderVisible {
|
||
isSettingsExpanded = false
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
|
||
HTMLView(
|
||
htmlContent: htmlContent,
|
||
fontSize: fontSize,
|
||
fontFamily: selectedFont,
|
||
fontWeight: fontWeight,
|
||
textAlignment: textAlignment,
|
||
lineSpacing: lineSpacing,
|
||
margin: margin,
|
||
isAutoScrolling: $isAutoScrolling,
|
||
autoScrollSpeed: autoScrollSpeed,
|
||
colorPreset: colorPresets[selectedColorPreset],
|
||
chapterHref: chapterHref,
|
||
onProgressChanged: { progress in
|
||
self.readingProgress = progress
|
||
|
||
if Date().timeIntervalSince(self.lastProgressUpdate) > 2.0 {
|
||
self.updateReadingProgress(progress: progress)
|
||
self.lastProgressUpdate = Date()
|
||
Logger.shared.log("Progress updated to \(progress)", type: "Debug")
|
||
}
|
||
}
|
||
)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.padding(.horizontal)
|
||
.simultaneousGesture(TapGesture().onEnded {
|
||
withAnimation(.easeInOut(duration: 0.6)) {
|
||
isHeaderVisible.toggle()
|
||
statusBarHidden = !isHeaderVisible
|
||
setStatusBarHidden(!isHeaderVisible)
|
||
if !isHeaderVisible {
|
||
isSettingsExpanded = false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
.padding(.top, UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.windows.first?.safeAreaInsets.top ?? 0)
|
||
}
|
||
|
||
headerView
|
||
.opacity(isHeaderVisible ? 1 : 0)
|
||
.offset(y: isHeaderVisible ? 0 : -100)
|
||
.allowsHitTesting(isHeaderVisible)
|
||
.animation(.easeInOut(duration: 0.6), value: isHeaderVisible)
|
||
.zIndex(1)
|
||
|
||
if isHeaderVisible {
|
||
footerView
|
||
.transition(.move(edge: .bottom))
|
||
.zIndex(2)
|
||
}
|
||
}
|
||
.navigationBarHidden(true)
|
||
.navigationBarBackButtonHidden(true)
|
||
.ignoresSafeArea()
|
||
.onAppear {
|
||
UserDefaults.standard.set(false, forKey: "navigatingToReaderView")
|
||
UserDefaults.standard.set(chapterHref, forKey: "lastReadChapter")
|
||
saveReadingProgress()
|
||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||
let window = windowScene.windows.first,
|
||
let navigationController = window.rootViewController?.children.first as? UINavigationController {
|
||
navigationController.interactivePopGestureRecognizer?.isEnabled = true
|
||
navigationController.interactivePopGestureRecognizer?.delegate = nil
|
||
}
|
||
|
||
NotificationCenter.default.post(name: .hideTabBar, object: nil)
|
||
UserDefaults.standard.set(true, forKey: "isReaderActive")
|
||
|
||
loadContent()
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||
withAnimation(.easeInOut(duration: 0.6)) {
|
||
isHeaderVisible = false
|
||
statusBarHidden = true
|
||
setStatusBarHidden(true)
|
||
}
|
||
}
|
||
}
|
||
.onDisappear {
|
||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||
let window = windowScene.windows.first,
|
||
let navigationController = window.rootViewController?.children.first as? UINavigationController {
|
||
navigationController.interactivePopGestureRecognizer?.isEnabled = true
|
||
navigationController.interactivePopGestureRecognizer?.delegate = nil
|
||
}
|
||
|
||
if navigator.currentChapter != nil && navigator.currentChapter?.href != chapterHref {
|
||
UserDefaults.standard.set(true, forKey: "navigatingToReaderView")
|
||
}
|
||
|
||
if let next = navigator.currentChapter,
|
||
next.href != chapterHref {
|
||
UserDefaults.standard.set(true, forKey: "navigatingToReaderView")
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||
let window = windowScene.windows.first,
|
||
let rootVC = window.rootViewController {
|
||
let nextReader = ReaderView(
|
||
moduleId: next.moduleId,
|
||
chapterHref: next.href,
|
||
chapterTitle: next.title,
|
||
chapters: next.chapters,
|
||
mediaTitle: next.mediaTitle,
|
||
chapterNumber: next.chapterNumber
|
||
)
|
||
|
||
|
||
let hostingController = UIHostingController(rootView: nextReader)
|
||
hostingController.modalPresentationStyle = .fullScreen
|
||
hostingController.modalTransitionStyle = .crossDissolve
|
||
|
||
findTopViewController.findViewController(rootVC).present(hostingController, animated: true)
|
||
}
|
||
}
|
||
} else {
|
||
if !htmlContent.isEmpty {
|
||
let validHtmlContent = (!htmlContent.isEmpty &&
|
||
!htmlContent.contains("undefined") &&
|
||
htmlContent.count > 50) ? htmlContent : nil
|
||
|
||
if validHtmlContent == nil {
|
||
Logger.shared.log("Not caching HTML content on disappear as it appears invalid", type: "Warning")
|
||
} else {
|
||
let item = ContinueReadingItem(
|
||
mediaTitle: mediaTitle,
|
||
chapterTitle: chapterTitle,
|
||
chapterNumber: chapterNumber,
|
||
imageUrl: UserDefaults.standard.string(forKey: "novelImageUrl_\(moduleId)_\(mediaTitle)") ?? "",
|
||
href: chapterHref,
|
||
moduleId: moduleId,
|
||
progress: readingProgress,
|
||
totalChapters: chapters.count,
|
||
lastReadDate: Date(),
|
||
cachedHtml: validHtmlContent
|
||
)
|
||
ContinueReadingManager.shared.save(item: item, htmlContent: validHtmlContent)
|
||
Logger.shared.log("Saved HTML content on view disappear for \(chapterHref)", type: "Debug")
|
||
}
|
||
}
|
||
}
|
||
UserDefaults.standard.set(false, forKey: "isReaderActive")
|
||
setStatusBarHidden(false)
|
||
}
|
||
.statusBar(hidden: statusBarHidden)
|
||
}
|
||
|
||
@MainActor
|
||
private func loadContent() {
|
||
do {
|
||
ensureModuleLoaded()
|
||
|
||
if let cachedContent = ContinueReadingManager.shared.getCachedHtml(for: self.chapterHref),
|
||
!cachedContent.isEmpty &&
|
||
!cachedContent.contains("undefined") &&
|
||
cachedContent.count > 50 {
|
||
|
||
Logger.shared.log("Using cached HTML content for \(self.chapterHref)", type: "Debug")
|
||
self.htmlContent = cachedContent
|
||
self.isLoading = false
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||
withAnimation(.easeInOut(duration: 0.3)) {
|
||
self.isHeaderVisible = false
|
||
self.statusBarHidden = true
|
||
self.setStatusBarHidden(true)
|
||
}
|
||
}
|
||
} else {
|
||
Logger.shared.log("No valid cached content found, fetching new content for \(self.chapterHref)", type: "Debug")
|
||
fetchContentWithRetries(attempts: 0, maxAttempts: 3)
|
||
}
|
||
} catch {
|
||
self.error = error
|
||
self.isLoading = false
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
DropManager.shared.showDrop(
|
||
title: "Error Loading Content",
|
||
subtitle: error.localizedDescription,
|
||
duration: 2.0,
|
||
icon: UIImage(systemName: "exclamationmark.triangle")
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func fetchContentWithRetries(attempts: Int, maxAttempts: Int, lastError: Error? = nil) {
|
||
guard attempts < maxAttempts else {
|
||
if let error = lastError {
|
||
self.error = error
|
||
} else {
|
||
self.error = JSError.emptyContent
|
||
}
|
||
self.isLoading = false
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
DropManager.shared.showDrop(
|
||
title: "Error Loading Content",
|
||
subtitle: self.error?.localizedDescription ?? "Failed to load content",
|
||
duration: 2.0,
|
||
icon: UIImage(systemName: "exclamationmark.triangle")
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
JSController.shared.extractText(moduleId: moduleId, href: chapterHref) { result in
|
||
switch result {
|
||
case .success(let content):
|
||
if content.isEmpty || content.contains("undefined") || content.count < 50 {
|
||
Logger.shared.log("Received invalid content on attempt \(attempts + 1), retrying...", type: "Warning")
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
self.fetchContentWithRetries(attempts: attempts + 1, maxAttempts: maxAttempts, lastError: JSError.emptyContent)
|
||
}
|
||
return
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
self.htmlContent = content
|
||
self.isLoading = false
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||
withAnimation(.easeInOut(duration: 0.3)) {
|
||
self.isHeaderVisible = false
|
||
self.statusBarHidden = true
|
||
self.setStatusBarHidden(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let cachedContent = ContinueReadingManager.shared.getCachedHtml(for: self.chapterHref),
|
||
cachedContent.isEmpty || cachedContent.contains("undefined") || cachedContent.count < 50 {
|
||
let item = ContinueReadingItem(
|
||
mediaTitle: self.mediaTitle,
|
||
chapterTitle: self.chapterTitle,
|
||
chapterNumber: self.chapterNumber,
|
||
imageUrl: UserDefaults.standard.string(forKey: "novelImageUrl_\(self.moduleId)_\(self.mediaTitle)") ?? "",
|
||
href: self.chapterHref,
|
||
moduleId: self.moduleId,
|
||
progress: self.readingProgress,
|
||
totalChapters: self.chapters.count,
|
||
lastReadDate: Date(),
|
||
cachedHtml: content
|
||
)
|
||
ContinueReadingManager.shared.save(item: item, htmlContent: content)
|
||
}
|
||
|
||
case .failure(let error):
|
||
Logger.shared.log("Error fetching content on attempt \(attempts + 1): \(error.localizedDescription)", type: "Error")
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
self.fetchContentWithRetries(attempts: attempts + 1, maxAttempts: maxAttempts, lastError: error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopAutoScroll() {
|
||
autoScrollTimer?.invalidate()
|
||
autoScrollTimer = nil
|
||
isAutoScrolling = false
|
||
}
|
||
|
||
private var headerView: some View {
|
||
VStack {
|
||
ZStack(alignment: .top) {
|
||
HStack {
|
||
Button(action: {
|
||
dismiss()
|
||
}) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundColor(currentTheme.text)
|
||
.padding(12)
|
||
.background(currentTheme.background.opacity(0.8))
|
||
.clipShape(Circle())
|
||
.circularGradientOutline()
|
||
.frame(width: 44, height: 44)
|
||
}
|
||
.padding(.leading)
|
||
|
||
Text(chapterTitle)
|
||
.font(.headline)
|
||
.foregroundColor(currentTheme.text)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.padding(.trailing, 100)
|
||
|
||
Spacer()
|
||
|
||
Color.clear
|
||
.frame(width: 44, height: 44)
|
||
.padding(.trailing)
|
||
}
|
||
.opacity(isHeaderVisible ? 1 : 0)
|
||
.offset(y: isHeaderVisible ? 0 : -100)
|
||
.animation(.easeInOut(duration: 0.6), value: isHeaderVisible)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
withAnimation(.easeInOut(duration: 0.6)) {
|
||
isHeaderVisible = false
|
||
isSettingsExpanded = false
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button(action: {
|
||
goToNextChapter()
|
||
}) {
|
||
Image(systemName: "forward.end.fill")
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundColor(currentTheme.text)
|
||
.padding(12)
|
||
.background(currentTheme.background.opacity(0.8))
|
||
.clipShape(Circle())
|
||
.circularGradientOutline()
|
||
.frame(width: 44, height: 44)
|
||
}
|
||
.opacity(isHeaderVisible ? 1 : 0)
|
||
.offset(y: isHeaderVisible ? 0 : -100)
|
||
.animation(.easeInOut(duration: 0.6), value: isHeaderVisible)
|
||
|
||
Button(action: {
|
||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||
isSettingsExpanded.toggle()
|
||
}
|
||
}) {
|
||
Image(systemName: "ellipsis")
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundColor(currentTheme.text)
|
||
.padding(12)
|
||
.background(currentTheme.background.opacity(0.8))
|
||
.clipShape(Circle())
|
||
.circularGradientOutline()
|
||
.frame(width: 44, height: 44)
|
||
.rotationEffect(.degrees(isSettingsExpanded ? 90 : 0))
|
||
}
|
||
.opacity(isHeaderVisible ? 1 : 0)
|
||
.offset(y: isHeaderVisible ? 0 : -100)
|
||
.animation(.easeInOut(duration: 0.6), value: isHeaderVisible)
|
||
}
|
||
.padding(.trailing, 8)
|
||
}
|
||
.padding(.top, (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.windows.first?.safeAreaInsets.top ?? 0))
|
||
.padding(.bottom, 30)
|
||
.background(ProgressiveBlurView())
|
||
.overlay(
|
||
Group {
|
||
if isSettingsExpanded {
|
||
VStack(spacing: 8) {
|
||
Menu {
|
||
VStack {
|
||
Text("Font Size: \(Int(fontSize))pt")
|
||
.font(.headline)
|
||
.padding(.bottom, 8)
|
||
|
||
Slider(value: Binding(
|
||
get: { fontSize },
|
||
set: { newValue in
|
||
fontSize = newValue
|
||
UserDefaults.standard.set(newValue, forKey: "readerFontSize")
|
||
}
|
||
), in: 12...32, step: 1) {
|
||
Text("Font Size")
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
.padding()
|
||
} label: {
|
||
settingsButtonLabel(icon: "textformat.size")
|
||
}
|
||
|
||
Menu {
|
||
ForEach(fontOptions, id: \.0) { font in
|
||
Button(action: {
|
||
selectedFont = font.0
|
||
UserDefaults.standard.set(font.0, forKey: "readerFontFamily")
|
||
}) {
|
||
HStack {
|
||
Text(font.1)
|
||
.font(.custom(font.0, size: 16))
|
||
Spacer()
|
||
if selectedFont == font.0 {
|
||
Image(systemName: "checkmark")
|
||
.foregroundColor(.blue)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
settingsButtonLabel(icon: "textformat.characters")
|
||
}
|
||
|
||
Menu {
|
||
ForEach(weightOptions, id: \.0) { weight in
|
||
Button(action: {
|
||
fontWeight = weight.0
|
||
UserDefaults.standard.set(weight.0, forKey: "readerFontWeight")
|
||
}) {
|
||
HStack {
|
||
Text(weight.1)
|
||
.fontWeight(weight.0 == "300" ? .light :
|
||
weight.0 == "normal" ? .regular :
|
||
weight.0 == "600" ? .semibold : .bold)
|
||
Spacer()
|
||
if fontWeight == weight.0 {
|
||
Image(systemName: "checkmark")
|
||
.foregroundColor(.blue)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
settingsButtonLabel(icon: "bold")
|
||
}
|
||
|
||
Menu {
|
||
ForEach(0..<colorPresets.count, id: \.self) { index in
|
||
Button(action: {
|
||
selectedColorPreset = index
|
||
UserDefaults.standard.set(index, forKey: "readerColorPreset")
|
||
}) {
|
||
Label {
|
||
HStack {
|
||
Text(colorPresets[index].name)
|
||
Spacer()
|
||
if selectedColorPreset == index {
|
||
Image(systemName: "checkmark")
|
||
.foregroundColor(.blue)
|
||
}
|
||
}
|
||
} icon: {
|
||
Circle()
|
||
.fill(Color(hex: colorPresets[index].background))
|
||
.frame(width: 16, height: 16)
|
||
.overlay(
|
||
Circle()
|
||
.stroke(Color(hex: colorPresets[index].text), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
settingsButtonLabel(icon: "paintpalette")
|
||
}
|
||
|
||
Menu {
|
||
VStack {
|
||
Text("Line Spacing: \(String(format: "%.1f", lineSpacing))")
|
||
.font(.headline)
|
||
.padding(.bottom, 8)
|
||
|
||
Slider(value: Binding(
|
||
get: { lineSpacing },
|
||
set: { newValue in
|
||
lineSpacing = newValue
|
||
UserDefaults.standard.set(newValue, forKey: "readerLineSpacing")
|
||
}
|
||
), in: 1.0...3.0, step: 0.1) {
|
||
Text("Line Spacing")
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
.padding()
|
||
} label: {
|
||
Image(systemName: "arrow.left.and.right.text.vertical")
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundColor(currentTheme.text)
|
||
.padding(10)
|
||
.background(currentTheme.background.opacity(0.8))
|
||
.clipShape(Circle())
|
||
.circularGradientOutline()
|
||
.frame(width: 44, height: 44)
|
||
.rotationEffect(.degrees(-90))
|
||
}
|
||
|
||
Menu {
|
||
VStack {
|
||
Text("Margin: \(Int(margin))px")
|
||
.font(.headline)
|
||
.padding(.bottom, 8)
|
||
|
||
Slider(value: Binding(
|
||
get: { margin },
|
||
set: { newValue in
|
||
margin = newValue
|
||
UserDefaults.standard.set(newValue, forKey: "readerMargin")
|
||
}
|
||
), in: 0...30, step: 1) {
|
||
Text("Margin")
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
.padding()
|
||
} label: {
|
||
settingsButtonLabel(icon: "rectangle.inset.filled")
|
||
}
|
||
|
||
Menu {
|
||
ForEach(alignmentOptions, id: \.0) { alignment in
|
||
Button(action: {
|
||
textAlignment = alignment.0
|
||
UserDefaults.standard.set(alignment.0, forKey: "readerTextAlignment")
|
||
}) {
|
||
HStack {
|
||
Image(systemName: alignment.2)
|
||
Text(alignment.1)
|
||
Spacer()
|
||
if textAlignment == alignment.0 {
|
||
Image(systemName: "checkmark")
|
||
.foregroundColor(.blue)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
settingsButtonLabel(icon: "text.alignleft")
|
||
}
|
||
}
|
||
.padding(.top, 80)
|
||
.padding(.trailing, 8)
|
||
.frame(width: 60, alignment: .trailing)
|
||
.transition(.opacity)
|
||
}
|
||
}, alignment: .topTrailing
|
||
)
|
||
|
||
Spacer()
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
|
||
private var footerView: some View {
|
||
VStack {
|
||
Spacer()
|
||
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 20) {
|
||
Spacer()
|
||
Button(action: {
|
||
isAutoScrolling.toggle()
|
||
}) {
|
||
Image(systemName: isAutoScrolling ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 18, weight: .bold))
|
||
.foregroundColor(isAutoScrolling ? .red : currentTheme.text)
|
||
.padding(12)
|
||
.background(currentTheme.background.opacity(0.8))
|
||
.clipShape(Circle())
|
||
.circularGradientOutline()
|
||
}
|
||
.contextMenu {
|
||
VStack {
|
||
Text("Auto Scroll Speed")
|
||
.font(.headline)
|
||
.padding(.bottom, 8)
|
||
|
||
Slider(value: $autoScrollSpeed, in: 0.2...3.0, step: 0.1) {
|
||
Text("Speed")
|
||
}
|
||
.padding(.horizontal)
|
||
|
||
Text("Speed: \(String(format: "%.1f", autoScrollSpeed))x")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
.padding(.top, 4)
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 12)
|
||
|
||
|
||
GeometryReader { geometry in
|
||
ZStack(alignment: .leading) {
|
||
Rectangle()
|
||
.fill(currentTheme.text.opacity(0.2))
|
||
.frame(height: 4)
|
||
|
||
Rectangle()
|
||
.fill(Color.accentColor)
|
||
.frame(width: max(0, min(CGFloat(readingProgress) * geometry.size.width, geometry.size.width)), height: 4)
|
||
|
||
Circle()
|
||
.fill(Color.accentColor)
|
||
.frame(width: 16, height: 16)
|
||
.shadow(color: Color.black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||
.offset(x: max(0, min(CGFloat(readingProgress) * geometry.size.width, geometry.size.width)) - 8)
|
||
}
|
||
.cornerRadius(2)
|
||
.contentShape(Rectangle())
|
||
.gesture(
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { value in
|
||
let percentage = min(max(value.location.x / geometry.size.width, 0), 1)
|
||
scrollToPosition(percentage)
|
||
}
|
||
)
|
||
}
|
||
.frame(height: 24)
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 8)
|
||
.padding(.bottom, (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.windows.first?.safeAreaInsets.bottom ?? 0) + 16)
|
||
|
||
.frame(maxWidth: .infinity)
|
||
.background(ProgressiveBlurView())
|
||
}
|
||
.opacity(isHeaderVisible ? 1 : 0)
|
||
.offset(y: isHeaderVisible ? 0 : 100)
|
||
.animation(.easeInOut(duration: 0.6), value: isHeaderVisible)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
withAnimation(.easeInOut(duration: 0.6)) {
|
||
isHeaderVisible = false
|
||
isSettingsExpanded = false
|
||
}
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
|
||
private func settingsButtonLabel(icon: String) -> some View {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundColor(currentTheme.text)
|
||
.padding(10)
|
||
.background(currentTheme.background.opacity(0.8))
|
||
.clipShape(Circle())
|
||
.circularGradientOutline()
|
||
}
|
||
|
||
private func goToNextChapter() {
|
||
guard let currentIndex = chapters.firstIndex(where: { $0["href"] as? String == chapterHref }),
|
||
currentIndex + 1 < chapters.count else {
|
||
DropManager.shared.showDrop(
|
||
title: NSLocalizedString("No Next Chapter", comment: ""),
|
||
subtitle: "",
|
||
duration: 0.5,
|
||
icon: UIImage(systemName: "xmark.circle")
|
||
)
|
||
return
|
||
}
|
||
|
||
let nextChapter = chapters[currentIndex + 1]
|
||
if let nextHref = nextChapter["href"] as? String,
|
||
let nextTitle = nextChapter["title"] as? String {
|
||
updateReadingProgress(progress: 1.0)
|
||
|
||
navigator.currentChapter = (moduleId: moduleId, href: nextHref, title: nextTitle, chapters: chapters, mediaTitle: mediaTitle, chapterNumber: nextChapter["number"] as? Int ?? 1)
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
private func saveReadingProgress() {
|
||
var novelTitle = self.mediaTitle
|
||
var currentChapterNumber = 1
|
||
var imageUrl = ""
|
||
|
||
Logger.shared.log("Using novel title: \(novelTitle)", type: "Debug")
|
||
|
||
if let savedImageUrl = UserDefaults.standard.string(forKey: "mediaInfoImageUrl_\(moduleId)") {
|
||
imageUrl = savedImageUrl
|
||
Logger.shared.log("Using saved MediaInfoView image URL: \(imageUrl)", type: "Debug")
|
||
}
|
||
|
||
if imageUrl.isEmpty {
|
||
for chapter in chapters {
|
||
for key in ["imageUrl", "coverUrl", "cover", "image", "thumbnail", "posterUrl", "poster"] {
|
||
if let url = chapter[key] as? String, !url.isEmpty {
|
||
imageUrl = url
|
||
Logger.shared.log("Found image URL from key \(key): \(imageUrl)", type: "Debug")
|
||
break
|
||
}
|
||
}
|
||
|
||
if !imageUrl.isEmpty {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if imageUrl.isEmpty, let currentChapter = chapters.first(where: { $0["href"] as? String == chapterHref }) {
|
||
for key in ["imageUrl", "coverUrl", "cover", "image", "thumbnail", "posterUrl", "poster"] {
|
||
if let url = currentChapter[key] as? String, !url.isEmpty {
|
||
imageUrl = url
|
||
Logger.shared.log("Found image URL from current chapter key \(key): \(imageUrl)", type: "Debug")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if novelTitle == "Unknown Novel" {
|
||
for chapter in chapters {
|
||
for key in ["novelTitle", "mediaTitle", "seriesTitle", "series", "bookTitle", "mangaTitle", "title"] {
|
||
if let title = chapter[key] as? String, !title.isEmpty, title != "Chapter" {
|
||
if !title.lowercased().contains("chapter") {
|
||
novelTitle = title
|
||
Logger.shared.log("Extracted title from key \(key): \(novelTitle)", type: "Debug")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if novelTitle != "Unknown Novel" {
|
||
break
|
||
}
|
||
}
|
||
|
||
if novelTitle == "Unknown Novel" && !chapterHref.isEmpty {
|
||
if let url = URL(string: chapterHref) {
|
||
let pathComponents = url.pathComponents
|
||
|
||
for (index, component) in pathComponents.enumerated() {
|
||
if component == "book" || component == "novel" {
|
||
if index + 1 < pathComponents.count {
|
||
let bookTitle = pathComponents[index + 1]
|
||
.replacingOccurrences(of: "-", with: " ")
|
||
.replacingOccurrences(of: "_", with: " ")
|
||
.capitalized
|
||
|
||
if !bookTitle.isEmpty {
|
||
novelTitle = bookTitle
|
||
Logger.shared.log("Extracted title from URL: \(novelTitle)", type: "Debug")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if novelTitle == "Unknown Novel" && !chapterTitle.isEmpty {
|
||
for separator in [" - ", " – ", ": ", " | ", " ~ "] {
|
||
if let range = chapterTitle.range(of: separator) {
|
||
let potentialTitle = chapterTitle[..<range.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !potentialTitle.isEmpty && !potentialTitle.lowercased().contains("chapter") {
|
||
novelTitle = String(potentialTitle)
|
||
Logger.shared.log("Extracted title from chapter title with separator \(separator): \(novelTitle)", type: "Debug")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if novelTitle == "Unknown Novel" && chapterTitle.lowercased().contains("chapter") {
|
||
if let range = chapterTitle.range(of: "Chapter", options: .caseInsensitive) {
|
||
let potentialTitle = chapterTitle[..<range.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !potentialTitle.isEmpty {
|
||
novelTitle = String(potentialTitle)
|
||
Logger.shared.log("Extracted title from chapter title before 'Chapter': \(novelTitle)", type: "Debug")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if let currentIndex = chapters.firstIndex(where: { $0["href"] as? String == chapterHref }) {
|
||
currentChapterNumber = chapters[currentIndex]["number"] as? Int ?? currentIndex + 1
|
||
}
|
||
|
||
if imageUrl.isEmpty {
|
||
imageUrl = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/novel_cover.jpg"
|
||
Logger.shared.log("Using default novel cover image URL", type: "Debug")
|
||
}
|
||
|
||
UserDefaults.standard.set(imageUrl, forKey: "novelImageUrl_\(moduleId)_\(novelTitle)")
|
||
|
||
var progress = UserDefaults.standard.double(forKey: "readingProgress_\(chapterHref)")
|
||
|
||
if progress < 0.01 {
|
||
progress = 0.01
|
||
}
|
||
|
||
Logger.shared.log("Saving continue reading item: title=\(novelTitle), chapter=\(chapterTitle), number=\(currentChapterNumber), href=\(chapterHref), progress=\(progress), imageUrl=\(imageUrl)", type: "Debug")
|
||
|
||
let validHtmlContent = (!htmlContent.isEmpty &&
|
||
!htmlContent.contains("undefined") &&
|
||
htmlContent.count > 50) ? htmlContent : nil
|
||
|
||
if validHtmlContent == nil && !htmlContent.isEmpty {
|
||
Logger.shared.log("Not caching HTML content as it appears invalid", type: "Warning")
|
||
}
|
||
|
||
let item = ContinueReadingItem(
|
||
mediaTitle: novelTitle,
|
||
chapterTitle: chapterTitle,
|
||
chapterNumber: currentChapterNumber,
|
||
imageUrl: imageUrl,
|
||
href: chapterHref,
|
||
moduleId: moduleId,
|
||
progress: progress,
|
||
totalChapters: chapters.count,
|
||
lastReadDate: Date(),
|
||
cachedHtml: validHtmlContent
|
||
)
|
||
|
||
ContinueReadingManager.shared.save(item: item, htmlContent: validHtmlContent)
|
||
}
|
||
|
||
private func updateReadingProgress(progress: Double) {
|
||
let roundedProgress = progress >= 0.95 ? 1.0 : progress
|
||
|
||
UserDefaults.standard.set(roundedProgress, forKey: "readingProgress_\(chapterHref)")
|
||
|
||
let novelTitle = self.mediaTitle
|
||
var currentChapterNumber = 1
|
||
var imageUrl = ""
|
||
|
||
if let savedImageUrl = UserDefaults.standard.string(forKey: "mediaInfoImageUrl_\(moduleId)") {
|
||
imageUrl = savedImageUrl
|
||
} else if let savedImageUrl = UserDefaults.standard.string(forKey: "novelImageUrl_\(moduleId)_\(novelTitle)") {
|
||
imageUrl = savedImageUrl
|
||
}
|
||
|
||
if imageUrl.isEmpty {
|
||
for chapter in chapters {
|
||
for key in ["imageUrl", "coverUrl", "cover", "image", "thumbnail", "posterUrl", "poster"] {
|
||
if let url = chapter[key] as? String, !url.isEmpty {
|
||
imageUrl = url
|
||
break
|
||
}
|
||
}
|
||
|
||
if !imageUrl.isEmpty {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if imageUrl.isEmpty, let currentChapter = chapters.first(where: { $0["href"] as? String == chapterHref }) {
|
||
for key in ["imageUrl", "coverUrl", "cover", "image", "thumbnail", "posterUrl", "poster"] {
|
||
if let url = currentChapter[key] as? String, !url.isEmpty {
|
||
imageUrl = url
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if imageUrl.isEmpty {
|
||
imageUrl = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/novel_cover.jpg"
|
||
}
|
||
|
||
if let currentIndex = chapters.firstIndex(where: { $0["href"] as? String == chapterHref }) {
|
||
currentChapterNumber = chapters[currentIndex]["number"] as? Int ?? currentIndex + 1
|
||
}
|
||
|
||
Logger.shared.log("Updating reading progress: \(roundedProgress) for \(chapterHref), title: \(novelTitle), image: \(imageUrl)", type: "Debug")
|
||
|
||
let validHtmlContent = (!htmlContent.isEmpty &&
|
||
!htmlContent.contains("undefined") &&
|
||
htmlContent.count > 50) ? htmlContent : nil
|
||
|
||
if validHtmlContent == nil && !htmlContent.isEmpty {
|
||
Logger.shared.log("Not caching HTML content as it appears invalid", type: "Warning")
|
||
}
|
||
|
||
let isCompleted = roundedProgress >= 0.98
|
||
|
||
if isCompleted && readingProgress < 0.98 {
|
||
DropManager.shared.showDrop(
|
||
title: NSLocalizedString("Chapter Completed", comment: ""),
|
||
subtitle: "",
|
||
duration: 0.5,
|
||
icon: UIImage(systemName: "checkmark.circle")
|
||
)
|
||
Logger.shared.log("Chapter marked as completed", type: "Debug")
|
||
|
||
ContinueReadingManager.shared.updateProgress(for: chapterHref, progress: roundedProgress, htmlContent: validHtmlContent)
|
||
} else {
|
||
let item = ContinueReadingItem(
|
||
mediaTitle: novelTitle,
|
||
chapterTitle: chapterTitle,
|
||
chapterNumber: currentChapterNumber,
|
||
imageUrl: imageUrl,
|
||
href: chapterHref,
|
||
moduleId: moduleId,
|
||
progress: roundedProgress,
|
||
totalChapters: chapters.count,
|
||
lastReadDate: Date(),
|
||
cachedHtml: validHtmlContent
|
||
)
|
||
|
||
ContinueReadingManager.shared.save(item: item, htmlContent: validHtmlContent)
|
||
}
|
||
}
|
||
|
||
private func setStatusBarHidden(_ hidden: Bool) {
|
||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||
let windows = windowScene.windows
|
||
windows.forEach { window in
|
||
let viewController = window.rootViewController
|
||
viewController?.setNeedsStatusBarAppearanceUpdate()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func scrollToPosition(_ percentage: CGFloat) {
|
||
readingProgress = Double(percentage)
|
||
|
||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||
let window = windowScene.windows.first,
|
||
let rootVC = window.rootViewController {
|
||
let script = """
|
||
(function() {
|
||
const scrollHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||
const scrollPosition = scrollHeight * \(percentage);
|
||
window.scrollTo({
|
||
top: scrollPosition,
|
||
behavior: 'auto'
|
||
});
|
||
return scrollPosition;
|
||
})();
|
||
"""
|
||
|
||
findWebView(in: rootVC.view)?.evaluateJavaScript(script, completionHandler: { _, error in
|
||
if let error = error {
|
||
Logger.shared.log("Error scrolling to position: \(error)", type: "Error")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
private func findWebView(in view: UIView) -> WKWebView? {
|
||
if let webView = view as? WKWebView {
|
||
return webView
|
||
}
|
||
|
||
for subview in view.subviews {
|
||
if let webView = findWebView(in: subview) {
|
||
return webView
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
}
|
||
|
||
struct ColorPreviewCircle: View {
|
||
let backgroundColor: String
|
||
let textColor: String
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [
|
||
Color(hex: backgroundColor),
|
||
Color(hex: textColor)
|
||
],
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct HTMLView: UIViewRepresentable {
|
||
let htmlContent: String
|
||
let fontSize: CGFloat
|
||
let fontFamily: String
|
||
let fontWeight: String
|
||
let textAlignment: String
|
||
let lineSpacing: CGFloat
|
||
let margin: CGFloat
|
||
@Binding var isAutoScrolling: Bool
|
||
let autoScrollSpeed: Double
|
||
let colorPreset: (name: String, background: String, text: String)
|
||
let chapterHref: String?
|
||
|
||
var onProgressChanged: ((Double) -> Void)? = nil
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(self)
|
||
}
|
||
|
||
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
||
coordinator.stopProgressTracking()
|
||
}
|
||
|
||
class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||
var parent: HTMLView
|
||
var scrollTimer: Timer?
|
||
var lastHtmlContent: String = ""
|
||
var lastFontSize: CGFloat = 0
|
||
var lastFontFamily: String = ""
|
||
var lastFontWeight: String = ""
|
||
var lastTextAlignment: String = ""
|
||
var lastLineSpacing: CGFloat = 0
|
||
var lastMargin: CGFloat = 0
|
||
var lastColorPreset: String = ""
|
||
var progressUpdateTimer: Timer?
|
||
weak var webView: WKWebView?
|
||
var savedScrollPosition: Double?
|
||
|
||
init(_ parent: HTMLView) {
|
||
self.parent = parent
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||
Logger.shared.log("WebView finished loading navigation", type: "Debug")
|
||
|
||
webView.evaluateJavaScript("document.body.innerText.length") { result, error in
|
||
if let textLength = result as? Int {
|
||
Logger.shared.log("WebView loaded content with text length: \(textLength)", type: "Debug")
|
||
} else {
|
||
Logger.shared.log("WebView error checking content length: \(error?.localizedDescription ?? "Unknown error")", type: "Error")
|
||
}
|
||
}
|
||
|
||
if let href = parent.chapterHref {
|
||
let savedPosition = UserDefaults.standard.double(forKey: "scrollPosition_\(href)")
|
||
if savedPosition > 0.01 {
|
||
let script = "window.scrollTo(0, document.documentElement.scrollHeight * \(savedPosition));"
|
||
webView.evaluateJavaScript(script, completionHandler: { _, error in
|
||
if let error = error {
|
||
Logger.shared.log("Error restoring scroll position after navigation: \(error)", type: "Error")
|
||
} else {
|
||
Logger.shared.log("Restored scroll position to \(savedPosition) after navigation", type: "Debug")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
startProgressTracking(webView: webView)
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||
Logger.shared.log("WebView navigation failed: \(error.localizedDescription)", type: "Error")
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||
Logger.shared.log("WebView provisional navigation failed: \(error.localizedDescription)", type: "Error")
|
||
}
|
||
|
||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||
if message.name == "scrollHandler", let webView = self.webView {
|
||
updateReadingProgress(webView: webView)
|
||
}
|
||
}
|
||
|
||
func startAutoScroll(webView: WKWebView) {
|
||
stopAutoScroll()
|
||
|
||
scrollTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in
|
||
let scrollAmount = self.parent.autoScrollSpeed * 0.5
|
||
|
||
webView.evaluateJavaScript("window.scrollBy(0, \(scrollAmount));") { _, error in
|
||
if let error = error {
|
||
print("Scroll error: \(error)")
|
||
}
|
||
}
|
||
|
||
webView.evaluateJavaScript("(window.pageYOffset + window.innerHeight) >= document.body.scrollHeight") { result, _ in
|
||
if let isAtBottom = result as? Bool, isAtBottom {
|
||
DispatchQueue.main.async {
|
||
self.parent.isAutoScrolling = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func stopAutoScroll() {
|
||
scrollTimer?.invalidate()
|
||
scrollTimer = nil
|
||
}
|
||
|
||
func startProgressTracking(webView: WKWebView) {
|
||
stopProgressTracking()
|
||
|
||
updateReadingProgress(webView: webView)
|
||
|
||
progressUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self, weak webView] _ in
|
||
guard let strongSelf = self, let webView = webView, webView.window != nil else {
|
||
self?.stopProgressTracking()
|
||
return
|
||
}
|
||
strongSelf.updateReadingProgress(webView: webView)
|
||
}
|
||
|
||
let script = """
|
||
document.addEventListener('scroll', function() {
|
||
window.webkit.messageHandlers.scrollHandler.postMessage('scroll');
|
||
}, { passive: true });
|
||
"""
|
||
|
||
let userScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
|
||
webView.configuration.userContentController.addUserScript(userScript)
|
||
|
||
webView.configuration.userContentController.add(self, name: "scrollHandler")
|
||
}
|
||
|
||
func stopProgressTracking() {
|
||
progressUpdateTimer?.invalidate()
|
||
progressUpdateTimer = nil
|
||
|
||
if let webView = self.webView {
|
||
webView.configuration.userContentController.removeAllUserScripts()
|
||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollHandler")
|
||
}
|
||
}
|
||
|
||
func updateReadingProgress(webView: WKWebView) {
|
||
guard webView.window != nil else {
|
||
stopProgressTracking()
|
||
return
|
||
}
|
||
|
||
let script = """
|
||
(function() {
|
||
var scrollHeight = document.documentElement.scrollHeight;
|
||
var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||
var clientHeight = document.documentElement.clientHeight;
|
||
|
||
var rawProgress = scrollHeight > 0 ? (scrollTop + clientHeight) / scrollHeight : 0;
|
||
|
||
var progress = rawProgress > 0.95 ? 1.0 : rawProgress;
|
||
|
||
return {
|
||
scrollHeight: scrollHeight,
|
||
scrollTop: scrollTop,
|
||
clientHeight: clientHeight,
|
||
progress: progress,
|
||
isAtBottom: (scrollTop + clientHeight >= scrollHeight - 10),
|
||
scrollPosition: scrollTop / scrollHeight
|
||
};
|
||
})();
|
||
"""
|
||
|
||
webView.evaluateJavaScript(script) { [weak self] result, error in
|
||
guard let self = self, let dict = result as? [String: Any],
|
||
let progress = dict["progress"] as? Double else {
|
||
return
|
||
}
|
||
|
||
if let scrollPosition = dict["scrollPosition"] as? Double {
|
||
self.savedScrollPosition = scrollPosition
|
||
|
||
if let href = self.parent.chapterHref {
|
||
UserDefaults.standard.set(scrollPosition, forKey: "scrollPosition_\(href)")
|
||
}
|
||
}
|
||
|
||
if let isAtBottom = dict["isAtBottom"] as? Bool, isAtBottom {
|
||
Logger.shared.log("Reader at bottom of page, setting progress to 100%", type: "Debug")
|
||
self.parent.onProgressChanged?(1.0)
|
||
} else {
|
||
self.parent.onProgressChanged?(progress)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func makeUIView(context: Context) -> WKWebView {
|
||
let webView = WKWebView()
|
||
webView.backgroundColor = .clear
|
||
webView.isOpaque = false
|
||
webView.scrollView.backgroundColor = .clear
|
||
|
||
webView.scrollView.showsHorizontalScrollIndicator = false
|
||
webView.scrollView.bounces = false
|
||
webView.scrollView.alwaysBounceHorizontal = false
|
||
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||
webView.navigationDelegate = context.coordinator
|
||
|
||
context.coordinator.webView = webView
|
||
|
||
return webView
|
||
}
|
||
|
||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||
let coordinator = context.coordinator
|
||
|
||
if isAutoScrolling {
|
||
coordinator.startAutoScroll(webView: webView)
|
||
} else {
|
||
coordinator.stopAutoScroll()
|
||
}
|
||
|
||
if webView.window != nil {
|
||
coordinator.startProgressTracking(webView: webView)
|
||
} else {
|
||
coordinator.stopProgressTracking()
|
||
}
|
||
|
||
guard !htmlContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
Logger.shared.log("HTMLView: Empty HTML content, skipping update", type: "Warning")
|
||
return
|
||
}
|
||
|
||
Logger.shared.log("HTMLView: Updating with content length: \(htmlContent.count)", type: "Debug")
|
||
|
||
let contentChanged = coordinator.lastHtmlContent != htmlContent
|
||
let fontSizeChanged = coordinator.lastFontSize != fontSize
|
||
let fontFamilyChanged = coordinator.lastFontFamily != fontFamily
|
||
let fontWeightChanged = coordinator.lastFontWeight != fontWeight
|
||
let alignmentChanged = coordinator.lastTextAlignment != textAlignment
|
||
let lineSpacingChanged = coordinator.lastLineSpacing != lineSpacing
|
||
let marginChanged = coordinator.lastMargin != margin
|
||
let colorChanged = coordinator.lastColorPreset != colorPreset.name
|
||
|
||
if contentChanged || fontSizeChanged || fontFamilyChanged || fontWeightChanged ||
|
||
alignmentChanged || lineSpacingChanged || marginChanged || colorChanged {
|
||
let htmlTemplate = """
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<style>
|
||
html, body {
|
||
font-family: \(fontFamily), system-ui;
|
||
font-size: \(fontSize)px;
|
||
font-weight: \(fontWeight);
|
||
line-height: \(lineSpacing);
|
||
text-align: \(textAlignment);
|
||
padding: \(margin)px;
|
||
padding-top: calc(\(margin)px + 20px); /* Add extra padding at the top */
|
||
margin: 0;
|
||
color: \(colorPreset.text);
|
||
background-color: \(colorPreset.background);
|
||
transition: all 0.3s ease;
|
||
overflow-x: hidden;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
word-wrap: break-word;
|
||
-webkit-user-select: text;
|
||
-webkit-touch-callout: none;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
body {
|
||
box-sizing: border-box;
|
||
}
|
||
p, div, span, h1, h2, h3, h4, h5, h6 {
|
||
font-size: inherit;
|
||
font-family: inherit;
|
||
font-weight: inherit;
|
||
line-height: inherit;
|
||
text-align: inherit;
|
||
color: inherit;
|
||
max-width: 100%;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
}
|
||
* {
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
\(htmlContent)
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
Logger.shared.log("Loading HTML content into WebView", type: "Debug")
|
||
webView.loadHTMLString(htmlTemplate, baseURL: nil)
|
||
|
||
if let href = chapterHref {
|
||
let savedPosition = UserDefaults.standard.double(forKey: "scrollPosition_\(href)")
|
||
if savedPosition > 0.01 {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
let script = "window.scrollTo(0, document.documentElement.scrollHeight * \(savedPosition));"
|
||
webView.evaluateJavaScript(script, completionHandler: { _, error in
|
||
if let error = error {
|
||
Logger.shared.log("Error restoring scroll position: \(error)", type: "Error")
|
||
} else {
|
||
Logger.shared.log("Restored scroll position to \(savedPosition)", type: "Debug")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
coordinator.lastHtmlContent = htmlContent
|
||
coordinator.lastFontSize = fontSize
|
||
coordinator.lastFontFamily = fontFamily
|
||
coordinator.lastFontWeight = fontWeight
|
||
coordinator.lastTextAlignment = textAlignment
|
||
coordinator.lastLineSpacing = lineSpacing
|
||
coordinator.lastMargin = margin
|
||
coordinator.lastColorPreset = colorPreset.name
|
||
}
|
||
}
|
||
}
|