131 lines
4.9 KiB
Swift
131 lines
4.9 KiB
Swift
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)
|
|
}
|
|
}
|