新增景区排队管理功能
This commit is contained in:
@ -0,0 +1,55 @@
|
||||
//
|
||||
// ScenicQueueQRCodeSaver.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
/// 排队二维码保存服务。
|
||||
enum ScenicQueueQRCodeSaver {
|
||||
/// 下载二维码图片并保存到系统相册。
|
||||
static func saveImage(from urlString: String) async throws {
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw ScenicQueueQRCodeSaverError.invalidURL
|
||||
}
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw ScenicQueueQRCodeSaverError.invalidImage
|
||||
}
|
||||
try await saveToPhotoLibrary(image)
|
||||
}
|
||||
|
||||
private static func saveToPhotoLibrary(_ image: UIImage) async throws {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
if status == .notDetermined {
|
||||
_ = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
}
|
||||
guard PHPhotoLibrary.authorizationStatus(for: .addOnly) == .authorized
|
||||
|| PHPhotoLibrary.authorizationStatus(for: .addOnly) == .limited else {
|
||||
throw ScenicQueueQRCodeSaverError.denied
|
||||
}
|
||||
try await PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetChangeRequest.creationRequestForAsset(from: image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队二维码保存错误。
|
||||
enum ScenicQueueQRCodeSaverError: LocalizedError {
|
||||
case invalidURL
|
||||
case invalidImage
|
||||
case denied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
"服务端返回的二维码地址无效"
|
||||
case .invalidImage:
|
||||
"下载或保存二维码失败,请检查网络与存储权限"
|
||||
case .denied:
|
||||
"未授予权限,无法将小程序码保存到相册"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
//
|
||||
// ScenicQueueSocketClient.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 排队 WebSocket 客户端,订阅打卡点队列刷新与叫号事件。
|
||||
final class ScenicQueueSocketClient {
|
||||
var onMessage: ((ScenicQueueSocketMessage) -> Void)?
|
||||
|
||||
private let environment: APIEnvironment
|
||||
private let session: URLSession
|
||||
private let decoder = JSONDecoder()
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var connectedSpotId: Int64?
|
||||
|
||||
init(environment: APIEnvironment = .current, session: URLSession = .shared) {
|
||||
self.environment = environment
|
||||
self.session = session
|
||||
}
|
||||
|
||||
/// 建立连接并订阅指定打卡点。
|
||||
func connect(socketToken: String, scenicId: Int64, scenicSpotId: Int64) {
|
||||
guard !socketToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
disconnect()
|
||||
connectedSpotId = scenicSpotId
|
||||
let webSocketTask = session.webSocketTask(with: environment.webSocketURL)
|
||||
task = webSocketTask
|
||||
webSocketTask.resume()
|
||||
sendSubscribePayloads(scenicSpotId: scenicSpotId)
|
||||
receiveLoop()
|
||||
}
|
||||
|
||||
/// 断开连接。
|
||||
func disconnect() {
|
||||
task?.cancel(with: .normalClosure, reason: nil)
|
||||
task = nil
|
||||
connectedSpotId = nil
|
||||
}
|
||||
|
||||
private func sendSubscribePayloads(scenicSpotId: Int64) {
|
||||
[
|
||||
#"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
|
||||
#"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
|
||||
].forEach { payload in
|
||||
task?.send(.string(payload)) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLoop() {
|
||||
task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
if case .string(let text) = message {
|
||||
self.handle(text)
|
||||
}
|
||||
self.receiveLoop()
|
||||
case .failure:
|
||||
self.task = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ text: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return }
|
||||
guard let message = try? decoder.decode(ScenicQueueSocketMessage.self, from: data) else { return }
|
||||
guard message.isQueueEvent else { return }
|
||||
onMessage?(message)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
//
|
||||
// ScenicQueueTTSManager.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// 排队语音播报服务协议,隔离具体 TTS SDK。
|
||||
protocol ScenicQueueTTSManaging: AnyObject {
|
||||
/// 自定义文案是否正在循环播报。
|
||||
var customTextLooping: Bool { get }
|
||||
|
||||
/// 预热排队 TTS 引擎。
|
||||
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async
|
||||
|
||||
/// 开始循环播报指定排号。
|
||||
func startQueueCallLoop(queueNo: String)
|
||||
|
||||
/// 停止叫号循环。
|
||||
func stopQueueCallLoop()
|
||||
|
||||
/// 单次播报普通文本。
|
||||
@discardableResult
|
||||
func speakPlainTextOnce(_ text: String) async -> Bool
|
||||
|
||||
/// 开始循环播报自定义文本。
|
||||
@discardableResult
|
||||
func startCustomTextLoop(_ text: String) async -> Bool
|
||||
|
||||
/// 停止自定义文本循环。
|
||||
func stopCustomTextLoop()
|
||||
|
||||
/// 中断拍摄倒计时等临时播报。
|
||||
func interruptShootingAnnouncement()
|
||||
|
||||
/// 估算播报耗时。
|
||||
func estimateAnnouncementDurationMillis(_ text: String) -> Int64
|
||||
}
|
||||
|
||||
/// 阿里云 NLS TTS 管理器。
|
||||
///
|
||||
/// 当前工程未随仓库提供阿里云 iOS NativeNui SDK,类中保留 STS/NLS 预热链路与 SDK 桥接边界;
|
||||
/// 在 SDK framework 加入后可在 `prepareNativeEngineIfAvailable` 内完成真实 NativeNui 初始化。
|
||||
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate {
|
||||
static let shared = AliyunNLSTTSManager()
|
||||
|
||||
private enum LoopKind {
|
||||
case queueCall(String)
|
||||
case custom(String)
|
||||
}
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts")
|
||||
private var activeLoop: LoopKind?
|
||||
private var currentContinuation: CheckedContinuation<Void, Never>?
|
||||
private var nlsToken: String?
|
||||
private var lastCredentials: AliyunOSSCredentials?
|
||||
|
||||
private(set) var customTextLooping = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async {
|
||||
guard let api else { return }
|
||||
do {
|
||||
let sts = try await api.aliyunSTS(bucket: "vipsky")
|
||||
lastCredentials = sts.credentials
|
||||
nlsToken = nil
|
||||
prepareNativeEngineIfAvailable(credentials: sts.credentials)
|
||||
} catch {
|
||||
// Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails.
|
||||
}
|
||||
}
|
||||
|
||||
func startQueueCallLoop(queueNo: String) {
|
||||
let text = Self.callAnnouncementText(queueNo: queueNo)
|
||||
loopQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeLoop = .queueCall(queueNo)
|
||||
self.customTextLooping = false
|
||||
Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) }
|
||||
}
|
||||
}
|
||||
|
||||
func stopQueueCallLoop() {
|
||||
loopQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if case .queueCall = self.activeLoop {
|
||||
self.activeLoop = nil
|
||||
}
|
||||
self.stopSpeaking()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func speakPlainTextOnce(_ text: String) async -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
await speak(trimmed)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func startCustomTextLoop(_ text: String) async -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
loopQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeLoop = .custom(trimmed)
|
||||
self.customTextLooping = true
|
||||
Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stopCustomTextLoop() {
|
||||
loopQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if case .custom = self.activeLoop {
|
||||
self.activeLoop = nil
|
||||
}
|
||||
self.customTextLooping = false
|
||||
self.stopSpeaking()
|
||||
}
|
||||
}
|
||||
|
||||
func interruptShootingAnnouncement() {
|
||||
stopSpeaking()
|
||||
}
|
||||
|
||||
func estimateAnnouncementDurationMillis(_ text: String) -> Int64 {
|
||||
let count = max(1, text.count)
|
||||
return Int64(min(max(850 + count * 200, 900), 12_000))
|
||||
}
|
||||
|
||||
/// 排队号 TTS 格式化,降低字母数字连读概率。
|
||||
static func formatQueueNoForTTS(_ queueNo: String) -> String {
|
||||
let trimmed = queueNo.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let core = trimmed.hasSuffix("号") ? String(trimmed.dropLast()) : trimmed
|
||||
guard !core.isEmpty else { return trimmed.isEmpty ? "请" : trimmed }
|
||||
let pattern = #"^([A-Za-z]+)([0-9]+)$"#
|
||||
if let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: core, range: NSRange(core.startIndex..., in: core)),
|
||||
let lettersRange = Range(match.range(at: 1), in: core),
|
||||
let digitsRange = Range(match.range(at: 2), in: core) {
|
||||
return "\(core[lettersRange].uppercased()),\(core[digitsRange])号"
|
||||
}
|
||||
return "\(core)号"
|
||||
}
|
||||
|
||||
/// 叫号播报文案。
|
||||
static func callAnnouncementText(queueNo: String) -> String {
|
||||
"\(formatQueueNoForTTS(queueNo)),请到拍摄区域,准备拍摄。"
|
||||
}
|
||||
|
||||
private func runLoop(text: String, expected: LoopKind) async {
|
||||
while isLoopStillActive(expected) {
|
||||
await speak(text)
|
||||
guard isLoopStillActive(expected) else { break }
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
if case .custom = expected {
|
||||
customTextLooping = false
|
||||
}
|
||||
}
|
||||
|
||||
private func isLoopStillActive(_ expected: LoopKind) -> Bool {
|
||||
loopQueue.sync {
|
||||
switch (activeLoop, expected) {
|
||||
case (.queueCall(let lhs), .queueCall(let rhs)):
|
||||
lhs == rhs
|
||||
case (.custom(let lhs), .custom(let rhs)):
|
||||
lhs == rhs
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func speak(_ text: String) async {
|
||||
await MainActor.run {
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSpeaking() {
|
||||
DispatchQueue.main.async {
|
||||
if self.synthesizer.isSpeaking {
|
||||
self.synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
self.resumeCurrentContinuationIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
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(credentials: AliyunOSSCredentials) {
|
||||
_ = NSClassFromString("NativeNui")
|
||||
_ = credentials
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user