jsdw_ios/QuickLocation/Section/Group/GroupChat/VoicePlayerManager.swift

250 lines
8.0 KiB
Swift

//
// VoicePlayerManager.swift
// QuickLocation
//
// Created by on 2026/6/5.
//
import AVFoundation
final class VoicePlayerManager: NSObject {
static let shared = VoicePlayerManager()
enum State { case idle, playing, paused }
private var player: AVAudioPlayer?
private var downloadTask: URLSessionDataTask?
private let audioCache = NSCache<NSString, NSData>()
private var currentURL: String?
private var playbackID = UUID()
private var onStateChange: ((State) -> Void)?
private var onFinished: (() -> Void)?
private weak var playbackView: VoicePlaybackView?
private(set) var state: State = .idle
private override init() {
super.init()
audioCache.totalCostLimit = 20 * 1_024 * 1_024
}
func play(urlString: String,
playbackView: VoicePlaybackView,
onStateChange: @escaping (State) -> Void,
onFinished: @escaping () -> Void) {
let value = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
onFinished()
return
}
if currentURL == value, state == .paused, let player {
guard activateAudioSession() else {
stop()
return
}
self.playbackView = playbackView
self.onStateChange = onStateChange
self.onFinished = onFinished
player.play()
updateState(.playing)
return
}
stop()
let playbackID = UUID()
self.playbackID = playbackID
currentURL = value
self.playbackView = playbackView
self.onStateChange = onStateChange
self.onFinished = onFinished
if let localURL = Self.localFileURL(from: value) {
startPlayer(url: localURL, expectedURL: value)
return
}
guard let remoteURL = Self.remoteURL(from: value) else {
finishCurrentPlayback()
return
}
if let cachedData = audioCache.object(forKey: value as NSString) {
startPlayer(data: cachedData as Data, expectedURL: value)
return
}
downloadTask = URLSession.shared.dataTask(with: remoteURL) { [weak self] data, response, error in
DispatchQueue.main.async {
guard let self,
self.playbackID == playbackID,
self.currentURL == value else { return }
self.downloadTask = nil
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 200
guard let data, error == nil, (200..<300).contains(statusCode), !data.isEmpty else {
print("Voice download failed: \(error?.localizedDescription ?? "HTTP \(statusCode)")")
self.finishCurrentPlayback()
return
}
self.audioCache.setObject(data as NSData, forKey: value as NSString, cost: data.count)
self.startPlayer(data: data, expectedURL: value)
}
}
downloadTask?.resume()
}
func isCurrent(url: String) -> Bool {
currentURL == url.trimmingCharacters(in: .whitespacesAndNewlines)
}
func pause() {
guard state == .playing else { return }
player?.pause()
updateState(.paused)
}
func stop() {
downloadTask?.cancel()
downloadTask = nil
playbackID = UUID()
player?.pause()
player?.stop()
player = nil
finishCurrentPlayback()
}
private func startPlayer(url: URL, expectedURL: String) {
do {
let player = try AVAudioPlayer(contentsOf: url)
try start(player: player, expectedURL: expectedURL)
} catch {
print("Voice playback failed: \(error.localizedDescription)")
finishCurrentPlayback()
}
}
private func startPlayer(data: Data, expectedURL: String) {
do {
let player = try AVAudioPlayer(data: data)
try start(player: player, expectedURL: expectedURL)
} catch {
print("Voice playback failed: \(error.localizedDescription)")
finishCurrentPlayback()
}
}
private func start(player: AVAudioPlayer, expectedURL: String) throws {
guard currentURL == expectedURL else { return }
guard activateAudioSession() else {
finishCurrentPlayback()
return
}
player.delegate = self
_ = player.prepareToPlay()
guard player.duration > 0 else {
throw NSError(
domain: "VoicePlayerManager",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "invalid audio data"]
)
}
self.player = player
guard player.play() else {
self.player = nil
throw NSError(
domain: "VoicePlayerManager",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "audio player did not start"]
)
}
updateState(.playing)
}
private func updateState(_ state: State) {
self.state = state
onStateChange?(state)
if state == .playing {
playbackView?.play()
} else {
playbackView?.stop()
}
}
private func finishCurrentPlayback() {
let previousFinished = onFinished
let previousPlaybackView = playbackView
onStateChange = nil
onFinished = nil
playbackView = nil
player = nil
currentURL = nil
state = .idle
previousPlaybackView?.stop()
previousFinished?()
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private func activateAudioSession() -> Bool {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio)
try session.setActive(true)
return true
} catch {
print("Voice audio session failed: \(error.localizedDescription)")
return false
}
}
private static func localFileURL(from value: String) -> URL? {
if let url = URL(string: value), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) {
return url
}
if FileManager.default.fileExists(atPath: value) {
return URL(fileURLWithPath: value)
}
let relativePath = value.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let localURL = documentsURL.appendingPathComponent(relativePath)
if FileManager.default.fileExists(atPath: localURL.path) {
return localURL
}
}
return nil
}
private static func remoteURL(from value: String) -> URL? {
if let url = URL(string: value), let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" {
return url
}
guard let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: encoded),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
return url
}
}
extension VoicePlayerManager: AVAudioPlayerDelegate {
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
guard self.player === player else { return }
finishCurrentPlayback()
}
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
guard self.player === player else { return }
if let error {
print("Voice decode failed: \(error.localizedDescription)")
}
finishCurrentPlayback()
}
}
protocol VoicePlaybackView: AnyObject {
func play()
func stop()
}