mirror of https://github.com/oxen-io/session-ios
Implement new voice message design
parent
be4ef51416
commit
6ff0834065
@ -0,0 +1,136 @@
|
|||||||
|
import Accelerate
|
||||||
|
|
||||||
|
@objc(LKVoiceMessageView2)
|
||||||
|
final class VoiceMessageView2 : UIView {
|
||||||
|
private let audioFileURL: URL
|
||||||
|
private let player: AVAudioPlayer
|
||||||
|
private var duration: Double = 1
|
||||||
|
private var isAnimating = false
|
||||||
|
private var volumeSamples: [Float] = [] { didSet { updateShapeLayer() } }
|
||||||
|
|
||||||
|
// MARK: Components
|
||||||
|
private lazy var loader: UIView = {
|
||||||
|
let result = UIView()
|
||||||
|
result.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||||
|
result.layer.cornerRadius = Values.messageBubbleCornerRadius
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var backgroundShapeLayer: CAShapeLayer = {
|
||||||
|
let result = CAShapeLayer()
|
||||||
|
result.fillColor = Colors.text.cgColor
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var foregroundShapeLayer: CAShapeLayer = {
|
||||||
|
let result = CAShapeLayer()
|
||||||
|
result.fillColor = Colors.accent.cgColor
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: Settings
|
||||||
|
private let margin = Values.smallSpacing
|
||||||
|
private let sampleSpacing: CGFloat = 1
|
||||||
|
|
||||||
|
// MARK: Initialization
|
||||||
|
init(audioFileURL: URL) {
|
||||||
|
self.audioFileURL = audioFileURL
|
||||||
|
player = try! AVAudioPlayer(contentsOf: audioFileURL)
|
||||||
|
super.init(frame: CGRect.zero)
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
preconditionFailure("Use init(audioFileURL:) instead.")
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
preconditionFailure("Use init(audioFileURL:) instead.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize() {
|
||||||
|
setUpViewHierarchy()
|
||||||
|
AudioUtilities.getVolumeSamples(for: audioFileURL).done(on: DispatchQueue.main) { [weak self] duration, volumeSamples in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.duration = duration
|
||||||
|
self.volumeSamples = volumeSamples
|
||||||
|
self.stopAnimating()
|
||||||
|
}.catch(on: DispatchQueue.main) { error in
|
||||||
|
print("[Loki] Couldn't sample audio file due to error: \(error).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpViewHierarchy() {
|
||||||
|
set(.width, to: 200)
|
||||||
|
set(.height, to: 40)
|
||||||
|
addSubview(loader)
|
||||||
|
loader.pin(to: self)
|
||||||
|
backgroundColor = Colors.sentMessageBackground
|
||||||
|
layer.cornerRadius = Values.messageBubbleCornerRadius
|
||||||
|
layer.insertSublayer(backgroundShapeLayer, at: 0)
|
||||||
|
layer.insertSublayer(foregroundShapeLayer, at: 1)
|
||||||
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(togglePlayback))
|
||||||
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
|
showLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: User Interface
|
||||||
|
private func showLoader() {
|
||||||
|
isAnimating = true
|
||||||
|
loader.alpha = 1
|
||||||
|
animateLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animateLoader() {
|
||||||
|
loader.frame = CGRect(x: 0, y: 0, width: 0, height: 40)
|
||||||
|
UIView.animate(withDuration: 2) { [weak self] in
|
||||||
|
self?.loader.frame = CGRect(x: 0, y: 0, width: 200, height: 40)
|
||||||
|
} completion: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if self.isAnimating { self.animateLoader() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAnimating() {
|
||||||
|
isAnimating = false
|
||||||
|
loader.alpha = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
updateShapeLayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateShapeLayer() {
|
||||||
|
guard !volumeSamples.isEmpty else { return }
|
||||||
|
let max = CGFloat(volumeSamples.max()!)
|
||||||
|
let min = CGFloat(volumeSamples.min()!)
|
||||||
|
let w = width() - 2 * margin
|
||||||
|
let h = height() - 2 * margin
|
||||||
|
let sW = (w - sampleSpacing * CGFloat(volumeSamples.count)) / CGFloat(volumeSamples.count)
|
||||||
|
let backgroundPath = UIBezierPath()
|
||||||
|
let foregroundPath = UIBezierPath()
|
||||||
|
for (i, value) in volumeSamples.enumerated() {
|
||||||
|
let x = margin + CGFloat(i) * (sW + sampleSpacing)
|
||||||
|
let fraction = (CGFloat(value) - min) / (max - min)
|
||||||
|
let sH = h * fraction
|
||||||
|
let y = margin + (h - sH) / 2
|
||||||
|
let subPath = UIBezierPath(roundedRect: CGRect(x: x, y: y, width: sW, height: sH), cornerRadius: sW / 2)
|
||||||
|
backgroundPath.append(subPath)
|
||||||
|
if player.currentTime / duration > Double(i) / Double(volumeSamples.count) { foregroundPath.append(subPath) }
|
||||||
|
}
|
||||||
|
backgroundPath.close()
|
||||||
|
foregroundPath.close()
|
||||||
|
backgroundShapeLayer.path = backgroundPath.cgPath
|
||||||
|
foregroundShapeLayer.path = foregroundPath.cgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func togglePlayback() {
|
||||||
|
player.play()
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||||
|
guard let self = self else { return timer.invalidate() }
|
||||||
|
self.updateShapeLayer()
|
||||||
|
if !self.player.isPlaying { timer.invalidate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,173 @@
|
|||||||
|
import Accelerate
|
||||||
|
import PromiseKit
|
||||||
|
|
||||||
|
enum AudioUtilities {
|
||||||
|
private static let noiseFloor: Float = -80
|
||||||
|
|
||||||
|
private struct FileInfo {
|
||||||
|
let url: URL
|
||||||
|
let sampleCount: Int
|
||||||
|
let asset: AVAsset
|
||||||
|
let track: AVAssetTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Error : LocalizedError {
|
||||||
|
case noAudioTrack
|
||||||
|
case noAudioFormatDescription
|
||||||
|
case loadingFailed
|
||||||
|
case parsingFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noAudioTrack: return "No audio track."
|
||||||
|
case .noAudioFormatDescription: return "No audio format description."
|
||||||
|
case .loadingFailed: return "Couldn't load asset."
|
||||||
|
case .parsingFailed: return "Couldn't parse asset."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getVolumeSamples(for audioFileURL: URL, targetSampleCount: Int = 32) -> Promise<(duration: Double, volumeSamples: [Float])> {
|
||||||
|
return loadFile(audioFileURL).then { fileInfo in
|
||||||
|
AudioUtilities.parseSamples(from: fileInfo, with: targetSampleCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadFile(_ audioFileURL: URL) -> Promise<FileInfo> {
|
||||||
|
let asset = AVURLAsset(url: audioFileURL)
|
||||||
|
guard let track = asset.tracks(withMediaType: AVMediaType.audio).first else {
|
||||||
|
return Promise(error: Error.noAudioTrack)
|
||||||
|
}
|
||||||
|
let (promise, seal) = Promise<FileInfo>.pending()
|
||||||
|
asset.loadValuesAsynchronously(forKeys: [ "duration" ]) {
|
||||||
|
var nsError: NSError?
|
||||||
|
let status = asset.statusOfValue(forKey: "duration", error: &nsError)
|
||||||
|
switch status {
|
||||||
|
case .loaded:
|
||||||
|
guard let formatDescriptions = track.formatDescriptions as? [CMAudioFormatDescription],
|
||||||
|
let audioFormatDescription = formatDescriptions.first,
|
||||||
|
let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(audioFormatDescription)
|
||||||
|
else { return seal.reject(Error.noAudioFormatDescription) }
|
||||||
|
let sampleCount = Int((asbd.pointee.mSampleRate) * Float64(asset.duration.value) / Float64(asset.duration.timescale))
|
||||||
|
let fileInfo = FileInfo(url: audioFileURL, sampleCount: sampleCount, asset: asset, track: track)
|
||||||
|
seal.fulfill(fileInfo)
|
||||||
|
default:
|
||||||
|
print("Couldn't load asset due to error: \(nsError?.localizedDescription ?? "no description provided").")
|
||||||
|
seal.reject(Error.loadingFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseSamples(from fileInfo: FileInfo, with targetSampleCount: Int) -> Promise<(duration: Double, volumeSamples: [Float])> {
|
||||||
|
// Prepare the reader
|
||||||
|
guard let reader = try? AVAssetReader(asset: fileInfo.asset) else { return Promise(error: Error.parsingFailed) }
|
||||||
|
let range = 0..<fileInfo.sampleCount
|
||||||
|
reader.timeRange = CMTimeRange(start: CMTime(value: Int64(range.lowerBound), timescale: fileInfo.asset.duration.timescale),
|
||||||
|
duration: CMTime(value: Int64(range.count), timescale: fileInfo.asset.duration.timescale))
|
||||||
|
let outputSettings: [String:Any] = [
|
||||||
|
AVFormatIDKey : Int(kAudioFormatLinearPCM),
|
||||||
|
AVLinearPCMBitDepthKey : 16,
|
||||||
|
AVLinearPCMIsBigEndianKey : false,
|
||||||
|
AVLinearPCMIsFloatKey : false,
|
||||||
|
AVLinearPCMIsNonInterleaved : false
|
||||||
|
]
|
||||||
|
let output = AVAssetReaderTrackOutput(track: fileInfo.track, outputSettings: outputSettings)
|
||||||
|
output.alwaysCopiesSampleData = false
|
||||||
|
reader.add(output)
|
||||||
|
var channelCount = 1
|
||||||
|
let formatDescriptions = fileInfo.track.formatDescriptions as! [CMAudioFormatDescription]
|
||||||
|
for audioFormatDescription in formatDescriptions {
|
||||||
|
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(audioFormatDescription) else {
|
||||||
|
return Promise(error: Error.parsingFailed)
|
||||||
|
}
|
||||||
|
channelCount = Int(asbd.pointee.mChannelsPerFrame)
|
||||||
|
}
|
||||||
|
let samplesPerPixel = max(1, channelCount * range.count / targetSampleCount)
|
||||||
|
let filter = [Float](repeating: 1 / Float(samplesPerPixel), count: samplesPerPixel)
|
||||||
|
var result = [Float]()
|
||||||
|
var sampleBuffer = Data()
|
||||||
|
// Read the file
|
||||||
|
reader.startReading()
|
||||||
|
defer { reader.cancelReading() }
|
||||||
|
while reader.status == .reading {
|
||||||
|
guard let readSampleBuffer = output.copyNextSampleBuffer(),
|
||||||
|
let readBuffer = CMSampleBufferGetDataBuffer(readSampleBuffer) else { break }
|
||||||
|
var readBufferLength = 0
|
||||||
|
var readBufferPointer: UnsafeMutablePointer<Int8>?
|
||||||
|
CMBlockBufferGetDataPointer(readBuffer,
|
||||||
|
atOffset: 0,
|
||||||
|
lengthAtOffsetOut: &readBufferLength,
|
||||||
|
totalLengthOut: nil,
|
||||||
|
dataPointerOut: &readBufferPointer)
|
||||||
|
sampleBuffer.append(UnsafeBufferPointer(start: readBufferPointer, count: readBufferLength))
|
||||||
|
CMSampleBufferInvalidate(readSampleBuffer)
|
||||||
|
let sampleCount = sampleBuffer.count / MemoryLayout<Int16>.size
|
||||||
|
let downSampledLength = sampleCount / samplesPerPixel
|
||||||
|
let samplesToProcess = downSampledLength * samplesPerPixel
|
||||||
|
guard samplesToProcess > 0 else { continue }
|
||||||
|
processSamples(from: &sampleBuffer,
|
||||||
|
outputSamples: &result,
|
||||||
|
samplesToProcess: samplesToProcess,
|
||||||
|
downSampledLength: downSampledLength,
|
||||||
|
samplesPerPixel: samplesPerPixel,
|
||||||
|
filter: filter)
|
||||||
|
}
|
||||||
|
// Process any remaining samples
|
||||||
|
let samplesToProcess = sampleBuffer.count / MemoryLayout<Int16>.size
|
||||||
|
if samplesToProcess > 0 {
|
||||||
|
let downSampledLength = 1
|
||||||
|
let samplesPerPixel = samplesToProcess
|
||||||
|
let filter = [Float](repeating: 1.0 / Float(samplesPerPixel), count: samplesPerPixel)
|
||||||
|
processSamples(from: &sampleBuffer,
|
||||||
|
outputSamples: &result,
|
||||||
|
samplesToProcess: samplesToProcess,
|
||||||
|
downSampledLength: downSampledLength,
|
||||||
|
samplesPerPixel: samplesPerPixel,
|
||||||
|
filter: filter)
|
||||||
|
}
|
||||||
|
guard reader.status == .completed else { return Promise(error: Error.parsingFailed) }
|
||||||
|
// Return
|
||||||
|
let duration = fileInfo.asset.duration.seconds
|
||||||
|
return Promise { $0.fulfill((duration, result)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func processSamples(from sampleBuffer: inout Data, outputSamples: inout [Float], samplesToProcess: Int,
|
||||||
|
downSampledLength: Int, samplesPerPixel: Int, filter: [Float]) {
|
||||||
|
sampleBuffer.withUnsafeBytes { (samples: UnsafeRawBufferPointer) in
|
||||||
|
var processingBuffer = [Float](repeating: 0, count: samplesToProcess)
|
||||||
|
let sampleCount = vDSP_Length(samplesToProcess)
|
||||||
|
// Create an UnsafePointer<Int16> from the samples
|
||||||
|
let unsafeBufferPointer = samples.bindMemory(to: Int16.self)
|
||||||
|
let unsafePointer = unsafeBufferPointer.baseAddress!
|
||||||
|
// Convert 16 bit int samples to floats
|
||||||
|
vDSP_vflt16(unsafePointer, 1, &processingBuffer, 1, sampleCount)
|
||||||
|
// Take the absolute values to get the amplitude
|
||||||
|
vDSP_vabs(processingBuffer, 1, &processingBuffer, 1, sampleCount)
|
||||||
|
// Get the corresponding dB values and clip the results
|
||||||
|
getdB(from: &processingBuffer)
|
||||||
|
// Downsample and average
|
||||||
|
var downSampledData = [Float](repeating: 0, count: downSampledLength)
|
||||||
|
vDSP_desamp(processingBuffer,
|
||||||
|
vDSP_Stride(samplesPerPixel),
|
||||||
|
filter,
|
||||||
|
&downSampledData,
|
||||||
|
vDSP_Length(downSampledLength),
|
||||||
|
vDSP_Length(samplesPerPixel))
|
||||||
|
// Remove the processed samples
|
||||||
|
sampleBuffer.removeFirst(samplesToProcess * MemoryLayout<Int16>.size)
|
||||||
|
// Update the output samples
|
||||||
|
outputSamples += downSampledData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getdB(from normalizedSamples: inout [Float]) {
|
||||||
|
// Convert samples to a log scale
|
||||||
|
var zero: Float = 32768.0
|
||||||
|
vDSP_vdbcon(normalizedSamples, 1, &zero, &normalizedSamples, 1, vDSP_Length(normalizedSamples.count), 1)
|
||||||
|
// Clip to [noiseFloor, 0]
|
||||||
|
var ceil: Float = 0.0
|
||||||
|
var noiseFloorMutable = AudioUtilities.noiseFloor
|
||||||
|
vDSP_vclip(normalizedSamples, 1, &noiseFloorMutable, &ceil, &normalizedSamples, 1, vDSP_Length(normalizedSamples.count))
|
||||||
|
}
|
||||||
|
}
|
@ -1,27 +0,0 @@
|
|||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@class ConversationStyle;
|
|
||||||
@class TSAttachment;
|
|
||||||
|
|
||||||
@protocol ConversationViewItem;
|
|
||||||
|
|
||||||
@interface OWSAudioMessageView : UIStackView
|
|
||||||
|
|
||||||
- (instancetype)initWithAttachment:(TSAttachment *)attachment
|
|
||||||
isIncoming:(BOOL)isIncoming
|
|
||||||
viewItem:(id<ConversationViewItem>)viewItem
|
|
||||||
conversationStyle:(ConversationStyle *)conversationStyle;
|
|
||||||
|
|
||||||
- (void)createContents;
|
|
||||||
|
|
||||||
+ (CGFloat)bubbleHeight;
|
|
||||||
|
|
||||||
- (void)updateContents;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
@ -1,305 +0,0 @@
|
|||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "OWSAudioMessageView.h"
|
|
||||||
#import "ConversationViewItem.h"
|
|
||||||
#import "Session-Swift.h"
|
|
||||||
#import "UIColor+OWS.h"
|
|
||||||
#import "ViewControllerUtils.h"
|
|
||||||
#import <SignalMessaging/OWSFormat.h>
|
|
||||||
#import <SignalMessaging/UIColor+OWS.h>
|
|
||||||
#import <SessionServiceKit/MIMETypeUtil.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface OWSAudioMessageView ()
|
|
||||||
|
|
||||||
@property (nonatomic) TSAttachment *attachment;
|
|
||||||
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
|
|
||||||
@property (nonatomic) BOOL isIncoming;
|
|
||||||
@property (nonatomic, weak) id<ConversationViewItem> viewItem;
|
|
||||||
@property (nonatomic, readonly) ConversationStyle *conversationStyle;
|
|
||||||
|
|
||||||
@property (nonatomic, nullable) UIButton *audioPlayPauseButton;
|
|
||||||
@property (nonatomic, nullable) UILabel *audioBottomLabel;
|
|
||||||
@property (nonatomic, nullable) AudioProgressView *audioProgressView;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@implementation OWSAudioMessageView
|
|
||||||
|
|
||||||
- (instancetype)initWithAttachment:(TSAttachment *)attachment
|
|
||||||
isIncoming:(BOOL)isIncoming
|
|
||||||
viewItem:(id<ConversationViewItem>)viewItem
|
|
||||||
conversationStyle:(ConversationStyle *)conversationStyle
|
|
||||||
{
|
|
||||||
self = [super init];
|
|
||||||
|
|
||||||
if (self) {
|
|
||||||
_attachment = attachment;
|
|
||||||
if ([attachment isKindOfClass:[TSAttachmentStream class]]) {
|
|
||||||
_attachmentStream = (TSAttachmentStream *)attachment;
|
|
||||||
}
|
|
||||||
_isIncoming = isIncoming;
|
|
||||||
_viewItem = viewItem;
|
|
||||||
_conversationStyle = conversationStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)updateContents
|
|
||||||
{
|
|
||||||
[self updateAudioProgressView];
|
|
||||||
[self updateAudioBottomLabel];
|
|
||||||
|
|
||||||
if (self.audioPlaybackState == AudioPlaybackState_Playing) {
|
|
||||||
[self setAudioIconToPause];
|
|
||||||
} else {
|
|
||||||
[self setAudioIconToPlay];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)audioProgressSeconds
|
|
||||||
{
|
|
||||||
return [self.viewItem audioProgressSeconds];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)audioDurationSeconds
|
|
||||||
{
|
|
||||||
return self.viewItem.audioDurationSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (AudioPlaybackState)audioPlaybackState
|
|
||||||
{
|
|
||||||
return [self.viewItem audioPlaybackState];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)isAudioPlaying
|
|
||||||
{
|
|
||||||
return self.audioPlaybackState == AudioPlaybackState_Playing;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)updateAudioBottomLabel
|
|
||||||
{
|
|
||||||
if (self.isAudioPlaying && self.audioProgressSeconds > 0 && self.audioDurationSeconds > 0) {
|
|
||||||
self.audioBottomLabel.text =
|
|
||||||
[NSString stringWithFormat:@"%@ / %@",
|
|
||||||
[OWSFormat formatDurationSeconds:(long)round(self.audioProgressSeconds)],
|
|
||||||
[OWSFormat formatDurationSeconds:(long)round(self.audioDurationSeconds)]];
|
|
||||||
} else {
|
|
||||||
self.audioBottomLabel.text =
|
|
||||||
[NSString stringWithFormat:@"%@", [OWSFormat formatDurationSeconds:(long)round(self.audioDurationSeconds)]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setAudioIcon:(UIImage *)icon
|
|
||||||
{
|
|
||||||
icon = [icon resizedImageToSize:CGSizeMake(self.iconSize, self.iconSize)];
|
|
||||||
[_audioPlayPauseButton setImage:icon forState:UIControlStateNormal];
|
|
||||||
[_audioPlayPauseButton setImage:icon forState:UIControlStateDisabled];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setAudioIconToPlay
|
|
||||||
{
|
|
||||||
[self setAudioIcon:[UIImage imageNamed:@"CirclePlay"]];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setAudioIconToPause
|
|
||||||
{
|
|
||||||
[self setAudioIcon:[UIImage imageNamed:@"CirclePause"]];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)updateAudioProgressView
|
|
||||||
{
|
|
||||||
[self.audioProgressView
|
|
||||||
setProgress:(self.audioDurationSeconds > 0 ? self.audioProgressSeconds / self.audioDurationSeconds : 0.f)];
|
|
||||||
|
|
||||||
UIColor *progressColor = [self.conversationStyle bubbleSecondaryTextColorWithIsIncoming:self.isIncoming];
|
|
||||||
self.audioProgressView.horizontalBarColor = progressColor;
|
|
||||||
self.audioProgressView.progressColor = progressColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)replaceIconWithDownloadProgressIfNecessary:(UIView *)iconView
|
|
||||||
{
|
|
||||||
if (!self.viewItem.attachmentPointer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (self.viewItem.attachmentPointer.state) {
|
|
||||||
case TSAttachmentPointerStateFailed:
|
|
||||||
// We don't need to handle the "tap to retry" state here,
|
|
||||||
// only download progress.
|
|
||||||
return;
|
|
||||||
case TSAttachmentPointerStateEnqueued:
|
|
||||||
case TSAttachmentPointerStateDownloading:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
switch (self.viewItem.attachmentPointer.pointerType) {
|
|
||||||
case TSAttachmentPointerTypeRestoring:
|
|
||||||
// TODO: Show "restoring" indicator and possibly progress.
|
|
||||||
return;
|
|
||||||
case TSAttachmentPointerTypeUnknown:
|
|
||||||
case TSAttachmentPointerTypeIncoming:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
NSString *_Nullable uniqueId = self.viewItem.attachmentPointer.uniqueId;
|
|
||||||
if (uniqueId.length < 1) {
|
|
||||||
OWSFailDebug(@"Missing uniqueId.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CGFloat downloadViewSize = self.iconSize;
|
|
||||||
MediaDownloadView *downloadView =
|
|
||||||
[[MediaDownloadView alloc] initWithAttachmentId:uniqueId radius:downloadViewSize * 0.5f];
|
|
||||||
iconView.layer.opacity = 0.01f;
|
|
||||||
[self addSubview:downloadView];
|
|
||||||
[downloadView autoSetDimensionsToSize:CGSizeMake(downloadViewSize, downloadViewSize)];
|
|
||||||
[downloadView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:iconView];
|
|
||||||
[downloadView autoAlignAxis:ALAxisVertical toSameAxisOfView:iconView];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
- (CGFloat)hMargin
|
|
||||||
{
|
|
||||||
return 0.f;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)hSpacing
|
|
||||||
{
|
|
||||||
return 8.f;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (CGFloat)vMargin
|
|
||||||
{
|
|
||||||
return 0.f;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)vMargin
|
|
||||||
{
|
|
||||||
return [OWSAudioMessageView vMargin];
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (CGFloat)bubbleHeight
|
|
||||||
{
|
|
||||||
CGFloat iconHeight = self.iconSize;
|
|
||||||
CGFloat labelsHeight = ([OWSAudioMessageView labelFont].lineHeight * 2 +
|
|
||||||
[OWSAudioMessageView audioProgressViewHeight] + [OWSAudioMessageView labelVSpacing] * 2);
|
|
||||||
CGFloat contentHeight = MAX(iconHeight, labelsHeight);
|
|
||||||
return contentHeight + self.vMargin * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)bubbleHeight
|
|
||||||
{
|
|
||||||
return [OWSAudioMessageView bubbleHeight];
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (CGFloat)iconSize
|
|
||||||
{
|
|
||||||
return 72.f;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)iconSize
|
|
||||||
{
|
|
||||||
return [OWSAudioMessageView iconSize];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)isVoiceMessage
|
|
||||||
{
|
|
||||||
return self.attachment.isVoiceMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)createContents
|
|
||||||
{
|
|
||||||
self.axis = UILayoutConstraintAxisHorizontal;
|
|
||||||
self.alignment = UIStackViewAlignmentCenter;
|
|
||||||
self.spacing = self.hSpacing;
|
|
||||||
self.layoutMarginsRelativeArrangement = YES;
|
|
||||||
self.layoutMargins = UIEdgeInsetsMake(self.vMargin, 0, self.vMargin, 0);
|
|
||||||
|
|
||||||
_audioPlayPauseButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
|
||||||
self.audioPlayPauseButton.enabled = NO;
|
|
||||||
[self addArrangedSubview:self.audioPlayPauseButton];
|
|
||||||
self.audioPlayPauseButton.imageView.contentMode = UIViewContentModeCenter;
|
|
||||||
[self.audioPlayPauseButton autoSetDimension:ALDimensionWidth toSize:56.f];
|
|
||||||
[self.audioPlayPauseButton autoSetDimension:ALDimensionHeight toSize:56.f];
|
|
||||||
self.audioPlayPauseButton.imageView.clipsToBounds = NO;
|
|
||||||
self.audioPlayPauseButton.clipsToBounds = NO;
|
|
||||||
self.clipsToBounds = NO;
|
|
||||||
|
|
||||||
[self replaceIconWithDownloadProgressIfNecessary:self.audioPlayPauseButton];
|
|
||||||
|
|
||||||
NSString *_Nullable filename = self.attachment.sourceFilename;
|
|
||||||
if (filename.length < 1) {
|
|
||||||
filename = [self.attachmentStream.originalFilePath lastPathComponent];
|
|
||||||
}
|
|
||||||
NSString *topText = [[filename stringByDeletingPathExtension] ows_stripped];
|
|
||||||
if (topText.length < 1) {
|
|
||||||
topText = [MIMETypeUtil fileExtensionForMIMEType:self.attachment.contentType].localizedUppercaseString;
|
|
||||||
}
|
|
||||||
if (topText.length < 1) {
|
|
||||||
topText = NSLocalizedString(@"GENERIC_ATTACHMENT_LABEL", @"A label for generic attachments.");
|
|
||||||
}
|
|
||||||
if (self.isVoiceMessage) {
|
|
||||||
topText = nil;
|
|
||||||
}
|
|
||||||
UILabel *topLabel = [UILabel new];
|
|
||||||
topLabel.text = topText;
|
|
||||||
topLabel.textColor = [self.conversationStyle bubbleTextColorWithIsIncoming:self.isIncoming];
|
|
||||||
topLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
|
|
||||||
topLabel.font = [OWSAudioMessageView labelFont];
|
|
||||||
|
|
||||||
AudioProgressView *audioProgressView = [AudioProgressView new];
|
|
||||||
self.audioProgressView = audioProgressView;
|
|
||||||
[self updateAudioProgressView];
|
|
||||||
[audioProgressView autoSetDimension:ALDimensionHeight toSize:[OWSAudioMessageView audioProgressViewHeight]];
|
|
||||||
|
|
||||||
UILabel *bottomLabel = [UILabel new];
|
|
||||||
self.audioBottomLabel = bottomLabel;
|
|
||||||
[self updateAudioBottomLabel];
|
|
||||||
bottomLabel.textColor = [self.conversationStyle bubbleSecondaryTextColorWithIsIncoming:self.isIncoming];
|
|
||||||
bottomLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
|
|
||||||
bottomLabel.font = [OWSAudioMessageView labelFont];
|
|
||||||
|
|
||||||
UIStackView *labelsView = [UIStackView new];
|
|
||||||
labelsView.axis = UILayoutConstraintAxisVertical;
|
|
||||||
labelsView.spacing = [OWSAudioMessageView labelVSpacing];
|
|
||||||
[labelsView addArrangedSubview:topLabel];
|
|
||||||
[labelsView addArrangedSubview:audioProgressView];
|
|
||||||
[labelsView addArrangedSubview:bottomLabel];
|
|
||||||
|
|
||||||
// Ensure the "audio progress" and "play button" are v-center-aligned using a container.
|
|
||||||
UIView *labelsContainerView = [UIView containerView];
|
|
||||||
[self addArrangedSubview:labelsContainerView];
|
|
||||||
[labelsContainerView addSubview:labelsView];
|
|
||||||
[labelsView autoPinWidthToSuperview];
|
|
||||||
[labelsView autoPinEdgeToSuperviewMargin:ALEdgeTop relation:NSLayoutRelationGreaterThanOrEqual];
|
|
||||||
[labelsView autoPinEdgeToSuperviewMargin:ALEdgeBottom relation:NSLayoutRelationGreaterThanOrEqual];
|
|
||||||
|
|
||||||
[audioProgressView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.audioPlayPauseButton];
|
|
||||||
|
|
||||||
[self updateContents];
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (CGFloat)audioProgressViewHeight
|
|
||||||
{
|
|
||||||
return 12.f;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (UIFont *)labelFont
|
|
||||||
{
|
|
||||||
return [UIFont ows_dynamicTypeCaption2Font];
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (CGFloat)labelVSpacing
|
|
||||||
{
|
|
||||||
return 2.f;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
Loading…
Reference in New Issue