// // ScenicQueueRuntime.swift // suixinkan // // Created by Codex on 2026/6/25. // import AVFoundation import Combine import SwiftUI import UIKit @MainActor /// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。 final class ScenicQueueRuntime: ObservableObject { @Published private(set) var isMonitoring = false @Published private(set) var lastPollText = "--" @Published private(set) var lastSpokenText = "" @Published private(set) var lastQueueCount = 0 @Published private(set) var lastError: String? private let speaker = ScenicQueueSpeechService() private weak var api: (any ScenicQueueServing)? @Published private var userId: String? @Published private var scenicId: Int? @Published private var scenePhase: ScenePhase = .active @Published private var pollTask: Task? @Published private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid @Published private var announcementState = ScenicQueueAnnouncementState() @Published private var lastScenicId: Int? @Published private var lastSpotId: Int? @Published private var suspendedByQueueScreen = false /// 语音播报是否开启。 var voiceEnabled: Bool { get { if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.ttsEnabledKey) == nil { return true } return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.ttsEnabledKey) } set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.ttsEnabledKey) } } /// 后台短时轮询是否开启。 var backgroundPollingEnabled: Bool { get { if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) == nil { return false } return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) } set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) } } /// 更新运行时上下文。 func update( api: any ScenicQueueServing, userId: String?, scenicId: Int?, scenePhase: ScenePhase ) { self.api = api self.userId = userId self.scenicId = scenicId self.scenePhase = scenePhase guard !suspendedByQueueScreen else { stop() return } guard let scenicId else { stop() resetSnapshot() return } let spotId = selectedSpotId() guard spotId > 0 else { stop() lastError = "排队监听未启动:请先在排队设置里选择打卡点" return } if lastScenicId != scenicId || lastSpotId != spotId { resetSnapshot() lastScenicId = scenicId lastSpotId = spotId } if scenePhase == .background, !backgroundPollingEnabled { stop() return } startIfNeeded() } /// 页面自身接管实时监听时暂停运行时轮询。 func setSuspendedByQueueScreen(_ suspended: Bool) { guard suspendedByQueueScreen != suspended else { return } suspendedByQueueScreen = suspended if suspended { stop() } else if let api { update(api: api, userId: userId, scenicId: scenicId, scenePhase: scenePhase) } } /// 停止运行时轮询和播报。 func stop() { pollTask?.cancel() pollTask = nil isMonitoring = false speaker.stop() endBackgroundTask() } /// 立即轮询一次。 func pollNow() { guard pollTask != nil else { return } Task { await pollOnce() } } private func startIfNeeded() { guard pollTask == nil else { return } isMonitoring = true if scenePhase == .background { beginBackgroundTask() } pollTask = Task { [weak self] in await self?.runLoop() } } private func runLoop() async { while !Task.isCancelled { await pollOnce() let seconds = pollIntervalSeconds() try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) } } private func pollOnce() async { guard let api, let scenicId else { return } let spotId = selectedSpotId() guard spotId > 0 else { return } if scenePhase == .background { beginBackgroundTask() } do { if lastScenicId != scenicId || lastSpotId != spotId { resetSnapshot() lastScenicId = scenicId lastSpotId = spotId } async let statsData = api.scenicQueueStats(scenicId: scenicId, scenicSpotId: spotId) async let homeData = api.scenicQueueHome( scenicId: scenicId, scenicSpotId: spotId, type: QueueListType.queueing.rawValue, page: 1, pageSize: 20 ) let (stats, home) = try await (statsData, homeData) handle(stats: stats, tickets: home.list?.list ?? []) lastError = nil } catch { lastError = error.localizedDescription } } private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" lastPollText = formatter.string(from: Date()) lastQueueCount = stats.queueCount guard voiceEnabled else { return } let customText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: lastSpotId) if let announcement = announcementState.nextAnnouncement(stats: stats, tickets: tickets, customCallText: customText) { lastSpokenText = announcement.text speaker.speak(announcement.text) } } private func pollIntervalSeconds() -> UInt64 { switch scenePhase { case .active: return 12 case .inactive: return 20 case .background: return 30 @unknown default: return 20 } } private func selectedSpotId() -> Int { ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) ?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) } private func resetSnapshot() { announcementState.reset() lastQueueCount = 0 lastPollText = "--" lastSpokenText = "" } private func beginBackgroundTask() { guard backgroundTaskId == .invalid else { return } backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in Task { @MainActor in self?.endBackgroundTask() self?.stop() } } } private func endBackgroundTask() { guard backgroundTaskId != .invalid else { return } UIApplication.shared.endBackgroundTask(backgroundTaskId) backgroundTaskId = .invalid } } @MainActor /// 排队语音播报服务。 final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { private let synthesizer = AVSpeechSynthesizer() private var pendingContinuations: [CheckedContinuation] = [] override init() { super.init() synthesizer.delegate = self } /// 播放语音文本。 func speak(_ text: String) { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { return } enqueue(normalized, continuation: nil, replacePending: true) } /// 播放语音并等待结束,供测试和设置页试听使用。 func speakAndWait(_ text: String) async { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { return } await withCheckedContinuation { continuation in enqueue(normalized, continuation: continuation, replacePending: false) } } /// 停止语音播报。 func stop() { synthesizer.stopSpeaking(at: .immediate) resumePending() } private func enqueue(_ normalized: String, continuation: CheckedContinuation?, replacePending: Bool) { if replacePending { synthesizer.stopSpeaking(at: .immediate) resumePending() } if let continuation { pendingContinuations.append(continuation) } let utterance = AVSpeechUtterance(string: normalized) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = 0.48 synthesizer.speak(utterance) } nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { Task { @MainActor in self.resumePending() } } nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { Task { @MainActor in self.resumePending() } } private func resumePending() { let continuations = pendingContinuations pendingContinuations.removeAll() continuations.forEach { $0.resume() } } }