浏览代码

feat: 补充语音条播放动画

陈文艺 3 月之前
父节点
当前提交
cdec9919de

+ 3 - 2
Lanu.xcodeproj/project.pbxproj

@@ -77,6 +77,7 @@
 				Common/Views/LNOnlineView.swift,
 				Common/Views/LNPopupView.swift,
 				Common/Views/LNSortedEditView.swift,
+				Common/Views/LNVoiceWaveView.swift,
 				Common/Views/Loading/LNLoadingView.swift,
 				Common/Views/Menu/LNBottomSheetMenu.swift,
 				Common/Views/Menu/LNCommonAlertView.swift,
@@ -527,7 +528,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 11;
+				CURRENT_PROJECT_VERSION = 12;
 				DEVELOPMENT_TEAM = 5H8D98R72W;
 				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -570,7 +571,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 11;
+				CURRENT_PROJECT_VERSION = 12;
 				DEVELOPMENT_TEAM = 5H8D98R72W;
 				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 				GENERATE_INFOPLIST_FILE = YES;

+ 126 - 0
Lanu/Common/Views/LNVoiceWaveView.swift

@@ -0,0 +1,126 @@
+//
+//  LNVoiceWaveView.swift
+//  Lanu
+//
+//  Created by OneeChan on 2026/1/5.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNVoiceWaveView: UIView {
+    var itemCount = 5
+    var duration = 0.17
+    var fillColor: UIColor = .fill
+    var itemWidth: CGFloat = 1.7
+    private let stackView = UIStackView()
+    private var itemViews: [UIView] = []
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func build() {
+        stackView.arrangedSubviews.forEach {
+            stackView.removeArrangedSubview($0)
+            $0.removeFromSuperview()
+        }
+        itemViews.removeAll()
+        
+        let mid: Double = Double(itemCount - 1) / 2.0
+        for index in 0..<itemCount {
+            let scale = 0.3 + (mid - abs(Double(index) - mid)) * (0.7 / mid)
+            let line = UIView()
+            line.layer.cornerRadius = itemWidth * 0.5
+            line.backgroundColor = fillColor
+            stackView.addArrangedSubview(line)
+            line.snp.makeConstraints { make in
+                make.width.equalTo(itemWidth)
+                make.height.equalToSuperview().multipliedBy(scale)
+            }
+            itemViews.append(line)
+        }
+    }
+    
+    func startAnimate() {
+        for (index, view) in itemViews.enumerated() {
+            let scaleAnim = CABasicAnimation(keyPath: "transform.scale.y")
+            scaleAnim.fromValue = 0.7
+            scaleAnim.toValue = 1.0
+            scaleAnim.duration = duration
+            scaleAnim.autoreverses = true
+            scaleAnim.fillMode = .both
+            
+            let animationGroup = CAAnimationGroup()
+            animationGroup.animations = [scaleAnim]
+            animationGroup.duration = duration + Double(itemCount) * duration
+            animationGroup.repeatCount = .infinity
+            animationGroup.isRemovedOnCompletion = false
+            animationGroup.fillMode = .both
+            animationGroup.beginTime = CACurrentMediaTime() + Double(index) * 0.1
+            
+            view.layer.add(animationGroup, forKey: "scaleAnimated")
+        }
+    }
+    
+    func stopAnimate() {
+        itemViews.forEach { $0.layer.removeAllAnimations() }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNVoiceWaveView {
+    private func setupViews() {
+        stackView.axis = .horizontal
+        stackView.distribution = .equalSpacing
+        stackView.alignment = .center
+        addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.directionalEdges.equalToSuperview()
+        }
+    }
+}
+
+
+#if DEBUG
+
+import SwiftUI
+
+struct LNVoiceWaveViewPreview: UIViewRepresentable {
+    func makeUIView(context: Context) -> some UIView {
+        let container = UIView()
+        container.backgroundColor = .lightGray
+        
+        let view = LNVoiceWaveView()
+        view.fillColor = .red
+        view.itemWidth = 10
+        container.addSubview(view)
+        view.snp.makeConstraints { make in
+            make.center.equalToSuperview()
+            make.height.equalTo(100)
+            make.width.equalTo(150)
+        }
+        view.build()
+        
+        view.onTap {
+            view.startAnimate()
+        }
+        
+        return container
+    }
+    
+    func updateUIView(_ uiView: UIViewType, context: Context) { }
+}
+
+#Preview(body: {
+    LNVoiceWaveViewPreview()
+})
+#endif
+

+ 5 - 6
Lanu/Common/Voice/LNVoicePlayer.swift

@@ -12,12 +12,12 @@ import AVFoundation
 
 protocol LNVoicePlayerNotify {
     func onAudioStartPlay(path: String)
-    func onAudioUpdateDuration(path: String, cur: TimeInterval)
+    func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval)
     func onAudioStopPlay(path: String)
 }
 extension LNVoicePlayerNotify {
     func onAudioStartPlay(path: String) { }
-    func onAudioUpdateDuration(path: String, cur: TimeInterval) { }
+    func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval) { }
     func onAudioStopPlay(path: String) { }
 }
 
