Something went wrong. Try again.
forked from cranci1/sora
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478//// ReaderView.swift// Sora//// Created by paul on 18/06/25.//
import SwiftUIimport 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 = .overFullScreen hostingController.modalTransitionStyle = .crossDissolve hostingController.isModalInPresentation = true 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: { dismissReaderView() }) { 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 dismissReaderView() { dismiss() if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { let topVC = findTopViewController.findViewController(rootVC) if topVC is UIHostingController<ReaderView> { topVC.dismiss(animated: true) } } } 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 = """ (function() { let lastUpdate = 0; const throttleInterval = 16; function updateProgress() { const now = Date.now(); if (now - lastUpdate >= throttleInterval) { window.webkit.messageHandlers.scrollHandler.postMessage('scroll'); lastUpdate = now; } requestAnimationFrame(updateProgress); } requestAnimationFrame(updateProgress); document.addEventListener('scroll', function() { requestAnimationFrame(updateProgress); }, { 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 } }}