Files
libnovel/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift
Admin 2793ad8cfa
Some checks failed
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 10s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
iOS: codebase cleanup — remove dead code, unify design, deduplicate patterns
- Remove swift-markdown-ui dependency (project.yml, pbxproj, Package.resolved)
- Remove all debug print() statements (APIClient, BrowseViewModel, ChapterReaderViewModel, ChapterReaderView)
- Remove dead UI stubs: isFavorited/isLiked state, heart/star buttons, empty Share/Add to Library menu items in FullPlayerView
- Remove unused audioToolbarButton toolbar builder in ChapterReaderView
- Remove unused model types: NovelListing, BookDetailData, ChapterContent
- Remove amberLight color (never referenced)
- Remove mini player interactive seek (horizontal drag/tap to seek) — progress bar is now display-only
- Fix AccentColor asset to amber #f59e0b (was orange-pink mismatch)
- Fix Discover tab icon: globe → globe.americas.fill
- Fix speed slider max: 3.0 → 2.0 in ProfileView
- Fix yearText: use static DateFormatter instead of allocating on every access
- Fix ChangePasswordView.save(): DispatchQueue.main.asyncAfter → Task.sleep
- Deduplicate navigationDestination blocks via .appNavigationDestination() View extension
- Deduplicate .alert(Error) pattern via .errorAlert() View extension
2026-03-08 15:30:43 +05:00

243 lines
8.9 KiB
Swift

import SwiftUI
import WebKit
// MARK: - Chapter Reader
struct ChapterReaderView: View {
let slug: String
let chapterNumber: Int
/// Tracks the currently displayed chapter updated in-place by skip/auto-next
/// so we never accumulate stale listeners on the navigation stack.
@State private var currentChapter: Int
@StateObject private var vm: ChapterReaderViewModel
@EnvironmentObject var audioPlayer: AudioPlayerService
@EnvironmentObject var authStore: AuthStore
init(slug: String, chapterNumber: Int) {
self.slug = slug
self.chapterNumber = chapterNumber
_currentChapter = State(initialValue: chapterNumber)
_vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
}
var body: some View {
Group {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let content = vm.content {
readerContent(content)
} else if let errMsg = vm.error {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.orange)
Text(errMsg)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
Button("Retry") { Task { await vm.load() } }
.buttonStyle(.borderedProminent)
.tint(.amber)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Color.clear
}
}
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
.navigationBarTitleDisplayMode(.inline)
.overlay(alignment: .bottomTrailing) {
// Floating audio button when player is not active
if !audioPlayer.isActive {
floatingAudioButton
}
}
.task(id: currentChapter) {
await vm.load()
}
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
guard let next = note.userInfo?["next"] as? Int else { return }
let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false
// Only handle if this is the top-most (currently active) chapter view
guard shouldAutoNavigate, currentChapter == audioPlayer.chapter else { return }
navigateToChapter(next)
}
.onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in
guard let next = note.userInfo?["next"] as? Int else { return }
// Only the view whose chapter matches the currently playing chapter should handle this
guard currentChapter == audioPlayer.chapter else { return }
navigateToChapter(next)
}
.onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in
guard let prev = note.userInfo?["prev"] as? Int else { return }
guard currentChapter == audioPlayer.chapter else { return }
navigateToChapter(prev)
}
}
/// Navigate to a chapter in-place: reloads content without pushing to the navigation stack.
/// Back button always returns to BookDetailView regardless of how many chapters were visited.
private func navigateToChapter(_ chapter: Int) {
vm.switchChapter(to: chapter)
currentChapter = chapter
}
// MARK: - Content
@State private var webHeight: CGFloat = 800
@ViewBuilder
private func readerContent(_ content: ChapterResponse) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(content.chapter.title.strippingTrailingDate())
.font(.title2.bold())
if !content.chapter.dateLabel.isEmpty {
Text(content.chapter.dateLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
Divider()
// Chapter body
HTMLContentView(html: content.html, height: $webHeight)
.frame(height: webHeight)
.padding(.horizontal)
Divider()
// Prev / Next navigation in-place swap so back button always returns to book
HStack(spacing: 12) {
if let prev = content.prev {
Button {
navigateToChapter(prev)
} label: {
Label("Ch.\(prev)", systemImage: "chevron.left")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
if let next = content.next {
Button {
navigateToChapter(next)
} label: {
Label("Ch.\(next)", systemImage: "chevron.right")
.labelStyle(ReverseLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
}
.padding()
}
.padding(.vertical)
}
// Ensure the Prev/Next buttons clear the mini-player bar when it is visible.
.safeAreaInset(edge: .bottom) {
if audioPlayer.isActive {
Color.clear.frame(height: AppLayout.miniPlayerBarHeight)
}
}
}
// MARK: - Floating audio button
@ViewBuilder
private var floatingAudioButton: some View {
Button {
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
} label: {
HStack(spacing: 8) {
Image(systemName: "play.circle.fill")
.font(.system(size: 22))
Text("Listen")
.font(.subheadline.weight(.semibold))
}
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
Capsule()
.fill(Color.amber)
.shadow(color: .black.opacity(0.25), radius: 8, y: 4)
)
}
.buttonStyle(.plain)
.padding(.trailing, 20)
.padding(.bottom, 20)
}
}
// MARK: - HTML content renderer using WKWebView
struct HTMLContentView: UIViewRepresentable {
let html: String
@Binding var height: CGFloat
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView {
let wv = WKWebView()
wv.scrollView.isScrollEnabled = false
wv.isOpaque = false
wv.backgroundColor = .clear
wv.scrollView.backgroundColor = .clear
wv.navigationDelegate = context.coordinator
return wv
}
func updateUIView(_ uiView: WKWebView, context: Context) {
let isDark = UITraitCollection.current.userInterfaceStyle == .dark
let textColor = isDark ? "#e5e5e5" : "#1a1a1a"
let css = """
body {
font-family: -apple-system, Georgia, serif;
font-size: 17px;
line-height: 1.7;
color: \(textColor);
background: transparent;
margin: 0; padding: 0;
word-break: break-word;
}
p { margin: 0 0 1em 0; }
"""
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
uiView.loadHTMLString(wrapped, baseURL: nil)
}
class Coordinator: NSObject, WKNavigationDelegate {
var parent: HTMLContentView
init(_ parent: HTMLContentView) { self.parent = parent }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript("document.body.scrollHeight") { result, error in
DispatchQueue.main.async {
if let h = result as? CGFloat, h > 0 {
self.parent.height = h
} else if let h = result as? Double, h > 0 {
self.parent.height = CGFloat(h)
}
}
}
}
}
}
// MARK: - Reverse label style (icon on right)
struct ReverseLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.title
configuration.icon
}
}
}