完善景区排队设置页与全局按钮配置迁移。

重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 16:51:23 +08:00
parent 6336feff27
commit 5eef31b8da
17 changed files with 181 additions and 124 deletions

View File

@ -41,8 +41,67 @@ protocol ScenicQueueTTSManaging: AnyObject {
func estimateAnnouncementDurationMillis(_ text: String) -> Int64
}
/// 线 continuation
@MainActor
private final class SystemVoiceSynthesizer: NSObject, @preconcurrency AVSpeechSynthesizerDelegate {
private let synthesizer = AVSpeechSynthesizer()
private var currentContinuation: CheckedContinuation<Void, Never>?
private var currentUtterance: AVSpeechUtterance?
nonisolated override init() {
super.init()
}
///
func speak(_ text: String) async {
synthesizer.delegate = self
finishCurrentSpeech()
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
await withCheckedContinuation { continuation in
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86
utterance.volume = 1.0
currentContinuation = continuation
currentUtterance = utterance
synthesizer.speak(utterance)
}
}
///
func stop() {
finishCurrentSpeech()
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
finishCurrentSpeech(matching: utterance)
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
finishCurrentSpeech(matching: utterance)
}
private func finishCurrentSpeech(matching utterance: AVSpeechUtterance? = nil) {
if let utterance, utterance !== currentUtterance {
return
}
currentUtterance = nil
currentContinuation?.resume()
currentContinuation = nil
}
}
/// NLS TTS 使 NUI SDK退
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate {
///
/// 线 `loopQueue` `nativeQueue`
/// `SystemVoiceSynthesizer` Actor
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, @unchecked Sendable {
static let shared = AliyunNLSTTSManager()
private enum LoopKind {
@ -62,7 +121,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
let voicePath: String
}
private let synthesizer = AVSpeechSynthesizer()
private let systemVoiceSynthesizer = SystemVoiceSynthesizer()
private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts")
private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS")
private let appStore: AppStore
@ -71,15 +130,15 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
private let streamingPlayer: AliyunPCMStreamingPlaying
private let workspacePathProvider: () -> String?
private var activeLoop: LoopKind?
private var currentContinuation: CheckedContinuation<Void, Never>?
private var nativeContinuation: CheckedContinuation<Bool, Never>?
private var nlsToken: AliyunNLSToken?
private var lastCredentials: AliyunOSSCredentials?
private var nativeReady = false
private var nativeEngineKind: NativeEngineKind?
private var nativeSampleRate: Double
private var customTextLoopingStorage = false
private(set) var customTextLooping = false
var customTextLooping: Bool {
loopQueue.sync { customTextLoopingStorage }
}
var supportsOfflineTTS: Bool {
bridge.sdkAvailable && offlineResources() != nil
@ -99,24 +158,22 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath
nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate
super.init()
synthesizer.delegate = self
self.bridge.delegate = self
}
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
guard let api else { return }
guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else {
nativeReady = false
await setNativeReady(false)
return
}
do {
let sts = try await api.aliyunSTS(bucket: "vipsky")
lastCredentials = sts.credentials
let token = try await tokenProvider.createToken(credentials: sts.credentials)
nlsToken = token
nativeReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials)
let isReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials)
await setNativeReady(isReady)
} catch {
nativeReady = false
await setNativeReady(false)
}
}
@ -125,7 +182,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
loopQueue.async { [weak self] in
guard let self else { return }
self.activeLoop = .queueCall(queueNo)
self.customTextLooping = false
self.customTextLoopingStorage = false
Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) }
}
}
@ -155,7 +212,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
loopQueue.async { [weak self] in
guard let self else { return }
self.activeLoop = .custom(trimmed)
self.customTextLooping = true
self.customTextLoopingStorage = true
Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) }
}
return true
@ -167,7 +224,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
if case .custom = self.activeLoop {
self.activeLoop = nil
}
self.customTextLooping = false
self.customTextLoopingStorage = false
self.stopSpeaking()
}
}
@ -208,7 +265,9 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
if case .custom = expected {
customTextLooping = false
loopQueue.async { [weak self] in
self?.customTextLoopingStorage = false
}
}
}
@ -254,49 +313,28 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
}
}
private func speakWithSystemVoice(_ text: String) async {
await MainActor.run {
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
}
private func setNativeReady(_ isReady: Bool) async {
await withCheckedContinuation { continuation in
currentContinuation = continuation
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86
utterance.volume = 1.0
DispatchQueue.main.async {
self.synthesizer.speak(utterance)
nativeQueue.async { [weak self] in
self?.nativeReady = isReady
continuation.resume()
}
}
}
private func speakWithSystemVoice(_ text: String) async {
await systemVoiceSynthesizer.speak(text)
}
private func stopSpeaking() {
nativeQueue.async { [weak self] in
self?.cancelNativeSpeakLocked(resumeExisting: true)
}
DispatchQueue.main.async {
if self.synthesizer.isSpeaking {
self.synthesizer.stopSpeaking(at: .immediate)
}
self.resumeCurrentContinuationIfNeeded()
Task { @MainActor [systemVoiceSynthesizer] in
systemVoiceSynthesizer.stop()
}
}
private func resumeCurrentContinuationIfNeeded() {
currentContinuation?.resume()
currentContinuation = nil
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
resumeCurrentContinuationIfNeeded()
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
resumeCurrentContinuationIfNeeded()
}
private func prepareNativeEngineIfAvailable(
token: AliyunNLSToken,
credentials: AliyunOSSCredentials