From dd80c7d7e322d5d010511ffe27bb59f2936fb86d Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 13 Apr 2026 00:19:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20voice=20blob=20=E2=80=94=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20shapeLayer.bounds,=20=D1=83=D0=BC=D0=B5=D0=BD=D1=8C?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D0=B4=D0=BE=2056pt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/VoiceBlobView.swift | 6 +- .../Chats/ChatDetail/ComposerView.swift | 2 + .../Chats/ChatDetail/MessageVoiceView.swift | 56 +-- .../Chats/ChatDetail/NativeMessageCell.swift | 77 +++- .../ChatDetail/RecordingPreviewPanel.swift | 370 +++++++++++++----- 5 files changed, 349 insertions(+), 162 deletions(-) diff --git a/Rosetta/DesignSystem/Components/VoiceBlobView.swift b/Rosetta/DesignSystem/Components/VoiceBlobView.swift index 1743c97..5097d2e 100644 --- a/Rosetta/DesignSystem/Components/VoiceBlobView.swift +++ b/Rosetta/DesignSystem/Components/VoiceBlobView.swift @@ -246,7 +246,11 @@ private final class BlobLayer { func updateBounds(_ size: CGSize) { boundsSize = size - shapeLayer.bounds = CGRect(origin: .zero, size: size) + // DON'T set shapeLayer.bounds — leave at default (0,0,0,0). + // Telegram BlobNode.layout(): shapeLayer has zero-sized bounds + position at center. + // With zero bounds, local (0,0) maps to the position point = view center. + // Blob paths are centered at (0,0), so they render at the view's center. Correct! + // Setting bounds to (0,0,68,68) shifts local (0,0) to the view's top-left. Wrong! shapeLayer.position = CGPoint(x: size.width / 2, y: size.height / 2) if isCircle { diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 946b61a..21c8243 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -1278,12 +1278,14 @@ extension ComposerView: RecordingMicButtonDelegate { if enabled { inputContainer.alpha = 1 + inputContainer.clipsToBounds = false // RecordMore floats above panel attachButton.alpha = 0 micButton.alpha = 0 micGlass.alpha = 0 micIconView?.alpha = 0 bringSubviewToFront(inputContainer) } else { + inputContainer.clipsToBounds = true replyBar.alpha = isReplyVisible ? 1 : 0 bringSubviewToFront(micButton) } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift index e0abb6c..c755690 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift @@ -21,7 +21,8 @@ final class MessageVoiceView: UIView { private var isOutgoing = false private var isShowingPause = false // tracks Lottie visual state private var totalDuration: TimeInterval = 0 // original duration for label reset - private var blobView: VoiceBlobView? // pulsing ring around play button during playback + /// Center of play button in this view's coordinate space (for external blob positioning). + var playButtonCenter: CGPoint { playButton.center } // MARK: - Layout Constants (Telegram exact: ChatMessageInteractiveFileNode) @@ -136,11 +137,6 @@ final class MessageVoiceView: UIView { playButton.setImage(nil, for: .normal) } - // Remove blob from previous cell reuse - blobView?.stopAnimating() - blobView?.removeFromSuperview() - blobView = nil - self.totalDuration = duration // Decode waveform from preview @@ -202,9 +198,6 @@ final class MessageVoiceView: UIView { } waveformView.enableScrubbing = isPlaying - // Blob animation (Telegram: VoiceBlobNode around play button during playback) - updateBlobState(isPlaying: isPlaying) - let shouldShowPause = isPlaying guard shouldShowPause != isShowingPause else { return } isShowingPause = shouldShowPause @@ -226,51 +219,6 @@ final class MessageVoiceView: UIView { } } - // MARK: - Blob Animation (Telegram: ChatMessageInteractiveFileNode lines 1843-1866) - - private func updateBlobState(isPlaying: Bool) { - if isPlaying { - if blobView == nil { - // Telegram params: maxLevel=0.3, no small blob, medium 0.7-0.8, big 0.8-0.9 - let blob = VoiceBlobView( - frame: .zero, - maxLevel: 0.3, - smallBlobRange: (min: 0, max: 0), - mediumBlobRange: (min: 0.7, max: 0.8), - bigBlobRange: (min: 0.8, max: 0.9) - ) - // Frame: 12pt larger on each side than play button (68×68) - let blobInset: CGFloat = -12 - blob.frame = playButton.frame.insetBy(dx: blobInset, dy: blobInset) - - // Even-odd mask to cut out the inner play button circle - let maskLayer = CAShapeLayer() - let fullRect = CGRect(origin: .zero, size: blob.bounds.size) - let path = UIBezierPath(rect: fullRect) - let innerDiameter = playButtonSize - let innerOrigin = CGPoint(x: (fullRect.width - innerDiameter) / 2, - y: (fullRect.height - innerDiameter) / 2) - let innerRect = CGRect(origin: innerOrigin, size: CGSize(width: innerDiameter, height: innerDiameter)) - path.append(UIBezierPath(ovalIn: innerRect)) - maskLayer.path = path.cgPath - maskLayer.fillRule = .evenOdd - blob.layer.mask = maskLayer - - let isDark = traitCollection.userInterfaceStyle == .dark - let colors = RosettaColors.Voice.colors(isOutgoing: isOutgoing, isDark: isDark) - blob.setColor(colors.playButtonBg) - - insertSubview(blob, belowSubview: playButton) - blobView = blob - } - blobView?.startAnimating() - // Gentle fixed pulse (VoiceMessagePlayer doesn't expose audio level metering) - blobView?.updateLevel(0.2) - } else { - blobView?.stopAnimating() - } - } - /// Updates duration label to show elapsed time during playback, total when stopped. func updateDurationDuringPlayback(currentTime: TimeInterval, totalDuration: TimeInterval, isPlaying: Bool) { if isPlaying && currentTime > 0 { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 71d44ee..ef9ba50 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -192,6 +192,7 @@ final class NativeMessageCell: UICollectionViewCell { // Voice message private let voiceView = MessageVoiceView() + private var voiceBlobView: VoiceBlobView? // Avatar-specific private let avatarImageView = UIImageView() @@ -318,6 +319,7 @@ final class NativeMessageCell: UICollectionViewCell { // Raster bubble image (Telegram-exact tail) — added last so it renders above outline bubbleImageView.contentMode = .scaleToFill bubbleView.addSubview(bubbleImageView) + bubbleView.clipsToBounds = true // clips voice blob animation to bubble bounds contentView.addSubview(bubbleView) // Text (CoreTextLabel — no font/color/lines config; all baked into CoreTextTextLayout) @@ -1289,14 +1291,25 @@ final class NativeMessageCell: UICollectionViewCell { fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19) fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16) } else if !voiceView.isHidden { - // Voice layout: center voiceView vertically, hide file subviews + // Voice layout: position in upper area with padding (Telegram: bubble insets top=15, left=9) let contentH: CGFloat = 38 - let topY = max(0, (centerableH - contentH) / 2) - voiceView.frame = CGRect(x: 0, y: topY, width: fileW, height: contentH) + let voiceLeftPad: CGFloat = 6 // left padding inside fileContainer + let voiceTopPad: CGFloat = 8 // top padding — positions content in upper portion + voiceView.frame = CGRect(x: voiceLeftPad, y: voiceTopPad, + width: fileW - voiceLeftPad * 2, height: contentH) + voiceView.layoutIfNeeded() // ensure playButton.frame is set before blob positioning fileIconView.isHidden = true fileNameLabel.isHidden = true fileSizeLabel.isHidden = true avatarImageView.isHidden = true + // Reposition blob if it exists (keeps in sync on relayout) + if let blob = voiceBlobView { + let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView) + let blobSize: CGFloat = 56 + blob.frame = CGRect(x: playCenter.x - blobSize / 2, + y: playCenter.y - blobSize / 2, + width: blobSize, height: blobSize) + } } else { // File layout: vertically centered icon + title + size let contentH: CGFloat = 44 // icon height dominates @@ -2875,6 +2888,7 @@ final class NativeMessageCell: UICollectionViewCell { highlightOverlay.alpha = 0 fileContainer.isHidden = true voiceView.isHidden = true + cleanupVoiceBlob() callArrowView.isHidden = true callBackButton.isHidden = true groupInviteContainer.isHidden = true @@ -2999,6 +3013,63 @@ final class NativeMessageCell: UICollectionViewCell { guard !voiceView.isHidden else { return } voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress) voiceView.updateDurationDuringPlayback(currentTime: currentTime, totalDuration: duration, isPlaying: isPlaying) + updateVoiceBlobState(isPlaying: isPlaying) + } + + // MARK: - Voice Blob (rendered in bubbleView, clipped to bubble bounds) + + private func updateVoiceBlobState(isPlaying: Bool) { + if isPlaying { + if voiceBlobView == nil { + let blob = VoiceBlobView( + frame: .zero, + maxLevel: 0.3, + smallBlobRange: (min: 0, max: 0), + mediumBlobRange: (min: 0.7, max: 0.8), + bigBlobRange: (min: 0.8, max: 0.9) + ) + let isDark = traitCollection.userInterfaceStyle == .dark + let isOut = currentLayout?.isOutgoing ?? false + let colors = RosettaColors.Voice.colors(isOutgoing: isOut, isDark: isDark) + blob.setColor(colors.playButtonBg) + + // Even-odd mask to cut out the inner 44pt circle (ring only) + let blobSize: CGFloat = 56 + let maskLayer = CAShapeLayer() + let fullRect = CGRect(origin: .zero, size: CGSize(width: blobSize, height: blobSize)) + let path = UIBezierPath(rect: fullRect) + let innerDiameter: CGFloat = 44 + let innerOrigin = CGPoint(x: (blobSize - innerDiameter) / 2, + y: (blobSize - innerDiameter) / 2) + path.append(UIBezierPath(ovalIn: CGRect(origin: innerOrigin, + size: CGSize(width: innerDiameter, height: innerDiameter)))) + maskLayer.path = path.cgPath + maskLayer.fillRule = .evenOdd + blob.layer.mask = maskLayer + + // Insert below fileContainer so it's behind the play button + bubbleView.insertSubview(blob, belowSubview: fileContainer) + voiceBlobView = blob + } + // Force voiceView layout so playButton.frame is up-to-date + voiceView.layoutIfNeeded() + // Position blob centered on play button in bubbleView coords + let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView) + let blobSize: CGFloat = 56 + voiceBlobView?.frame = CGRect(x: playCenter.x - blobSize / 2, + y: playCenter.y - blobSize / 2, + width: blobSize, height: blobSize) + voiceBlobView?.startAnimating() + voiceBlobView?.updateLevel(0.2) + } else { + voiceBlobView?.stopAnimating() + } + } + + private func cleanupVoiceBlob() { + voiceBlobView?.stopAnimating() + voiceBlobView?.removeFromSuperview() + voiceBlobView = nil } func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? { diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift index b32286a..855d88c 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift @@ -14,8 +14,8 @@ protocol RecordingPreviewPanelDelegate: AnyObject { // MARK: - RecordingPreviewPanel -/// Preview panel shown after `lock -> stop`, before sending voice message. -/// Includes waveform scrubbing + trim handles + send/delete/record-more controls. +/// Telegram-parity recording preview: glass delete circle + dark glass panel + blue send circle. +/// Blue accent fill covers only the trim range. Play button floats inside waveform. final class RecordingPreviewPanel: UIView { private enum PanMode { @@ -31,21 +31,39 @@ final class RecordingPreviewPanel: UIView { weak var delegate: RecordingPreviewPanelDelegate? - // MARK: - Subviews + // MARK: - Background elements (3 separate visual blocks) + + private let deleteGlassCircle = TelegramGlassUIView(frame: .zero) + private let centerGlassBackground = TelegramGlassUIView(frame: .zero) + private let recordMoreGlassCircle = TelegramGlassUIView(frame: .zero) + private let accentFillView = UIImageView() + + // MARK: - Buttons - private let glassBackground = TelegramGlassUIView(frame: .zero) private let deleteButton = UIButton(type: .system) - private let playButton = UIButton(type: .system) - private let playPauseAnimationView = LottieAnimationView() + private let recordMoreButton = UIButton(type: .system) + private let sendButton = UIButton(type: .system) + + // MARK: - Waveform + private let waveformContainer = UIView() private let waveformView = WaveformView() private let leftTrimMask = UIView() private let rightTrimMask = UIView() private let leftTrimHandle = UIView() private let rightTrimHandle = UIView() + private let leftCapsuleView = UIView() + private let rightCapsuleView = UIView() + + // MARK: - Play button pill (floats inside waveform) + + private let playButtonPill = UIButton(type: .custom) + private let playPillBackground = UIImageView() + private let playPauseAnimationView: LottieAnimationView = { + let config = LottieConfiguration(renderingEngine: .mainThread) + return LottieAnimationView(configuration: config) + }() private let durationLabel = UILabel() - private let recordMoreButton = UIButton(type: .system) - private let sendButton = UIButton(type: .system) // MARK: - Audio Playback @@ -64,6 +82,12 @@ final class RecordingPreviewPanel: UIView { private var minTrimDuration: TimeInterval = 1 private var activePanMode: PanMode? + // MARK: - Layout cache + + private var centerPanelFrame: CGRect = .zero + + // MARK: - Colors + private var panelControlColor: UIColor { UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor.white : UIColor.black @@ -78,8 +102,12 @@ final class RecordingPreviewPanel: UIView { panelControlColor.withAlphaComponent(0.7) } - private var panelWaveformBackgroundColor: UIColor { - panelControlColor.withAlphaComponent(0.4) + private var playPillBackgroundColor: UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 0.12, alpha: 0.7) + : UIColor(white: 0.95, alpha: 0.7) + } } var selectedTrimRange: ClosedRange { @@ -94,9 +122,7 @@ final class RecordingPreviewPanel: UIView { self.waveformSamples = waveform super.init(frame: frame) self.trimEnd = self.duration - clipsToBounds = true - layer.cornerRadius = 21 - layer.cornerCurve = .continuous + clipsToBounds = false accessibilityIdentifier = "voice.preview.panel" setupSubviews() } @@ -107,88 +133,110 @@ final class RecordingPreviewPanel: UIView { // MARK: - Setup private func setupSubviews() { - glassBackground.fixedCornerRadius = 21 - glassBackground.isUserInteractionEnabled = false - addSubview(glassBackground) + // A) Delete glass circle + button + deleteGlassCircle.fixedCornerRadius = 20 + deleteGlassCircle.isUserInteractionEnabled = false + addSubview(deleteGlassCircle) deleteButton.setImage(VoiceRecordingAssets.image(.delete, templated: true), for: .normal) - deleteButton.tintColor = UIColor(red: 1, green: 45/255.0, blue: 85/255.0, alpha: 1) + deleteButton.tintColor = panelControlColor deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) - deleteButton.isAccessibilityElement = true deleteButton.accessibilityLabel = "Delete recording" - deleteButton.accessibilityHint = "Deletes the current voice draft." deleteButton.accessibilityIdentifier = "voice.preview.delete" addSubview(deleteButton) - playPauseAnimationView.backgroundBehavior = .pauseAndRestore - playPauseAnimationView.contentMode = .scaleAspectFit - playPauseAnimationView.isUserInteractionEnabled = false - playButton.addSubview(playPauseAnimationView) - if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.playPause.rawValue) { - playPauseAnimationView.animation = animation - } - configurePlayButton(playing: false, animated: false) - playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside) - playButton.isAccessibilityElement = true - playButton.accessibilityLabel = "Play recording" - playButton.accessibilityHint = "Plays or pauses voice preview." - playButton.accessibilityIdentifier = "voice.preview.playPause" - addSubview(playButton) + // B) Central dark glass panel + centerGlassBackground.isUserInteractionEnabled = false + addSubview(centerGlassBackground) + // Blue accent fill (dynamic trim range) + accentFillView.image = Self.makeStretchablePill( + diameter: 34, + color: panelControlAccentColor + ) + accentFillView.isUserInteractionEnabled = false + addSubview(accentFillView) + + // Waveform container waveformContainer.clipsToBounds = true - waveformContainer.layer.cornerRadius = 6 addSubview(waveformContainer) waveformView.setSamples(waveformSamples) waveformView.progress = 0 waveformContainer.addSubview(waveformView) + // Trim masks leftTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25) rightTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25) waveformContainer.addSubview(leftTrimMask) waveformContainer.addSubview(rightTrimMask) - leftTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) - leftTrimHandle.layer.cornerRadius = 2 - waveformContainer.addSubview(leftTrimHandle) + // Trim handles (transparent, 16pt wide) + leftTrimHandle.backgroundColor = .clear + rightTrimHandle.backgroundColor = .clear + addSubview(leftTrimHandle) + addSubview(rightTrimHandle) - rightTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) - rightTrimHandle.layer.cornerRadius = 2 - waveformContainer.addSubview(rightTrimHandle) + // White capsule indicators inside handles + leftCapsuleView.backgroundColor = .white + leftCapsuleView.layer.cornerRadius = 1.5 + leftTrimHandle.addSubview(leftCapsuleView) + rightCapsuleView.backgroundColor = .white + rightCapsuleView.layer.cornerRadius = 1.5 + rightTrimHandle.addSubview(rightCapsuleView) + + // Pan gesture for waveform let pan = UIPanGestureRecognizer(target: self, action: #selector(handleWaveformPan(_:))) waveformContainer.addGestureRecognizer(pan) - waveformContainer.isAccessibilityElement = true waveformContainer.accessibilityLabel = "Waveform trim area" - waveformContainer.accessibilityHint = "Drag to scrub, or drag edges to trim." waveformContainer.accessibilityIdentifier = "voice.preview.waveform" + // Play button pill (inside waveform) + playPillBackground.isUserInteractionEnabled = false + playButtonPill.addSubview(playPillBackground) + + playPauseAnimationView.backgroundBehavior = .pauseAndRestore + playPauseAnimationView.contentMode = .scaleAspectFit + playPauseAnimationView.isUserInteractionEnabled = false + if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.playPause.rawValue) { + playPauseAnimationView.animation = animation + } + playButtonPill.addSubview(playPauseAnimationView) + durationLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold) - durationLabel.textColor = panelSecondaryTextColor durationLabel.textAlignment = .right - addSubview(durationLabel) + playButtonPill.addSubview(durationLabel) - recordMoreButton.setImage(VoiceRecordingAssets.image(.iconMicrophone, templated: true), for: .normal) - recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85) - recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside) - recordMoreButton.isAccessibilityElement = true - recordMoreButton.accessibilityLabel = "Record more" - recordMoreButton.accessibilityHint = "Resume recording and append more audio." - recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore" - addSubview(recordMoreButton) + playButtonPill.addTarget(self, action: #selector(playTapped), for: .touchUpInside) + playButtonPill.accessibilityLabel = "Play recording" + playButtonPill.accessibilityIdentifier = "voice.preview.playPause" + waveformContainer.addSubview(playButtonPill) + configurePlayButton(playing: false, animated: false) + // C) Send button (solid blue circle) sendButton.setImage(VoiceRecordingAssets.image(.send, templated: true), for: .normal) - sendButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + sendButton.backgroundColor = panelControlAccentColor sendButton.layer.cornerRadius = 18 sendButton.clipsToBounds = true sendButton.tintColor = .white sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) - sendButton.isAccessibilityElement = true sendButton.accessibilityLabel = "Send recording" - sendButton.accessibilityHint = "Sends current trimmed voice message." sendButton.accessibilityIdentifier = "voice.preview.send" addSubview(sendButton) + // D) Record More glass circle + button (floating above) + recordMoreGlassCircle.fixedCornerRadius = 20 + recordMoreGlassCircle.isUserInteractionEnabled = false + addSubview(recordMoreGlassCircle) + + recordMoreButton.setImage(VoiceRecordingAssets.image(.iconMicrophone, templated: true), for: .normal) + recordMoreButton.tintColor = panelControlColor + recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside) + recordMoreButton.accessibilityLabel = "Record more" + recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore" + addSubview(recordMoreButton) + updateThemeColors() } @@ -198,37 +246,47 @@ final class RecordingPreviewPanel: UIView { super.layoutSubviews() let h = bounds.height let w = bounds.width - let trailingInset: CGFloat = 4 - let controlGap: CGFloat = 4 - glassBackground.frame = bounds - glassBackground.applyCornerRadius() + // Delete — glass circle, left edge + let deleteSize: CGFloat = 40 + let deleteFrame = CGRect(x: 0, y: (h - deleteSize) / 2, width: deleteSize, height: deleteSize) + deleteGlassCircle.frame = deleteFrame + deleteGlassCircle.applyCornerRadius() + deleteButton.frame = deleteFrame - deleteButton.frame = CGRect(x: 4, y: (h - 40) / 2, width: 40, height: 40) - playButton.frame = CGRect(x: 44, y: (h - 30) / 2, width: 30, height: 30) - playPauseAnimationView.frame = CGRect(x: 4, y: 4, width: 22, height: 22) + // Send — solid blue circle, right edge + let sendSize: CGFloat = 36 + sendButton.frame = CGRect(x: w - sendSize, y: (h - sendSize) / 2, width: sendSize, height: sendSize) - sendButton.frame = CGRect(x: w - trailingInset - 36, y: (h - 36) / 2, width: 36, height: 36) - recordMoreButton.frame = CGRect( - x: sendButton.frame.minX - controlGap - 30, - y: (h - 30) / 2, - width: 30, - height: 30 - ) + // Central dark glass panel — between delete and send + let panelGap: CGFloat = 6 + let panelX = deleteSize + panelGap + let panelW = w - panelX - sendSize - panelGap + let panelH = h - 6 + let panelY: CGFloat = 3 + let panelCornerRadius = panelH / 2 + centerGlassBackground.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH) + centerGlassBackground.fixedCornerRadius = panelCornerRadius + centerGlassBackground.applyCornerRadius() + centerPanelFrame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH) - let durationW: CGFloat = 48 - durationLabel.frame = CGRect( - x: recordMoreButton.frame.minX - durationW - 6, - y: (h - 20) / 2, - width: durationW, - height: 20 - ) - - let waveX = playButton.frame.maxX + 8 - let waveW = durationLabel.frame.minX - 8 - waveX - waveformContainer.frame = CGRect(x: waveX, y: 4, width: max(0, waveW), height: h - 8) + // Waveform — inside central panel, 18pt insets from panel edges + let wfInset: CGFloat = 18 + let wfX = panelX + wfInset + let wfW = panelW - wfInset * 2 + let wfH: CGFloat = 13 + let wfY = floor((h - wfH) / 2) + waveformContainer.frame = CGRect(x: wfX, y: wfY, width: max(0, wfW), height: wfH) waveformView.frame = waveformContainer.bounds + // RecordMore — floating above, right side + let rmSize: CGFloat = 40 + let rmFrame = CGRect(x: w - rmSize - 10, y: -52, width: rmSize, height: rmSize) + recordMoreGlassCircle.frame = rmFrame + recordMoreGlassCircle.applyCornerRadius() + recordMoreButton.frame = rmFrame + + // Trim computation minTrimDuration = VoiceRecordingParityConstants.minTrimDuration( duration: duration, waveformWidth: waveformContainer.bounds.width @@ -297,7 +355,6 @@ final class RecordingPreviewPanel: UIView { playPauseState = targetState if playPauseAnimationView.animation != nil { - playButton.setImage(nil, for: .normal) switch (previous, targetState) { case (.play, .pause): if animated { @@ -316,13 +373,7 @@ final class RecordingPreviewPanel: UIView { case (.pause, .pause): playPauseAnimationView.currentFrame = 41 } - return } - - let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold) - let fallbackName = playing ? "pause.fill" : "play.fill" - playButton.setImage(UIImage(systemName: fallbackName, withConfiguration: config), for: .normal) - playButton.tintColor = panelSecondaryTextColor } // MARK: - Display Link @@ -367,9 +418,9 @@ final class RecordingPreviewPanel: UIView { case .began: let leftX = xForTime(trimStart) let rightX = xForTime(trimEnd) - if abs(location.x - leftX) <= 14 { + if abs(location.x - leftX) <= 17 { activePanMode = .trimLeft - } else if abs(location.x - rightX) <= 14 { + } else if abs(location.x - rightX) <= 17 { activePanMode = .trimRight } else { activePanMode = .scrub @@ -377,6 +428,12 @@ final class RecordingPreviewPanel: UIView { if activePanMode != .scrub { pausePlayback() } + // Hide play pill during scrub + if activePanMode == .scrub { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { + self.playButtonPill.alpha = 0 + } + } case .changed: switch activePanMode { case .trimLeft: @@ -408,22 +465,98 @@ final class RecordingPreviewPanel: UIView { } default: activePanMode = nil + // Show play pill again + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { + self.playButtonPill.alpha = 1 + } } } private func updateTrimVisuals() { - let h = waveformContainer.bounds.height - let w = waveformContainer.bounds.width - guard w > 0 else { return } + let wfW = waveformContainer.bounds.width + let wfH = waveformContainer.bounds.height + guard wfW > 0 else { return } let startX = xForTime(trimStart) let endX = xForTime(trimEnd) - leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: h) - rightTrimMask.frame = CGRect(x: min(w, endX), y: 0, width: max(0, w - endX), height: h) - let handleW: CGFloat = 4 - leftTrimHandle.frame = CGRect(x: max(0, startX - handleW / 2), y: 0, width: handleW, height: h) - rightTrimHandle.frame = CGRect(x: min(w - handleW, endX - handleW / 2), y: 0, width: handleW, height: h) + // Trim masks (dim areas outside trim range) + leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: wfH) + rightTrimMask.frame = CGRect(x: min(wfW, endX), y: 0, width: max(0, wfW - endX), height: wfH) + + // Trim handles (16pt wide, positioned at panel level) + let handleW: CGFloat = 16 + let handleH = centerPanelFrame.height + let handleY = centerPanelFrame.minY + let wfOriginX = waveformContainer.frame.minX + + leftTrimHandle.frame = CGRect( + x: wfOriginX + startX - handleW, + y: handleY, + width: handleW, + height: handleH + ) + rightTrimHandle.frame = CGRect( + x: wfOriginX + endX, + y: handleY, + width: handleW, + height: handleH + ) + + // Capsule indicators (3×12, centered vertically, 8pt from outer edge) + let capsuleW: CGFloat = 3 + let capsuleH: CGFloat = 12 + let capsuleY = (handleH - capsuleH) / 2 + // Left capsule: 8pt from right edge of left handle (outer edge faces left, capsule near waveform) + leftCapsuleView.frame = CGRect(x: handleW - 8 - capsuleW, y: capsuleY, width: capsuleW, height: capsuleH) + // Right capsule: 8pt from left edge of right handle + rightCapsuleView.frame = CGRect(x: 8, y: capsuleY, width: capsuleW, height: capsuleH) + + // Hide trim handles when duration < 2s + let showTrim = duration >= 2.0 + leftTrimHandle.isHidden = !showTrim + rightTrimHandle.isHidden = !showTrim + + // Blue accent fill — dynamic between trim handles (covers panel area) + let fillX: CGFloat + let fillW: CGFloat + if showTrim { + fillX = wfOriginX + startX - 18 // extend to panel-edge inset + let fillEndX = wfOriginX + endX + 18 + let clampedX = max(centerPanelFrame.minX, fillX) + let clampedEndX = min(centerPanelFrame.maxX, fillEndX) + fillW = clampedEndX - clampedX + accentFillView.frame = CGRect(x: clampedX, y: centerPanelFrame.minY, width: max(0, fillW), height: centerPanelFrame.height) + } else { + // No trim — fill entire central panel + accentFillView.frame = centerPanelFrame + } + + // Play button pill — centered between trim handles + let space = endX - startX + let pillW: CGFloat = space >= 70 ? 63 : 27 + let pillH: CGFloat = 22 + let pillX = startX + (space - pillW) / 2 + let pillY = (wfH - pillH) / 2 + playButtonPill.frame = CGRect(x: pillX, y: pillY, width: pillW, height: pillH) + + // Pill background + let pillCornerRadius = pillH / 2 + if playPillBackground.image == nil || playPillBackground.frame.size != playButtonPill.bounds.size { + playPillBackground.image = Self.makeStretchablePill(diameter: pillH, color: .white)?.withRenderingMode(.alwaysTemplate) + playPillBackground.tintColor = playPillBackgroundColor + } + playPillBackground.frame = playButtonPill.bounds + playPillBackground.layer.cornerRadius = pillCornerRadius + playPillBackground.clipsToBounds = true + + // Lottie icon inside pill + playPauseAnimationView.frame = CGRect(x: 3, y: 1, width: 21, height: 21) + + // Duration label inside pill + let showDuration = pillW > 27 + durationLabel.isHidden = !showDuration + durationLabel.frame = CGRect(x: 18, y: 3, width: 35, height: 20) } private func xForTime(_ time: TimeInterval) -> CGFloat { @@ -447,15 +580,29 @@ final class RecordingPreviewPanel: UIView { } private func updateThemeColors() { - durationLabel.textColor = panelSecondaryTextColor - recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85) - waveformView.backgroundColor_ = panelWaveformBackgroundColor - waveformView.foregroundColor_ = panelControlAccentColor + // Waveform colors: white bars on blue accent background + waveformView.backgroundColor_ = UIColor.white.withAlphaComponent(0.4) + waveformView.foregroundColor_ = UIColor.white waveformView.setNeedsDisplay() + + // Delete button tint + deleteButton.tintColor = panelControlColor + + // Record more tint + recordMoreButton.tintColor = panelControlColor + + // Play pill text + icon + durationLabel.textColor = panelSecondaryTextColor applyPlayPauseTintColor(panelSecondaryTextColor) - if playPauseAnimationView.animation == nil { - playButton.tintColor = panelSecondaryTextColor - } + + // Pill background + playPillBackground.tintColor = playPillBackgroundColor + + // Accent fill + accentFillView.image = Self.makeStretchablePill( + diameter: 34, + color: panelControlAccentColor + ) } private func applyPlayPauseTintColor(_ color: UIColor) { @@ -511,6 +658,21 @@ final class RecordingPreviewPanel: UIView { }) } + // MARK: - Helpers + + private static func makeStretchablePill(diameter: CGFloat, color: UIColor) -> UIImage? { + let size = CGSize(width: diameter, height: diameter) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { _ in + color.setFill() + UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill() + } + let half = diameter / 2 + return image.resizableImage(withCapInsets: UIEdgeInsets( + top: half, left: half, bottom: half, right: half + )) + } + deinit { stopDisplayLink() }