import SwiftUI import AVFoundation import Combine /// Vault 内一段问诊录音的回放控件(2026-06-28)。播放/暂停 + 进度条 + 时间。 /// 录音存在加密 Vault 里,App 前台即解锁可读;读不到(损坏/异常)显示占位,不崩。 /// 不做拖拽 seek(降复杂度):进度条只读,demo 够用。 struct ConsultationAudioPlayer: View { let asset: Asset @State private var player: AVAudioPlayer? @State private var isPlaying = false @State private var currentTime: Double = 0 @State private var duration: Double = 0 @State private var loadFailed = false /// 播放时刷新进度/时间;不播放时空转(开销可忽略)。AVAudioPlayer 播完 isPlaying 自然转 false。 private let ticker = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Image(systemName: "waveform") .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.ink2) Text(String(appLoc: "问诊录音")) .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) Spacer() Text(String(appLoc: "本机存储")) .font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) } if loadFailed { Text("录音无法读取") .font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) } else { HStack(spacing: 12) { Button(action: togglePlay) { Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.tjScaled( 16, weight: .bold)) .foregroundStyle(Tj.Palette.paper) .frame(width: 40, height: 40) .background(Circle().fill(Tj.Palette.ink2)) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 6) { GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Tj.Palette.sand2) Capsule().fill(Tj.Palette.ink2) .frame(width: geo.size.width * progressFraction) } } .frame(height: 5) HStack { Text(Self.timeText(currentTime)) Spacer() Text(Self.timeText(duration)) } .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) .onAppear(perform: load) .onDisappear { player?.stop() isPlaying = false } .onReceive(ticker) { _ in guard let p = player, isPlaying else { return } currentTime = p.currentTime if !p.isPlaying { // 播完:复位 isPlaying = false currentTime = 0 p.currentTime = 0 } } } private var progressFraction: CGFloat { guard duration > 0 else { return 0 } return CGFloat(min(max(currentTime / duration, 0), 1)) } private func load() { guard player == nil, !loadFailed else { return } do { let url = try FileVault.shared.absoluteURL(forReading: asset.relativePath) let p = try AVAudioPlayer(contentsOf: url) p.prepareToPlay() player = p duration = p.duration } catch { loadFailed = true } } private func togglePlay() { guard let p = player else { return } if isPlaying { p.pause() isPlaying = false } else { try? AVAudioSession.sharedInstance().setCategory(.playback, options: []) try? AVAudioSession.sharedInstance().setActive(true) p.play() isPlaying = true } } private static func timeText(_ seconds: Double) -> String { let s = Int(seconds.rounded()) return String(format: "%d:%02d", s / 60, s % 60) } }