Files
legado-ios/App/Features/Reader/ReaderView.swift
T

2141 lines
94 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@preconcurrency import AVFoundation
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct ReaderView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
@EnvironmentObject private var replaceRules: ReplaceRuleLibraryModel
@EnvironmentObject private var activity: ReadingActivityModel
@StateObject private var model: ReaderViewModel
private var book: Book { model.book }
@State private var sourceSwitchBook: Book?
@StateObject private var speech = ReaderSpeechController()
@State private var showsTableOfContents = false
@State private var showsBookmarks = false
@State private var showsAutoReadControls = false
@State private var showsStyleControls = false
@State private var isAutoReading = false
@State private var showsReaderMenu = false
@State private var followsSystemBrightness = true
@State private var readerBrightness = Double(UIScreen.main.brightness)
@State private var brightnessBeforeReading = Double(UIScreen.main.brightness)
@State private var tocSearch = ""
@State private var tocDescending = false
@State private var showsContentSearch = false
@State private var showsReplaceRules = false
@State private var showsReaderSettings = false
@State private var showsClickActionSettings = false
@State private var showsContentEditor = false
@State private var editedContent = ""
@State private var contentEditError: String?
@State private var backgroundImage: UIImage?
@State private var backgroundError: String?
@State private var showsFontImporter = false
@State private var fontError: String?
@State private var readerJumpPosition: Int?
@State private var contentSearchQuery = ""
@State private var readerPosition = 0
@State private var readerPageRanges: [NSRange] = []
@State private var speechPageIndex = 0
@State private var currentPage = 1
@State private var totalPages = 1
@State private var readerRenderRevision = 0
@State private var isWaitingForNextChapter = false
@State private var isWaitingForPreviousChapter = false
@State private var httpTTSEngines: [HttpTTS] = []
@State private var resumesSpeechAfterChapterLoad = false
@State private var speechTimerTask: Task<Void, Never>?
@State private var progressSaveTask: Task<Void, Never>?
@State private var progressSaveGeneration = 0
@State private var speechStartGeneration = 0
@State private var speechTimerRemaining = 0
@State private var bookmarkSaved = false
@ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21
@AppStorage("textSize") private var fontSize = 20.0
@AppStorage("letterSpacing") private var letterSpacing = 0.1
@AppStorage("lineSpacingExtra") private var lineSpacing = 12.0
@AppStorage("paragraphSpacing") private var paragraphSpacing = 2.0
@AppStorage("paragraphIndent") private var paragraphIndent = "  "
@AppStorage("paddingLeft") private var paddingLeft = 16.0
@AppStorage("paddingRight") private var paddingRight = 16.0
@AppStorage("paddingTop") private var paddingTop = 6.0
@AppStorage("paddingBottom") private var paddingBottom = 6.0
@AppStorage("textBold") private var fontWeight = 0
@AppStorage("textFont") private var fontPath = ""
@AppStorage("titleMode") private var titleMode = 0
@AppStorage("titleSize") private var titleSize = 0.0
@AppStorage("titleTopSpacing") private var titleTopSpacing = 0.0
@AppStorage("titleBottomSpacing") private var titleBottomSpacing = 0.0
@AppStorage("underlineMode") private var underlineMode = 0
@AppStorage("headerPaddingBottom") private var headerPaddingBottom = 0.0
@AppStorage("headerPaddingLeft") private var headerPaddingLeft = 16.0
@AppStorage("headerPaddingRight") private var headerPaddingRight = 16.0
@AppStorage("headerPaddingTop") private var headerPaddingTop = 0.0
@AppStorage("footerPaddingBottom") private var footerPaddingBottom = 6.0
@AppStorage("footerPaddingLeft") private var footerPaddingLeft = 16.0
@AppStorage("footerPaddingRight") private var footerPaddingRight = 16.0
@AppStorage("footerPaddingTop") private var footerPaddingTop = 6.0
@AppStorage("showHeaderLine") private var showHeaderLine = false
@AppStorage("showFooterLine") private var showFooterLine = true
@AppStorage("tipHeaderLeft") private var tipHeaderLeft = 2
@AppStorage("tipHeaderMiddle") private var tipHeaderMiddle = 0
@AppStorage("tipHeaderRight") private var tipHeaderRight = 3
@AppStorage("tipFooterLeft") private var tipFooterLeft = 1
@AppStorage("tipFooterMiddle") private var tipFooterMiddle = 0
@AppStorage("tipFooterRight") private var tipFooterRight = 6
@AppStorage("headerMode") private var headerMode = 0
@AppStorage("footerMode") private var footerMode = 0
@AppStorage("readerTheme") private var theme = "wechat"
@AppStorage("readerPreviousDayTheme") private var previousDayTheme = "wechat"
@AppStorage("autoReadSpeed") private var autoReadSpeed = 10
@AppStorage("ttsFollowSys") private var ttsFollowSystem = true
@AppStorage("ttsSpeechRate") private var ttsSpeechRate = 5
@AppStorage("appTtsEngine") private var appTTSEngine = ""
@AppStorage("systemTtsVoice") private var systemTTSVoice = ""
@AppStorage("readAloudByPage") private var readAloudByPage = false
@AppStorage("streamReadAloudAudio") private var streamReadAloudAudio = true
@AppStorage("readAloudWakeLock") private var readAloudWakeLock = false
@AppStorage("ignoreAudioFocus") private var ignoreAudioFocus = false
@AppStorage("pauseReadAloudWhilePhoneCalls") private var pauseReadAloudWhilePhoneCalls = false
@AppStorage("mediaButtonPerNext") private var mediaButtonPerNext = false
@AppStorage("ttsTimer") private var ttsTimer = 0
@AppStorage("readerFullJustify") private var fullJustify = true
@AppStorage("readerKeepScreenOn") private var keepScreenOn = false
@AppStorage("readerHideHomeIndicator") private var hideHomeIndicator = false
@AppStorage("readerHideStatusBar") private var hideStatusBar = false
@AppStorage("readerShowBrightness") private var showBrightness = true
@AppStorage("readerBrightness") private var storedReaderBrightness = 0.0
@AppStorage("readerUseBackgroundImage") private var useBackgroundImage = false
@AppStorage("themeMode") private var themeMode = "0"
@AppStorage("enableReadRecord") private var enableReadRecord = true
@State private var pageAnimation: Int
@AppStorage("screenOrientation") private var screenOrientation = "0"
@AppStorage("clickActionTopLeft") private var clickActionTL = 2
@AppStorage("clickActionTopCenter") private var clickActionTC = 2
@AppStorage("clickActionTopRight") private var clickActionTR = 1
@AppStorage("clickActionMiddleLeft") private var clickActionML = 2
@AppStorage("clickActionMiddleRight") private var clickActionMR = 1
@AppStorage("clickActionBottomLeft") private var clickActionBL = 2
@AppStorage("clickActionBottomCenter") private var clickActionBC = 1
@AppStorage("clickActionBottomRight") private var clickActionBR = 1
init(book: Book, chapters: [BookChapter], initialIndex: Int, source: BookSource) {
_pageAnimation = State(initialValue: book.readConfig?.pageAnim.flatMap { $0 >= 0 ? $0 : nil }
?? Self.globalPageAnimation())
_model = StateObject(wrappedValue: ReaderViewModel(
book: book,
chapters: chapters,
initialIndex: initialIndex,
source: source
))
}
init(book: Book, chapters: [BookChapter], initialIndex: Int) {
_pageAnimation = State(initialValue: book.readConfig?.pageAnim.flatMap { $0 >= 0 ? $0 : nil }
?? Self.globalPageAnimation())
_model = StateObject(wrappedValue: ReaderViewModel(
book: book,
chapters: chapters,
initialIndex: initialIndex
))
}
init(demoBook book: Book, chapters: [BookChapter], initialIndex: Int, content: String) {
var readerChapters = chapters
var readerInitialIndex = initialIndex
var readerContent = content
#if DEBUG
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("-uiScrollRegression") {
readerChapters = (0..<80).map {
BookChapter(url: "demo://scroll/\($0)", title: "第\($0 + 1)章 · 连续阅读", bookUrl: book.bookUrl, index: $0)
}
readerInitialIndex = 20
if arguments.contains("-uiShortChapters") { readerContent = "短章正文,继续向下可以自然读到下一章。" }
}
#endif
_pageAnimation = State(initialValue: book.readConfig?.pageAnim.flatMap { $0 >= 0 ? $0 : nil }
?? Self.globalPageAnimation())
#if DEBUG
if arguments.contains("-uiReaderFailure") {
_model = StateObject(wrappedValue: ReaderViewModel(
book: book, chapters: readerChapters, initialIndex: readerInitialIndex,
source: BookSource(bookSourceUrl: "https://reader-failure.invalid", bookSourceName: "正文失败测试源"),
service: ReaderFailureTestLoader()
))
return
}
#endif
_model = StateObject(wrappedValue: ReaderViewModel(
demoBook: book,
chapters: readerChapters,
initialIndex: readerInitialIndex,
content: readerContent
))
}
var body: some View {
readerCanvas
.preferredColorScheme(resolvedStyle.id == "night" ? .dark : .light)
.readingTabBarHidden()
.navigationBarBackButtonHidden(true)
.background {
ReaderInteractivePopEnabler()
.frame(width: 0, height: 0)
}
.statusBarHidden(hideStatusBar)
.readingHomeIndicatorHidden(hideHomeIndicator)
.overlay(alignment: .top) {
if showsReaderMenu {
readerTopChrome
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.easeOut(duration: 0.22), value: showsReaderMenu)
.onAppear {
activity.beginReading(book)
speech.onNextChapter = { moveSpeechToChapter(direction: 1) }
speech.onPreviousChapter = { moveSpeechToChapter(direction: -1) }
speech.onRemoteStop = {
speechTimerTask?.cancel()
speechTimerRemaining = 0
resumesSpeechAfterChapterLoad = false
}
UIDevice.current.isBatteryMonitoringEnabled = true
brightnessBeforeReading = Double(UIScreen.main.brightness)
if storedReaderBrightness > 0.05 {
followsSystemBrightness = false
readerBrightness = storedReaderBrightness
UIScreen.main.brightness = CGFloat(storedReaderBrightness)
} else {
readerBrightness = brightnessBeforeReading
}
backgroundImage = ReaderBackgroundStore.load()
ReaderScreenOrientation.apply(screenOrientation)
Task { await loadHTTPEngines() }
if ProcessInfo.processInfo.arguments.contains("-uiDemoBookmarks") {
showsBookmarks = true
}
if ProcessInfo.processInfo.arguments.contains("-uiDemoReaderMenu") {
showsReaderMenu = true
}
if ProcessInfo.processInfo.arguments.contains("-uiDemoReaderStyle") {
DispatchQueue.main.async { showsStyleControls = true }
}
}
.onDisappear {
isAutoReading = false
UIApplication.shared.isIdleTimerDisabled = false
if speech.isSpeaking || speech.isPaused { speech.toggle(text: "") }
saveProgress(immediate: true)
ReaderScreenOrientation.restoreApplicationDefault()
activity.cancelReading(book)
UIDevice.current.isBatteryMonitoringEnabled = false
if !followsSystemBrightness {
UIScreen.main.brightness = CGFloat(brightnessBeforeReading)
}
speechTimerTask?.cancel()
speechTimerRemaining = 0
speech.onNextChapter = nil
speech.onPreviousChapter = nil
speech.onRemoteStop = nil
}
.overlay(alignment: .bottom) {
if showsReaderMenu {
readerBottomChrome
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.sheet(isPresented: $showsTableOfContents) { tableOfContents }
.sheet(isPresented: $showsBookmarks) { bookmarksSheet }
.sheet(isPresented: $showsAutoReadControls) { autoReadControls }
.sheet(isPresented: $showsStyleControls) { styleControls }
.sheet(isPresented: $showsContentSearch) { contentSearchSheet }
.sheet(item: $sourceSwitchBook) { currentBook in
ReadingNavigationStack {
SourceSwitchView(book: currentBook, sources: sourceLibrary.sources) { report, source in
Task {
await progressSaveTask?.value
await activity.endReading(book)
await library.replace(oldBookURL: book.bookUrl, with: report.book)
model.applySourceSwitch(report, source: source)
readerPosition = report.book.durChapterPos
readerJumpPosition = nil
readerPageRanges = []
readerRenderRevision &+= 1
activity.beginReading(report.book)
}
}
}
}
.sheet(isPresented: $showsReplaceRules) {
ReadingNavigationStack { ReplaceRuleManagementView() }
}
.sheet(isPresented: $showsReaderSettings) { readerSettings }
.sheet(isPresented: $showsClickActionSettings) { clickActionSettings }
.sheet(isPresented: $showsContentEditor) { contentEditor }
.fileImporter(
isPresented: $showsFontImporter,
allowedContentTypes: [.font, .data],
allowsMultipleSelection: false
) { result in
fontError = nil
do {
guard let source = try result.get().first else { return }
fontPath = try ReaderFontStore.install(from: source)
} catch {
fontError = error.localizedDescription
}
}
.task(id: model.chapterLoadGeneration) {
isWaitingForNextChapter = false
isWaitingForPreviousChapter = false
let needsInitialPosition = !model.hasLoadedCurrentChapter
if let pending = progressSaveTask {
let pendingGeneration = progressSaveGeneration
await pending.value
// A newer page callback may have scheduled another save while
// this chapter task was waiting. Never erase that newer task's
// handle, or a subsequent chapter transition could skip it.
if progressSaveGeneration == pendingGeneration {
progressSaveTask = nil
}
}
readerJumpPosition = nil
readerPageRanges = []
if needsInitialPosition { readerPosition = model.requestedPosition }
await model.load(rules: model.usesReplaceRules ? replaceRules.rules : [], prefetchNextChapter: pageAnimation != 3)
guard !Task.isCancelled else { return }
if needsInitialPosition {
readerPosition = pageAnimation == 3
? continuousPosition(model.requestedPosition, content: model.content)
: min(model.requestedPosition, max(model.content.utf16.count - 1, 0))
}
if let chapter = model.currentChapter {
await library.updateProgress(for: book, chapter: chapter, position: readerPosition)
}
if resumesSpeechAfterChapterLoad, !model.content.isEmpty {
resumesSpeechAfterChapterLoad = false
startConfiguredSpeech()
}
}
.task(id: "\(model.chapterLoadGeneration):\(pageAnimation == 3):\(model.hasLoadedCurrentChapter):\(model.scrollRevision)") {
guard pageAnimation == 3 else { return }
await model.prepareContinuousReading(rules: model.usesReplaceRules ? replaceRules.rules : [])
}
.onAppear { updateIdleTimer() }
.onChange(of: keepScreenOn) { _ in
updateIdleTimer()
}
.onChange(of: adjacentReadiness) { _ in
if model.isContinuationWindowReady, isWaitingForNextChapter {
isWaitingForNextChapter = false
readerRenderRevision &+= 1
}
if model.isPreviousWindowReady, isWaitingForPreviousChapter {
advanceToPreparedPreviousChapter()
}
}
.onChange(of: isAutoReading) { _ in
updateIdleTimer()
}
.onChange(of: speech.isSpeaking) { _ in
updateIdleTimer()
}
.onChange(of: speech.completionCount) { _ in
if readAloudByPage {
continueSpeechToNextPage()
} else {
continueSpeechToNextChapter()
}
}
.onChange(of: speech.currentSegmentIndex) { index in
guard model.paragraphs.indices.contains(index), !readAloudByPage else { return }
readerJumpPosition = nil
DispatchQueue.main.async { readerJumpPosition = model.paragraphs[index].id }
}
.onChange(of: readAloudByPage) { _ in
restartSpeechAfterReaderLayoutChange()
}
.onChange(of: readAloudWakeLock) { _ in
updateIdleTimer()
}
.onChange(of: scenePhase) { phase in
if phase == .active {
if !followsSystemBrightness {
UIScreen.main.brightness = CGFloat(readerBrightness)
}
activity.beginReading(book)
} else {
isAutoReading = false
if !followsSystemBrightness {
UIScreen.main.brightness = CGFloat(brightnessBeforeReading)
}
saveProgress(immediate: true)
activity.cancelReading(book)
}
}
.onChange(of: screenOrientation) { value in
ReaderScreenOrientation.apply(value)
}
.onChange(of: themeMode) { _ in
isAutoReading = false
readerJumpPosition = nil
currentPage = 1
totalPages = 1
}
.onChange(of: pageAnimation) { value in
isAutoReading = false
saveProgress(immediate: true)
readerJumpPosition = readerPosition
currentPage = 1
totalPages = 1
UserDefaults.standard.set(value, forKey: "pageAnim")
let updatedBook = model.useGlobalPageAnimation()
Task { await library.addOrUpdate(updatedBook) }
}
}
private var readerCanvas: some View {
ZStack {
readerContent
readerMenuDismissOverlay
}
.background {
ZStack {
backgroundColor
if themeMode != "3", useBackgroundImage, let backgroundImage {
Image(uiImage: backgroundImage)
.resizable()
.scaledToFill()
}
}
.clipped()
.ignoresSafeArea()
}
}
@ViewBuilder private var readerContent: some View {
if pageAnimation != 3,
(model.isLoading && model.content.isEmpty) || (model.content.isEmpty && !model.hasLoadedCurrentChapter) {
VStack(spacing: 14) {
ProgressView()
.controlSize(.regular)
Text("正在打开本章")
.font(.subheadline.weight(.medium))
Text(model.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundStyle(foregroundColor)
.background(backgroundColor.ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture { showsReaderMenu.toggle() }
.transition(.opacity)
} else if pageAnimation != 3, model.content.isEmpty && model.hasLoadedCurrentChapter {
VStack(spacing: 18) {
Image(systemName: "text.page.slash").font(.largeTitle)
Text(model.currentChapterError == nil ? "正文为空" : "本章加载失败").font(.title3.weight(.semibold))
Text(model.currentChapterError ?? "本章暂无正文。")
.font(.subheadline).multilineTextAlignment(.center)
.foregroundStyle(.secondary)
readerRecoveryActions
Button("打开阅读菜单") { showsReaderMenu = true }
.accessibilityIdentifier("reader.failureMenu")
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundStyle(foregroundColor)
.background(backgroundColor.ignoresSafeArea())
} else {
VStack(spacing: 0) {
if headerVisible { readerTipBar(isHeader: true) }
readerSurface
if footerVisible { readerTipBar(isHeader: false) }
}
.frame(maxWidth: readingColumnMaxWidth)
.frame(maxWidth: .infinity)
.overlay(alignment: .bottom) {
if pageAnimation == 3, let error = model.currentChapterError, !showsReaderMenu {
VStack(spacing: 10) {
Text("本章加载失败").font(.subheadline.weight(.semibold))
Text(error).font(.caption).lineLimit(2).multilineTextAlignment(.center)
readerRecoveryActions
}
.padding(16)
.frame(maxWidth: .infinity)
.foregroundStyle(foregroundColor)
.background(backgroundColor)
}
}
}
}
private var readerRecoveryActions: some View {
HStack(spacing: 16) {
Button {
Task {
if pageAnimation == 3, model.scrollChapters.contains(where: { $0.id == model.currentIndex }) {
await model.retryContinuousChapter(index: model.currentIndex, rules: model.usesReplaceRules ? replaceRules.rules : [])
} else {
await model.load(rules: model.usesReplaceRules ? replaceRules.rules : [], forceRefresh: true)
}
}
} label: {
Label(model.scrollRetries.contains(model.currentIndex) ? "重试中…" : "重试本章", systemImage: "arrow.clockwise")
}
.disabled(model.isLoading || model.scrollRetries.contains(model.currentIndex))
.accessibilityIdentifier("reader.retryChapter")
if model.canEditContent {
Button(action: openSourceSwitch) {
Label("换源", systemImage: "arrow.triangle.2.circlepath")
}
.accessibilityIdentifier("reader.failureChangeSource")
}
}
.buttonStyle(.bordered)
}
private func openSourceSwitch() {
stopSpeechIfNeeded()
isAutoReading = false
saveProgress(immediate: true)
showsReaderMenu = false
sourceSwitchBook = model.bookForSourceSwitch(position: readerPosition)
}
@ViewBuilder private var readerSurface: some View {
if pageAnimation == 3 {
ReaderScrollView(
configuration: readerTextConfiguration,
chapters: model.scrollChapters,
resetID: model.scrollRevision,
activeIndex: model.currentIndex,
loadingAhead: model.isLoadingScrollAhead,
loadingBehind: model.isLoadingScrollBehind,
onNeedChapters: { direction in
Task { await model.extendContinuousReading(direction: direction, rules: model.usesReplaceRules ? replaceRules.rules : []) }
},
onPositionChanged: { index, position in
if index != model.currentIndex {
saveProgress(immediate: true)
model.updateContinuousPosition(chapter: index)
readerJumpPosition = nil
}
let resolved = min(max(position, 0), model.content.utf16.count)
if readerPosition != resolved { readerPosition = resolved }
},
onRetainChapters: { model.retainContinuousChapters($0) }
)
} else {
readerTextConfiguration
}
}
private var readerTextConfiguration: ReaderTextView {
ReaderTextView(
text: readerDisplayText,
title: model.title,
previousTitle: pageAnimation == 3 ? model.previousTitle : nil,
previousText: pageAnimation == 3 ? model.previousContent : "",
isPreviousReady: pageAnimation == 3 && model.isPreviousWindowReady,
continuationTitle: pageAnimation == 3 ? model.continuationTitle : nil,
continuationText: pageAnimation == 3 ? model.continuationContent : "",
isContinuationReady: pageAnimation == 3 && model.isContinuationWindowReady,
isVolume: model.currentChapter?.isVolume == true,
fontSize: fontSize,
fontPath: fontPath,
letterSpacing: letterSpacing,
lineSpacing: lineSpacing,
paragraphSpacing: paragraphSpacing,
paragraphIndent: paragraphIndent,
paddingLeft: paddingLeft,
paddingRight: paddingRight,
paddingTop: paddingTop,
paddingBottom: paddingBottom,
headerInset: 0,
footerInset: 0,
fontWeight: fontWeight,
titleMode: titleMode,
titleSize: titleSize,
titleTopSpacing: titleTopSpacing,
titleBottomSpacing: titleBottomSpacing,
underlineMode: underlineMode,
isSelectable: false,
fullJustify: fullJustify,
pageAnimation: pageAnimation,
isEInkMode: themeMode == "3",
clickActions: clickActions,
backgroundColor: themeMode != "3" && useBackgroundImage && backgroundImage != nil ? .clear : resolvedStyle.background,
foregroundColor: resolvedStyle.foreground,
requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition,
jumpPosition: readerJumpPosition,
renderRevision: readerRenderRevision,
readerPosition: $readerPosition,
pageRanges: $readerPageRanges,
currentPage: $currentPage,
totalPages: $totalPages,
isAutoReading: isAutoReading,
isAutoReadPaused: pausesAutoReading,
isLoadingNextChapter: isWaitingForNextChapter,
secondsPerScreen: Double(autoReadSpeed),
onReachedEnd: { boundaryPosition in
if pageAnimation == 3 {
requestNextChapter()
return
}
if model.canGoNext {
showsReaderMenu = false
readerPosition = boundaryPosition
saveProgress(immediate: true)
readerJumpPosition = nil
// Keep every chapter in its own TextKit document. Appending
// text here makes the absolute UTF-16 offset belong to the
// previous chapter while progress is saved under the next.
model.goNext()
readerPosition = model.requestedPosition
} else {
isAutoReading = false
}
},
onReachedStart: { boundaryPosition in
if pageAnimation == 3 {
requestPreviousChapter()
return
}
guard model.canGoPrevious else { return }
showsReaderMenu = false
readerPosition = boundaryPosition
saveProgress(immediate: true)
readerJumpPosition = nil
model.goPrevious(position: Int.max)
},
onEnteredPrevious: { position in
guard pageAnimation == 3, model.canGoPrevious, model.hasPreviousWindow else { return }
saveProgress(immediate: true)
readerPosition = continuousPosition(position, content: model.previousContent)
readerJumpPosition = nil
model.promotePrevious(position: readerPosition)
},
onEnteredContinuation: { position in
guard pageAnimation == 3, model.canGoNext, model.hasContinuationWindow else { return }
saveProgress(immediate: true)
readerPosition = continuousPosition(position, content: model.continuationContent)
readerJumpPosition = nil
model.promoteContinuation(position: readerPosition)
},
onDeferredRender: { readerRenderRevision &+= 1 },
onCancelNextLoading: { isWaitingForNextChapter = false },
onPageChanged: pageDidChange,
onTapAction: handleTapAction
)
}
@ViewBuilder private var readerMenuDismissOverlay: some View {
if showsReaderMenu {
Color.black
.opacity(resolvedStyle.id == "night" ? 0.08 : 0.03)
.ignoresSafeArea()
.contentShape(ReaderMenuDismissShape())
.onTapGesture { showsReaderMenu = false }
.accessibilityHidden(true)
}
}
private var readingColumnMaxWidth: CGFloat {
UIDevice.current.userInterfaceIdiom == .pad ? 760 : .greatestFiniteMagnitude
}
private var adjacentReadiness: Int {
(model.isPreviousWindowReady ? 1 : 0) | (model.isContinuationWindowReady ? 2 : 0)
}
private var readerDisplayText: String {
if !model.content.isEmpty { return model.content }
if pageAnimation == 3 {
return String(repeating: "\n", count: 80)
}
if model.isLoading || !model.hasLoadedCurrentChapter {
return "正文正在后台加载…\n\n你可以继续向下滚动,下一章已经提前生成。"
}
let reason = model.diagnostics.first?.message ?? "当前书源没有返回正文。"
return "本章加载失败\n\n\(reason)\n\n本章位置会保留,请继续向下进入下一章。"
}
private func continuousPosition(_ position: Int, content: String) -> Int {
let length = content.isEmpty ? 80 : content.utf16.count
return min(max(position, 0), max(length - 1, 0))
}
private var backgroundColor: Color {
Color(uiColor: resolvedStyle.background)
}
private var foregroundColor: Color {
Color(uiColor: resolvedStyle.foreground)
}
private var resolvedStyle: ReaderStylePreset {
themeMode == "3" ? .eInk : ReaderStylePreset.resolve(theme)
}
private var headerVisible: Bool {
switch headerMode {
case 1: true
case 2: false
default: hideStatusBar
}
}
private var footerVisible: Bool { footerMode == 0 }
private var pausesAutoReading: Bool {
isWaitingForNextChapter
|| isWaitingForPreviousChapter
|| showsReaderMenu
|| showsTableOfContents
|| showsBookmarks
|| showsAutoReadControls
|| showsStyleControls
|| showsContentSearch
|| showsReplaceRules
|| showsReaderSettings
|| showsClickActionSettings
|| showsContentEditor
}
private func readerTipBar(isHeader: Bool) -> some View {
TimelineView(.periodic(from: .now, by: 30)) { context in
readerTipRow(
isHeader ? [tipHeaderLeft, tipHeaderMiddle, tipHeaderRight] : [tipFooterLeft, tipFooterMiddle, tipFooterRight],
date: context.date,
showDivider: isHeader ? showHeaderLine : showFooterLine,
isHeader: isHeader
)
.padding(.top, isHeader ? headerPaddingTop : footerPaddingTop)
.padding(.bottom, isHeader ? headerPaddingBottom : footerPaddingBottom)
}
.allowsHitTesting(false)
}
private func readerTipRow(_ values: [Int], date: Date, showDivider: Bool, isHeader: Bool) -> some View {
HStack(spacing: 8) {
Text(readerTipText(values[0], date: date))
.frame(maxWidth: .infinity, alignment: .leading)
Text(readerTipText(values[1], date: date))
.frame(maxWidth: .infinity, alignment: .center)
Text(readerTipText(values[2], date: date))
.frame(maxWidth: .infinity, alignment: .trailing)
}
.font(.system(size: 12))
.foregroundStyle(foregroundColor)
.lineLimit(1)
.padding(.leading, isHeader ? headerPaddingLeft : footerPaddingLeft)
.padding(.trailing, isHeader ? headerPaddingRight : footerPaddingRight)
.overlay(alignment: .bottom) {
if showDivider { Divider().overlay(foregroundColor.opacity(0.35)) }
}
}
private func readerTipText(_ value: Int, date: Date) -> String {
let time = date.formatted(.dateTime.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits))
let batteryValue = UIDevice.current.batteryLevel
let battery = batteryValue >= 0 ? Int((batteryValue * 100).rounded()) : 100
let chapterCount = max(model.chapters.filter { !$0.isVolume }.count, 1)
let readableIndex = model.chapters.prefix(model.currentIndex + 1).filter { !$0.isVolume }.count
let chapterFraction = chapterPageFraction
let progress = min(max((Double(max(readableIndex - 1, 0)) + chapterFraction) / Double(chapterCount), 0), 1)
let progressText = String(format: "%.1f%%", progress * 100)
switch value {
case 1: return model.title
case 2: return time
case 3: return "▰"
case 4: return "\(currentPage)/\(totalPages)"
case 5: return progressText
case 6: return "\(currentPage)/\(totalPages) \(progressText)"
case 7: return book.name
case 8: return "\(time) ▰"
case 9: return "\(time) \(battery)%"
case 10: return "\(battery)%"
case 11: return "\(readableIndex)/\(chapterCount)"
default: return ""
}
}
private var readerChromeTint: Color {
.primary
}
@ViewBuilder private var readerChromeBackground: some View {
if resolvedStyle.id == "night" {
Color(uiColor: .secondarySystemBackground)
} else {
Color(uiColor: .systemBackground)
}
}
private var chapterProgress: Double {
let chapter = max(model.chapters.filter { !$0.isVolume }.count, 1)
let readable = model.chapters.prefix(model.currentIndex + 1).filter { !$0.isVolume }.count
let pagePart = chapterPageFraction
return min(max((Double(max(readable - 1, 0)) + pagePart) / Double(chapter), 0), 1)
}
private var chapterPageFraction: Double {
guard totalPages > 1 else { return 1 }
return min(max(Double(max(currentPage - 1, 0)) / Double(totalPages - 1), 0), 1)
}
private var readerTopChrome: some View {
HStack(spacing: 4) {
readerChromeIconButton("chevron.backward", label: "返回") { dismiss() }
VStack(alignment: .leading, spacing: 3) {
Text(book.name)
.font(.subheadline.weight(.medium))
.lineLimit(1)
HStack(spacing: 4) {
Text(model.title).lineLimit(1)
if model.loadedFromCache {
Image(systemName: "arrow.down.circle")
.accessibilityLabel("已缓存")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
readerChromeIconButton(bookmarkSaved ? "bookmark.fill" : "bookmark", label: "书签") {
addBookmark()
}
.accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置")
readerMoreMenu
}
.foregroundStyle(readerChromeTint)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(maxWidth: .infinity)
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.background { readerChromeBackground.ignoresSafeArea(edges: .top) }
.overlay(alignment: .bottom) {
Rectangle().fill(Color(uiColor: .separator).opacity(0.35)).frame(height: 0.5)
}
}
private var readerMoreMenu: some View {
Menu {
Button("搜索正文", systemImage: "magnifyingglass") {
showsReaderMenu = false
showsContentSearch = true
}
Button("书签列表", systemImage: "bookmark") {
showsReaderMenu = false
showsBookmarks = true
}
Button(isAutoReading ? "停止自动阅读" : "自动阅读", systemImage: isAutoReading ? "stop" : "play") {
if isAutoReading {
isAutoReading = false
} else {
showsReaderMenu = false
showsAutoReadControls = true
}
}
Divider()
Button(resolvedStyle.id == "night" ? "切换为日间" : "切换为夜间", systemImage: resolvedStyle.id == "night" ? "sun.max" : "moon") {
toggleNightTheme()
}
.disabled(themeMode == "3")
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 20, weight: .regular))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("更多")
}
private var readerBottomChrome: some View {
VStack(spacing: 12) {
readerChapterControls
Rectangle().fill(Color(uiColor: .separator).opacity(0.35)).frame(height: 0.5)
LazyVGrid(columns: readerToolbarColumns, spacing: 12) {
readerChromeAction("list.bullet", "目录") {
showsReaderMenu = false
showsTableOfContents = true
}
if model.canEditContent {
readerChromeAction("arrow.triangle.2.circlepath", "换源", action: openSourceSwitch)
.accessibilityIdentifier("reader.changeSource")
.accessibilityValue(book.originName)
}
readerChromeAction(speech.isSpeaking ? "stop" : "headphones", speech.isSpeaking ? "停止" : "朗读", isActive: speech.isSpeaking) { toggleSpeech() }
readerChromeAction("textformat.size", "外观") {
showsReaderMenu = false
showsStyleControls = true
}
readerChromeAction("gearshape", "设置") {
showsReaderMenu = false
showsReaderSettings = true
}
}
}
.foregroundStyle(readerChromeTint)
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 10)
.frame(maxWidth: .infinity)
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.background { readerChromeBackground.ignoresSafeArea(edges: .bottom) }
.overlay(alignment: .top) {
Rectangle().fill(Color(uiColor: .separator).opacity(0.35)).frame(height: 0.5)
}
}
private var readerToolbarColumns: [GridItem] {
let count = dynamicTypeSize.isAccessibilitySize ? (model.canEditContent ? 3 : 2) : (model.canEditContent ? 5 : 4)
return Array(repeating: GridItem(.flexible(), spacing: 0), count: count)
}
private var readerChapterControls: some View {
HStack(spacing: 16) {
readerChromeIconButton("chevron.left", label: "上一章") { moveChapter(-1) }
.disabled(!model.canGoPrevious)
.opacity(model.canGoPrevious ? 1 : 0.25)
VStack(spacing: 8) {
HStack {
let chapterCount = model.chapters.filter { !$0.isVolume }.count
let chapterNumber = model.chapters.prefix(model.currentIndex + 1).filter { !$0.isVolume }.count
if #available(iOS 16.0, *) {
ViewThatFits(in: .horizontal) {
Text("第 \(chapterNumber) 章 / 共 \(chapterCount) 章").fixedSize()
Text("\(chapterNumber) / \(chapterCount) 章").fixedSize()
}
} else {
Text("\(chapterNumber) / \(chapterCount) 章").fixedSize()
}
Spacer(minLength: 8)
Text(chapterProgress, format: .percent.precision(.fractionLength(0)))
}
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
ProgressView(value: chapterProgress)
.tint(readerChromeTint)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("阅读进度")
.accessibilityValue(chapterProgress.formatted(.percent.precision(.fractionLength(0))))
readerChromeIconButton("chevron.right", label: "下一章") { moveChapter(1) }
.disabled(!model.canGoNext)
.opacity(model.canGoNext ? 1 : 0.25)
}
}
private func readerChromeIconButton(_ icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 19, weight: .regular))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
private func readerChromeAction(_ icon: String, _ title: String, isActive: Bool = false, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: min(toolbarIconSize, 28), weight: .regular))
.frame(height: 28)
Text(title)
.font(.caption)
.foregroundStyle(isActive ? Color.primary : .secondary)
}
.frame(maxWidth: .infinity, minHeight: 58)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
}
private var readerBrightnessBinding: Binding<Double> {
Binding(
get: { readerBrightness },
set: { value in
followsSystemBrightness = false
readerBrightness = value
storedReaderBrightness = value
UIScreen.main.brightness = CGFloat(value)
}
)
}
private func toggleNightTheme() {
if resolvedStyle.id == "night" {
theme = previousDayTheme == "night" ? "wechat" : previousDayTheme
} else {
previousDayTheme = resolvedStyle.id
theme = "night"
}
}
private func moveChapter(_ direction: Int) {
stopSpeechIfNeeded()
showsReaderMenu = false
saveProgress(immediate: true)
readerJumpPosition = nil
if direction < 0 { model.goPrevious() } else { model.goNext() }
readerPosition = model.requestedPosition
}
private func requestNextChapter() {
if pageAnimation == 3 {
guard model.canGoNext else { isAutoReading = false; return }
Task { await model.extendContinuousReading(direction: 1, rules: model.usesReplaceRules ? replaceRules.rules : []) }
return
}
guard model.canGoNext, model.hasContinuationWindow else {
isAutoReading = false
return
}
if model.isContinuationWindowReady {
isWaitingForNextChapter = false
readerRenderRevision &+= 1
} else {
isWaitingForNextChapter = true
}
}
private func requestPreviousChapter() {
guard model.canGoPrevious, model.hasPreviousWindow else { return }
if model.isPreviousWindowReady {
advanceToPreparedPreviousChapter()
} else {
isWaitingForPreviousChapter = true
}
}
private func advanceToPreparedPreviousChapter() {
guard model.canGoPrevious, model.hasPreviousWindow, model.isPreviousWindowReady else { return }
isWaitingForPreviousChapter = false
saveProgress(immediate: true)
readerJumpPosition = nil
let position = max(model.previousContent.utf16.count - 1, 0)
readerPosition = position
model.promotePrevious(position: position)
}
private func startAutoReading() {
stopSpeechIfNeeded()
showsReaderMenu = false
isAutoReading = true
}
private func updateIdleTimer() {
UIApplication.shared.isIdleTimerDisabled = keepScreenOn || isAutoReading
|| (readAloudWakeLock && speech.isSpeaking)
}
private func pageDidChange() {
saveProgress()
guard enableReadRecord else { return }
Task { await activity.checkpoint(book) }
}
private var autoReadControls: some View {
ReadingNavigationStack {
Form {
Section {
HStack {
Text("自动翻页速度")
Spacer()
Text("\(autoReadSpeed)s").monospacedDigit().foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(autoReadSpeed) },
set: { autoReadSpeed = max(1, min(Int($0.rounded()), 120)) }
),
in: 1...120,
step: 1
)
} footer: {
Text("数值表示自动阅读一屏所用的秒数,范围 1–120 秒。")
}
Section {
Button("目录", systemImage: "list.bullet") {
showsAutoReadControls = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsTableOfContents = true }
}
Button("开始自动阅读", systemImage: "play.fill") {
showsAutoReadControls = false
startAutoReading()
}
Button("主菜单", systemImage: "menubar.rectangle") {
showsAutoReadControls = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsReaderMenu = true }
}
Button("停止自动阅读", systemImage: "stop.fill", role: .destructive) {
isAutoReading = false
showsAutoReadControls = false
}
Button("设置", systemImage: "gearshape") {
showsAutoReadControls = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsStyleControls = true }
}
}
}
.navigationTitle("自动阅读")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("继续阅读") { showsAutoReadControls = false }
}
}
}
.readingPresentationDetents([.medium])
}
private var styleControls: some View {
ReadingNavigationStack {
Form {
Section("快速排版") {
HStack(spacing: 12) {
Button {
fontSize = max(5, fontSize - 1)
} label: {
Image(systemName: "textformat.size.smaller")
.frame(width: 38, height: 32)
}
.buttonStyle(.bordered)
VStack(spacing: 2) {
Text("字号")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(Int(fontSize))")
.font(.headline.monospacedDigit())
}
.frame(maxWidth: .infinity)
Button {
fontSize = min(50, fontSize + 1)
} label: {
Image(systemName: "textformat.size.larger")
.frame(width: 38, height: 32)
}
.buttonStyle(.bordered)
}
HStack(spacing: 8) {
Text("行距")
.font(.subheadline)
Spacer(minLength: 8)
readerChoiceChip("紧凑", selected: lineSpacing <= 7) { lineSpacing = 6 }
readerChoiceChip("标准", selected: lineSpacing > 7 && lineSpacing < 18) { lineSpacing = 12 }
readerChoiceChip("宽松", selected: lineSpacing >= 18) { lineSpacing = 22 }
}
Toggle("两端对齐", isOn: $fullJustify)
}
Section("阅读背景") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(ReaderStylePreset.legadoDefaults) { preset in
Button {
theme = preset.id
} label: {
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 7)
.fill(Color(uiColor: preset.background))
.frame(width: 54, height: 72)
.overlay {
Text("文字")
.font(.caption)
.foregroundStyle(Color(uiColor: preset.foreground))
}
.overlay {
RoundedRectangle(cornerRadius: 7)
.stroke(resolvedStyle.id == preset.id ? ReadingTheme.accent : Color.secondary, lineWidth: resolvedStyle.id == preset.id ? 3 : 1)
}
Text(preset.name).font(.caption2).foregroundStyle(.primary)
}
}
.buttonStyle(.plain)
}
}
}
ReadingPhotoPicker { data in
backgroundError = nil
do {
backgroundImage = try ReaderBackgroundStore.save(data)
useBackgroundImage = true
} catch {
backgroundError = error.localizedDescription
}
} onError: { error in
backgroundError = error.localizedDescription
} label: {
Label("选择背景图片", systemImage: "photo")
}
if backgroundImage != nil {
Toggle("使用背景图片", isOn: $useBackgroundImage)
Button("删除背景图片", systemImage: "trash", role: .destructive) {
ReaderBackgroundStore.remove()
backgroundImage = nil
useBackgroundImage = false
}
}
if let backgroundError {
Text(backgroundError).font(.footnote).foregroundStyle(.red)
}
}
Section("文字") {
valueSlider("字号", value: $fontSize, range: 5...50, format: { "\(Int($0))" })
valueSlider("字间距", value: $letterSpacing, range: -0.5...0.5, step: 0.01, format: { String(format: "%.2f", $0) })
valueSlider("行间距", value: $lineSpacing, range: 0...50, format: { "\(Int($0))" })
valueSlider("段距", value: $paragraphSpacing, range: 0...50, format: { String(format: "%.1f", $0) })
Picker("字重", selection: $fontWeight) {
Text("正常").tag(0)
Text("粗体").tag(1)
Text("细体").tag(2)
}
TextField("段落缩进字符", text: $paragraphIndent)
.textInputAutocapitalization(.never)
Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") {
showsFontImporter = true
}
if !fontPath.isEmpty {
HStack {
Text("当前字体")
Spacer()
Text(URL(fileURLWithPath: fontPath).lastPathComponent)
.foregroundStyle(.secondary)
}
Button("恢复默认字体", role: .destructive) { fontPath = "" }
}
if let fontError {
Text(fontError).font(.footnote).foregroundStyle(.red)
}
Picker("下划线", selection: $underlineMode) {
Text("关闭").tag(0)
Text("实线").tag(1)
Text("虚线").tag(2)
}
}
Section("章节标题") {
Picker("标题模式", selection: $titleMode) {
Text("左对齐").tag(0)
Text("居中").tag(1)
Text("隐藏").tag(2)
}
.pickerStyle(.segmented)
valueSlider("标题字号增量", value: $titleSize, range: -10...30, format: { String(format: "%+.0f", $0) })
valueSlider("标题上间距", value: $titleTopSpacing, range: 0...100, format: { "\(Int($0))" })
valueSlider("标题下间距", value: $titleBottomSpacing, range: 0...100, format: { "\(Int($0))" })
}
Section("翻页动画") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Self.pageAnimationChoices, id: \.0) { choice in
Button {
pageAnimation = choice.value
} label: {
VStack(spacing: 6) {
Image(systemName: choice.icon)
.font(.headline)
Text(choice.title)
.font(.caption2)
}
.foregroundStyle(pageAnimation == choice.value ? Color.white : Color.primary)
.frame(width: 58, height: 54)
.background(
pageAnimation == choice.value ? ReadingTheme.accent : Color.secondary.opacity(0.1),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
}
.buttonStyle(.plain)
}
}
}
Text(pageAnimation == 3
? "滚动模式连续滚动;其余模式按整屏翻页。左右滑动或点击左右三分之一区域翻页,点击中间区域打开菜单。"
: "当前模式按整屏翻页;左右滑动或点击左右三分之一区域翻页,点击中间区域打开菜单。")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("正文边距") {
valueSlider("左边距", value: $paddingLeft, range: 0...100, format: { "\(Int($0))" })
valueSlider("右边距", value: $paddingRight, range: 0...100, format: { "\(Int($0))" })
valueSlider("上边距", value: $paddingTop, range: 0...100, format: { "\(Int($0))" })
valueSlider("下边距", value: $paddingBottom, range: 0...100, format: { "\(Int($0))" })
}
Section("页眉页脚") {
Picker("页眉显示", selection: $headerMode) {
Text("状态栏显示时隐藏").tag(0)
Text("显示").tag(1)
Text("隐藏").tag(2)
}
Picker("页脚显示", selection: $footerMode) {
Text("显示").tag(0)
Text("隐藏").tag(1)
}
tipPicker("页眉左", selection: $tipHeaderLeft)
tipPicker("页眉中", selection: $tipHeaderMiddle)
tipPicker("页眉右", selection: $tipHeaderRight)
tipPicker("页脚左", selection: $tipFooterLeft)
tipPicker("页脚中", selection: $tipFooterMiddle)
tipPicker("页脚右", selection: $tipFooterRight)
Toggle("页眉分隔线", isOn: $showHeaderLine)
Toggle("页脚分隔线", isOn: $showFooterLine)
}
Section("页眉边距") {
valueSlider("左边距", value: $headerPaddingLeft, range: 0...100, format: { "\(Int($0))" })
valueSlider("右边距", value: $headerPaddingRight, range: 0...100, format: { "\(Int($0))" })
valueSlider("上边距", value: $headerPaddingTop, range: 0...100, format: { "\(Int($0))" })
valueSlider("下边距", value: $headerPaddingBottom, range: 0...100, format: { "\(Int($0))" })
}
Section("页脚边距") {
valueSlider("左边距", value: $footerPaddingLeft, range: 0...100, format: { "\(Int($0))" })
valueSlider("右边距", value: $footerPaddingRight, range: 0...100, format: { "\(Int($0))" })
valueSlider("上边距", value: $footerPaddingTop, range: 0...100, format: { "\(Int($0))" })
valueSlider("下边距", value: $footerPaddingBottom, range: 0...100, format: { "\(Int($0))" })
}
}
.navigationTitle("阅读样式")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("完成") { showsStyleControls = false }
}
}
}
.readingPresentationDetents([.medium])
.readingPresentationDragIndicator()
}
private static let pageAnimationChoices: [(value: Int, title: String, icon: String)] = [
(0, "覆盖", "rectangle.on.rectangle"),
(1, "滑动", "rectangle.portrait.and.arrow.right"),
(2, "仿真", "book.pages"),
(3, "滚动", "arrow.up.and.down"),
(4, "无动画", "rectangle")
]
private func readerChoiceChip(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(selected ? Color.white : Color.primary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(selected ? ReadingTheme.accent : Color.secondary.opacity(0.1), in: Capsule())
}
.buttonStyle(.plain)
}
private func valueSlider(
_ title: String,
value: Binding<Double>,
range: ClosedRange<Double>,
step: Double = 1,
format: @escaping (Double) -> String
) -> some View {
VStack(spacing: 6) {
HStack {
Text(title)
Spacer()
Text(format(value.wrappedValue)).monospacedDigit().foregroundStyle(.secondary)
}
Slider(value: value, in: range, step: step)
}
}
private static let readerTipChoices: [(Int, String)] = [
(0, "无"), (7, "书名"), (1, "标题"), (2, "时间"), (3, "电量"),
(10, "电量%"), (4, "页数"), (5, "进度(%)"), (11, "进度(xx/yyy)"),
(6, "页数及进度"), (8, "时间及电量"), (9, "时间及电量%")
]
private func tipPicker(_ title: String, selection: Binding<Int>) -> some View {
Picker(title, selection: selection) {
ForEach(Self.readerTipChoices, id: \.0) { value, name in
Text(name).tag(value)
}
}
}
private var clickActions: [Int] {
// Keep persisted action IDs stable; the removed sync action becomes a no-op.
[clickActionTL, clickActionTC, clickActionTR, clickActionML, 0, clickActionMR, clickActionBL, clickActionBC, clickActionBR]
.map { $0 == 12 ? -1 : $0 }
}
private static let tapActionChoices: [(Int, String)] = [
(-1, "无动作"), (0, "菜单"), (1, "下一页"), (2, "上一页"),
(3, "下一章"), (4, "上一章"), (5, "朗读上一段"), (6, "朗读下一段"),
(7, "添加书签"), (8, "编辑正文"), (9, "替换(启用/禁用)"), (10, "目录"),
(11, "搜索正文"), (13, "暂停/继续朗读")
]
private var clickActionSettings: some View {
ReadingNavigationStack {
VStack(spacing: 8) {
ForEach(0..<3, id: \.self) { row in
HStack(spacing: 8) {
ForEach(0..<3, id: \.self) { column in
let index = row * 3 + column
VStack(spacing: 8) {
Text(["左上", "上中", "右上", "左中", "中央", "右中", "左下", "下中", "右下"][index])
.font(.caption).foregroundStyle(.secondary)
if index == 4 {
Text("固定:菜单")
.font(.caption.weight(.medium))
.foregroundStyle(ReadingTheme.accent)
.frame(maxWidth: .infinity, minHeight: 32)
} else {
Picker("动作", selection: clickActionBinding(at: index)) {
ForEach(Self.tapActionChoices, id: \.0) { action in
Text(action.1).tag(action.0)
}
}
.labelsHidden()
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(6)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10))
}
}
}
}
.padding(10)
.navigationTitle("点击区域设置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("完成") { showsClickActionSettings = false }
}
}
}
}
private func clickActionBinding(at index: Int) -> Binding<Int> {
let binding: Binding<Int> = switch index {
case 0: $clickActionTL
case 1: $clickActionTC
case 2: $clickActionTR
case 3: $clickActionML
case 5: $clickActionMR
case 6: $clickActionBL
case 7: $clickActionBC
default: $clickActionBR
}
return Binding(
get: { binding.wrappedValue == 12 ? -1 : binding.wrappedValue },
set: { binding.wrappedValue = $0 }
)
}
private func handleTapAction(_ action: Int) {
switch action {
case 0: showsReaderMenu.toggle()
case 3: if model.canGoNext { moveChapter(1) }
case 4: if model.canGoPrevious { moveChapter(-1) }
case 5: speech.previousSegment()
case 6: speech.nextSegment()
case 7: addBookmark()
case 8:
guard model.canEditContent else { return }
showsReaderMenu = false
editedContent = model.content
contentEditError = nil
showsContentEditor = true
case 9:
showsReaderMenu = false
let updatedBook = model.toggleReplaceRules()
Task {
await library.addOrUpdate(updatedBook)
await model.load(rules: model.usesReplaceRules ? replaceRules.rules : [])
}
case 10:
showsReaderMenu = false
showsTableOfContents = true
case 11:
showsReaderMenu = false
showsContentSearch = true
case 13:
isAutoReading = false
configureSpeech()
if let engine = selectedHTTPEngine {
speech.pauseOrResumeHTTP(engine: engine, text: model.content, continuesAcrossChapters: true)
} else {
speech.pauseOrResume(text: model.content, continuesAcrossChapters: true)
}
default: break
}
}
private var readerSettings: some View {
ReadingNavigationStack {
Form {
Section {
Toggle("两端对齐", isOn: $fullJustify)
Button("编辑本章正文", systemImage: "square.and.pencil") {
editedContent = model.content
contentEditError = nil
showsReaderSettings = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsContentEditor = true }
}
.disabled(!model.canEditContent || model.content.isEmpty)
Button("重新获取本章", systemImage: "arrow.clockwise") {
showsReaderSettings = false
refreshCurrentChapter()
}
.disabled(model.isLoading || !model.canEditContent)
} header: {
Text("正文")
} footer: {
if !model.canEditContent {
Text("本地 TXT 直接来自原文件;为避免破坏章节偏移,本章编辑与网络刷新仅对网络书籍开放。")
}
}
Section("屏幕") {
Picker("屏幕方向", selection: $screenOrientation) {
Text("跟随系统").tag("0")
Text("竖向").tag("1")
Text("横向").tag("2")
Text("跟随传感器").tag("3")
Text("反向竖屏").tag("4")
Text("反向横屏").tag("5")
}
Toggle("阅读时不自动锁屏", isOn: $keepScreenOn)
if #available(iOS 16.0, *) {
Toggle("阅读时隐藏小横条", isOn: $hideHomeIndicator)
}
Toggle("隐藏状态栏", isOn: $hideStatusBar)
Toggle("显示亮度调节", isOn: $showBrightness)
Button("跟随系统亮度", systemImage: "sun.max") {
followsSystemBrightness = true
storedReaderBrightness = 0
UIScreen.main.brightness = CGFloat(brightnessBeforeReading)
readerBrightness = brightnessBeforeReading
}
}
Section("朗读") {
Picker("全局朗读引擎", selection: $appTTSEngine) {
Text("系统 TTS").tag("")
ForEach(httpTTSEngines) { engine in
Text(engine.name).tag(String(engine.id))
}
}
Picker("本书朗读引擎", selection: bookTTSEngineBinding) {
Text("跟随全局").tag("__global__")
Text("系统 TTS").tag("")
ForEach(httpTTSEngines) { engine in
Text(engine.name).tag(String(engine.id))
}
}
Picker("系统声音", selection: $systemTTSVoice) {
Text("系统默认中文声音").tag("")
ForEach(systemTTSVoices, id: \.identifier) { voice in
Text("\(voice.name)(\(voice.language))").tag(voice.identifier)
}
}
Toggle("跟随系统语速", isOn: $ttsFollowSystem)
Stepper("朗读语速:\(ttsSpeechRate)", value: $ttsSpeechRate, in: 0...45)
.disabled(ttsFollowSystem)
Toggle("按页朗读", isOn: $readAloudByPage)
Toggle("流式播放在线音频", isOn: $streamReadAloudAudio)
Toggle("朗读时保持亮屏", isOn: $readAloudWakeLock)
Toggle("忽略音频焦点", isOn: $ignoreAudioFocus)
Toggle("忽略焦点时仍在来电时暂停", isOn: $pauseReadAloudWhilePhoneCalls)
.disabled(!ignoreAudioFocus)
Toggle("媒体上一首/下一首切换章节", isOn: $mediaButtonPerNext)
Stepper("默认定时:\(ttsTimer) 分钟", value: $ttsTimer, in: 0...180)
if speechTimerRemaining > 0 {
Text("本次朗读还剩 \(speechTimerRemaining) 分钟")
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
}
if let error = speech.errorMessage {
Text(error).font(.footnote).foregroundStyle(.red)
}
}
Section("操作") {
Button("自动阅读设置", systemImage: "play.circle") {
showsReaderSettings = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsAutoReadControls = true }
}
Button("点击区域设置", systemImage: "square.grid.3x3") {
showsReaderSettings = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsClickActionSettings = true }
}
}
}
.navigationTitle("阅读设置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("完成") { showsReaderSettings = false }
}
}
}
.readingPresentationDetents([.medium, .large])
}
private var contentEditor: some View {
ReadingNavigationStack {
VStack(spacing: 0) {
TextEditor(text: $editedContent)
.font(.body.monospaced())
.padding(8)
if let contentEditError {
Text(contentEditError)
.font(.footnote)
.foregroundStyle(.red)
.padding(.horizontal)
.padding(.bottom, 8)
}
}
.navigationTitle(model.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { showsContentEditor = false }
}
ToolbarItemGroup(placement: .topBarTrailing) {
Button("重置") { refreshEditorFromSource() }
Button("保存") { saveEditedContent() }
.disabled(editedContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
private func refreshCurrentChapter() {
stopSpeechIfNeeded()
Task {
await model.load(rules: replaceRules.rules, forceRefresh: true, prefetchNextChapter: pageAnimation != 3)
readerPosition = min(readerPosition, model.content.utf16.count)
}
}
private func refreshEditorFromSource() {
contentEditError = nil
Task {
await model.load(rules: replaceRules.rules, forceRefresh: true, prefetchNextChapter: pageAnimation != 3)
editedContent = model.content
if let error = model.diagnostics.first(where: { $0.severity == .error }) {
contentEditError = error.message
}
}
}
private func saveEditedContent() {
contentEditError = nil
Task {
do {
try await model.saveEditedContent(editedContent)
readerPosition = min(readerPosition, editedContent.utf16.count)
showsContentEditor = false
} catch {
contentEditError = error.localizedDescription
}
}
}
private var tableOfContents: some View {
ReadingNavigationStack {
List {
Section {
ForEach(tocChapters) { chapter in
if chapter.isVolume {
HStack(spacing: 10) {
Image(systemName: "books.vertical.fill")
Text(chapter.title).font(.headline)
Spacer()
}
.foregroundStyle(ReadingTheme.accent)
.padding(.vertical, 4)
.listRowBackground(ReadingTheme.accent.opacity(0.07))
} else {
let isCurrent = chapter.id == model.currentChapter?.id
Button {
guard let index = model.chapters.firstIndex(where: { $0.id == chapter.id }) else { return }
stopSpeechIfNeeded()
saveProgress(immediate: true)
readerJumpPosition = nil
model.select(index: index)
readerPosition = 0
showsTableOfContents = false
} label: {
HStack(spacing: 12) {
Text(tocChapterNumber(chapter))
.font(.caption.monospacedDigit())
.foregroundStyle(isCurrent ? ReadingTheme.accent : .secondary)
.frame(width: 30, alignment: .trailing)
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title)
.foregroundStyle(.primary)
.lineLimit(2)
if isCurrent {
HStack(spacing: 6) {
Text("正在阅读")
ProgressView(value: chapterProgress)
.tint(ReadingTheme.accent)
}
.font(.caption2)
.foregroundStyle(ReadingTheme.accent)
}
}
Spacer(minLength: 8)
if isCurrent {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ReadingTheme.accent)
}
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
.listRowBackground(isCurrent ? ReadingTheme.accent.opacity(0.08) : Color.clear)
}
}
} header: {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(book.name).font(.headline).lineLimit(1)
Spacer()
Text("\(tocReadableChapterCount) 章")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
ProgressView(value: chapterProgress)
.tint(ReadingTheme.accent)
Text("第 \(max(model.currentIndex + 1, 1)) 章 · \(Int(chapterProgress * 100))%")
.font(.caption)
.foregroundStyle(.secondary)
}
.textCase(nil)
.padding(.vertical, 4)
}
}
.listStyle(.insetGrouped)
.navigationTitle("目录")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $tocSearch, prompt: "搜索章节")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("关闭") { showsTableOfContents = false } }
ToolbarItem(placement: .topBarTrailing) {
Button(tocDescending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") {
tocDescending.toggle()
}
}
}
}
.readingPresentationDetents([.medium, .large])
.readingPresentationDragIndicator()
}
private var tocReadableChapterCount: Int {
model.chapters.filter { !$0.isVolume }.count
}
private func tocChapterNumber(_ chapter: BookChapter) -> String {
guard let index = model.chapters.firstIndex(where: { $0.id == chapter.id }) else { return "—" }
let number = model.chapters.prefix(index + 1).filter { !$0.isVolume }.count
return number > 0 ? "\(number)" : "—"
}
private var contentSearchSheet: some View {
ReadingNavigationStack {
Group {
if contentSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ReadingUnavailableView("搜索正文", systemImage: "magnifyingglass", description: Text("输入本章正文关键字"))
} else if contentSearchResults.isEmpty {
ReadingUnavailableView.search(text: contentSearchQuery)
} else {
List(contentSearchResults) { paragraph in
Button {
readerPosition = paragraph.id
readerJumpPosition = nil
DispatchQueue.main.async { readerJumpPosition = paragraph.id }
showsContentSearch = false
} label: {
Text(paragraph.text)
.foregroundStyle(.primary)
.lineLimit(3)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
.navigationTitle("正文搜索")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $contentSearchQuery, prompt: "搜索本章")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { showsContentSearch = false }
}
}
}
}
private var contentSearchResults: [ReaderParagraph] {
let query = contentSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return [] }
return model.paragraphs.filter { $0.text.localizedCaseInsensitiveContains(query) }
}
private var tocChapters: [BookChapter] {
let query = tocSearch.trimmingCharacters(in: .whitespacesAndNewlines)
let filtered = query.isEmpty ? model.chapters : model.chapters.filter {
$0.title.localizedCaseInsensitiveContains(query)
}
return tocDescending ? Array(filtered.reversed()) : filtered
}
private var bookmarksSheet: some View {
ReadingNavigationStack {
Group {
let bookmarks = activity.bookmarks(for: book)
if bookmarks.isEmpty {
ReadingUnavailableView(
"本书还没有书签",
systemImage: "bookmark",
description: Text("阅读时点击顶部书签按钮即可记录当前位置。")
)
} else {
List(bookmarks) { bookmark in
Button {
jump(to: bookmark)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(bookmark.chapterName).font(.headline).foregroundStyle(.primary)
if !bookmark.bookText.isEmpty {
Text(bookmark.bookText).font(.subheadline).foregroundStyle(.secondary).lineLimit(3)
}
Text("位置 \(bookmark.chapterPosition)").font(.caption.monospacedDigit()).foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.swipeActions {
Button("删除", role: .destructive) { Task { await activity.removeBookmark(bookmark) } }
}
}
}
}
.navigationTitle("书签")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { showsBookmarks = false } } }
}
}
private func addBookmark() {
guard let chapter = model.currentChapter else { return }
bookmarkSaved = true
Task {
await activity.addBookmark(
book: book,
chapter: chapter,
position: readerPosition,
content: model.content
)
try? await Task.sleep(nanoseconds: 1_200_000_000)
bookmarkSaved = false
}
}
private func jump(to bookmark: Bookmark) {
guard let index = bookmark.resolvedChapterIndex(in: model.chapters) else { return }
stopSpeechIfNeeded()
saveProgress(immediate: true)
model.select(index: index, position: bookmark.chapterPosition)
readerPosition = bookmark.chapterPosition
readerJumpPosition = nil
showsBookmarks = false
}
private func saveProgress(immediate: Bool = false) {
guard let chapter = model.currentChapter else { return }
let position = readerPosition
let currentBook = book
progressSaveGeneration &+= 1
progressSaveTask?.cancel()
progressSaveTask = Task { @MainActor [weak library] in
if !immediate { try? await Task.sleep(nanoseconds: 100_000_000) }
guard !Task.isCancelled, let library else { return }
await library.updateProgress(for: currentBook, chapter: chapter, position: position)
}
}
private func stopSpeechIfNeeded() {
speechStartGeneration &+= 1
if speech.isSpeaking || speech.isPaused { speech.toggle(text: "") }
}
private func restartSpeechAfterReaderLayoutChange() {
readerPageRanges = []
guard speech.isSpeaking || speech.isPaused else { return }
speechStartGeneration &+= 1
speech.stop()
configureSpeech()
startConfiguredSpeech()
}
private func toggleSpeech() {
isAutoReading = false
configureSpeech()
if speech.isSpeaking || speech.isPaused {
speechStartGeneration &+= 1
speech.stop()
speechTimerTask?.cancel()
speechTimerRemaining = 0
return
}
scheduleSpeechTimer()
startConfiguredSpeech()
}
private func startConfiguredSpeech() {
if readAloudByPage {
guard !readerPageRanges.isEmpty else {
guard !model.content.isEmpty else { return }
let generation = speechStartGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
guard generation == speechStartGeneration,
readAloudByPage,
!speech.isSpeaking,
!speech.isPaused else { return }
startConfiguredSpeech()
}
return
}
let pageIndex = readerPageRanges.firstIndex { readerPosition < NSMaxRange($0) }
?? max(readerPageRanges.count - 1, 0)
speechPageIndex = pageIndex
speakPage(at: pageIndex, from: readerPosition)
return
}
let start = model.paragraphs.lastIndex(where: { $0.id <= readerPosition }) ?? 0
let text = model.paragraphs.indices.contains(start)
? model.paragraphs[start...].map(\.text).joined(separator: "\n")
: model.content
if let engine = selectedHTTPEngine {
speech.speakHTTP(
engine: engine,
text: text,
continuesAcrossChapters: true,
segmentIndexOffset: start
)
prefetchNextChapterSpeech(engine: engine)
} else {
speech.speak(text, continuesAcrossChapters: true, segmentIndexOffset: start)
}
}
private func speakPage(at index: Int, from position: Int? = nil) {
guard readerPageRanges.indices.contains(index) else {
continueSpeechToNextChapter()
return
}
let range = readerPageRanges[index]
let start = min(max(position ?? range.location, range.location), NSMaxRange(range))
let pageRange = NSRange(location: start, length: max(NSMaxRange(range) - start, 0))
guard pageRange.length > 0 else {
continueSpeechToNextPage()
return
}
let pageText = (model.content as NSString).substring(with: pageRange)
if let engine = selectedHTTPEngine {
speech.speakHTTP(
engine: engine,
text: pageText,
continuesAcrossChapters: true,
segmentIndexOffset: 0
)
} else {
speech.speak(pageText, continuesAcrossChapters: true, segmentIndexOffset: 0)
}
}
private func continueSpeechToNextPage() {
guard readAloudByPage, readerPageRanges.indices.contains(speechPageIndex) else {
continueSpeechToNextChapter()
return
}
readerPosition = NSMaxRange(readerPageRanges[speechPageIndex])
guard readerPageRanges.indices.contains(speechPageIndex + 1) else {
continueSpeechToNextChapter()
return
}
speechPageIndex += 1
let nextPosition = readerPageRanges[speechPageIndex].location
readerPosition = nextPosition
readerJumpPosition = nextPosition
speakPage(at: speechPageIndex)
}
private func prefetchNextChapterSpeech(engine: HttpTTS) {
Task {
guard let content = await model.nextSpeechContent(
rules: model.usesReplaceRules ? replaceRules.rules : []
), selectedHTTPEngine?.id == engine.id else { return }
speech.prefetchHTTP(engine: engine, text: content, limit: 10)
}
}
private func continueSpeechToNextChapter() {
guard model.canGoNext else {
speechTimerTask?.cancel()
speechTimerRemaining = 0
return
}
saveProgress(immediate: true)
resumesSpeechAfterChapterLoad = true
readerJumpPosition = nil
model.goNext()
readerPosition = model.requestedPosition
}
private func moveSpeechToChapter(direction: Int) {
let canMove = direction < 0 ? model.canGoPrevious : model.canGoNext
guard canMove else { return }
speech.stop()
saveProgress(immediate: true)
resumesSpeechAfterChapterLoad = true
readerJumpPosition = nil
if direction < 0 { model.goPrevious() } else { model.goNext() }
readerPosition = model.requestedPosition
}
private func scheduleSpeechTimer() {
speechTimerTask?.cancel()
speechTimerRemaining = ttsTimer
guard speechTimerRemaining > 0 else { return }
speechTimerTask = Task { @MainActor in
do {
while speechTimerRemaining > 0 {
try await Task.sleep(nanoseconds: 60 * 1_000_000_000)
if !speech.isSpeaking && !speech.isPaused && !resumesSpeechAfterChapterLoad {
speechTimerRemaining = 0
break
}
if !speech.isPaused { speechTimerRemaining -= 1 }
if speechTimerRemaining == 0 {
speech.stop()
resumesSpeechAfterChapterLoad = false
}
}
} catch { }
}
}
private func configureSpeech() {
speech.configure(
followsSystemRate: ttsFollowSystem,
speechRate: ttsSpeechRate,
readByPage: readAloudByPage,
streamsHTTPAudio: streamReadAloudAudio,
voiceIdentifier: systemTTSVoice,
ignoresAudioFocus: ignoreAudioFocus,
pausesForPhoneCalls: pauseReadAloudWhilePhoneCalls,
mediaButtonPerNext: mediaButtonPerNext,
bookName: model.book.name,
chapterTitle: model.title,
author: model.book.author
)
}
private static func globalPageAnimation() -> Int {
let defaults = UserDefaults.standard
if defaults.object(forKey: "pageAnim") == nil,
let legacy = defaults.object(forKey: "readerPageAnimation") as? NSNumber {
defaults.set(legacy.intValue, forKey: "pageAnim")
}
return defaults.integer(forKey: "pageAnim")
}
private var selectedHTTPEngine: HttpTTS? {
let value = model.book.readConfig?.ttsEngine ?? appTTSEngine
guard let id = Int64(value) else { return nil }
return httpTTSEngines.first { $0.id == id }
}
private var systemTTSVoices: [AVSpeechSynthesisVoice] {
AVSpeechSynthesisVoice.speechVoices()
.filter { $0.language.hasPrefix("zh") }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
private var bookTTSEngineBinding: Binding<String> {
Binding(
get: { model.book.readConfig?.ttsEngine ?? "__global__" },
set: { value in
let updated = model.setTTSEngine(value == "__global__" ? nil : value)
Task { await library.addOrUpdate(updated) }
}
)
}
private func loadHTTPEngines() async {
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else { return }
let repository = SQLiteConfigurationRepository(database: database)
var values = (try? await repository.loadHttpTTS()) ?? []
if values.isEmpty,
let url = Bundle.main.url(forResource: "httpTTS", withExtension: "json"),
let data = try? Data(contentsOf: url),
let defaults = try? JSONDecoder().decode([HttpTTS].self, from: data) {
values = defaults
try? await repository.saveHttpTTS(values)
}
httpTTSEngines = values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
if !appTTSEngine.isEmpty, selectedHTTPEngine == nil { appTTSEngine = "" }
}
}
#if DEBUG
private struct ReaderFailureTestLoader: ChapterContentLoading {
func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport {
.init(diagnostics: [.init(severity: .error, code: "content.request", message: "测试书源正文请求失败", rule: chapter.url)])
}
}
#endif