@@ -153,7 +153,7 @@ extension LNVoicePlayer {
         let timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [weak self] _ in
             guard let self else { return }
             currentTime = curPlayer?.currentTime ?? 0
-            notifyDuration()
+            notifyDuration(cur: currentTime, total: curPlayer?.duration ?? 0)
         })
         RunLoop.main.add(timer, forMode: .common)
         durationTimer = timer
@@ -187,11 +187,10 @@ extension LNVoicePlayer {
         }
     }
     
-    private func notifyDuration() {
+    private func notifyDuration(cur: TimeInterval, total: TimeInterval) {
         guard let url = playingUrl, !url.isEmpty else { return }
-        let cur = currentTime
         LNEventDeliver.notifyEvent {
-            ($0 as? LNVoicePlayerNotify)?.onAudioUpdateDuration(path: url, cur: cur)
+            ($0 as? LNVoicePlayerNotify)?.onAudioUpdateDuration(path: url, cur: cur, total: total)
         }
     }
 }

+ 20 - 3
Lanu/Common/Voice/LNVoiceResourceManager.swift

@@ -26,7 +26,7 @@ class LNVoiceResourceManager {
     private var voiceFileCache: [LNVoiceSource: [String: LNVoiceLocalSourceInfo]] = LNUserDefaults[.voiceCache, [:]]
     
     private var loadValueAssets: [String: AVURLAsset] = [:]
-    
+    private var voiceDurationMap: [String: Double] = [:]
     
     func voicePath(_ name: String, type: LNVoiceSource) -> URL {
         return URL.voiceCacheFolder
@@ -99,9 +99,23 @@ class LNVoiceResourceManager {
             })
     }
     
