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
- 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
243 lines
8.9 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|