All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
1233 lines
47 KiB
Swift
1233 lines
47 KiB
Swift
import SwiftUI
|
|
import CoreText
|
|
|
|
// MARK: - Chapter Reader View
|
|
|
|
struct ChapterReaderView: View {
|
|
let slug: String
|
|
let chapterNumber: Int
|
|
|
|
@State private var currentChapter: Int
|
|
@State private var vm: ChapterReaderViewModel
|
|
@State private var readerSettings = ReaderSettingsStore()
|
|
@EnvironmentObject var audioPlayer: AudioPlayerService
|
|
@EnvironmentObject var authStore: AuthStore
|
|
|
|
@State private var chromeVisible = true
|
|
@State private var showSettingsPanel = false
|
|
@State private var showToCSheet = false
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
init(slug: String, chapterNumber: Int) {
|
|
self.slug = slug
|
|
self.chapterNumber = chapterNumber
|
|
_currentChapter = State(initialValue: chapterNumber)
|
|
_vm = State(initialValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Full-bleed background
|
|
readerSettings.settings.theme.backgroundColor
|
|
.ignoresSafeArea()
|
|
|
|
if vm.isLoading {
|
|
ProgressView()
|
|
.tint(readerSettings.settings.theme.textColor)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let content = vm.content {
|
|
if readerSettings.settings.scrollMode {
|
|
ScrollReaderContent(
|
|
content: content,
|
|
readerSettings: readerSettings,
|
|
chromeVisible: $chromeVisible,
|
|
onNavigateChapter: navigateToChapter
|
|
)
|
|
} else {
|
|
PaginatedReaderContent(
|
|
content: content,
|
|
readerSettings: readerSettings,
|
|
chromeVisible: $chromeVisible,
|
|
onNavigateChapter: navigateToChapter
|
|
)
|
|
}
|
|
} else if let errMsg = vm.error {
|
|
readerErrorView(errMsg)
|
|
}
|
|
|
|
// Chrome overlay
|
|
if chromeVisible {
|
|
VStack(spacing: 0) {
|
|
topChrome
|
|
Spacer()
|
|
if let content = vm.content {
|
|
bottomChrome(content: content)
|
|
}
|
|
}
|
|
.transition(.opacity.animation(.easeInOut(duration: 0.22)))
|
|
.ignoresSafeArea(edges: .top)
|
|
}
|
|
}
|
|
.ignoresSafeArea(edges: .all)
|
|
.navigationBarHidden(true)
|
|
.toolbar(.hidden, for: .tabBar)
|
|
.preferredColorScheme(readerSettings.settings.theme.colorScheme)
|
|
.hideMiniPlayer()
|
|
.task(id: currentChapter) { await vm.load() }
|
|
.sheet(isPresented: $showSettingsPanel) {
|
|
ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel)
|
|
.presentationDetents([.height(460)])
|
|
.presentationDragIndicator(.visible)
|
|
.presentationCornerRadius(24)
|
|
.presentationBackground(.regularMaterial)
|
|
}
|
|
.sheet(isPresented: $showToCSheet) {
|
|
if let content = vm.content {
|
|
ChaptersListSheet(
|
|
chapters: content.chapters,
|
|
currentChapter: currentChapter,
|
|
onChapterSelect: { selected in
|
|
showToCSheet = false
|
|
navigateToChapter(selected)
|
|
}
|
|
)
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
|
|
guard let next = note.userInfo?["next"] as? Int,
|
|
let autoNext = note.userInfo?["autoNext"] as? Bool,
|
|
autoNext, currentChapter == audioPlayer.chapter else { return }
|
|
navigateToChapter(next)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in
|
|
guard let next = note.userInfo?["next"] as? Int,
|
|
currentChapter == audioPlayer.chapter else { return }
|
|
navigateToChapter(next)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in
|
|
guard let prev = note.userInfo?["prev"] as? Int,
|
|
currentChapter == audioPlayer.chapter else { return }
|
|
navigateToChapter(prev)
|
|
}
|
|
}
|
|
|
|
// MARK: - Top chrome
|
|
|
|
private var topChrome: some View {
|
|
ZStack(alignment: .bottom) {
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.colorScheme(readerSettings.settings.theme.colorScheme ?? .light)
|
|
.ignoresSafeArea(edges: .top)
|
|
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
// Back
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(readerSettings.settings.theme.textColor)
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.accessibilityLabel("Back")
|
|
|
|
Spacer()
|
|
|
|
// Chapter title
|
|
if let content = vm.content {
|
|
Text(content.chapter.title.strippingTrailingDate())
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85))
|
|
.lineLimit(1)
|
|
.frame(maxWidth: 200)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// ToC + Aa
|
|
HStack(spacing: 0) {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
showToCSheet = true
|
|
} label: {
|
|
Image(systemName: "list.bullet")
|
|
.font(.system(size: 16, weight: .regular))
|
|
.foregroundStyle(readerSettings.settings.theme.textColor)
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.accessibilityLabel("Table of Contents")
|
|
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
|
showSettingsPanel.toggle()
|
|
}
|
|
} label: {
|
|
Text("Aa")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(readerSettings.settings.theme.textColor)
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.accessibilityLabel("Reader Settings")
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.frame(height: 44)
|
|
|
|
// Progress bar
|
|
if let content = vm.content {
|
|
ChapterProgressBar(
|
|
currentChapter: content.chapter.number,
|
|
totalChapters: content.chapters.last?.number ?? content.chapter.number,
|
|
color: accentColor
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
// MARK: - Bottom chrome
|
|
|
|
private func bottomChrome(content: ChapterResponse) -> some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
|
|
// Prev chapter
|
|
if let prev = content.prev {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
navigateToChapter(prev)
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 12, weight: .bold))
|
|
Text("Ch.\(prev)")
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7))
|
|
.frame(minWidth: 64)
|
|
.padding(.vertical, 10)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Previous chapter \(prev)")
|
|
} else {
|
|
Color.clear.frame(width: 64, height: 40)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
// Download
|
|
DownloadAudioButton(
|
|
slug: slug,
|
|
chapter: currentChapter,
|
|
voice: audioPlayer.voice,
|
|
theme: readerSettings.settings.theme
|
|
)
|
|
|
|
// Listen pill
|
|
ListenButton(
|
|
audioPlayer: audioPlayer,
|
|
vm: vm,
|
|
authStore: authStore,
|
|
theme: readerSettings.settings.theme
|
|
)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
// Next chapter
|
|
if let next = content.next {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
navigateToChapter(next)
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text("Ch.\(next)")
|
|
.font(.caption.weight(.semibold))
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .bold))
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(minWidth: 64)
|
|
.padding(.vertical, 10)
|
|
.background(Capsule().fill(accentColor))
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Next chapter \(next)")
|
|
} else {
|
|
Color.clear.frame(width: 64, height: 40)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.colorScheme(readerSettings.settings.theme.colorScheme ?? .light)
|
|
.ignoresSafeArea(edges: .bottom)
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var accentColor: Color {
|
|
readerSettings.settings.theme == .sepia
|
|
? Color(red: 0.65, green: 0.45, blue: 0.15)
|
|
: .amber
|
|
}
|
|
|
|
private func readerErrorView(_ msg: String) -> some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.largeTitle)
|
|
.foregroundStyle(.orange)
|
|
Text(msg)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7))
|
|
.padding(.horizontal)
|
|
Button("Retry") { Task { await vm.load() } }
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.amber)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func navigateToChapter(_ chapter: Int) {
|
|
vm.switchChapter(to: chapter)
|
|
currentChapter = chapter
|
|
}
|
|
}
|
|
|
|
// MARK: - Paginated reader content
|
|
|
|
private struct PaginatedReaderContent: View {
|
|
let content: ChapterResponse
|
|
let readerSettings: ReaderSettingsStore
|
|
@Binding var chromeVisible: Bool
|
|
let onNavigateChapter: (Int) -> Void
|
|
|
|
@State private var pages: [AttributedString] = []
|
|
@State private var currentPage: Int = 0
|
|
@State private var geometrySize: CGSize = .zero
|
|
|
|
private let topReserve: CGFloat = 80
|
|
private let bottomReserve: CGFloat = 64
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let size = geo.size
|
|
TabView(selection: $currentPage) {
|
|
ChapterTitlePage(content: content, readerSettings: readerSettings)
|
|
.tag(-1)
|
|
.onTapGesture { toggleChrome() }
|
|
|
|
ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in
|
|
ReaderPage(
|
|
text: page,
|
|
readerSettings: readerSettings,
|
|
pageNumber: idx + 1,
|
|
totalPages: pages.count
|
|
)
|
|
.tag(idx)
|
|
.onTapGesture { toggleChrome() }
|
|
}
|
|
|
|
ChapterEndPage(
|
|
content: content,
|
|
readerSettings: readerSettings,
|
|
onNavigateChapter: onNavigateChapter
|
|
)
|
|
.tag(pages.count)
|
|
.onTapGesture { toggleChrome() }
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
.onAppear {
|
|
if geometrySize != size {
|
|
geometrySize = size
|
|
repaginate(size: size)
|
|
}
|
|
}
|
|
.onChange(of: size) { _, newSize in
|
|
geometrySize = newSize
|
|
repaginate(size: newSize)
|
|
}
|
|
.onChange(of: readerSettings.settings) { _, _ in
|
|
repaginate(size: geometrySize)
|
|
}
|
|
.onChange(of: content.chapter.number) { _, _ in
|
|
currentPage = -1
|
|
repaginate(size: geometrySize)
|
|
}
|
|
}
|
|
.ignoresSafeArea()
|
|
.onAppear { currentPage = -1 }
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 40, coordinateSpace: .global)
|
|
.onEnded { value in
|
|
let isHorizontal = abs(value.translation.width) > abs(value.translation.height) * 1.5
|
|
guard isHorizontal else { return }
|
|
if value.translation.width > 0, currentPage == -1, let prev = content.prev {
|
|
onNavigateChapter(prev)
|
|
} else if value.translation.width < 0, currentPage == pages.count, let next = content.next {
|
|
onNavigateChapter(next)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func toggleChrome() {
|
|
withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() }
|
|
}
|
|
|
|
private func repaginate(size: CGSize) {
|
|
guard size.width > 0, size.height > 0 else { return }
|
|
let settings = readerSettings.settings
|
|
let hPad: CGFloat = 28
|
|
let textWidth = size.width - hPad * 2
|
|
let textHeight = size.height - topReserve - bottomReserve
|
|
|
|
let attributed = HTMLParser.toAttributedString(
|
|
html: content.html,
|
|
fontSize: settings.fontSize,
|
|
lineSpacing: settings.lineSpacing,
|
|
fontName: settings.font.fontName,
|
|
textColor: settings.theme.textColor
|
|
)
|
|
pages = TextPaginator.paginate(
|
|
attributed: attributed,
|
|
width: textWidth,
|
|
height: textHeight,
|
|
fontSize: settings.fontSize
|
|
)
|
|
if currentPage > pages.count - 1 {
|
|
currentPage = max(0, pages.count - 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Scroll mode reader content
|
|
|
|
private struct ScrollReaderContent: View {
|
|
let content: ChapterResponse
|
|
let readerSettings: ReaderSettingsStore
|
|
@Binding var chromeVisible: Bool
|
|
let onNavigateChapter: (Int) -> Void
|
|
|
|
var body: some View {
|
|
let settings = readerSettings.settings
|
|
let hPad: CGFloat = 24
|
|
let accent: Color = settings.theme == .sepia
|
|
? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
|
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Chapter header
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(content.book.title)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(settings.theme.textColor.opacity(0.45))
|
|
.textCase(.uppercase)
|
|
.tracking(1.2)
|
|
Rectangle()
|
|
.fill(accent.opacity(0.6))
|
|
.frame(width: 36, height: 2)
|
|
Text(content.chapter.title.strippingTrailingDate())
|
|
.font(.system(size: 22, weight: .bold, design: .serif))
|
|
.foregroundStyle(settings.theme.textColor)
|
|
if !content.chapter.dateLabel.isEmpty {
|
|
Text(content.chapter.dateLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(settings.theme.textColor.opacity(0.4))
|
|
}
|
|
}
|
|
.padding(.horizontal, hPad)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 20)
|
|
|
|
// Body
|
|
let attributed = HTMLParser.toAttributedString(
|
|
html: content.html,
|
|
fontSize: settings.fontSize,
|
|
lineSpacing: settings.lineSpacing,
|
|
fontName: settings.font.fontName,
|
|
textColor: settings.theme.textColor
|
|
)
|
|
Text(attributed)
|
|
.padding(.horizontal, hPad)
|
|
|
|
// Footer
|
|
VStack(spacing: 16) {
|
|
Divider().padding(.horizontal, hPad)
|
|
if let next = content.next {
|
|
Button { onNavigateChapter(next) } label: {
|
|
HStack {
|
|
Text("Next Chapter")
|
|
.fontWeight(.semibold)
|
|
Image(systemName: "arrow.right")
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 50)
|
|
.background(Capsule().fill(accent))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, hPad)
|
|
}
|
|
}
|
|
.padding(.vertical, 24)
|
|
.padding(.bottom, 80)
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .top) { Color.clear.frame(height: 52) }
|
|
.background(settings.theme.backgroundColor)
|
|
.ignoresSafeArea()
|
|
.onTapGesture {
|
|
withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Individual reader page (paginated mode)
|
|
|
|
private struct ReaderPage: View {
|
|
let text: AttributedString
|
|
let readerSettings: ReaderSettingsStore
|
|
let pageNumber: Int
|
|
let totalPages: Int
|
|
|
|
var body: some View {
|
|
let settings = readerSettings.settings
|
|
let hPad: CGFloat = 28
|
|
let topPad: CGFloat = 80
|
|
let bottomPad: CGFloat = 56
|
|
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .bottom) {
|
|
Text(text)
|
|
.frame(width: geo.size.width - hPad * 2, alignment: .topLeading)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.padding(.horizontal, hPad)
|
|
.padding(.top, topPad)
|
|
.padding(.bottom, bottomPad)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Text("\(pageNumber) of \(totalPages)")
|
|
.font(.system(size: 11, weight: .regular).monospacedDigit())
|
|
.foregroundStyle(settings.theme.textColor.opacity(0.3))
|
|
.padding(.bottom, bottomPad - 24)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(settings.theme.backgroundColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapter title page
|
|
|
|
private struct ChapterTitlePage: View {
|
|
let content: ChapterResponse
|
|
let readerSettings: ReaderSettingsStore
|
|
|
|
private var totalChapters: Int {
|
|
content.chapters.last?.number ?? content.chapter.number
|
|
}
|
|
|
|
private var progressPercent: Int {
|
|
guard totalChapters > 1 else { return 100 }
|
|
return Int((Double(content.chapter.number) / Double(totalChapters)) * 100)
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
readerSettings.settings.theme == .sepia
|
|
? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
|
}
|
|
|
|
var body: some View {
|
|
let settings = readerSettings.settings
|
|
GeometryReader { geo in
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Spacer()
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(content.book.title)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(settings.theme.textColor.opacity(0.45))
|
|
.textCase(.uppercase)
|
|
.tracking(1.4)
|
|
.lineLimit(2)
|
|
|
|
Rectangle()
|
|
.fill(accentColor)
|
|
.frame(width: 36, height: 2)
|
|
.clipShape(Capsule())
|
|
|
|
Text(content.chapter.title.strippingTrailingDate())
|
|
.font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif))
|
|
.foregroundStyle(settings.theme.textColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.lineSpacing(4)
|
|
|
|
HStack(spacing: 8) {
|
|
if !content.chapter.dateLabel.isEmpty {
|
|
Text(content.chapter.dateLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(settings.theme.textColor.opacity(0.4))
|
|
}
|
|
if totalChapters > 1 {
|
|
if !content.chapter.dateLabel.isEmpty {
|
|
Circle()
|
|
.fill(settings.theme.textColor.opacity(0.25))
|
|
.frame(width: 3, height: 3)
|
|
}
|
|
Text("\(progressPercent)% through")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(accentColor.opacity(0.85))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 36)
|
|
|
|
Spacer()
|
|
Spacer()
|
|
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "arrow.right")
|
|
.font(.caption2.weight(.semibold))
|
|
Text("Swipe to read")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(settings.theme.textColor.opacity(0.5))
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.bottom, 96)
|
|
.phaseAnimator([false, true]) { v, phase in
|
|
v.offset(x: phase ? 4 : -2).opacity(phase ? 0.55 : 0.15)
|
|
} animation: { _ in .easeInOut(duration: 0.9) }
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.background(settings.theme.backgroundColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapter end page
|
|
|
|
private struct ChapterEndPage: View {
|
|
let content: ChapterResponse
|
|
let readerSettings: ReaderSettingsStore
|
|
let onNavigateChapter: (Int) -> Void
|
|
|
|
@State private var appeared = false
|
|
|
|
private var accentColor: Color {
|
|
readerSettings.settings.theme == .sepia
|
|
? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
|
}
|
|
|
|
var body: some View {
|
|
let settings = readerSettings.settings
|
|
VStack(spacing: 32) {
|
|
Spacer()
|
|
|
|
VStack(spacing: 20) {
|
|
ZStack {
|
|
Circle().fill(accentColor.opacity(0.07)).frame(width: 96, height: 96)
|
|
Circle().fill(accentColor.opacity(0.14)).frame(width: 72, height: 72)
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 28, weight: .semibold))
|
|
.foregroundStyle(accentColor)
|
|
.symbolEffect(.bounce, value: appeared)
|
|
}
|
|
.scaleEffect(appeared ? 1 : 0.7)
|
|
.opacity(appeared ? 1 : 0)
|
|
.animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared)
|
|
|
|
VStack(spacing: 6) {
|
|
Text("Chapter \(content.chapter.number)")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(accentColor)
|
|
.textCase(.uppercase)
|
|
.tracking(1.2)
|
|
Text("Complete")
|
|
.font(.title2.bold())
|
|
.foregroundStyle(settings.theme.textColor)
|
|
if content.next == nil {
|
|
Text("You've reached the latest chapter")
|
|
.font(.subheadline)
|
|
.foregroundStyle(settings.theme.textColor.opacity(0.4))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 10)
|
|
.animation(.easeOut(duration: 0.35).delay(0.15), value: appeared)
|
|
}
|
|
|
|
if let next = content.next {
|
|
Button { onNavigateChapter(next) } label: {
|
|
HStack(spacing: 8) {
|
|
Text("Chapter \(next)").fontWeight(.semibold)
|
|
Image(systemName: "arrow.right").font(.system(size: 14, weight: .semibold))
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(height: 52)
|
|
.frame(maxWidth: 240)
|
|
.background(Capsule().fill(accentColor))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 12)
|
|
.animation(.easeOut(duration: 0.35).delay(0.25), value: appeared)
|
|
.accessibilityLabel("Go to chapter \(next)")
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(settings.theme.backgroundColor)
|
|
.onAppear { appeared = true }
|
|
.onDisappear { appeared = false }
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapter progress bar
|
|
|
|
private struct ChapterProgressBar: View {
|
|
let currentChapter: Int
|
|
let totalChapters: Int
|
|
let color: Color
|
|
|
|
private var progress: Double {
|
|
guard totalChapters > 1 else { return 1.0 }
|
|
return Double(currentChapter) / Double(totalChapters)
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Rectangle().fill(color.opacity(0.10))
|
|
Rectangle()
|
|
.fill(LinearGradient(
|
|
colors: [color.opacity(0.7), color],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
))
|
|
.frame(width: geo.size.width * progress)
|
|
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress)
|
|
}
|
|
}
|
|
.frame(height: 3)
|
|
}
|
|
}
|
|
|
|
// MARK: - Listen button
|
|
|
|
/// Isolated sub-view to avoid re-rendering ChapterReaderView on every audioPlayer update.
|
|
private struct ListenButton: View {
|
|
@ObservedObject var audioPlayer: AudioPlayerService
|
|
let vm: ChapterReaderViewModel
|
|
@ObservedObject var authStore: AuthStore
|
|
let theme: ReaderTheme
|
|
|
|
private var isActive: Bool {
|
|
audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter
|
|
}
|
|
|
|
private var isGenerating: Bool {
|
|
audioPlayer.status == .generating
|
|
&& audioPlayer.slug == vm.slug
|
|
&& audioPlayer.chapter == vm.chapter
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
|
}
|
|
|
|
var body: some View {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
|
|
} label: {
|
|
HStack(spacing: 7) {
|
|
if isGenerating {
|
|
ProgressView()
|
|
.scaleEffect(0.75)
|
|
.tint(isActive ? .white : accentColor)
|
|
} else {
|
|
Image(systemName: isActive ? "waveform" : "headphones")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.contentTransition(.symbolEffect(.replace.downUp))
|
|
.symbolEffect(.variableColor.cumulative, isActive: isActive)
|
|
}
|
|
Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen"))
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
.foregroundStyle(isActive ? .white : accentColor)
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 10)
|
|
.background(Capsule().fill(isActive ? accentColor : accentColor.opacity(0.13)))
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating)
|
|
.accessibilityLabel(isGenerating ? "Generating audio" : (isActive ? "Pause audio" : "Listen"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Download audio button
|
|
|
|
private struct DownloadAudioButton: View {
|
|
let slug: String
|
|
let chapter: Int
|
|
let voice: String
|
|
let theme: ReaderTheme
|
|
|
|
@EnvironmentObject private var downloadService: AudioDownloadService
|
|
|
|
private var key: String { "\(slug)::\(chapter)::\(voice)" }
|
|
private var isDownloaded: Bool { downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) }
|
|
private var progress: Double? { downloadService.downloads[key]?.progress }
|
|
|
|
private var accentColor: Color {
|
|
theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
|
}
|
|
private var iconColor: Color { theme.textColor.opacity(0.6) }
|
|
|
|
var body: some View {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
if isDownloaded {
|
|
try? downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice)
|
|
} else if downloadService.downloads[key] != nil {
|
|
downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice)
|
|
} else {
|
|
Task { try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) }
|
|
}
|
|
} label: {
|
|
Group {
|
|
if let frac = progress {
|
|
// In-progress ring
|
|
ZStack {
|
|
Circle().stroke(accentColor.opacity(0.2), lineWidth: 2)
|
|
.frame(width: 22, height: 22)
|
|
Circle().trim(from: 0, to: frac)
|
|
.stroke(accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
|
.frame(width: 22, height: 22)
|
|
.rotationEffect(.degrees(-90))
|
|
.animation(.linear(duration: 0.2), value: frac)
|
|
}
|
|
} else {
|
|
Image(systemName: isDownloaded ? "arrow.down.circle.fill" : "arrow.down.circle")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(isDownloaded ? accentColor : iconColor)
|
|
.contentTransition(.symbolEffect(.replace.downUp))
|
|
}
|
|
}
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(isDownloaded ? "Delete downloaded audio" : "Download audio")
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapters list sheet (ToC)
|
|
|
|
private struct ChaptersListSheet: View {
|
|
let chapters: [ChapterBrief]
|
|
let currentChapter: Int
|
|
let onChapterSelect: (Int) -> Void
|
|
|
|
@State private var searchText = ""
|
|
|
|
private var filtered: [ChapterBrief] {
|
|
guard !searchText.isEmpty else { return chapters }
|
|
return chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List(filtered) { ch in
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
onChapterSelect(ch.number)
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(ch.title)
|
|
.font(.subheadline)
|
|
.foregroundStyle(ch.number == currentChapter ? Color.amber : .primary)
|
|
Text("Chapter \(ch.number)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if ch.number == currentChapter {
|
|
Image(systemName: "bookmark.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(Color.amber)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.listStyle(.plain)
|
|
.searchable(text: $searchText, prompt: "Search chapters")
|
|
.navigationTitle("Chapters")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Reader settings panel
|
|
|
|
struct ReaderSettingsPanel: View {
|
|
@ObservedObject var store: ReaderSettingsStore
|
|
@Binding var isPresented: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Capsule()
|
|
.fill(Color(.systemGray4))
|
|
.frame(width: 36, height: 5)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 18)
|
|
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
VStack(spacing: 22) {
|
|
|
|
// Font size
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ReaderSectionLabel("Font Size")
|
|
HStack(spacing: 0) {
|
|
Button { adjustFontSize(-1) } label: {
|
|
Text("A").font(.system(size: 13, weight: .regular))
|
|
.frame(width: 44, height: 44).contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain).foregroundStyle(.primary)
|
|
Slider(
|
|
value: Binding(
|
|
get: { store.settings.fontSize },
|
|
set: { v in var s = store.settings; s.fontSize = v; store.update(s) }
|
|
),
|
|
in: 12...26, step: 1
|
|
)
|
|
.tint(.amber)
|
|
.padding(.horizontal, 8)
|
|
Button { adjustFontSize(1) } label: {
|
|
Text("A").font(.system(size: 21, weight: .semibold))
|
|
.frame(width: 44, height: 44).contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain).foregroundStyle(.primary)
|
|
}
|
|
}
|
|
|
|
Divider().padding(.horizontal, 4)
|
|
|
|
// Font family
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ReaderSectionLabel("Font")
|
|
HStack(spacing: 8) {
|
|
ForEach(ReaderFont.allCases, id: \.self) { font in
|
|
ReaderFontChip(font: font, isSelected: store.settings.font == font) {
|
|
var s = store.settings; s.font = font; store.update(s)
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider().padding(.horizontal, 4)
|
|
|
|
// Theme
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ReaderSectionLabel("Theme")
|
|
HStack(spacing: 8) {
|
|
ForEach(ReaderTheme.allCases, id: \.self) { theme in
|
|
ReaderThemeChip(theme: theme, isSelected: store.settings.theme == theme) {
|
|
var s = store.settings; s.theme = theme; store.update(s)
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider().padding(.horizontal, 4)
|
|
|
|
// Line spacing
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ReaderSectionLabel("Line Spacing")
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "text.alignleft")
|
|
.font(.system(size: 13)).foregroundStyle(.secondary).frame(width: 28)
|
|
Slider(
|
|
value: Binding(
|
|
get: { store.settings.lineSpacing },
|
|
set: { v in var s = store.settings; s.lineSpacing = v; store.update(s) }
|
|
),
|
|
in: 1.2...2.4, step: 0.1
|
|
)
|
|
.tint(.amber)
|
|
Image(systemName: "text.alignleft")
|
|
.font(.system(size: 20)).foregroundStyle(.secondary).frame(width: 28)
|
|
}
|
|
}
|
|
|
|
Divider().padding(.horizontal, 4)
|
|
|
|
// Scroll vs pages
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(store.settings.scrollMode ? "Scroll" : "Pages")
|
|
.font(.subheadline.weight(.medium))
|
|
Text(store.settings.scrollMode
|
|
? "Continuous vertical scroll"
|
|
: "Swipe horizontally between pages")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Toggle("", isOn: Binding(
|
|
get: { store.settings.scrollMode },
|
|
set: { v in
|
|
var s = store.settings; s.scrollMode = v; store.update(s)
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
}
|
|
))
|
|
.tint(.amber)
|
|
.labelsHidden()
|
|
}
|
|
|
|
Color.clear.frame(height: 8)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func adjustFontSize(_ delta: CGFloat) {
|
|
var s = store.settings
|
|
s.fontSize = max(12, min(26, s.fontSize + delta))
|
|
store.update(s)
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
}
|
|
}
|
|
|
|
// MARK: - ReaderSettingsStore
|
|
|
|
final class ReaderSettingsStore: ObservableObject {
|
|
@Published private(set) var settings: ReaderSettings
|
|
|
|
init() { settings = ReaderSettings.load() }
|
|
|
|
func update(_ new: ReaderSettings) {
|
|
settings = new
|
|
new.save()
|
|
}
|
|
}
|
|
|
|
// MARK: - Settings sub-components
|
|
|
|
private struct ReaderSectionLabel: View {
|
|
let title: String
|
|
init(_ title: String) { self.title = title }
|
|
var body: some View {
|
|
Text(title)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
.tracking(0.8)
|
|
}
|
|
}
|
|
|
|
private struct ReaderFontChip: View {
|
|
let font: ReaderFont
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Text(font.rawValue)
|
|
.font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15))
|
|
.frame(maxWidth: .infinity).frame(height: 46)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5)
|
|
)
|
|
)
|
|
.foregroundStyle(isSelected ? Color.amber : .primary)
|
|
.scaleEffect(isSelected ? 1.03 : 1.0)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected)
|
|
}
|
|
}
|
|
|
|
private struct ReaderThemeChip: View {
|
|
let theme: ReaderTheme
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
private var label: String {
|
|
switch theme {
|
|
case .white: return "White"
|
|
case .sepia: return "Sepia"
|
|
case .night: return "Night"
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Text(label)
|
|
.font(.subheadline.weight(isSelected ? .semibold : .regular))
|
|
.frame(maxWidth: .infinity).frame(height: 46)
|
|
.background(theme.backgroundColor)
|
|
.foregroundStyle(theme.textColor)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(isSelected ? Color.amber : Color(.systemGray4),
|
|
lineWidth: isSelected ? 2 : 1)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.scaleEffect(isSelected ? 1.03 : 1.0)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected)
|
|
}
|
|
}
|
|
|
|
// MARK: - HTML → AttributedString parser
|
|
|
|
enum HTMLParser {
|
|
static func stripLeadingChapterHeader(from html: String) -> String {
|
|
var result = html
|
|
for _ in 0..<3 {
|
|
let pattern = #"^(\s*<p[^>]*>)(.*?)(</p>)"#
|
|
guard let regex = try? NSRegularExpression(
|
|
pattern: pattern, options: [.dotMatchesLineSeparators, .caseInsensitive]
|
|
) else { break }
|
|
|
|
guard let match = regex.firstMatch(
|
|
in: result, range: NSRange(result.startIndex..., in: result)
|
|
) else { break }
|
|
|
|
let innerRange = match.range(at: 2)
|
|
guard innerRange.location != NSNotFound,
|
|
let swiftRange = Range(innerRange, in: result) else { break }
|
|
|
|
let inner = String(result[swiftRange])
|
|
let plain = inner
|
|
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
guard plain.range(of: #"^\d*\s*[Cc]hapter\s+\d+"#, options: .regularExpression) != nil
|
|
else { break }
|
|
|
|
guard let fullRange = Range(match.range(at: 0), in: result) else { break }
|
|
result.removeSubrange(fullRange)
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func toAttributedString(
|
|
html: String,
|
|
fontSize: CGFloat,
|
|
lineSpacing: CGFloat,
|
|
fontName: String?,
|
|
textColor: Color
|
|
) -> AttributedString {
|
|
let uiFont: UIFont = fontName.flatMap { UIFont(name: $0, size: fontSize) }
|
|
?? UIFont.systemFont(ofSize: fontSize)
|
|
|
|
let uiColor = UIColor(textColor)
|
|
let paragraphStyle = NSMutableParagraphStyle()
|
|
paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize
|
|
paragraphStyle.paragraphSpacing = fontSize * 0.7
|
|
|
|
let cleanedHtml = stripLeadingChapterHeader(from: html)
|
|
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
|
.documentType: NSAttributedString.DocumentType.html,
|
|
.characterEncoding: String.Encoding.utf8.rawValue
|
|
]
|
|
|
|
let nsAttr: NSMutableAttributedString
|
|
if let parsed = try? NSMutableAttributedString(
|
|
data: Data(cleanedHtml.utf8), options: options, documentAttributes: nil
|
|
) {
|
|
nsAttr = parsed
|
|
} else {
|
|
let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
|
nsAttr = NSMutableAttributedString(string: plain)
|
|
}
|
|
|
|
let fullRange = NSRange(location: 0, length: nsAttr.length)
|
|
nsAttr.addAttribute(.font, value: uiFont, range: fullRange)
|
|
nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange)
|
|
nsAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange)
|
|
|
|
return (try? AttributedString(nsAttr, including: \.uiKit)) ?? AttributedString(nsAttr.string)
|
|
}
|
|
}
|
|
|
|
// MARK: - Text paginator
|
|
|
|
enum TextPaginator {
|
|
static func paginate(
|
|
attributed: AttributedString,
|
|
width: CGFloat,
|
|
height: CGFloat,
|
|
fontSize: CGFloat
|
|
) -> [AttributedString] {
|
|
guard width > 0, height > 0 else { return [attributed] }
|
|
let nsAttr = NSAttributedString(attributed)
|
|
guard nsAttr.length > 0 else { return [] }
|
|
|
|
let framesetter = CTFramesetterCreateWithAttributedString(nsAttr)
|
|
let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil)
|
|
|
|
var pages: [AttributedString] = []
|
|
var startIndex = 0
|
|
let totalLength = nsAttr.length
|
|
var guard_ = 0
|
|
|
|
while startIndex < totalLength {
|
|
guard_ += 1
|
|
if guard_ > 2000 { break }
|
|
|
|
let range = CFRange(location: startIndex, length: totalLength - startIndex)
|
|
let frame = CTFramesetterCreateFrame(framesetter, range, path, nil)
|
|
let visible = CTFrameGetVisibleStringRange(frame)
|
|
|
|
let pageLength = visible.length > 0 ? visible.length : max(1, totalLength - startIndex)
|
|
let endIndex = min(startIndex + pageLength, totalLength)
|
|
|
|
let pageAttr = nsAttr.attributedSubstring(from: NSRange(location: startIndex, length: endIndex - startIndex))
|
|
if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) {
|
|
pages.append(pageAS)
|
|
}
|
|
|
|
if visible.length <= 0 { break }
|
|
startIndex = endIndex
|
|
}
|
|
|
|
return pages.isEmpty ? [attributed] : pages
|
|
}
|
|
}
|
|
|
|
|