mycode/myApp/Keging/Shared/HapticEnginePlayer.swift
songyc macbook 8f634c83f4 fix(keging): 실기기 3차 피드백 6건 — DI 멈춤 근본 원인(햅틱 엔진 세션 충돌) 제거·워치 세팅 pull 동기화·완료 진동·하루 타임라인·칼로리 정합
- 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
2026-09-02 14:30:08 +09:00

90 lines
3.4 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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