-    func getRemoteAudioDuration(urlStr: String, completion: @escaping (TimeInterval?, Error?) -> Void) {
+    func getRemoteAudioDuration(urlStr: String?, completion: @escaping (TimeInterval?, Error?) -> Void) {
+        guard let urlStr else {
+            completion(nil, nil)
+            return
+        }
+        
+        lock.lock()
+        let cache = voiceDurationMap[urlStr]
+        lock.unlock()
+        
+        if let cache {
+            completion(cache, nil)
+            return
+        }
+        
         guard let url = URL(string: urlStr) else {
-            completion(0, nil)
+            completion(nil, nil)
             return
         }
         
@@ -122,6 +136,9 @@ class LNVoiceResourceManager {
                 switch status {
                 case .loaded:
                     let duration = asset.duration.seconds
+                    lock.lock()
+                    voiceDurationMap[urlStr] = duration
+                    lock.unlock()
                     completion(duration, nil)
                 case .failed:
                     completion(nil, error)

+ 49 - 11
Lanu/Views/Game/MateList/LNGameMateListCell.swift

@@ -16,6 +16,7 @@ class LNGameMateListCell: UITableViewCell {
     
     private let playButton = UIButton()
     private let voiceLabel = UILabel()
+    private let voiceWaveView = LNVoiceWaveView()
     
     private let priceLabel = UILabel()
     private let unitLabel = UILabel()
@@ -44,6 +45,8 @@ class LNGameMateListCell: UITableViewCell {
         super.init(style: style, reuseIdentifier: reuseIdentifier)
         
         setupViews()
+        
+        LNEventDeliver.addObserver(self)
     }
     
     func update(_ item: LNGameMateListItemVO) {
@@ -66,11 +69,19 @@ class LNGameMateListCell: UITableViewCell {
         
         updatePhotos(item.images)
         
-        curItem = item
-        
         if let curItem {
             LNVoiceResourceManager.shared.cancelLoadingAsset(urlStr: curItem.voiceBar)
         }
+        
+        curItem = item
+        
+        if LNVoicePlayer.shared.playingUrl == curItem?.voiceBar {
+            voiceWaveView.startAnimate()
+        } else {
+            voiceWaveView.stopAnimate()
+        }
+        
+        playButton.isHidden = true
         if !item.voiceBar.isEmpty {
             LNVoiceResourceManager.shared.getRemoteAudioDuration(urlStr: item.voiceBar)
             { [weak self] duration, error in
@@ -80,8 +91,6 @@ class LNGameMateListCell: UITableViewCell {
                 voiceLabel.text = "\(Int(duration.rounded()))\""
                 playButton.isHidden = false
             }
-        } else {
-            playButton.isHidden = true
         }
     }
     
@@ -90,6 +99,31 @@ class LNGameMateListCell: UITableViewCell {
     }
 }
 
+extension LNGameMateListCell: LNVoicePlayerNotify {
+    func onAudioStartPlay(path: String) {
+        guard path == curItem?.voiceBar else { return }
+        voiceWaveView.startAnimate()
+    }
+    
+    func onAudioStopPlay(path: String) {
+        guard path == curItem?.voiceBar else { return }
+        voiceWaveView.stopAnimate()
+        let url = curItem?.voiceBar
+        LNVoiceResourceManager.shared.getRemoteAudioDuration(urlStr: curItem?.voiceBar)
+        { [weak self] duration, err in
+            guard let self else { return }
+            guard let duration, err == nil else { return }
+            guard curItem?.voiceBar == url else { return }
+            voiceLabel.text = "\(Int(duration.rounded()))\""
+        }
+    }
+    
+    func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval) {
+        guard path == curItem?.voiceBar else { return }
+        voiceLabel.text = "\(Int((total - cur).rounded()))\""
+    }
+}
+
 extension LNGameMateListCell {
     private func updatePhotos(_ photos: [String]) {
         var old = photoStackView.arrangedSubviews
@@ -205,8 +239,11 @@ extension LNGameMateListCell {
         playButton.addAction(UIAction(handler: { [weak self] _ in
             guard let self else { return }
             guard let curItem, !curItem.voiceBar.isEmpty else { return }
-            
-            LNVoicePlayer.shared.play(curItem.voiceBar)
+            if LNVoicePlayer.shared.playingUrl == curItem.voiceBar {
+                LNVoicePlayer.shared.stop()
+            } else {
+                LNVoicePlayer.shared.play(curItem.voiceBar)
+            }
         }), for: .touchUpInside)
         container.addSubview(playButton)
         playButton.snp.makeConstraints { make in
@@ -223,11 +260,12 @@ extension LNGameMateListCell {
             make.center.equalToSuperview()
         }
         
-        let ic = UIImageView()
-        ic.image = .init(named: "ic_voice")
-        voice.addSubview(ic)
-        ic.snp.makeConstraints { make in
+        voiceWaveView.build()
+        voice.addSubview(voiceWaveView)
+        voiceWaveView.snp.makeConstraints { make in
             make.leading.centerY.equalToSuperview()
+            make.width.equalTo(19)
+            make.height.equalTo(11)
         }
         
         voiceLabel.font = .heading_h5
@@ -236,7 +274,7 @@ extension LNGameMateListCell {
         voiceLabel.snp.makeConstraints { make in
             make.verticalEdges.equalToSuperview()
             make.trailing.equalToSuperview()
-            make.leading.equalTo(ic.snp.trailing).offset(4)
+            make.leading.equalTo(voiceWaveView.snp.trailing).offset(4)
         }
         
         let price = UIView()

+ 1 - 1
Lanu/Views/IM/Chat/Cells/LNIMChatVoiceMessageCell.swift

@@ -194,7 +194,7 @@ extension LNIMChatVoiceMessageCell: LNVoicePlayerNotify {
         updatePlayUI(animated: true)
     }
     
-    func onAudioUpdateDuration(path: String, cur: TimeInterval) {
+    func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval) {
         guard isPlaying else { return }
         updatePlayUI(animated: true)
     }

+ 0 - 2
Lanu/Views/Profile/Profile/LNProfileVoiceBarView.swift

@@ -37,8 +37,6 @@ extension LNProfileVoiceBarView: LNVoicePlayerNotify {
         statusIc.image = .init(named: "ic_voice_pause")
     }
     
-    func onAudioUpdateDuration(path: String, cur: TimeInterval) { }
-    
     func onAudioStopPlay(path: String) {
         guard path == curUrl else { return }
         statusIc.image = .init(named: "ic_voice_play")

+ 1 - 1
Lanu/Views/Wallet/LNWalletViewController.swift

@@ -122,7 +122,7 @@ extension LNWalletViewController {
         history.setImage(.init(named: "ic_wallet_history"), for: .normal)
         history.addAction(UIAction(handler: { [weak self] _ in
             guard let self else { return }
-            view.pushToWebView(.init(url: .walletHistoryUrl))
+            view.pushToWebView(.init(url: .walletHistoryUrl, showNavigationBar: false))
         }), for: .touchUpInside)
         barView.addSubview(history)
         history.snp.makeConstraints { make in

+ 7 - 2
Lanu/Views/Web/LNWebViewController.swift

@@ -15,10 +15,12 @@ import Combine
 class LNJumpWebViewConfig {
     let url: String
     let customTitle: String
+    var showNavigationBar = true
     
-    init(url: String, title: String = "") {
+    init(url: String, title: String = "", showNavigationBar: Bool = true) {
         self.url = url
         customTitle = title
+        self.showNavigationBar = showNavigationBar
     }
 }
 
@@ -94,11 +96,14 @@ extension LNWebViewController {
         if !config.customTitle.isEmpty {
             title = config.customTitle
         }
+        showNavigationBar = config.showNavigationBar
         
         let webView = buildWebView()
         view.addSubview(webView)
         webView.snp.makeConstraints { make in
-            make.directionalEdges.equalToSuperview()
+            make.directionalHorizontalEdges.equalToSuperview()
+            make.top.equalToSuperview().offset(UIView.statusBarHeight)
+            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
         }
     }