Files
ReadViewSDK/Sources/RDSpeechReaderView/Sources/RDSpeechReaderController.swift
T
shenlei 3e60bf1869 修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 20:52:21 +09:00

424 lines
17 KiB
Swift

import AVFAudio
import Foundation
import MediaPlayer
@MainActor
public final class RDSpeechReaderController: NSObject {
public weak var delegate: RDSpeechReaderControllerDelegate?
public private(set) var state: RDSpeechReaderState = .idle {
didSet {
updateNowPlayingInfo()
delegate?.speechReaderController(self, didChange: state)
}
}
public private(set) var configuration: RDSpeechReaderConfiguration
private let contentProvider: RDSpeechContentProvider
private let progressStore: RDSpeechProgressPersisting?
private let synthesizer = AVSpeechSynthesizer()
private var currentUnit: RDSpeechTextUnit?
private var queuedUnits: [RDSpeechTextUnit] = []
private var completedUnits: [RDSpeechTextUnit] = []
private var nextLocation: RDSpeechLocation?
private var loadingGeneration: UInt?
private var playbackGeneration: UInt = 0
private var activeUtterance: AVSpeechUtterance?
private var isStopping = false
private var shouldResumeAfterInterruption = false
private var sleepTimerTask: Task<Void, Never>?
private var notificationTokens: [NSObjectProtocol] = []
private var remoteCommandTargets: [(command: MPRemoteCommand, token: Any)] = []
private static weak var activeRemoteController: RDSpeechReaderController?
public init(
contentProvider: RDSpeechContentProvider,
configuration: RDSpeechReaderConfiguration = .default,
progressStore: RDSpeechProgressPersisting? = RDSpeechUserDefaultsProgressStore()
) {
self.contentProvider = contentProvider
self.configuration = configuration
self.progressStore = progressStore
super.init()
synthesizer.delegate = self
installAudioObservers()
if configuration.enablesRemoteControls { installRemoteControls() }
}
deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver)
remoteCommandTargets.forEach { $0.command.removeTarget($0.token) }
}
public func start(from location: RDSpeechLocation? = nil) async throws {
playbackGeneration &+= 1
let generation = playbackGeneration
resetPlayback(clearProgress: false)
isStopping = false
completedUnits.removeAll()
claimRemoteControlsIfNeeded()
state = .preparing
do {
if configuration.configuresAudioSession { try configureAudioSession() }
let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
try await loadBatch(startingAt: initialLocation, requiresContent: true, generation: generation)
guard isCurrentPlaybackGeneration(generation) else { return }
try await speakNextUnit(generation: generation)
} catch {
guard isCurrentPlaybackGeneration(generation) else { return }
state = .failed(error.localizedDescription)
throw error
}
}
public func pause() {
guard synthesizer.isSpeaking else { return }
synthesizer.pauseSpeaking(at: .word)
}
public func resume() {
guard synthesizer.isPaused else { return }
claimRemoteControlsIfNeeded()
synthesizer.continueSpeaking()
}
public func stop() { stop(clearProgress: false) }
public func stop(clearProgress: Bool) {
playbackGeneration &+= 1
resetPlayback(clearProgress: clearProgress)
state = .idle
}
private func resetPlayback(clearProgress: Bool) {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
currentUnit = nil
queuedUnits.removeAll()
nextLocation = nil
loadingGeneration = nil
sleepTimerTask?.cancel()
sleepTimerTask = nil
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
}
public func skipToNextSentence() {
guard state.isActive else { return }
let generation = playbackGeneration
stopCurrentUtteranceForNavigation()
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit(generation: generation) }
catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
}
}
public func skipToPreviousSentence() {
guard state.isActive else { return }
let current = currentUnit
let previous = completedUnits.popLast() ?? current
guard let previous else { return }
if let current, previous.id != current.id { queuedUnits.insert(current, at: 0) }
stopCurrentUtteranceForNavigation()
currentUnit = previous
speak(previous)
}
/// Restarts the current sentence so a changed rate takes effect immediately.
public func updateRate(_ rate: Float) {
configuration.rate = min(max(rate, 0.0), 1.0)
restartCurrentUnitIfNeeded()
}
/// Restarts the current sentence so a changed voice takes effect immediately.
public func updateVoice(identifier: String?) {
configuration.voiceIdentifier = identifier
restartCurrentUnitIfNeeded()
}
public func availableVoices(languagePrefix: String? = nil) -> [RDSpeechVoice] {
AVSpeechSynthesisVoice.speechVoices()
.filter { voice in languagePrefix.map { voice.language.hasPrefix($0) } ?? true }
.map(RDSpeechVoice.init)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
public func setSleepTimer(after interval: TimeInterval?) {
sleepTimerTask?.cancel()
guard let interval, interval > 0 else {
sleepTimerTask = nil
return
}
sleepTimerTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
guard !Task.isCancelled else { return }
self?.stop()
}
}
private func loadBatch(
startingAt location: RDSpeechLocation?,
requiresContent: Bool,
generation: UInt
) async throws {
guard loadingGeneration != generation, isCurrentPlaybackGeneration(generation) else { return }
loadingGeneration = generation
defer {
if loadingGeneration == generation {
loadingGeneration = nil
}
}
var requestedLocation = location
var mustContainContent = requiresContent
while isCurrentPlaybackGeneration(generation) {
let batch = try await contentProvider.speechContentBatch(
startingAt: requestedLocation,
limit: configuration.batchSize
)
guard isCurrentPlaybackGeneration(generation) else { return }
if !batch.units.isEmpty {
queuedUnits.append(contentsOf: batch.units)
nextLocation = batch.nextLocation
return
}
guard let next = batch.nextLocation else {
if mustContainContent { throw RDSpeechReaderError.noReadableContent }
state = .finished
progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
return
}
requestedLocation = next
mustContainContent = false
}
}
private func speakNextUnit(generation: UInt) async throws {
guard isCurrentPlaybackGeneration(generation) else { return }
if queuedUnits.isEmpty, let nextLocation {
try await loadBatch(startingAt: nextLocation, requiresContent: false, generation: generation)
}
guard isCurrentPlaybackGeneration(generation) else { return }
guard !queuedUnits.isEmpty else {
if state != .finished {
state = .finished
progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
}
return
}
let unit = queuedUnits.removeFirst()
currentUnit = unit
speak(unit)
}
private func speak(_ unit: RDSpeechTextUnit) {
let utterance = AVSpeechUtterance(string: unit.text)
utterance.rate = configuration.rate
utterance.voice = resolvedVoice(for: unit)
activeUtterance = utterance
synthesizer.speak(utterance)
}
private func restartCurrentUnitIfNeeded() {
guard let currentUnit, state.isActive else { return }
let wasPaused = synthesizer.isPaused
stopCurrentUtteranceForNavigation()
self.currentUnit = currentUnit
speak(currentUnit)
if wasPaused { synthesizer.pauseSpeaking(at: .immediate) }
}
private func stopCurrentUtteranceForNavigation() {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
isStopping = false
}
private func isCurrentPlaybackGeneration(_ generation: UInt) -> Bool {
playbackGeneration == generation && !isStopping
}
private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? {
if let identifier = configuration.voiceIdentifier,
let voice = AVSpeechSynthesisVoice(identifier: identifier) {
return voice
}
return AVSpeechSynthesisVoice(language: unit.language ?? configuration.defaultLanguage)
?? AVSpeechSynthesisVoice(language: configuration.defaultLanguage)
}
private func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
var options: AVAudioSession.CategoryOptions = []
if configuration.ducksOtherAudio { options.insert(.duckOthers) }
try session.setCategory(.playback, mode: .spokenAudio, options: options)
try session.setActive(true, options: [])
}
private func installAudioObservers() {
let center = NotificationCenter.default
notificationTokens.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] notification in
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt ?? 0
guard let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
Task { @MainActor [weak self] in
guard let self else { return }
if type == .began {
self.shouldResumeAfterInterruption = self.state.isSpeaking
self.pause()
} else if self.shouldResumeAfterInterruption {
self.shouldResumeAfterInterruption = false
self.resume()
}
}
})
notificationTokens.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] notification in
guard let rawValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
AVAudioSession.RouteChangeReason(rawValue: rawValue) == .oldDeviceUnavailable else { return }
Task { @MainActor [weak self] in self?.pause() }
})
}
private func installRemoteControls() {
let commands = MPRemoteCommandCenter.shared()
commands.playCommand.isEnabled = true
commands.pauseCommand.isEnabled = true
commands.togglePlayPauseCommand.isEnabled = true
commands.nextTrackCommand.isEnabled = true
commands.previousTrackCommand.isEnabled = true
remoteCommandTargets.append((commands.playCommand, commands.playCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.resume() }
return .success
}))
remoteCommandTargets.append((commands.pauseCommand, commands.pauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.pause() }
return .success
}))
remoteCommandTargets.append((commands.togglePlayPauseCommand, commands.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in
guard let self else { return }
self.synthesizer.isPaused ? self.resume() : self.pause()
}
return .success
}))
remoteCommandTargets.append((commands.nextTrackCommand, commands.nextTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToNextSentence() }
return .success
}))
remoteCommandTargets.append((commands.previousTrackCommand, commands.previousTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToPreviousSentence() }
return .success
}))
}
private func claimRemoteControlsIfNeeded() {
guard configuration.enablesRemoteControls else { return }
Self.activeRemoteController = self
}
private func updateNowPlayingInfo() {
guard configuration.enablesRemoteControls, Self.activeRemoteController === self else { return }
let book = contentProvider.speechBookDescriptor()
var info: [String: Any] = [
MPMediaItemPropertyTitle: book.title,
MPMediaItemPropertyArtist: "ReadViewSDK"
]
info[MPNowPlayingInfoPropertyPlaybackRate] = state.isSpeaking ? 1.0 : 0.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidStart(utterance) }
}
public nonisolated func speechSynthesizer(
_ synthesizer: AVSpeechSynthesizer,
willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance
) {
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange, utterance: utterance) }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidPause(utterance) }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidContinue(utterance) }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidFinish(utterance) }
}
}
private extension RDSpeechReaderController {
func handleDidStart(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier)
state = .speaking(currentUnit.location)
}
func handleWillSpeak(_ characterRange: NSRange, utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
var location = currentUnit.location
location.textOffset = currentUnit.textRange.location + characterRange.location
progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier)
delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange))
}
func handleDidPause(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
state = .paused(currentUnit.location)
}
func handleDidContinue(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
state = .speaking(currentUnit.location)
}
func handleDidFinish(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, !isStopping, let finished = currentUnit else { return }
completedUnits.append(finished)
activeUtterance = nil
currentUnit = nil
let generation = playbackGeneration
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit(generation: generation) }
catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
}
}
}
private extension RDSpeechReaderState {
var isActive: Bool {
switch self {
case .preparing, .speaking, .paused: return true
case .idle, .finished, .failed: return false
}
}
var isSpeaking: Bool {
if case .speaking = self { return true }
return false
}
}