- CHHapticEngine을 공유 AVAudioSession에 부착(기본 생성 시 독자 오디오 정책이 백그라운드 무음 루프를 끊던 것이 DI 멈춤 원인) + 오디오 키퍼 하드닝(루트 변경· 미디어 서버 리셋·ensureAlive) + 운동 중 2초 하트비트(오디오·엔진·LA 자가 치유) - Live Activity: 실제 phaseStart 사용·동일 상태 no-op·staleDate 8초·isStale 흐림 - 워치 세팅 동기화 3중화: 변경 push + reachability 재푸시 + 워치 앱 열릴 때 sendMessage pull - 완주 진동 신설: 폰 2.4초 연속(백그라운드 6방 체인, 오디오 정지 4초 지연), 워치 retry×2+success - 통계 하루 탭 맨 위 '오늘의 타임라인'(24시간 축 점 그래프, 시각 라벨·지금 룰) - 폰 칼로리 공식을 애플워치 '기타' 운동 규칙(빠르게 걷기 상당)에 정합: MET 4.0+강도×0.15 - 도움말 4건 갱신·en/ja 번역·CFBundleName 보충, CLAUDE.md 현행화 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
90 lines
3.4 KiB
Swift
90 lines
3.4 KiB
Swift
//
|
||
// HapticEnginePlayer.swift
|
||
// Keging
|
||
//
|
||
// Core Haptics 정밀 패턴 재생 — 아이폰 전용·포그라운드 전용 (watchOS SDK엔 CoreHaptics 없음).
|
||
// 시스템 바이브로는 불가능한 짧은 간격의 연타와 1초 이상의 연속 진동을 만든다.
|
||
//
|
||
// ⚠️엔진은 반드시 공유 AVAudioSession에 부착해 만든다(CHHapticEngine(audioSession:)).
|
||
// 기본 생성하면 햅틱 엔진이 독자 오디오 정책으로 세션을 건드려, 운동 중 백그라운드 유지용
|
||
// 무음 루프가 끊길 수 있다(실기기 3차 사고 — 포그라운드에서 진동 몇 번 후 잠그면 DI 멈춤).
|
||
// 엔진이 죽어 있으면 1회 재생성 후 재시도, 그래도 실패면 false(호출 측이 시스템 진동으로 폴백).
|
||
//
|
||
|
||
#if canImport(CoreHaptics)
|
||
import AVFoundation
|
||
import CoreHaptics
|
||
|
||
@MainActor
|
||
final class HapticEnginePlayer {
|
||
static let shared = HapticEnginePlayer()
|
||
|
||
private var engine: CHHapticEngine?
|
||
|
||
private init() {}
|
||
|
||
@discardableResult
|
||
func play(_ pattern: HapticPattern) -> Bool {
|
||
play(events: pattern.hapticEvents)
|
||
}
|
||
|
||
/// 완주 축하 진동 — 아주 긴 "우우우우웅"
|
||
@discardableResult
|
||
func playFinish() -> Bool {
|
||
play(events: FinishHaptic.events)
|
||
}
|
||
|
||
private func play(events: [(time: Double, duration: Double, intensity: Float, sharpness: Float)]) -> Bool {
|
||
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return false }
|
||
ensureSession()
|
||
if attempt(events) { return true }
|
||
engine = nil
|
||
return attempt(events)
|
||
}
|
||
|
||
/// 공유 세션을 재생 카테고리로 유지 — 운동 중엔 BackgroundAudioKeeper와 같은 설정이라 무해하고,
|
||
/// 세팅의 '미리 느끼기'처럼 키퍼가 없는 상황에서도 엔진이 도는 기반이 된다
|
||
private func ensureSession() {
|
||
let session = AVAudioSession.sharedInstance()
|
||
if session.category != .playback {
|
||
try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||
}
|
||
try? session.setActive(true)
|
||
}
|
||
|
||
private func attempt(_ specs: [(time: Double, duration: Double, intensity: Float, sharpness: Float)]) -> Bool {
|
||
do {
|
||
let engine = try currentEngine()
|
||
let events = specs.map { spec in
|
||
CHHapticEvent(
|
||
eventType: .hapticContinuous,
|
||
parameters: [
|
||
CHHapticEventParameter(parameterID: .hapticIntensity, value: spec.intensity),
|
||
CHHapticEventParameter(parameterID: .hapticSharpness, value: spec.sharpness),
|
||
],
|
||
relativeTime: spec.time,
|
||
duration: spec.duration
|
||
)
|
||
}
|
||
let player = try engine.makePlayer(with: try CHHapticPattern(events: events, parameters: []))
|
||
try engine.start()
|
||
try player.start(atTime: CHHapticTimeImmediate)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func currentEngine() throws -> CHHapticEngine {
|
||
if let engine { return engine }
|
||
let newEngine = try CHHapticEngine(audioSession: AVAudioSession.sharedInstance())
|
||
newEngine.isAutoShutdownEnabled = true
|
||
newEngine.resetHandler = { [weak self] in
|
||
Task { @MainActor in self?.engine = nil }
|
||
}
|
||
engine = newEngine
|
||
return newEngine
|
||
}
|
||
}
|
||
#endif
|