import Foundation import RDSpeechReaderView /// EPUB/TXT 朗读会话。它负责把通用朗读进度映射回阅读器的页码和临时高亮, /// 但不会写入用户的高亮或批注持久化数据。 @MainActor public final class RDEPUBSpeechSession: NSObject, RDSpeechReaderControllerDelegate { public let controller: RDSpeechReaderController public let contentProvider: RDEPUBSpeechContentProvider public var onStateChange: ((RDSpeechReaderState) -> Void)? private weak var reader: RDEPUBReaderController? init(reader: RDEPUBReaderController, configuration: RDSpeechReaderConfiguration) { self.reader = reader contentProvider = reader.makeSpeechContentProvider() controller = RDSpeechReaderController(contentProvider: contentProvider, configuration: configuration) super.init() controller.delegate = self } /// 默认从当前阅读位置所在章节开始;可传入已保存的朗读位置继续播放。 public func start(from location: RDSpeechLocation? = nil) async throws { try await controller.start(from: location ?? currentSpeechLocation()) } public func pause() { controller.pause() } public func resume() { controller.resume() } public func stop() { controller.stop() reader?.clearTransientHighlights() } public func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState) { switch state { case .idle, .finished, .failed: reader?.clearTransientHighlights() case .preparing, .speaking, .paused: break } onStateChange?(state) } public func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange) { guard let reader else { return } let href = range.unit.location.resourceIdentifier let absoluteRange = NSRange( location: range.unit.textRange.location + range.range.location, length: max(range.range.length, 1) ) if let chapterData = reader.textChapterData(forNormalizedHref: href), chapterData.attributedContent.length > 0 { let boundedLocation = min(max(absoluteRange.location, 0), max(chapterData.attributedContent.length - 1, 0)) let boundedLength = min(max(absoluteRange.length, 1), max(chapterData.attributedContent.length - boundedLocation, 1)) let boundedRange = NSRange(location: boundedLocation, length: boundedLength) if let page = chapterData.page(containing: boundedRange.location), reader.currentPageNumber != page.absolutePageIndex + 1 { _ = reader.go(toPageNumber: page.absolutePageIndex + 1, animated: false) } let highlight = RDEPUBHighlight( id: "rd-speech-highlight", bookIdentifier: reader.currentBookIdentifier, location: chapterData.location(for: boundedRange, bookIdentifier: reader.currentBookIdentifier), text: chapterData.attributedContent.attributedSubstring(from: boundedRange).string, rangeInfo: RDEPUBTextOffsetRangeInfo( href: chapterData.href, start: boundedRange.location, end: NSMaxRange(boundedRange) ).jsonString(), style: .highlight, color: "#86D7FF" ) reader.setTransientHighlights([highlight]) return } // Web EPUB 不具备与原始 HTML 文本严格一致的原生文本索引。按章节定位, // 并禁用临时文字高亮,避免把一个词级回调误判为多次页面跳转。 if reader.currentLocation?.href != href { reader.go(to: RDEPUBLocation( bookIdentifier: reader.currentBookIdentifier, href: href, progression: progression(for: range) )) } reader.clearTransientHighlights() } private func currentSpeechLocation() -> RDSpeechLocation? { guard let reader, let location = reader.currentLocation else { return nil } let offset = reader.textChapterData(forNormalizedHref: location.href) .flatMap { $0.absoluteRange(for: location)?.location } ?? 0 return RDSpeechLocation( bookIdentifier: contentProvider.speechBookDescriptor().identifier, resourceIdentifier: location.href, textOffset: offset ) } private func progression(for range: RDSpeechSpokenRange) -> Double { let totalLength = max(range.unit.textRange.location + range.unit.textRange.length, 1) let offset = range.unit.textRange.location + range.range.location return min(max(Double(offset) / Double(totalLength), 0), 1) } } public extension RDEPUBReaderController { func makeSpeechSession( configuration: RDSpeechReaderConfiguration = .default ) -> RDEPUBSpeechSession { RDEPUBSpeechSession(reader: self, configuration: configuration) } } public extension RDEpubURLReaderController { /// CBZ 等图片阅读器没有可朗读的文字阅读器时返回 `nil`。 func makeSpeechSession( configuration: RDSpeechReaderConfiguration = .default ) -> RDEPUBSpeechSession? { readerController.map { $0.makeSpeechSession(configuration: configuration) } } }