Фикс: конвертация голосовых сообщений desktop - iOS
This commit is contained in:
@@ -11,6 +11,8 @@ final class MessageVoiceView: UIView {
|
||||
|
||||
private let playButton = UIButton(type: .system)
|
||||
private let playPauseAnimationView = LottieAnimationView()
|
||||
private let downloadArrowView = UIImageView() // ↓ arrow before download
|
||||
private let downloadRingView = VoiceDownloadRingView()
|
||||
private let waveformView = WaveformView()
|
||||
private let durationLabel = UILabel()
|
||||
|
||||
@@ -20,6 +22,7 @@ final class MessageVoiceView: UIView {
|
||||
private var attachmentId: String = ""
|
||||
private var isOutgoing = false
|
||||
private var isShowingPause = false // tracks Lottie visual state
|
||||
private var isDownloaded = false // false = show ↓, true = show play/pause
|
||||
private var totalDuration: TimeInterval = 0 // original duration for label reset
|
||||
/// Center of play button in this view's coordinate space (for external blob positioning).
|
||||
var playButtonCenter: CGPoint { playButton.center }
|
||||
@@ -50,6 +53,8 @@ final class MessageVoiceView: UIView {
|
||||
|
||||
// Playback
|
||||
var onPlayTapped: (() -> Void)?
|
||||
/// Download cancel callback (forwarded from VoiceDownloadRingView).
|
||||
var onDownloadCancel: (() -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@@ -73,6 +78,17 @@ final class MessageVoiceView: UIView {
|
||||
playButton.setImage(UIImage(systemName: "play.fill", withConfiguration: config), for: .normal)
|
||||
}
|
||||
|
||||
// Download arrow (shown before voice is downloaded)
|
||||
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .bold)
|
||||
downloadArrowView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
|
||||
downloadArrowView.contentMode = .center
|
||||
downloadArrowView.isUserInteractionEnabled = false
|
||||
playButton.addSubview(downloadArrowView)
|
||||
|
||||
// Download progress ring (overlays play button during CDN download)
|
||||
downloadRingView.onCancel = { [weak self] in self?.onDownloadCancel?() }
|
||||
addSubview(downloadRingView)
|
||||
|
||||
waveformView.peakHeight = 18 // Telegram AudioWaveformComponent peak
|
||||
waveformView.distance = 2.0 // Telegram AudioWaveformComponent (bubble context)
|
||||
waveformView.gravity = .bottom // Telegram: bars grow upward from bottom
|
||||
@@ -106,6 +122,11 @@ final class MessageVoiceView: UIView {
|
||||
width: playButtonSize - lottieInset * 2,
|
||||
height: playButtonSize - lottieInset * 2
|
||||
)
|
||||
// Download arrow: same frame as button interior
|
||||
downloadArrowView.frame = playButton.bounds
|
||||
|
||||
// Download ring: same frame as play button
|
||||
downloadRingView.frame = playButton.frame
|
||||
|
||||
// Waveform: from x=57 to near right edge, height=18, y=1
|
||||
let waveW = bounds.width - waveformX - 4
|
||||
@@ -163,6 +184,8 @@ final class MessageVoiceView: UIView {
|
||||
|
||||
playButton.backgroundColor = colors.playButtonBg
|
||||
playButton.tintColor = colors.playButtonFg
|
||||
downloadArrowView.tintColor = colors.playButtonFg
|
||||
downloadRingView.setRingColor(colors.playButtonFg)
|
||||
durationLabel.textColor = colors.durationText
|
||||
waveformView.foregroundColor_ = colors.waveformPlayed
|
||||
waveformView.backgroundColor_ = colors.waveformUnplayed
|
||||
@@ -188,6 +211,32 @@ final class MessageVoiceView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download State
|
||||
|
||||
/// Set whether voice data is already downloaded (cached).
|
||||
/// `false` → show ↓ arrow. `true` → show play/pause icon.
|
||||
func setDownloaded(_ downloaded: Bool) {
|
||||
isDownloaded = downloaded
|
||||
downloadArrowView.isHidden = downloaded
|
||||
playPauseAnimationView.isHidden = !downloaded
|
||||
}
|
||||
|
||||
/// Show download progress ring overlaying the play button.
|
||||
func showDownloadProgress(_ progress: CGFloat) {
|
||||
downloadRingView.show()
|
||||
downloadRingView.setProgress(progress)
|
||||
downloadArrowView.isHidden = true
|
||||
playPauseAnimationView.isHidden = true
|
||||
}
|
||||
|
||||
/// Hide download progress ring and show play icon (download complete).
|
||||
func hideDownloadProgress() {
|
||||
downloadRingView.hide()
|
||||
isDownloaded = true
|
||||
downloadArrowView.isHidden = true
|
||||
playPauseAnimationView.isHidden = false
|
||||
}
|
||||
|
||||
// MARK: - Play Action
|
||||
|
||||
@objc private func playTapped() {
|
||||
@@ -295,12 +344,14 @@ final class MessageVoiceView: UIView {
|
||||
|
||||
// MARK: - Waveform Encoding
|
||||
|
||||
/// Encode waveform samples to 5-bit packed base64 string (for sending).
|
||||
/// Encode waveform samples to comma-separated floats (Desktop parity).
|
||||
/// Desktop DialogInput.tsx:217 sends `interpolateCompressWaves(35).join(",")`.
|
||||
/// Desktop MessageVoice.tsx parses with `split(",").map(parseFloat)`.
|
||||
static func encodeWaveform(_ samples: [Float]) -> String {
|
||||
guard !samples.isEmpty else { return "" }
|
||||
|
||||
// Resample to ~63 bars (Telegram standard)
|
||||
let targetCount = min(63, samples.count)
|
||||
// Resample to 35 bars (Desktop standard: interpolateCompressWaves(35))
|
||||
let targetCount = min(35, samples.count)
|
||||
let step = Float(samples.count) / Float(targetCount)
|
||||
var resampled = [Float](repeating: 0, count: targetCount)
|
||||
for i in 0..<targetCount {
|
||||
@@ -310,28 +361,7 @@ final class MessageVoiceView: UIView {
|
||||
resampled[i] = samples[start..<end].max() ?? 0
|
||||
}
|
||||
|
||||
// Pack as 5-bit values
|
||||
let bitCount = targetCount * 5
|
||||
let byteCount = (bitCount + 7) / 8
|
||||
var bytes = [UInt8](repeating: 0, count: byteCount)
|
||||
|
||||
for i in 0..<targetCount {
|
||||
let value = UInt8(min(31, max(0, resampled[i] * 31)))
|
||||
let bitOffset = i * 5
|
||||
let byteIndex = bitOffset / 8
|
||||
let bitIndex = bitOffset % 8
|
||||
|
||||
if bitIndex + 5 <= 8 {
|
||||
bytes[byteIndex] |= value << (8 - bitIndex - 5)
|
||||
} else {
|
||||
let bitsInFirst = 8 - bitIndex
|
||||
bytes[byteIndex] |= value >> (5 - bitsInFirst)
|
||||
if byteIndex + 1 < bytes.count {
|
||||
bytes[byteIndex + 1] |= value << (8 - (5 - bitsInFirst))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Data(bytes).base64EncodedString()
|
||||
// Comma-separated floats (Desktop parity)
|
||||
return resampled.map { String(format: "%.2f", $0) }.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
// Voice message
|
||||
private let voiceView = MessageVoiceView()
|
||||
private var voiceBlobView: VoiceBlobView?
|
||||
private var activeVoiceDownloadTask: Task<Void, Never>?
|
||||
|
||||
// Avatar-specific
|
||||
private let avatarImageView = UIImageView()
|
||||
@@ -881,30 +882,61 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
duration: previewParts.duration,
|
||||
isOutgoing: layout.isOutgoing
|
||||
)
|
||||
let voiceAttachment = voiceAtt
|
||||
let storedPassword = message.attachmentPassword
|
||||
let playbackDuration = previewParts.duration
|
||||
let playbackMessageId = message.id
|
||||
|
||||
// Check cache: ↓ arrow if not downloaded, play icon if cached
|
||||
let voiceFileName = "voice_\(Int(playbackDuration))s.m4a"
|
||||
let isCached = Self.playableVoiceURLFromCache(
|
||||
attachmentId: voiceAttachment.id, fileName: voiceFileName
|
||||
) != nil
|
||||
// Own outgoing voice = always "downloaded" (data came from local recording)
|
||||
let isOwnVoice = layout.isOutgoing
|
||||
voiceView.setDownloaded(isCached || isOwnVoice)
|
||||
|
||||
let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == message.id
|
||||
voiceView.updatePlaybackState(
|
||||
isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying,
|
||||
progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0
|
||||
)
|
||||
let voiceAttachment = voiceAtt
|
||||
let storedPassword = message.attachmentPassword
|
||||
let playbackDuration = previewParts.duration
|
||||
let playbackMessageId = message.id
|
||||
|
||||
voiceView.onPlayTapped = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task.detached(priority: .userInitiated) {
|
||||
guard let playableURL = await Self.resolvePlayableVoiceURL(
|
||||
|
||||
// If already cached — play immediately
|
||||
if let cached = Self.playableVoiceURLFromCache(
|
||||
attachmentId: voiceAttachment.id, fileName: voiceFileName
|
||||
) {
|
||||
self.voiceView.setDownloaded(true)
|
||||
VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: cached)
|
||||
return
|
||||
}
|
||||
|
||||
// Show progress ring and start download
|
||||
self.voiceView.showDownloadProgress(0.027)
|
||||
let downloadTask = Task {
|
||||
let playableURL = await Self.resolvePlayableVoiceURL(
|
||||
attachment: voiceAttachment,
|
||||
duration: playbackDuration,
|
||||
storedPassword: storedPassword
|
||||
) else {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
guard self.message?.id == playbackMessageId else { return }
|
||||
storedPassword: storedPassword,
|
||||
onProgress: { [weak self] progress in
|
||||
self?.voiceView.showDownloadProgress(CGFloat(progress))
|
||||
}
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.voiceView.hideDownloadProgress()
|
||||
if let playableURL, self.message?.id == playbackMessageId {
|
||||
VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: playableURL)
|
||||
}
|
||||
}
|
||||
self.activeVoiceDownloadTask = downloadTask
|
||||
}
|
||||
voiceView.onDownloadCancel = { [weak self] in
|
||||
self?.activeVoiceDownloadTask?.cancel()
|
||||
self?.voiceView.hideDownloadProgress()
|
||||
self?.activeVoiceDownloadTask = nil
|
||||
}
|
||||
fileIconView.isHidden = true
|
||||
fileNameLabel.isHidden = true
|
||||
@@ -1550,14 +1582,19 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
private static func resolvePlayableVoiceURL(
|
||||
attachment: MessageAttachment,
|
||||
duration: TimeInterval,
|
||||
storedPassword: String?
|
||||
storedPassword: String?,
|
||||
onProgress: (@MainActor (Double) -> Void)? = nil
|
||||
) async -> URL? {
|
||||
let fileName = "voice_\(Int(duration))s.m4a"
|
||||
if let cached = playableVoiceURLFromCache(attachmentId: attachment.id, fileName: fileName) {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let downloaded = await downloadVoiceData(attachment: attachment, storedPassword: storedPassword) else {
|
||||
guard let downloaded = await downloadVoiceData(
|
||||
attachment: attachment,
|
||||
storedPassword: storedPassword,
|
||||
onProgress: onProgress
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
_ = AttachmentCache.shared.saveFile(downloaded, forAttachmentId: attachment.id, fileName: fileName)
|
||||
@@ -1591,7 +1628,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private static func downloadVoiceData(attachment: MessageAttachment, storedPassword: String?) async -> Data? {
|
||||
private static func downloadVoiceData(
|
||||
attachment: MessageAttachment,
|
||||
storedPassword: String?,
|
||||
onProgress: (@MainActor (Double) -> Void)? = nil
|
||||
) async -> Data? {
|
||||
let tag = attachment.effectiveDownloadTag
|
||||
guard !tag.isEmpty else { return nil }
|
||||
guard let storedPassword, !storedPassword.isEmpty else { return nil }
|
||||
@@ -1599,14 +1640,24 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
do {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(
|
||||
tag: tag,
|
||||
server: attachment.transportServer
|
||||
server: attachment.transportServer,
|
||||
onProgress: onProgress
|
||||
)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
||||
guard let decrypted = decryptAttachmentData(encryptedString: encryptedString, passwords: passwords) else {
|
||||
return nil
|
||||
}
|
||||
return parseAttachmentFileData(decrypted)
|
||||
let rawData = parseAttachmentFileData(decrypted)
|
||||
|
||||
// Desktop sends WebM/Opus — convert to M4A for iOS playback.
|
||||
// Transcoding (~200ms) runs off MainActor to avoid UI hitch.
|
||||
if WebMOpusConverter.isWebM(rawData) {
|
||||
return await Task.detached(priority: .userInitiated) {
|
||||
WebMOpusConverter.convertToPlayable(rawData)
|
||||
}.value
|
||||
}
|
||||
return rawData
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -1632,12 +1683,20 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
}
|
||||
|
||||
private static func parseAttachmentFileData(_ data: Data) -> Data {
|
||||
// 1. Data URI format (iOS images, files, legacy voice)
|
||||
if let string = String(data: data, encoding: .utf8),
|
||||
string.hasPrefix("data:"),
|
||||
let comma = string.firstIndex(of: ",") {
|
||||
let payload = String(string[string.index(after: comma)...])
|
||||
return Data(base64Encoded: payload) ?? data
|
||||
}
|
||||
// 2. Hex-encoded raw bytes (Desktop voice: Buffer.toString('hex'))
|
||||
if let string = String(data: data, encoding: .utf8),
|
||||
string.count >= 100,
|
||||
string.allSatisfy({ $0.isHexDigit }) {
|
||||
return Data(hexString: string)
|
||||
}
|
||||
// 3. Raw binary (fallback)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
169
Rosetta/Features/Chats/ChatDetail/VoiceDownloadRingView.swift
Normal file
169
Rosetta/Features/Chats/ChatDetail/VoiceDownloadRingView.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
import UIKit
|
||||
|
||||
/// Telegram-parity circular progress ring for voice message downloads.
|
||||
/// Overlays the 44×44 play button during CDN download.
|
||||
///
|
||||
/// Reference: `SemanticStatusNodeProgressContext.swift` in Telegram-iOS.
|
||||
/// - Arc starts at 12 o'clock (-π/2), fills clockwise
|
||||
/// - Rounded line caps, ~2pt stroke, 2.5pt inset
|
||||
/// - Continuous rotation animation (4× speed)
|
||||
/// - Cancel ✕ in center (12pt, 1.8pt stroke)
|
||||
/// - Minimum visible progress: 2.7%
|
||||
final class VoiceDownloadRingView: UIView {
|
||||
|
||||
// MARK: - Telegram-exact constants
|
||||
|
||||
private let lineWidth: CGFloat = 2.0
|
||||
private let inset: CGFloat = 2.5
|
||||
private let startAngle: CGFloat = -.pi / 2
|
||||
private let minProgress: CGFloat = 0.027
|
||||
private let cancelCrossSize: CGFloat = 12.0
|
||||
private let cancelLineWidth: CGFloat = 1.8
|
||||
|
||||
// MARK: - Callbacks
|
||||
|
||||
var onCancel: (() -> Void)?
|
||||
|
||||
// MARK: - Layers
|
||||
|
||||
private let progressLayer = CAShapeLayer()
|
||||
private let cancelLayer = CAShapeLayer()
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setup()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
isUserInteractionEnabled = true
|
||||
isHidden = true
|
||||
backgroundColor = .clear
|
||||
|
||||
// Progress ring
|
||||
progressLayer.fillColor = nil
|
||||
progressLayer.lineCap = .round
|
||||
progressLayer.lineWidth = lineWidth
|
||||
progressLayer.strokeStart = 0
|
||||
progressLayer.strokeEnd = minProgress
|
||||
layer.addSublayer(progressLayer)
|
||||
|
||||
// Cancel ✕
|
||||
cancelLayer.fillColor = nil
|
||||
cancelLayer.lineCap = .round
|
||||
cancelLayer.lineWidth = cancelLineWidth
|
||||
layer.addSublayer(cancelLayer)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
|
||||
addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let size = bounds.size
|
||||
guard size.width > 0 else { return }
|
||||
|
||||
// Progress arc path (full circle — strokeEnd controls visible portion)
|
||||
let pathDiameter = size.width - lineWidth - inset * 2
|
||||
let radius = pathDiameter / 2
|
||||
let center = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
let circlePath = UIBezierPath(
|
||||
arcCenter: center,
|
||||
radius: radius,
|
||||
startAngle: startAngle,
|
||||
endAngle: startAngle + .pi * 2,
|
||||
clockwise: true
|
||||
)
|
||||
progressLayer.path = circlePath.cgPath
|
||||
progressLayer.frame = bounds
|
||||
|
||||
// Cancel ✕ centered
|
||||
let half = cancelCrossSize / 2
|
||||
let crossPath = UIBezierPath()
|
||||
crossPath.move(to: CGPoint(x: center.x - half, y: center.y - half))
|
||||
crossPath.addLine(to: CGPoint(x: center.x + half, y: center.y + half))
|
||||
crossPath.move(to: CGPoint(x: center.x + half, y: center.y - half))
|
||||
crossPath.addLine(to: CGPoint(x: center.x - half, y: center.y + half))
|
||||
cancelLayer.path = crossPath.cgPath
|
||||
cancelLayer.frame = bounds
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Update ring color to match play button foreground.
|
||||
func setRingColor(_ color: UIColor) {
|
||||
progressLayer.strokeColor = color.cgColor
|
||||
cancelLayer.strokeColor = color.cgColor
|
||||
}
|
||||
|
||||
/// Set download progress (0.0–1.0). Values below 2.7% are clamped up.
|
||||
func setProgress(_ value: CGFloat, animated: Bool = true) {
|
||||
let clamped = max(minProgress, min(1.0, value))
|
||||
|
||||
if animated {
|
||||
let anim = CABasicAnimation(keyPath: "strokeEnd")
|
||||
anim.fromValue = progressLayer.presentation()?.strokeEnd ?? progressLayer.strokeEnd
|
||||
anim.toValue = clamped
|
||||
anim.duration = 0.2
|
||||
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
anim.isRemovedOnCompletion = false
|
||||
anim.fillMode = .forwards
|
||||
progressLayer.add(anim, forKey: "progress")
|
||||
} else {
|
||||
progressLayer.removeAnimation(forKey: "progress")
|
||||
}
|
||||
progressLayer.strokeEnd = clamped
|
||||
}
|
||||
|
||||
/// Show the ring and start rotation.
|
||||
func show() {
|
||||
guard isHidden else { return }
|
||||
isHidden = false
|
||||
alpha = 0
|
||||
UIView.animate(withDuration: 0.18) { self.alpha = 1 }
|
||||
startRotation()
|
||||
}
|
||||
|
||||
/// Hide the ring and stop rotation.
|
||||
func hide() {
|
||||
guard !isHidden else { return }
|
||||
UIView.animate(withDuration: 0.18) {
|
||||
self.alpha = 0
|
||||
} completion: { _ in
|
||||
self.isHidden = true
|
||||
self.stopRotation()
|
||||
self.setProgress(self.minProgress, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rotation
|
||||
|
||||
private func startRotation() {
|
||||
guard progressLayer.animation(forKey: "rotation") == nil else { return }
|
||||
let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||
rotation.fromValue = 0
|
||||
rotation.toValue = CGFloat.pi * 2
|
||||
rotation.duration = 1.6 // ~4× per 2π normalized (Telegram: angle * 4.0)
|
||||
rotation.repeatCount = .infinity
|
||||
rotation.isRemovedOnCompletion = false
|
||||
progressLayer.add(rotation, forKey: "rotation")
|
||||
}
|
||||
|
||||
private func stopRotation() {
|
||||
progressLayer.removeAnimation(forKey: "rotation")
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
onCancel?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user