mycode/myApp/Keging/IOS/BackgroundAudioKeeper.swift
songyc macbook 32737d74fd feat(keging): Live Activity 자가 렌더링 재설계 — 앱이 정지해도 움직이는 DI/잠금화면 (실기기 4차 피드백)
- ActivityKit엔 미래 상태 예약 API가 없음을 확인(푸시 서버 없이는 국면 갱신이 앱
  런타임 의존) → 상태에 세트 전체 일정을 실어 시스템 렌더 요소로 심장을 재구성:
  국면 색 타임라인 바(하드 스톱 그라데이션) + 실시간 플레이헤드
  ProgressView(timerInterval:) + 전체 남은 시간. 플레이헤드가 걸친 색 = 지금 국면
- 국면 라벨·횟수는 앱 갱신 유지(syncCurrent 단일 진입점), stale 시 흐림
- 오디오 키퍼: 완전 무음 대신 ±2 LSB 디더 노이즈(무음 감지 정지 회피) +
  백그라운드 전환 ~30초 브리지 태스크
- 통계 타임라인 점별 시각 라벨 제거(겹침 — 시각은 오늘 기록 목록에서)
- 도움말·번역 갱신, CLAUDE.md 현행화. 시뮬: DI 백그라운드 80초+(10/20) 진행 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
2026-09-02 16:26:29 +09:00

169 lines
6.8 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.

//
// BackgroundAudioKeeper.swift
// Keging
//
// ··Live Activity .
// (UIBackgroundModes=audio, .mixWithOthers )
//
// ( , ,
// ) ,
// WorkoutCoordinator ensureAlive() .
//
import AVFoundation
import UIKit
@MainActor
final class BackgroundAudioKeeper {
static let shared = BackgroundAudioKeeper()
private var player: AVAudioPlayer?
private var observers: [NSObjectProtocol] = []
/// stop()
private var isActive = false
/// ~30
private var bridgeTask: UIBackgroundTaskIdentifier = .invalid
private init() {}
func start() {
isActive = true
activateSessionAndPlay()
installObservers()
}
func stop() {
isActive = false
removeObservers()
endBridgeTask()
player?.stop()
player = nil
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
}
/// ·
func ensureAlive() {
guard isActive else { return }
if let player, player.isPlaying { return }
activateSessionAndPlay()
}
private func activateSessionAndPlay() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try? session.setActive(true)
if player == nil { player = Self.makePlayer() }
if player?.play() != true {
//
player = Self.makePlayer()
player?.play()
}
}
private static func makePlayer() -> AVAudioPlayer? {
let player = try? AVAudioPlayer(data: silenceWAV)
player?.numberOfLoops = -1
// 0.0
player?.volume = 0.1
player?.prepareToPlay()
return player
}
private func installObservers() {
guard observers.isEmpty else { return }
let center = NotificationCenter.default
// ( )
observers.append(center.addObserver(
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
) { [weak self] note in
guard let self,
let info = note.userInfo,
let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return }
Task { @MainActor in self.ensureAlive() }
})
//
observers.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.ensureAlive() }
})
// ·
observers.append(center.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.player = nil
self.ensureAlive()
}
})
observers.append(center.addObserver(
forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.beginBridgeTask()
self.ensureAlive()
}
})
observers.append(center.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.endBridgeTask()
self.ensureAlive()
}
})
}
private func removeObservers() {
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers.removeAll()
}
private func beginBridgeTask() {
guard isActive, bridgeTask == .invalid else { return }
bridgeTask = UIApplication.shared.beginBackgroundTask(withName: "keging.audio.bridge") { [weak self] in
Task { @MainActor in self?.endBridgeTask() }
}
}
private func endBridgeTask() {
guard bridgeTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(bridgeTask)
bridgeTask = .invalid
}
/// 20 WAV (8kHz 16bit mono, ±2 LSB -84dBFS) .
/// 0 ' '
/// (volume 0.1 -100dB ).
///
private static let silenceWAV: Data = {
let sampleRate = 8000
let sampleCount = sampleRate * 20
let dataSize = sampleCount * 2
var d = Data()
func append(_ s: String) { d.append(s.data(using: .ascii)!) }
func appendU32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
func appendU16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
append("RIFF"); appendU32(UInt32(36 + dataSize)); append("WAVE")
append("fmt "); appendU32(16); appendU16(1); appendU16(1)
appendU32(UInt32(sampleRate)); appendU32(UInt32(sampleRate * 2)); appendU16(2); appendU16(16)
append("data"); appendU32(UInt32(dataSize))
var samples = Data(capacity: dataSize)
for _ in 0..<sampleCount {
let v = Int16.random(in: -2...2)
withUnsafeBytes(of: v.littleEndian) { samples.append(contentsOf: $0) }
}
d.append(samples)
return d
}()
}