250 lines
8.0 KiB
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()
|
|
}
|