Files
libnovel/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

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
}
}