Files
suixinkan_ios_new/suixinkan/Core/Queue/ScenicQueueRuntime.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

289 lines
9.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<Void, Never>?
@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<Void, Never>] = []
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<Void, Never>?, 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() }
}
}