Исправлен рендеринг voice blob — убран shapeLayer.bounds, уменьшен размер до 56pt

This commit is contained in:
2026-04-13 00:19:07 +05:00
parent 08a1da64a8
commit dd80c7d7e3
5 changed files with 349 additions and 162 deletions

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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? {

View File

@@ -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<TimeInterval> {
@@ -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()
}