// // LNVoicePlayer.swift // Lanu // // Created by OneeChan on 2025/12/5. // import Foundation import AVFAudio import AVFoundation protocol LNVoicePlayerNotify { func onAudioStartPlay(path: String) func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval) func onAudioStopPlay(path: String) } extension LNVoicePlayerNotify { func onAudioStartPlay(path: String) { } func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval) { } func onAudioStopPlay(path: String) { } } enum LNVoiceSource: Codable { case unknown case im case game var folderName: String { switch self { case .unknown: "common" case .im: "im" case .game: "game" } } } class LNVoicePlayer: NSObject { static let shared = LNVoicePlayer() private var curPlayer: AVAudioPlayer? private(set) var playingUrl: String? var isPlaying: Bool { curPlayer?.isPlaying == true } private var durationTimer: Timer? private(set) var currentTime: TimeInterval = 0.0 private(set) var duration: TimeInterval = 0.0 var curSpeed: Float { curPlayer?.rate ?? 1.0 } private override init() { super.init() } func play(path: String) { stop() playingUrl = path playVoice(path) } func play(_ url: String, type: LNVoiceSource = .unknown) { stop() playingUrl = url LNVoiceResourceManager.shared.voiceResourceFor(url: url, type: type) { [weak self] path in guard let self else { return } guard playingUrl == url, curPlayer == nil else { return } playVoice(path) } } func playVoiceMessage(message: LNIMMessageData) { stop() let uuid = message.imMessage.soundElem?.uuid playingUrl = uuid if let path = message.content, FileManager.default.fileExists(atPath: path) { playVoice(path) return } let cachePath: URL? = if let uuid { LNVoiceResourceManager.shared.voicePath(uuid, type: .im) } else { nil } if let cachePath { if FileManager.default.fileExists(atPath: cachePath.path) { playVoice(cachePath.path) return } } message.imMessage.soundElem?.getUrl { url in guard let url else { return } LNVoiceResourceManager.shared.voiceResourceFor( url: url, type: .im, cachePath: cachePath) { [weak self] path in guard let self else { return } guard playingUrl == uuid, curPlayer == nil else { return } playVoice(path) } } } func stop() { curPlayer?.stop() curPlayer = nil stopTimer() duration = 0.0 currentTime = 0.0 notifyStopPlay() playingUrl = nil } func setSpeed(speed: Float) { curPlayer?.rate = speed } } extension LNVoicePlayer { private func playVoice(_ path: String) { guard FileManager.default.fileExists(atPath: path) else { return } try? AVAudioSession.sharedInstance().setCategory(.playback) guard let player = try? AVAudioPlayer.init(contentsOf: URL(fileURLWithPath: path)) else { return } player.delegate = self player.enableRate = true if player.play() { duration = player.duration curPlayer = player notifyStartPlay() startTimer() } else { stop() } } } extension LNVoicePlayer { private func startTimer() { stopTimer() currentTime = 0.0 let timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [weak self] _ in guard let self else { return } currentTime = curPlayer?.currentTime ?? 0 notifyDuration(cur: currentTime, total: curPlayer?.duration ?? 0) }) RunLoop.main.add(timer, forMode: .common) durationTimer = timer } private func stopTimer() { durationTimer?.invalidate() durationTimer = nil } } extension LNVoicePlayer: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { stop() notifyStopPlay() } } extension LNVoicePlayer { private func notifyStartPlay() { guard let url = playingUrl, !url.isEmpty else { return } LNEventDeliver.notifyEvent { ($0 as? LNVoicePlayerNotify)?.onAudioStartPlay(path: url) } } private func notifyStopPlay() { guard let url = playingUrl, !url.isEmpty else { return } LNEventDeliver.notifyEvent { ($0 as? LNVoicePlayerNotify)?.onAudioStopPlay(path: url) } } private func notifyDuration(cur: TimeInterval, total: TimeInterval) { guard let url = playingUrl, !url.isEmpty else { return } LNEventDeliver.notifyEvent { ($0 as? LNVoicePlayerNotify)?.onAudioUpdateDuration(path: url, cur: cur, total: total) } } }