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

重构排队设置与变更日志交互,补充 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

@ -170,8 +170,8 @@ final class CloudTransferManager {
fileName: seed.fileName,
fileType: seed.fileType,
scenicId: scenicIdProvider()
) { [weak self] progress in
Task { @MainActor in
) { progress in
Task { @MainActor [weak self] in
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
self.updateUploadProgress(id: id, progress: progress)
}
@ -220,8 +220,8 @@ final class CloudTransferManager {
let localURL = try await downloader.downloadFile(
remoteURL: seed.remoteURL,
fileName: seed.fileName
) { [weak self] progress in
Task { @MainActor in
) { progress in
Task { @MainActor [weak self] in
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
self.updateDownloadProgress(id: id, progress: progress)
}

View File

@ -6,14 +6,15 @@
import Foundation
/// Android `GeocodeSearch`
@MainActor
final class LocationGeocoder: NSObject, AMapSearchDelegate {
static let shared = LocationGeocoder()
nonisolated static let shared = LocationGeocoder()
private var searchAPI: AMapSearchAPI?
private var continuation: CheckedContinuation<String, Never>?
private override init() {
nonisolated private override init() {
super.init()
}

View File

@ -7,19 +7,19 @@ import CoreLocation
import Foundation
/// `AMapLocationManager` Android `LocationProvider`
@MainActor
final class LocationProvider: NSObject, LocationProviding {
static let shared = LocationProvider()
nonisolated static let shared = LocationProvider()
private let permissionManager = CLLocationManager()
private let geocoder: LocationGeocoder
private let geocoder = LocationGeocoder.shared
init(geocoder: LocationGeocoder = .shared) {
self.geocoder = geocoder
nonisolated override init() {
super.init()
permissionManager.desiredAccuracy = kCLLocationAccuracyBest
}
@MainActor
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
try AMapBootstrap.requireConfigured()
try await ensureLocationPermission()
@ -51,6 +51,7 @@ final class LocationProvider: NSObject, LocationProviding {
}
}
@MainActor
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
try AMapBootstrap.requireConfigured()
try await ensureLocationPermission()
@ -77,12 +78,14 @@ final class LocationProvider: NSObject, LocationProviding {
}
}
@MainActor
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
}
///
func ensureLocationPermission() async throws {
permissionManager.desiredAccuracy = kCLLocationAccuracyBest
let initialStatus = permissionManager.authorizationStatus
if initialStatus == .notDetermined {
permissionManager.requestWhenInUseAuthorization()

View File

@ -12,7 +12,7 @@ protocol ScenicApplicationImageUploading: AnyObject {
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
onProgress: @escaping @MainActor (Int) -> Void
) async throws -> String
}
@ -247,20 +247,19 @@ final class ScenicApplicationViewModel {
notifyStateChange()
do {
let progressBatch = batch
let url = try await uploader.uploadScenicApplyImage(
data: data,
fileName: image.fileName,
scenicId: scenicId,
onProgress: { [weak self] progress in
Task { @MainActor in
self?.handleUploadProgress(
index: index,
batch: batch,
currentPosition: currentPosition,
total: total,
progress: progress
)
}
self?.handleUploadProgress(
index: index,
batch: progressBatch,
currentPosition: currentPosition,
total: total,
progress: progress
)
}
)
updateImage(at: index) { item in

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