修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
This commit is contained in:
@@ -20,11 +20,16 @@ public final class RDSpeechReaderController: NSObject {
|
||||
private var queuedUnits: [RDSpeechTextUnit] = []
|
||||
private var completedUnits: [RDSpeechTextUnit] = []
|
||||
private var nextLocation: RDSpeechLocation?
|
||||
private var isLoadingBatch = false
|
||||
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,
|
||||
@@ -42,24 +47,25 @@ public final class RDSpeechReaderController: NSObject {
|
||||
|
||||
deinit {
|
||||
notificationTokens.forEach(NotificationCenter.default.removeObserver)
|
||||
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil)
|
||||
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
|
||||
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
|
||||
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
|
||||
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
|
||||
remoteCommandTargets.forEach { $0.command.removeTarget($0.token) }
|
||||
}
|
||||
|
||||
public func start(from location: RDSpeechLocation? = nil) async throws {
|
||||
stop(clearProgress: false)
|
||||
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)
|
||||
try await speakNextUnit()
|
||||
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
|
||||
}
|
||||
@@ -72,31 +78,40 @@ public final class RDSpeechReaderController: NSObject {
|
||||
|
||||
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
|
||||
isLoadingBatch = false
|
||||
loadingGeneration = nil
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
|
||||
state = .idle
|
||||
}
|
||||
|
||||
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() }
|
||||
catch { self.state = .failed(error.localizedDescription) }
|
||||
do { try await self.speakNextUnit(generation: generation) }
|
||||
catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,18 +158,26 @@ public final class RDSpeechReaderController: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBatch(startingAt location: RDSpeechLocation?, requiresContent: Bool) async throws {
|
||||
guard !isLoadingBatch, !isStopping else { return }
|
||||
isLoadingBatch = true
|
||||
defer { isLoadingBatch = false }
|
||||
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 !isStopping {
|
||||
while isCurrentPlaybackGeneration(generation) {
|
||||
let batch = try await contentProvider.speechContentBatch(
|
||||
startingAt: requestedLocation,
|
||||
limit: configuration.batchSize
|
||||
)
|
||||
guard !isStopping else { return }
|
||||
guard isCurrentPlaybackGeneration(generation) else { return }
|
||||
if !batch.units.isEmpty {
|
||||
queuedUnits.append(contentsOf: batch.units)
|
||||
nextLocation = batch.nextLocation
|
||||
@@ -171,12 +194,12 @@ public final class RDSpeechReaderController: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func speakNextUnit() async throws {
|
||||
guard !isStopping else { return }
|
||||
private func speakNextUnit(generation: UInt) async throws {
|
||||
guard isCurrentPlaybackGeneration(generation) else { return }
|
||||
if queuedUnits.isEmpty, let nextLocation {
|
||||
try await loadBatch(startingAt: nextLocation, requiresContent: false)
|
||||
try await loadBatch(startingAt: nextLocation, requiresContent: false, generation: generation)
|
||||
}
|
||||
guard !isStopping else { return }
|
||||
guard isCurrentPlaybackGeneration(generation) else { return }
|
||||
guard !queuedUnits.isEmpty else {
|
||||
if state != .finished {
|
||||
state = .finished
|
||||
@@ -193,6 +216,7 @@ public final class RDSpeechReaderController: NSObject {
|
||||
let utterance = AVSpeechUtterance(string: unit.text)
|
||||
utterance.rate = configuration.rate
|
||||
utterance.voice = resolvedVoice(for: unit)
|
||||
activeUtterance = utterance
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
@@ -208,9 +232,14 @@ public final class RDSpeechReaderController: NSObject {
|
||||
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) {
|
||||
@@ -266,33 +295,43 @@ public final class RDSpeechReaderController: NSObject {
|
||||
commands.togglePlayPauseCommand.isEnabled = true
|
||||
commands.nextTrackCommand.isEnabled = true
|
||||
commands.previousTrackCommand.isEnabled = true
|
||||
commands.playCommand.addTarget { [weak self] _ in
|
||||
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
|
||||
}
|
||||
commands.pauseCommand.addTarget { [weak self] _ in
|
||||
}))
|
||||
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
|
||||
}
|
||||
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
}))
|
||||
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
|
||||
}
|
||||
commands.nextTrackCommand.addTarget { [weak self] _ in
|
||||
}))
|
||||
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
|
||||
}
|
||||
commands.previousTrackCommand.addTarget { [weak self] _ in
|
||||
}))
|
||||
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 else { return }
|
||||
guard configuration.enablesRemoteControls, Self.activeRemoteController === self else { return }
|
||||
let book = contentProvider.speechBookDescriptor()
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: book.title,
|
||||
@@ -305,7 +344,7 @@ public final class RDSpeechReaderController: NSObject {
|
||||
|
||||
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
|
||||
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor [weak self] in self?.handleDidStart() }
|
||||
Task { @MainActor [weak self] in self?.handleDidStart(utterance) }
|
||||
}
|
||||
|
||||
public nonisolated func speechSynthesizer(
|
||||
@@ -313,55 +352,58 @@ extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
|
||||
willSpeakRangeOfSpeechString characterRange: NSRange,
|
||||
utterance: AVSpeechUtterance
|
||||
) {
|
||||
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange) }
|
||||
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() }
|
||||
Task { @MainActor [weak self] in self?.handleDidPause(utterance) }
|
||||
}
|
||||
|
||||
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor [weak self] in self?.handleDidContinue() }
|
||||
Task { @MainActor [weak self] in self?.handleDidContinue(utterance) }
|
||||
}
|
||||
|
||||
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor [weak self] in self?.handleDidFinish() }
|
||||
Task { @MainActor [weak self] in self?.handleDidFinish(utterance) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension RDSpeechReaderController {
|
||||
func handleDidStart() {
|
||||
guard let currentUnit else { return }
|
||||
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) {
|
||||
guard let currentUnit else { return }
|
||||
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() {
|
||||
guard let currentUnit else { return }
|
||||
func handleDidPause(_ utterance: AVSpeechUtterance) {
|
||||
guard utterance === activeUtterance, let currentUnit else { return }
|
||||
state = .paused(currentUnit.location)
|
||||
}
|
||||
|
||||
func handleDidContinue() {
|
||||
guard let currentUnit else { return }
|
||||
func handleDidContinue(_ utterance: AVSpeechUtterance) {
|
||||
guard utterance === activeUtterance, let currentUnit else { return }
|
||||
state = .speaking(currentUnit.location)
|
||||
}
|
||||
|
||||
func handleDidFinish() {
|
||||
guard !isStopping, let finished = currentUnit else { return }
|
||||
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() }
|
||||
catch { self.state = .failed(error.localizedDescription) }
|
||||
do { try await self.speakNextUnit(generation: generation) }
|
||||
catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user