|
@ -5,7 +5,7 @@ Changes to be released in next version
|
|||
*
|
||||
|
||||
🙌 Improvements
|
||||
*
|
||||
* Room: Added support for Voice Messages (#4090, #4091, #4092, #4094, #4095, #4096)
|
||||
|
||||
🐛 Bugfix
|
||||
* Room: Fixed mentioning users from room info member details (#4583)
|
||||
|
|
|
@ -309,6 +309,10 @@ final class BuildSettings: NSObject {
|
|||
static let messageDetailsAllowCopyMedia: Bool = true
|
||||
static let messageDetailsAllowPasteMedia: Bool = true
|
||||
|
||||
// MARK: - Voice Message
|
||||
|
||||
static let voiceMessagesEnabled = false
|
||||
|
||||
// MARK: - HTTP
|
||||
/// Additional HTTP headers will be sent by all requests. Not recommended to use request-specific headers, like `Authorization`.
|
||||
/// Empty dictionary by default.
|
||||
|
|
|
@ -45,6 +45,10 @@ import UIKit
|
|||
/// - Icons
|
||||
var quarterlyContent: UIColor { get }
|
||||
|
||||
/// - Text
|
||||
/// - Icons
|
||||
var quinaryContent: UIColor { get }
|
||||
|
||||
/// Separating line
|
||||
var separator: UIColor { get }
|
||||
|
||||
|
|
|
@ -32,6 +32,8 @@ public class DarkColors: Colors {
|
|||
|
||||
public let quarterlyContent: UIColor = UIColor(rgb: 0x6F7882)
|
||||
|
||||
public let quinaryContent: UIColor = UIColor(rgb: 0x394049)
|
||||
|
||||
public let separator: UIColor = UIColor(rgb: 0x21262C)
|
||||
|
||||
public let tile: UIColor = UIColor(rgb: 0x394049)
|
||||
|
|
|
@ -32,6 +32,8 @@ public class LightColors: Colors {
|
|||
|
||||
public let quarterlyContent: UIColor = UIColor(rgb: 0xC1C6CD)
|
||||
|
||||
public let quinaryContent: UIColor = UIColor(rgb: 0xE3E8F0)
|
||||
|
||||
public let separator: UIColor = UIColor(rgb: 0xE3E8F0)
|
||||
|
||||
public let tile: UIColor = UIColor(rgb: 0xF3F8FD)
|
||||
|
|
2
Podfile
|
@ -69,6 +69,8 @@ abstract_target 'RiotPods' do
|
|||
pod 'SwiftBase32', '~> 0.9.0'
|
||||
pod 'SwiftJWT', '~> 3.6.200'
|
||||
pod 'SideMenu', '~> 6.5'
|
||||
pod 'DSWaveformImage', '~> 6.1.1'
|
||||
pod 'ffmpeg-kit-ios-audio', '~> 4.4.LTS'
|
||||
|
||||
pod 'FLEX', '~> 4.4.1', :configurations => ['Debug']
|
||||
|
||||
|
|
14
Podfile.lock
|
@ -19,6 +19,7 @@ PODS:
|
|||
- BlueRSA (1.0.34)
|
||||
- DGCollectionViewLeftAlignFlowLayout (1.0.4)
|
||||
- Down (0.11.0)
|
||||
- DSWaveformImage (6.1.1)
|
||||
- DTCoreText (1.6.26):
|
||||
- DTCoreText/Core (= 1.6.26)
|
||||
- DTFoundation/Core (~> 1.7.5)
|
||||
|
@ -36,6 +37,7 @@ PODS:
|
|||
- DTFoundation/Core
|
||||
- DTFoundation/UIKit (1.7.18):
|
||||
- DTFoundation/Core
|
||||
- ffmpeg-kit-ios-audio (4.4)
|
||||
- FLEX (4.4.1)
|
||||
- FlowCommoniOS (1.10.0)
|
||||
- GBDeviceInfo (6.6.0):
|
||||
|
@ -104,7 +106,7 @@ PODS:
|
|||
- BlueRSA (~> 1.0)
|
||||
- KituraContracts (~> 1.2)
|
||||
- LoggerAPI (~> 1.7)
|
||||
- SwiftLint (0.43.0)
|
||||
- SwiftLint (0.43.1)
|
||||
- SwiftyBeaver (1.9.5)
|
||||
- zxcvbn-ios (1.0.4)
|
||||
- ZXingObjC (3.6.5):
|
||||
|
@ -113,6 +115,8 @@ PODS:
|
|||
|
||||
DEPENDENCIES:
|
||||
- DGCollectionViewLeftAlignFlowLayout (~> 1.0.4)
|
||||
- DSWaveformImage (~> 6.1.1)
|
||||
- ffmpeg-kit-ios-audio (~> 4.4.LTS)
|
||||
- FLEX (~> 4.4.1)
|
||||
- FlowCommoniOS (~> 1.10.0)
|
||||
- GBDeviceInfo (~> 6.6.0)
|
||||
|
@ -142,8 +146,10 @@ SPEC REPOS:
|
|||
- BlueRSA
|
||||
- DGCollectionViewLeftAlignFlowLayout
|
||||
- Down
|
||||
- DSWaveformImage
|
||||
- DTCoreText
|
||||
- DTFoundation
|
||||
- ffmpeg-kit-ios-audio
|
||||
- FLEX
|
||||
- FlowCommoniOS
|
||||
- GBDeviceInfo
|
||||
|
@ -180,8 +186,10 @@ SPEC CHECKSUMS:
|
|||
BlueRSA: 6f9776d62d9773502415a7db3bcbb2bbb3f71fc3
|
||||
DGCollectionViewLeftAlignFlowLayout: a0fa58797373ded039cafba8133e79373d048399
|
||||
Down: b6ba1bc985c9d2f4e15e3b293d2207766fa12612
|
||||
DSWaveformImage: 3c718a0cf99291887ee70d1d0c18d80101d3d9ce
|
||||
DTCoreText: ec749e013f2e1f76de5e7c7634642e600a7467ce
|
||||
DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536
|
||||
ffmpeg-kit-ios-audio: ddfc3dac6f574e83d53f8ae33586711162685d3e
|
||||
FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab
|
||||
FlowCommoniOS: bcdf81a5f30717e711af08a8c812eb045411ba94
|
||||
GBDeviceInfo: ed0db16230d2fa280e1cbb39a5a7f60f6946aaec
|
||||
|
@ -206,11 +214,11 @@ SPEC CHECKSUMS:
|
|||
SwiftBase32: 9399c25a80666dc66b51e10076bf591e3bbb8f17
|
||||
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
||||
SwiftJWT: 88c412708f58c169d431d344c87bc79a87c830ae
|
||||
SwiftLint: 0c645fdc6feed3e390c1701ab3cc669f88b42752
|
||||
SwiftLint: 99f82d07b837b942dd563c668de129a03fc3fb52
|
||||
SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82
|
||||
zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 6e4ab90565384faccee0cb985abe05663c36f517
|
||||
PODFILE CHECKSUM: c7386ecfb38fc4302613c915aef79eebdb98a53d
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
@ -19,5 +19,8 @@
|
|||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 823 B After Width: | Height: | Size: 765 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_cancel_gradient.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_cancel_gradient@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_cancel_gradient@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 9.7 KiB |
23
Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_lock_chevron.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_lock_chevron.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_chevron@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_chevron@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 245 B |
After Width: | Height: | Size: 364 B |
After Width: | Height: | Size: 451 B |
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_locked.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_locked@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_locked@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 387 B |
After Width: | Height: | Size: 596 B |
After Width: | Height: | Size: 844 B |
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_unlocked.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_unlocked@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_unlocked@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 346 B |
After Width: | Height: | Size: 553 B |
After Width: | Height: | Size: 712 B |
26
Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_pause_button.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_pause_button.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_pause_button@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_pause_button@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 171 B |
After Width: | Height: | Size: 224 B |
After Width: | Height: | Size: 285 B |
26
Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_play_button.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_play_button.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_play_button@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_play_button@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 412 B |
After Width: | Height: | Size: 634 B |
After Width: | Height: | Size: 748 B |
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "action_voice_message.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "action_voice_message@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "action_voice_message@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 694 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.7 KiB |
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_record_button_recording.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_button_recording@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_button_recording@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 5.9 KiB |
23
Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_icon.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_record_icon.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 247 B |
After Width: | Height: | Size: 427 B |
After Width: | Height: | Size: 576 B |
|
@ -17,7 +17,7 @@
|
|||
// Permissions usage explanations
|
||||
"NSCameraUsageDescription" = "The camera is used to take photos and videos, make video calls.";
|
||||
"NSPhotoLibraryUsageDescription" = "The photo library is used to send photos and videos.";
|
||||
"NSMicrophoneUsageDescription" = "The microphone is used to take videos, make calls.";
|
||||
"NSMicrophoneUsageDescription" = "Element needs to access your microphone to make and receive calls, take videos, and record voice messages.";
|
||||
"NSContactsUsageDescription" = "To discover contacts already using Matrix, Element can send email addresses and phone numbers in your address book to your chosen Matrix identity server. Where supported, personal data is hashed before sending - please check your identity server's privacy policy for more details.";
|
||||
"NSCalendarsUsageDescription" = "See your scheduled meetings in the app.";
|
||||
"NSFaceIDUsageDescription" = "Face ID is used to access your app.";
|
||||
|
|
|
@ -534,6 +534,7 @@ Tap the + to start adding people.";
|
|||
"settings_labs_create_conference_with_jitsi" = "Create conference calls with jitsi";
|
||||
"settings_labs_message_reaction" = "React to messages with emoji";
|
||||
"settings_labs_enable_ringing_for_group_calls" = "Ring for group calls";
|
||||
"settings_labs_voice_messages" = "Voice messages";
|
||||
|
||||
"settings_version" = "Version %@";
|
||||
"settings_olm_version" = "Olm Version %@";
|
||||
|
@ -1681,3 +1682,9 @@ Tap the + to start adding people.";
|
|||
"side_menu_action_help" = "Help";
|
||||
"side_menu_action_feedback" = "Feedback";
|
||||
"side_menu_app_version" = "Version %@";
|
||||
|
||||
// Mark: - Voice Messages
|
||||
|
||||
"voice_message_release_to_send" = "Hold to record, release to send";
|
||||
"voice_message_remaining_recording_time" = "%@s left";
|
||||
"voice_message_stop_locked_mode_recording" = "Tap on the wavelength to stop and playback";
|
||||
|
|
|
@ -1688,7 +1688,203 @@
|
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
<br/><br/>
|
||||
</li>
|
||||
</li>
|
||||
<li>
|
||||
<b>DSWaveformImage</b> (<a href="https://github.com/dmrschmidt/DSWaveformImage">https://github.com/dmrschmidt/DSWaveformImage</a>)
|
||||
<br/><br/>
|
||||
The MIT License (MIT)
|
||||
<br/><br/>
|
||||
Copyright (c) 2013 Dennis Schmidt
|
||||
<br/><br/>
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
<br/><br/>
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
<br/><br/>
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
<br/><br/>
|
||||
</li>
|
||||
<li>
|
||||
<b>ffmpeg-kit-ios-audio</b> (<a href="https://github.com/tanersener/ffmpeg-kit">https://github.com/tanersener/ffmpeg-kit</a>)
|
||||
<br/><br/>
|
||||
<pre>
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -397,7 +397,7 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed =
|
|||
}
|
||||
|
||||
// Move this view in front
|
||||
[self.contentView bringSubviewToFront:self.bubbleOverlayContainer];
|
||||
[self.bubbleOverlayContainer.superview bringSubviewToFront:self.bubbleOverlayContainer];
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
@ -135,6 +135,15 @@ internal enum Asset {
|
|||
internal static let videoCall = ImageAsset(name: "video_call")
|
||||
internal static let voiceCallHangonIcon = ImageAsset(name: "voice_call_hangon_icon")
|
||||
internal static let voiceCallHangupIcon = ImageAsset(name: "voice_call_hangup_icon")
|
||||
internal static let voiceMessageCancelGradient = ImageAsset(name: "voice_message_cancel_gradient")
|
||||
internal static let voiceMessageLockChevron = ImageAsset(name: "voice_message_lock_chevron")
|
||||
internal static let voiceMessageLockIconLocked = ImageAsset(name: "voice_message_lock_icon_locked")
|
||||
internal static let voiceMessageLockIconUnlocked = ImageAsset(name: "voice_message_lock_icon_unlocked")
|
||||
internal static let voiceMessagePauseButton = ImageAsset(name: "voice_message_pause_button")
|
||||
internal static let voiceMessagePlayButton = ImageAsset(name: "voice_message_play_button")
|
||||
internal static let voiceMessageRecordButtonDefault = ImageAsset(name: "voice_message_record_button_default")
|
||||
internal static let voiceMessageRecordButtonRecording = ImageAsset(name: "voice_message_record_button_recording")
|
||||
internal static let voiceMessageRecordIcon = ImageAsset(name: "voice_message_record_icon")
|
||||
internal static let addMemberFloatingAction = ImageAsset(name: "add_member_floating_action")
|
||||
internal static let addParticipant = ImageAsset(name: "add_participant")
|
||||
internal static let addParticipants = ImageAsset(name: "add_participants")
|
||||
|
|
|
@ -4378,6 +4378,10 @@ internal enum VectorL10n {
|
|||
internal static var settingsLabsMessageReaction: String {
|
||||
return VectorL10n.tr("Vector", "settings_labs_message_reaction")
|
||||
}
|
||||
/// Voice messages
|
||||
internal static var settingsLabsVoiceMessages: String {
|
||||
return VectorL10n.tr("Vector", "settings_labs_voice_messages")
|
||||
}
|
||||
/// Mark all messages as read
|
||||
internal static var settingsMarkAllAsRead: String {
|
||||
return VectorL10n.tr("Vector", "settings_mark_all_as_read")
|
||||
|
@ -4882,6 +4886,18 @@ internal enum VectorL10n {
|
|||
internal static var voice: String {
|
||||
return VectorL10n.tr("Vector", "voice")
|
||||
}
|
||||
/// Hold to record, release to send
|
||||
internal static var voiceMessageReleaseToSend: String {
|
||||
return VectorL10n.tr("Vector", "voice_message_release_to_send")
|
||||
}
|
||||
/// %@s left
|
||||
internal static func voiceMessageRemainingRecordingTime(_ p1: String) -> String {
|
||||
return VectorL10n.tr("Vector", "voice_message_remaining_recording_time", p1)
|
||||
}
|
||||
/// Tap on the wavelength to stop and playback
|
||||
internal static var voiceMessageStopLockedModeRecording: String {
|
||||
return VectorL10n.tr("Vector", "voice_message_stop_locked_mode_recording")
|
||||
}
|
||||
/// Warning
|
||||
internal static var warning: String {
|
||||
return VectorL10n.tr("Vector", "warning")
|
||||
|
|
|
@ -52,6 +52,7 @@ final class RiotSettings: NSObject {
|
|||
static let roomCreationScreenRoomIsPublic = "roomCreationScreenRoomIsPublic"
|
||||
static let allowInviteExernalUsers = "allowInviteExernalUsers"
|
||||
static let enableRingingForGroupCalls = "enableRingingForGroupCalls"
|
||||
static let enableVoiceMessages = "enableVoiceMessages"
|
||||
static let roomSettingsScreenShowLowPriorityOption = "roomSettingsScreenShowLowPriorityOption"
|
||||
static let roomSettingsScreenShowDirectChatOption = "roomSettingsScreenShowDirectChatOption"
|
||||
static let roomSettingsScreenAllowChangingAccessSettings = "roomSettingsScreenAllowChangingAccessSettings"
|
||||
|
@ -93,6 +94,11 @@ final class RiotSettings: NSObject {
|
|||
return userDefaults
|
||||
}()
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
defaults.register(defaults: [UserDefaultsKeys.enableVoiceMessages: BuildSettings.voiceMessagesEnabled])
|
||||
}
|
||||
|
||||
// MARK: Servers
|
||||
|
||||
var homeserverUrlString: String {
|
||||
|
@ -215,6 +221,14 @@ final class RiotSettings: NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
var enableVoiceMessages: Bool {
|
||||
get {
|
||||
return defaults.bool(forKey: UserDefaultsKeys.enableVoiceMessages)
|
||||
} set {
|
||||
defaults.set(newValue, forKey: UserDefaultsKeys.enableVoiceMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Calls
|
||||
|
||||
/// Indicate if `allowStunServerFallback` settings has been set once.
|
||||
|
|
|
@ -112,6 +112,9 @@
|
|||
case MXKAttachmentTypeAudio:
|
||||
image = [UIImage imageNamed:@"file_music_icon"];
|
||||
break;
|
||||
case MXKAttachmentTypeVoiceMessage:
|
||||
image = [UIImage imageNamed:@"file_music_icon"];
|
||||
break;
|
||||
case MXKAttachmentTypeVideo:
|
||||
image = [UIImage imageNamed:@"file_video_icon"];
|
||||
break;
|
||||
|
|
|
@ -986,6 +986,9 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
|
|||
case MXKAttachmentTypeAudio:
|
||||
accessibilityLabel = NSLocalizedStringFromTable(@"media_type_accessibility_audio", @"Vector", nil);
|
||||
break;
|
||||
case MXKAttachmentTypeVoiceMessage:
|
||||
accessibilityLabel = NSLocalizedStringFromTable(@"media_type_accessibility_audio", @"Vector", nil);
|
||||
break;
|
||||
case MXKAttachmentTypeVideo:
|
||||
accessibilityLabel = NSLocalizedStringFromTable(@"media_type_accessibility_video", @"Vector", nil);
|
||||
break;
|
||||
|
|
|
@ -135,7 +135,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
@interface RoomViewController () <UISearchBarDelegate, UIGestureRecognizerDelegate, UIScrollViewAccessibilityDelegate, RoomTitleViewTapGestureDelegate, RoomParticipantsViewControllerDelegate, MXKRoomMemberDetailsViewControllerDelegate, ContactsTableViewControllerDelegate, MXServerNoticesDelegate, RoomContextualMenuViewControllerDelegate,
|
||||
ReactionsMenuViewModelCoordinatorDelegate, EditHistoryCoordinatorBridgePresenterDelegate, MXKDocumentPickerPresenterDelegate, EmojiPickerCoordinatorBridgePresenterDelegate,
|
||||
ReactionHistoryCoordinatorBridgePresenterDelegate, CameraPresenterDelegate, MediaPickerCoordinatorBridgePresenterDelegate,
|
||||
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate>
|
||||
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate>
|
||||
{
|
||||
|
||||
// The preview header
|
||||
|
@ -240,6 +240,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
@property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded;
|
||||
@property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden;
|
||||
|
||||
@property (nonatomic, strong) VoiceMessageController *voiceMessageController;
|
||||
|
||||
@end
|
||||
|
||||
@implementation RoomViewController
|
||||
|
@ -313,6 +315,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
|
||||
// Show / hide actions button in document preview according BuildSettings
|
||||
self.allowActionsInDocumentPreview = BuildSettings.messageDetailsAllowShare;
|
||||
|
||||
_voiceMessageController = [[VoiceMessageController alloc] initWithThemeService:ThemeService.shared mediaServiceProvider:VoiceMessageMediaServiceProvider.sharedProvider];
|
||||
self.voiceMessageController.delegate = self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
|
@ -386,6 +391,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
|
||||
[self.bubblesTableView registerNib:RoomTypingBubbleCell.nib forCellReuseIdentifier:RoomTypingBubbleCell.defaultReuseIdentifier];
|
||||
|
||||
[self.bubblesTableView registerClass:VoiceMessageBubbleCell.class forCellReuseIdentifier:VoiceMessageBubbleCell.defaultReuseIdentifier];
|
||||
[self.bubblesTableView registerClass:VoiceMessageWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceMessageWithoutSenderInfoBubbleCell.defaultReuseIdentifier];
|
||||
[self.bubblesTableView registerClass:VoiceMessageWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceMessageWithPaginationTitleBubbleCell.defaultReuseIdentifier];
|
||||
|
||||
[self vc_removeBackTitle];
|
||||
|
||||
[self setupRemoveJitsiWidgetRemoveView];
|
||||
|
@ -607,6 +616,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
self.roomDataSource.showReadMarker = YES;
|
||||
self.updateRoomReadMarker = NO;
|
||||
isAppeared = NO;
|
||||
|
||||
[VoiceMessageMediaServiceProvider.sharedProvider stopAllServices];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
|
@ -1114,6 +1125,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
if (!self.inputToolbarView || ![self.inputToolbarView isMemberOfClass:roomInputToolbarViewClass])
|
||||
{
|
||||
[super setRoomInputToolbarViewClass:roomInputToolbarViewClass];
|
||||
|
||||
[(RoomInputToolbarView *)self.inputToolbarView setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView];
|
||||
|
||||
[self updateInputToolBarViewHeight];
|
||||
}
|
||||
}
|
||||
|
@ -2359,6 +2373,16 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
{
|
||||
cellViewClass = RoomGroupCallStatusBubbleCell.class;
|
||||
}
|
||||
else if (bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage)
|
||||
{
|
||||
if (bubbleData.isPaginationFirstBubble) {
|
||||
cellViewClass = VoiceMessageWithPaginationTitleBubbleCell.class;
|
||||
} else if (bubbleData.shouldHideSenderInformation) {
|
||||
cellViewClass = VoiceMessageWithoutSenderInfoBubbleCell.class;
|
||||
} else {
|
||||
cellViewClass = VoiceMessageBubbleCell.class;
|
||||
}
|
||||
}
|
||||
else if (bubbleData.isIncoming)
|
||||
{
|
||||
if (bubbleData.isAttachmentWithThumbnail)
|
||||
|
@ -2718,12 +2742,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
[actionIdentifier isEqualToString:RoomGroupCallStatusBubbleCell.answerAction])
|
||||
{
|
||||
MXWeakify(self);
|
||||
NSString *appDisplayName = [[NSBundle mainBundle] infoDictionary][@"CFBundleDisplayName"];
|
||||
|
||||
// Check app permissions first
|
||||
[MXKTools checkAccessForCall:YES
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
showPopUpInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
MXStrongifyAndReturnIfNil(self);
|
||||
|
@ -3715,12 +3738,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
{
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
|
||||
NSString *appDisplayName = [[NSBundle mainBundle] infoDictionary][@"CFBundleDisplayName"];
|
||||
|
||||
// Check app permissions first
|
||||
[MXKTools checkAccessForCall:video
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
showPopUpInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
if (weakSelf)
|
||||
|
@ -5803,7 +5824,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
MXWeakify(self);
|
||||
|
||||
RoomContextualMenuItem *replyMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionReply];
|
||||
replyMenuItem.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId];
|
||||
replyMenuItem.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId] && !self.voiceMessageController.isRecordingAudio;
|
||||
replyMenuItem.action = ^{
|
||||
MXStrongifyAndReturnIfNil(self);
|
||||
|
||||
|
@ -6152,4 +6173,32 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
}];
|
||||
}
|
||||
|
||||
#pragma mark - VoiceMessageControllerDelegate
|
||||
|
||||
- (void)voiceMessageControllerDidRequestMicrophonePermission:(VoiceMessageController *)voiceMessageController
|
||||
{
|
||||
NSString *message = [NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_voice_message"], AppInfo.current.displayName];
|
||||
|
||||
[MXKTools checkAccessForMediaType:AVMediaTypeAudio
|
||||
manualChangeMessage: message
|
||||
showPopUpInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)voiceMessageController:(VoiceMessageController *)voiceMessageController
|
||||
didRequestSendForFileAtURL:(NSURL *)url
|
||||
duration:(NSUInteger)duration
|
||||
samples:(NSArray<NSNumber *> *)samples
|
||||
completion:(void (^)(BOOL))completion
|
||||
{
|
||||
[self.roomDataSource sendVoiceMessage:url mimeType:nil duration:duration samples:samples success:^(NSString *eventId) {
|
||||
MXLogDebug(@"Success with event id %@", eventId);
|
||||
completion(YES);
|
||||
} failure:^(NSError *error) {
|
||||
MXLogError(@"Failed sending voice message");
|
||||
completion(NO);
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoiceMessageBubbleCell: SizableBaseBubbleCell, BubbleCellReactionsDisplayable {
|
||||
|
||||
private var playbackController: VoiceMessagePlaybackController!
|
||||
|
||||
override func render(_ cellData: MXKCellData!) {
|
||||
super.render(cellData)
|
||||
|
||||
guard let data = cellData as? RoomBubbleCellData else {
|
||||
return
|
||||
}
|
||||
|
||||
guard data.attachment.type == MXKAttachmentTypeVoiceMessage else {
|
||||
fatalError("Invalid attachment type passed to a voice message cell.")
|
||||
}
|
||||
|
||||
if playbackController.attachment != data.attachment {
|
||||
playbackController.attachment = data.attachment
|
||||
}
|
||||
}
|
||||
|
||||
override func setupViews() {
|
||||
super.setupViews()
|
||||
|
||||
bubbleCellContentView?.backgroundColor = .clear
|
||||
bubbleCellContentView?.showSenderInfo = true
|
||||
bubbleCellContentView?.showPaginationTitle = false
|
||||
|
||||
guard let contentView = bubbleCellContentView?.innerContentView else {
|
||||
return
|
||||
}
|
||||
|
||||
playbackController = VoiceMessagePlaybackController(mediaServiceProvider: VoiceMessageMediaServiceProvider.sharedProvider,
|
||||
cacheManager: VoiceMessageAttachmentCacheManager.sharedManager)
|
||||
|
||||
contentView.vc_addSubViewMatchingParent(playbackController.playbackView)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoiceMessageWithPaginationTitleBubbleCell: VoiceMessageBubbleCell {
|
||||
override func setupViews() {
|
||||
super.setupViews()
|
||||
|
||||
bubbleCellContentView?.showPaginationTitle = true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoiceMessageWithoutSenderInfoBubbleCell: VoiceMessageBubbleCell {
|
||||
override func setupViews() {
|
||||
super.setupViews()
|
||||
|
||||
bubbleCellContentView?.showSenderInfo = false
|
||||
}
|
||||
}
|
|
@ -58,7 +58,6 @@ typedef enum : NSUInteger
|
|||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mainToolbarMinHeightConstraint;
|
||||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mainToolbarHeightConstraint;
|
||||
|
||||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerLeadingConstraint;
|
||||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerTrailingConstraint;
|
||||
|
||||
@property (weak, nonatomic) IBOutlet UIButton *attachMediaButton;
|
||||
|
@ -70,6 +69,7 @@ typedef enum : NSUInteger
|
|||
@property (weak, nonatomic) IBOutlet UILabel *inputContextLabel;
|
||||
@property (weak, nonatomic) IBOutlet UIButton *inputContextButton;
|
||||
@property (weak, nonatomic) IBOutlet RoomActionsBar *actionsBar;
|
||||
@property (weak, nonatomic) UIView *voiceMessageToolbarView;
|
||||
|
||||
/**
|
||||
Tell whether the filled data will be sent encrypted. NO by default.
|
||||
|
|
|
@ -34,6 +34,7 @@ const CGFloat kActionMenuAttachButtonSpringVelocity = 7;
|
|||
const CGFloat kActionMenuAttachButtonSpringDamping = .45;
|
||||
const NSTimeInterval kActionMenuContentAlphaAnimationDuration = .2;
|
||||
const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
const CGFloat kComposerContainerTrailingPadding = 12;
|
||||
|
||||
@interface RoomInputToolbarView()
|
||||
{
|
||||
|
@ -75,6 +76,24 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
[self.rightInputToolbarButton setTitle:nil forState:UIControlStateHighlighted];
|
||||
|
||||
self.isEncryptionEnabled = _isEncryptionEnabled;
|
||||
|
||||
[self updateUIWithTextMessage:nil animated:NO];
|
||||
}
|
||||
|
||||
- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView
|
||||
{
|
||||
if (RiotSettings.shared.enableVoiceMessages == NO) {
|
||||
return;
|
||||
}
|
||||
|
||||
_voiceMessageToolbarView = voiceMessageToolbarView;
|
||||
self.voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:self.voiceMessageToolbarView];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[[self.mainToolbarView.topAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.topAnchor],
|
||||
[self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor],
|
||||
[self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor],
|
||||
[self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]];
|
||||
}
|
||||
|
||||
#pragma mark - Override MXKView
|
||||
|
@ -133,7 +152,7 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
|
||||
- (void)setTextMessage:(NSString *)textMessage
|
||||
{
|
||||
[self updateSendButtonWithMessage:textMessage];
|
||||
[self updateUIWithTextMessage:textMessage animated:YES];
|
||||
[super setTextMessage:textMessage];
|
||||
}
|
||||
|
||||
|
@ -290,7 +309,7 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
- (BOOL)growingTextView:(HPGrowingTextView *)growingTextView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
|
||||
{
|
||||
NSString *newText = [growingTextView.text stringByReplacingCharactersInRange:range withString:text];
|
||||
[self updateSendButtonWithMessage:newText];
|
||||
[self updateUIWithTextMessage:newText animated:YES];
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
@ -354,24 +373,6 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
[super destroy];
|
||||
}
|
||||
|
||||
- (void)updateSendButtonWithMessage:(NSString *)textMessage
|
||||
{
|
||||
self.actionMenuOpened = NO;
|
||||
|
||||
if (textMessage.length)
|
||||
{
|
||||
self.rightInputToolbarButton.alpha = 1;
|
||||
self.messageComposerContainerTrailingConstraint.constant = self.frame.size.width - self.rightInputToolbarButton.frame.origin.x + 12;
|
||||
}
|
||||
else
|
||||
{
|
||||
self.rightInputToolbarButton.alpha = 0;
|
||||
self.messageComposerContainerTrailingConstraint.constant = 12;
|
||||
}
|
||||
|
||||
[self layoutIfNeeded];
|
||||
}
|
||||
|
||||
#pragma mark - properties
|
||||
|
||||
- (void)setActionMenuOpened:(BOOL)actionMenuOpened
|
||||
|
@ -406,6 +407,10 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
[UIView animateWithDuration:kActionMenuContentAlphaAnimationDuration delay:_actionMenuOpened ? 0 : .1 options:UIViewAnimationOptionCurveEaseIn animations:^{
|
||||
self->messageComposerContainer.alpha = actionMenuOpened ? 0 : 1;
|
||||
self.rightInputToolbarButton.alpha = self->growingTextView.text.length == 0 || actionMenuOpened ? 0 : 1;
|
||||
if (RiotSettings.shared.enableVoiceMessages)
|
||||
{
|
||||
self.voiceMessageToolbarView.alpha = self->growingTextView.text.length > 0 || actionMenuOpened ? 0 : 1;
|
||||
}
|
||||
} completion:nil];
|
||||
|
||||
[UIView animateWithDuration:kActionMenuComposerHeightAnimationDuration animations:^{
|
||||
|
@ -432,4 +437,25 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
[super paste:sender];
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (void)updateUIWithTextMessage:(NSString *)textMessage animated:(BOOL)animated
|
||||
{
|
||||
self.actionMenuOpened = NO;
|
||||
|
||||
if (RiotSettings.shared.enableVoiceMessages == NO) {
|
||||
self.rightInputToolbarButton.alpha = textMessage.length ? 1.0f : 0.0f;
|
||||
self.messageComposerContainerTrailingConstraint.constant = (textMessage.length ? self.frame.size.width - self.rightInputToolbarButton.frame.origin.x : 0.0f) + kComposerContainerTrailingPadding;
|
||||
|
||||
[self layoutIfNeeded];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[UIView animateWithDuration:(animated ? 0.15f : 0.0f) animations:^{
|
||||
self.rightInputToolbarButton.alpha = textMessage.length ? 1.0f : 0.0f;
|
||||
self.voiceMessageToolbarView.alpha = textMessage.length ? 0.0f : 1.0;
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="a84-Vc-6ud" userLabel="MainToolBar View">
|
||||
<rect key="frame" x="0.0" y="2" width="600" height="58"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Hga-l8-Wua" userLabel="attach Button">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Hga-l8-Wua">
|
||||
<rect key="frame" x="12" y="10" width="36" height="36"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="AttachButton"/>
|
||||
<constraints>
|
||||
|
@ -35,26 +35,26 @@
|
|||
<viewLayoutGuide key="contentLayoutGuide" id="F6O-76-cZl"/>
|
||||
<viewLayoutGuide key="frameLayoutGuide" id="rZR-Bv-AqG"/>
|
||||
</scrollView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QWp-NV-uh5" userLabel="Message Composer Container">
|
||||
<rect key="frame" x="60" y="9" width="528" height="36"/>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QWp-NV-uh5">
|
||||
<rect key="frame" x="60" y="9" width="484" height="36"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="input_text_background" translatesAutoresizingMaskIntoConstraints="NO" id="uH7-Q7-hpZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="528" height="36"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="484" height="36"/>
|
||||
</imageView>
|
||||
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jXI-9E-Bgl">
|
||||
<rect key="frame" x="0.0" y="0.0" width="528" height="32"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="484" height="32"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="input_edit_icon" translatesAutoresizingMaskIntoConstraints="NO" id="PZ4-0Y-TmL">
|
||||
<rect key="frame" x="8" y="16" width="10.5" height="10"/>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dVr-ZM-kkX">
|
||||
<rect key="frame" x="22.5" y="13.5" width="471.5" height="14.5"/>
|
||||
<rect key="frame" x="22.5" y="13.5" width="427.5" height="14.5"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="12"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="48y-kn-7b5">
|
||||
<rect key="frame" x="498" y="6" width="30" height="30"/>
|
||||
<rect key="frame" x="454" y="6" width="30" height="30"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="30" id="I17-S0-9fp"/>
|
||||
<constraint firstAttribute="width" constant="30" id="cCe-RB-ET2"/>
|
||||
|
@ -78,7 +78,7 @@
|
|||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wgb-ON-N29" customClass="KeyboardGrowingTextView">
|
||||
<rect key="frame" x="5" y="33" width="518" height="4"/>
|
||||
<rect key="frame" x="5" y="33" width="474" height="4"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="GrowingTextView"/>
|
||||
</view>
|
||||
|
@ -98,7 +98,7 @@
|
|||
<constraint firstAttribute="trailing" secondItem="uH7-Q7-hpZ" secondAttribute="trailing" id="wS9-oU-alv"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<button opaque="NO" alpha="0.0" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="G8Z-CM-tGs" userLabel="send Button">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="G8Z-CM-tGs">
|
||||
<rect key="frame" x="552" y="10" width="36" height="36"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="SendButton"/>
|
||||
<state key="normal" image="send_icon"/>
|
||||
|
@ -118,7 +118,7 @@
|
|||
<constraint firstAttribute="bottom" secondItem="G8Z-CM-tGs" secondAttribute="bottom" constant="12" id="Yam-dS-zwr"/>
|
||||
<constraint firstAttribute="height" constant="58" id="Yjj-ua-rbe"/>
|
||||
<constraint firstAttribute="bottom" secondItem="Hga-l8-Wua" secondAttribute="bottom" constant="12" id="b0G-CY-AmP"/>
|
||||
<constraint firstAttribute="trailing" secondItem="QWp-NV-uh5" secondAttribute="trailing" constant="12" id="hXO-cY-Jgz"/>
|
||||
<constraint firstAttribute="trailing" secondItem="QWp-NV-uh5" secondAttribute="trailing" constant="56" id="hXO-cY-Jgz"/>
|
||||
<constraint firstAttribute="trailing" secondItem="ESv-9w-KJF" secondAttribute="trailing" id="jCS-Tf-vxr"/>
|
||||
<constraint firstAttribute="bottom" secondItem="ESv-9w-KJF" secondAttribute="bottom" constant="12" id="v8r-ac-MKn"/>
|
||||
</constraints>
|
||||
|
@ -150,7 +150,7 @@
|
|||
<outlet property="messageComposerContainer" destination="QWp-NV-uh5" id="APR-B5-ogC"/>
|
||||
<outlet property="messageComposerContainerBottomConstraint" destination="NGr-2o-sOP" id="oez-6D-IKA"/>
|
||||
<outlet property="messageComposerContainerTopConstraint" destination="WyZ-3i-OHi" id="OcO-1f-bNA"/>
|
||||
<outlet property="messageComposerContainerTrailingConstraint" destination="hXO-cY-Jgz" id="lHZ-MU-vyC"/>
|
||||
<outlet property="messageComposerContainerTrailingConstraint" destination="hXO-cY-Jgz" id="0m7-AB-90i"/>
|
||||
<outlet property="rightInputToolbarButton" destination="G8Z-CM-tGs" id="NCk-5m-aNF"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="137.59999999999999" y="151.12443778110946"/>
|
||||
|
|
|
@ -0,0 +1,253 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DSWaveformImage
|
||||
|
||||
enum VoiceMessageAttachmentCacheManagerError: Error {
|
||||
case invalidEventId
|
||||
case invalidAttachmentType
|
||||
case decryptionError(Error)
|
||||
case preparationError(Error)
|
||||
case conversionError(Error)
|
||||
case invalidNumberOfSamples
|
||||
case samplingError
|
||||
}
|
||||
|
||||
/**
|
||||
Swift optimizes the callbacks to be the same instance. Wrap them so we can store them in an array.
|
||||
*/
|
||||
private class CompletionWrapper {
|
||||
let completion: (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void
|
||||
|
||||
init(_ completion: @escaping (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
}
|
||||
|
||||
private struct CompletionCallbackKey: Hashable {
|
||||
let eventIdentifier: String
|
||||
let requiredNumberOfSamples: Int
|
||||
}
|
||||
|
||||
struct VoiceMessageAttachmentCacheManagerLoadResult {
|
||||
let eventIdentifier: String
|
||||
let url: URL
|
||||
let duration: TimeInterval
|
||||
let samples: [Float]
|
||||
}
|
||||
|
||||
class VoiceMessageAttachmentCacheManager {
|
||||
|
||||
static let sharedManager = VoiceMessageAttachmentCacheManager()
|
||||
|
||||
private var completionCallbacks = [CompletionCallbackKey: [CompletionWrapper]]()
|
||||
private var samples = [String: [Int: [Float]]]()
|
||||
private var durations = [String: TimeInterval]()
|
||||
private var finalURLs = [String: URL]()
|
||||
|
||||
private let workQueue: DispatchQueue
|
||||
|
||||
private init() {
|
||||
workQueue = DispatchQueue(label: "io.element.VoiceMessageAttachmentCacheManager.queue", qos: .userInitiated)
|
||||
}
|
||||
|
||||
func loadAttachment(_ attachment: MXKAttachment, numberOfSamples: Int, completion: @escaping (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void) {
|
||||
guard attachment.type == MXKAttachmentTypeVoiceMessage else {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidAttachmentType))
|
||||
return
|
||||
}
|
||||
|
||||
guard let identifier = attachment.eventId else {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidEventId))
|
||||
return
|
||||
}
|
||||
|
||||
guard numberOfSamples > 0 else {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidNumberOfSamples))
|
||||
return
|
||||
}
|
||||
|
||||
workQueue.async {
|
||||
// Run this in the work queue to preserve order
|
||||
if let finalURL = self.finalURLs[identifier], let duration = self.durations[identifier], let samples = self.samples[identifier]?[numberOfSamples] {
|
||||
let result = VoiceMessageAttachmentCacheManagerLoadResult(eventIdentifier: identifier, url: finalURL, duration: duration, samples: samples)
|
||||
DispatchQueue.main.async {
|
||||
completion(Result.success(result))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.enqueueLoadAttachment(attachment, identifier: identifier, numberOfSamples: numberOfSamples, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueueLoadAttachment(_ attachment: MXKAttachment, identifier: String, numberOfSamples: Int, completion: @escaping (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void) {
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Started task")
|
||||
|
||||
let callbackKey = CompletionCallbackKey(eventIdentifier: identifier, requiredNumberOfSamples: numberOfSamples)
|
||||
|
||||
if var callbacks = completionCallbacks[callbackKey] {
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task - cached completion callback")
|
||||
callbacks.append(CompletionWrapper(completion))
|
||||
completionCallbacks[callbackKey] = callbacks
|
||||
return
|
||||
} else {
|
||||
completionCallbacks[callbackKey] = [CompletionWrapper(completion)]
|
||||
}
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
func sampleFileAtURL(_ url: URL, duration: TimeInterval) {
|
||||
let analyser = WaveformAnalyzer(audioAssetURL: url)
|
||||
|
||||
dispatchGroup.enter()
|
||||
analyser?.samples(count: numberOfSamples, completionHandler: { samples in
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished sampling voice message")
|
||||
|
||||
dispatchGroup.leave()
|
||||
|
||||
guard let samples = samples else {
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.samplingError)
|
||||
return
|
||||
}
|
||||
|
||||
if var existingSamples = self.samples[identifier] {
|
||||
existingSamples[numberOfSamples] = samples
|
||||
self.samples[identifier] = existingSamples
|
||||
} else {
|
||||
self.samples[identifier] = [numberOfSamples: samples]
|
||||
}
|
||||
|
||||
self.invokeSuccessCallbacksForIdentifier(identifier, url: url, duration: duration, samples: samples)
|
||||
})
|
||||
}
|
||||
|
||||
if let finalURL = finalURLs[identifier], let duration = durations[identifier] {
|
||||
sampleFileAtURL(finalURL, duration: duration)
|
||||
dispatchGroup.wait()
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task")
|
||||
return
|
||||
}
|
||||
|
||||
func convertFileAtPath(_ path: String?) {
|
||||
guard let filePath = path else {
|
||||
return
|
||||
}
|
||||
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let newURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
|
||||
|
||||
dispatchGroup.enter()
|
||||
VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.finalURLs[identifier] = newURL
|
||||
VoiceMessageAudioConverter.mediaDurationAt(newURL) { result in
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished converting voice message")
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
if let duration = try? result.get() {
|
||||
self.durations[identifier] = duration
|
||||
sampleFileAtURL(newURL, duration: duration)
|
||||
} else {
|
||||
MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: Failed to retrieve media duration")
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: failed getting audio duration with: \(error)")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
case .failure(let error):
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.conversionError(error))
|
||||
MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: failed decoding audio message with: \(error)")
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.enter()
|
||||
DispatchQueue.main.async { // These don't behave accordingly if called from a background thread
|
||||
if attachment.isEncrypted {
|
||||
attachment.decrypt(toTempFile: { filePath in
|
||||
convertFileAtPath(filePath)
|
||||
dispatchGroup.leave()
|
||||
}, failure: { error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed decrypting attachment with error: \(String(describing: error))")
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.decryptionError(error))
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
})
|
||||
} else {
|
||||
attachment.prepare({
|
||||
convertFileAtPath(attachment.cacheFilePath)
|
||||
dispatchGroup.leave()
|
||||
}, failure: { error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed preparing attachment with error: \(String(describing: error))")
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.preparationError(error))
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.wait()
|
||||
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task")
|
||||
}
|
||||
|
||||
private func invokeSuccessCallbacksForIdentifier(_ identifier: String, url: URL, duration: TimeInterval, samples: [Float]) {
|
||||
let callbackKey = CompletionCallbackKey(eventIdentifier: identifier, requiredNumberOfSamples: samples.count)
|
||||
|
||||
guard let callbacks = completionCallbacks[callbackKey] else {
|
||||
return
|
||||
}
|
||||
|
||||
let result = VoiceMessageAttachmentCacheManagerLoadResult(eventIdentifier: identifier, url: url, duration: duration, samples: samples)
|
||||
|
||||
let copy = callbacks.map { $0 }
|
||||
DispatchQueue.main.async {
|
||||
for wrapper in copy {
|
||||
wrapper.completion(Result.success(result))
|
||||
}
|
||||
}
|
||||
|
||||
self.completionCallbacks[callbackKey] = nil
|
||||
}
|
||||
|
||||
private func invokeFailureCallbacksForIdentifier(_ identifier: String, error: Error) {
|
||||
let callbackKey = CompletionCallbackKey(eventIdentifier: identifier, requiredNumberOfSamples: samples.count)
|
||||
|
||||
guard let callbacks = completionCallbacks[callbackKey] else {
|
||||
return
|
||||
}
|
||||
|
||||
let copy = callbacks.map { $0 }
|
||||
DispatchQueue.main.async {
|
||||
for wrapper in copy {
|
||||
wrapper.completion(Result.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
self.completionCallbacks[callbackKey] = nil
|
||||
}
|
||||
}
|
100
Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift
Normal file
|
@ -0,0 +1,100 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ffmpegkit
|
||||
|
||||
enum VoiceMessageAudioConverterError: Error {
|
||||
case generic(String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
struct VoiceMessageAudioConverter {
|
||||
static func convertToOpusOgg(sourceURL: URL, destinationURL: URL, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) {
|
||||
let command = "-hide_banner -y -i \"\(sourceURL.path)\" -c:a libopus \"\(destinationURL.path)\""
|
||||
executeCommand(command, completion: completion)
|
||||
}
|
||||
|
||||
static func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) {
|
||||
let command = "-hide_banner -y -i \"\(sourceURL.path)\" -c:a aac_at -b:a 192k \"\(destinationURL.path)\""
|
||||
executeCommand(command, completion: completion)
|
||||
}
|
||||
|
||||
static func mediaDurationAt(_ sourceURL: URL, completion: @escaping (Result<TimeInterval, VoiceMessageAudioConverterError>) -> Void) {
|
||||
FFprobeKit.getMediaInformationAsync(sourceURL.path) { session in
|
||||
guard let session = session as? MediaInformationSession else {
|
||||
completion(.failure(.generic("Invalid session")))
|
||||
return
|
||||
}
|
||||
|
||||
guard let returnCode = session.getReturnCode() else {
|
||||
completion(.failure(.generic("Invalid return code")))
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if returnCode.isSuccess() {
|
||||
let mediaInfo = session.getMediaInformation()
|
||||
if let duration = try? TimeInterval(value: mediaInfo?.getDuration() ?? "0") {
|
||||
completion(.success(duration))
|
||||
} else {
|
||||
completion(.failure(.generic("Failed to get media duration")))
|
||||
}
|
||||
} else if returnCode.isCancel() {
|
||||
completion(.failure(.cancelled))
|
||||
} else {
|
||||
completion(.failure(.generic(String(returnCode.getValue()))))
|
||||
MXLog.error("""
|
||||
getMediaInformationAsync failed with state: \(String(describing: FFmpegKitConfig.sessionState(toString: session.getState()))), \
|
||||
returnCode: \(String(describing: returnCode)), \
|
||||
stackTrace: \(String(describing: session.getFailStackTrace()))
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static private func executeCommand(_ command: String, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) {
|
||||
FFmpegKitConfig.setLogLevel(0)
|
||||
|
||||
FFmpegKit.executeAsync(command) { session in
|
||||
guard let session = session else {
|
||||
completion(.failure(.generic("Invalid session")))
|
||||
return
|
||||
}
|
||||
|
||||
guard let returnCode = session.getReturnCode() else {
|
||||
completion(.failure(.generic("Invalid return code")))
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if returnCode.isSuccess() {
|
||||
completion(.success(()))
|
||||
} else if returnCode.isCancel() {
|
||||
completion(.failure(.cancelled))
|
||||
} else {
|
||||
completion(.failure(.generic(String(returnCode.getValue()))))
|
||||
MXLog.error("""
|
||||
Failed converting voice message with state: \(String(describing: FFmpegKitConfig.sessionState(toString: session.getState()))), \
|
||||
returnCode: \(String(describing: returnCode)), \
|
||||
stackTrace: \(String(describing: session.getFailStackTrace()))
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
223
Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift
Normal file
|
@ -0,0 +1,223 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol VoiceMessageAudioPlayerDelegate: AnyObject {
|
||||
func audioPlayerDidStartLoading(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError: Error)
|
||||
}
|
||||
|
||||
enum VoiceMessageAudioPlayerError: Error {
|
||||
case genericError
|
||||
}
|
||||
|
||||
class VoiceMessageAudioPlayer: NSObject {
|
||||
|
||||
private var playerItem: AVPlayerItem?
|
||||
private var audioPlayer: AVPlayer?
|
||||
|
||||
private var statusObserver: NSKeyValueObservation?
|
||||
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
||||
private var rateObserver: NSKeyValueObservation?
|
||||
private var playToEndObserver: NSObjectProtocol?
|
||||
|
||||
private let delegateContainer = DelegateContainer()
|
||||
|
||||
private(set) var url: URL?
|
||||
|
||||
var isPlaying: Bool {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return false
|
||||
}
|
||||
|
||||
return (audioPlayer.rate > 0)
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
return abs(CMTimeGetSeconds(self.audioPlayer?.currentItem?.duration ?? .zero))
|
||||
}
|
||||
|
||||
var currentTime: TimeInterval {
|
||||
return abs(CMTimeGetSeconds(audioPlayer?.currentTime() ?? .zero))
|
||||
}
|
||||
|
||||
private(set) var isStopped = true
|
||||
|
||||
deinit {
|
||||
removeObservers()
|
||||
}
|
||||
|
||||
func loadContentFromURL(_ url: URL) {
|
||||
if self.url == url {
|
||||
return
|
||||
}
|
||||
|
||||
self.url = url
|
||||
|
||||
removeObservers()
|
||||
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStartLoading(self)
|
||||
}
|
||||
|
||||
playerItem = AVPlayerItem(url: url)
|
||||
audioPlayer = AVPlayer(playerItem: playerItem)
|
||||
|
||||
addObservers()
|
||||
}
|
||||
|
||||
func unloadContent() {
|
||||
url = nil
|
||||
audioPlayer?.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func play() {
|
||||
isStopped = false
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
MXLog.error("Could not redirect audio playback to speakers.")
|
||||
}
|
||||
|
||||
audioPlayer?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
audioPlayer?.pause()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if isStopped {
|
||||
return
|
||||
}
|
||||
|
||||
isStopped = true
|
||||
audioPlayer?.pause()
|
||||
audioPlayer?.seek(to: .zero)
|
||||
}
|
||||
|
||||
func seekToTime(_ time: TimeInterval) {
|
||||
audioPlayer?.seek(to: CMTime(seconds: time, preferredTimescale: 60000))
|
||||
}
|
||||
|
||||
func registerDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.registerDelegate(delegate)
|
||||
}
|
||||
|
||||
func deregisterDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.deregisterDelegate(delegate)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func addObservers() {
|
||||
guard let audioPlayer = audioPlayer, let playerItem = playerItem else {
|
||||
return
|
||||
}
|
||||
|
||||
statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] item, change in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch playerItem.status {
|
||||
case .failed:
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayer(self, didFailWithError: playerItem.error ?? VoiceMessageAudioPlayerError.genericError)
|
||||
}
|
||||
case .readyToPlay:
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishLoading(self)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
playbackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, options: [.old, .new]) { [weak self] item, change in
|
||||
guard let self = self else { return }
|
||||
|
||||
if playerItem.isPlaybackBufferEmpty {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStartLoading(self)
|
||||
}
|
||||
} else {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishLoading(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateObserver = audioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, change in
|
||||
guard let self = self else { return }
|
||||
|
||||
if audioPlayer.rate == 0.0 {
|
||||
if self.isStopped {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStopPlaying(self)
|
||||
}
|
||||
} else {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidPausePlaying(self)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStartPlaying(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playToEndObserver = NotificationCenter.default.addObserver(forName: Notification.Name.AVPlayerItemDidPlayToEndTime, object: playerItem, queue: nil) { [weak self] notification in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishPlaying(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
statusObserver?.invalidate()
|
||||
playbackBufferEmptyObserver?.invalidate()
|
||||
rateObserver?.invalidate()
|
||||
NotificationCenter.default.removeObserver(playToEndObserver as Any)
|
||||
}
|
||||
}
|
||||
|
||||
extension VoiceMessageAudioPlayerDelegate {
|
||||
func audioPlayerDidStartLoading(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError: Error) { }
|
||||
}
|
142
Riot/Modules/Room/VoiceMessages/VoiceMessageAudioRecorder.swift
Normal file
|
@ -0,0 +1,142 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
protocol VoiceMessageAudioRecorderDelegate: AnyObject {
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder)
|
||||
func audioRecorderDidFinishRecording(_ audioRecorder: VoiceMessageAudioRecorder)
|
||||
func audioRecorder(_ audioRecorder: VoiceMessageAudioRecorder, didFailWithError: Error)
|
||||
}
|
||||
|
||||
enum VoiceMessageAudioRecorderError: Error {
|
||||
case genericError
|
||||
}
|
||||
|
||||
class VoiceMessageAudioRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let silenceThreshold: Float = -50.0
|
||||
}
|
||||
|
||||
private var audioRecorder: AVAudioRecorder?
|
||||
private let delegateContainer = DelegateContainer()
|
||||
|
||||
var url: URL? {
|
||||
return audioRecorder?.url
|
||||
}
|
||||
|
||||
var currentTime: TimeInterval {
|
||||
return audioRecorder?.currentTime ?? 0
|
||||
}
|
||||
|
||||
var isRecording: Bool {
|
||||
return audioRecorder?.isRecording ?? false
|
||||
}
|
||||
|
||||
func recordWithOutputURL(_ url: URL) {
|
||||
|
||||
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 12000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
audioRecorder?.delegate = self
|
||||
audioRecorder?.isMeteringEnabled = true
|
||||
audioRecorder?.record()
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorderDidStartRecording(self)
|
||||
}
|
||||
} catch {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorder(self, didFailWithError: VoiceMessageAudioRecorderError.genericError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
audioRecorder?.stop()
|
||||
}
|
||||
|
||||
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
|
||||
guard self.isRecording, let audioRecorder = audioRecorder else {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
audioRecorder.updateMeters()
|
||||
|
||||
return self.normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber))
|
||||
}
|
||||
|
||||
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float {
|
||||
guard self.isRecording, let audioRecorder = audioRecorder else {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
audioRecorder.updateMeters()
|
||||
|
||||
return self.normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber))
|
||||
}
|
||||
|
||||
func registerDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.registerDelegate(delegate)
|
||||
}
|
||||
|
||||
func deregisterDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.deregisterDelegate(delegate)
|
||||
}
|
||||
|
||||
// MARK: - AVAudioRecorderDelegate
|
||||
|
||||
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
|
||||
if success {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorderDidFinishRecording(self)
|
||||
}
|
||||
} else {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorder(self, didFailWithError: VoiceMessageAudioRecorderError.genericError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorder(self, didFailWithError: VoiceMessageAudioRecorderError.genericError)
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedPowerLevelFromDecibels(_ decibels: Float) -> Float {
|
||||
return decibels / Constants.silenceThreshold
|
||||
}
|
||||
}
|
||||
|
||||
extension String: LocalizedError {
|
||||
public var errorDescription: String? { return self }
|
||||
}
|
||||
|
||||
extension VoiceMessageAudioRecorderDelegate {
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder) { }
|
||||
|
||||
func audioRecorderDidFinishRecording(_ audioRecorder: VoiceMessageAudioRecorder) { }
|
||||
|
||||
func audioRecorder(_ audioRecorder: VoiceMessageAudioRecorder, didFailWithError: Error) { }
|
||||
}
|
409
Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift
Normal file
|
@ -0,0 +1,409 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import DSWaveformImage
|
||||
|
||||
@objc public protocol VoiceMessageControllerDelegate: AnyObject {
|
||||
func voiceMessageControllerDidRequestMicrophonePermission(_ voiceMessageController: VoiceMessageController)
|
||||
func voiceMessageController(_ voiceMessageController: VoiceMessageController, didRequestSendForFileAtURL url: URL, duration: UInt, samples: [Float]?, completion: @escaping (Bool) -> Void)
|
||||
}
|
||||
|
||||
public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, VoiceMessageAudioRecorderDelegate, VoiceMessageAudioPlayerDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let maximumAudioRecordingDuration: TimeInterval = 120.0
|
||||
static let maximumAudioRecordingLengthReachedThreshold: TimeInterval = 10.0
|
||||
static let elapsedTimeFormat = "m:ss"
|
||||
static let minimumRecordingDuration = 1.0
|
||||
}
|
||||
|
||||
private let themeService: ThemeService
|
||||
private let mediaServiceProvider: VoiceMessageMediaServiceProvider
|
||||
private let temporaryFileURL: URL
|
||||
|
||||
private let _voiceMessageToolbarView: VoiceMessageToolbarView
|
||||
private var displayLink: CADisplayLink!
|
||||
|
||||
private var audioRecorder: VoiceMessageAudioRecorder?
|
||||
|
||||
private var audioPlayer: VoiceMessageAudioPlayer?
|
||||
private var waveformAnalyser: WaveformAnalyzer?
|
||||
|
||||
private var audioSamples: [Float] = []
|
||||
private var isInLockedMode: Bool = false
|
||||
private var notifiedRemainingTime = false
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = Constants.elapsedTimeFormat
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
@objc public weak var delegate: VoiceMessageControllerDelegate?
|
||||
|
||||
@objc public var isRecordingAudio: Bool {
|
||||
return audioRecorder?.isRecording ?? false || isInLockedMode
|
||||
}
|
||||
|
||||
@objc public var voiceMessageToolbarView: UIView {
|
||||
return _voiceMessageToolbarView
|
||||
}
|
||||
|
||||
@objc public init(themeService: ThemeService, mediaServiceProvider: VoiceMessageMediaServiceProvider) {
|
||||
self.themeService = themeService
|
||||
self.mediaServiceProvider = mediaServiceProvider
|
||||
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
|
||||
|
||||
_voiceMessageToolbarView = VoiceMessageToolbarView.loadFromNib()
|
||||
|
||||
super.init()
|
||||
|
||||
_voiceMessageToolbarView.delegate = self
|
||||
|
||||
displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector)
|
||||
displayLink.isPaused = true
|
||||
displayLink.add(to: .current, forMode: .common)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: .themeServiceDidChangeTheme, object: nil)
|
||||
updateTheme()
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageToolbarViewDelegate
|
||||
|
||||
func voiceMessageToolbarViewDidRequestRecordingStart(_ toolbarView: VoiceMessageToolbarView) {
|
||||
guard AVAudioSession.sharedInstance().recordPermission == .granted else {
|
||||
delegate?.voiceMessageControllerDidRequestMicrophonePermission(self)
|
||||
return
|
||||
}
|
||||
|
||||
// Haptic are not played during record on iOS by default. This fix works
|
||||
// only since iOS 13. A workaround for iOS 12 and earlier would be to
|
||||
// dispatch after at least 100ms recordWithOutputURL call
|
||||
if #available(iOS 13.0, *) {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playAndRecord)
|
||||
try? AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(true)
|
||||
}
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
|
||||
audioRecorder = mediaServiceProvider.audioRecorder()
|
||||
audioRecorder?.registerDelegate(self)
|
||||
audioRecorder?.recordWithOutputURL(temporaryFileURL)
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestRecordingFinish(_ toolbarView: VoiceMessageToolbarView) {
|
||||
finishRecording()
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestRecordingCancel(_ toolbarView: VoiceMessageToolbarView) {
|
||||
isInLockedMode = false
|
||||
audioPlayer?.stop()
|
||||
audioRecorder?.stopRecording()
|
||||
deleteRecordingAtURL(temporaryFileURL)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestLockedModeRecording(_ toolbarView: VoiceMessageToolbarView) {
|
||||
isInLockedMode = true
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestPlaybackToggle(_ toolbarView: VoiceMessageToolbarView) {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return
|
||||
}
|
||||
|
||||
if audioPlayer.url != nil {
|
||||
if audioPlayer.isPlaying {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
} else {
|
||||
audioPlayer.loadContentFromURL(temporaryFileURL)
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestSend(_ toolbarView: VoiceMessageToolbarView) {
|
||||
audioPlayer?.stop()
|
||||
audioRecorder?.stopRecording()
|
||||
|
||||
sendRecordingAtURL(temporaryFileURL)
|
||||
|
||||
isInLockedMode = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
// MARK: - AudioRecorderDelegate
|
||||
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder) {
|
||||
notifiedRemainingTime = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioRecorderDidFinishRecording(_ audioRecorder: VoiceMessageAudioRecorder) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioRecorder(_ audioRecorder: VoiceMessageAudioRecorder, didFailWithError: Error) {
|
||||
isInLockedMode = false
|
||||
updateUI()
|
||||
|
||||
MXLog.error("Failed recording voice message.")
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
audioPlayer.seekToTime(0.0)
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError: Error) {
|
||||
updateUI()
|
||||
|
||||
MXLog.error("Failed playing voice message.")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func finishRecording() {
|
||||
let recordDuration = audioRecorder?.currentTime
|
||||
audioRecorder?.stopRecording()
|
||||
|
||||
guard isInLockedMode else {
|
||||
if recordDuration ?? 0 >= Constants.minimumRecordingDuration {
|
||||
sendRecordingAtURL(temporaryFileURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
audioPlayer = mediaServiceProvider.audioPlayerForIdentifier(UUID().uuidString)
|
||||
audioPlayer?.registerDelegate(self)
|
||||
audioPlayer?.loadContentFromURL(temporaryFileURL)
|
||||
|
||||
audioSamples = []
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private func sendRecordingAtURL(_ sourceURL: URL) {
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
var duration = 0.0
|
||||
var invertedSamples: [Float]?
|
||||
var finalURL: URL?
|
||||
|
||||
dispatchGroup.enter()
|
||||
VoiceMessageAudioConverter.mediaDurationAt(sourceURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
if let someDuration = try? result.get() {
|
||||
duration = someDuration
|
||||
} else {
|
||||
MXLog.error("[VoiceMessageController] Failed retrieving media duration")
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.error("[VoiceMessageController] Failed getting audio duration with: \(error)")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
dispatchGroup.enter()
|
||||
let analyser = WaveformAnalyzer(audioAssetURL: sourceURL)
|
||||
analyser?.samples(count: 100, completionHandler: { samples in
|
||||
// Dispatch back from the WaveformAnalyzer's internal queue
|
||||
DispatchQueue.main.async {
|
||||
if let samples = samples {
|
||||
invertedSamples = samples.compactMap { return 1.0 - $0 } // linearly normalized to [0, 1] (1 -> -50 dB)
|
||||
} else {
|
||||
MXLog.error("[VoiceMessageController] Failed sampling recorder voice message.")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
})
|
||||
|
||||
dispatchGroup.enter()
|
||||
let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("opus")
|
||||
VoiceMessageAudioConverter.convertToOpusOgg(sourceURL: sourceURL, destinationURL: destinationURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
finalURL = destinationURL
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed failed encoding audio message with: \(error)")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
guard let url = finalURL else {
|
||||
return
|
||||
}
|
||||
|
||||
self.delegate?.voiceMessageController(self, didRequestSendForFileAtURL: url,
|
||||
duration: UInt(duration * 1000),
|
||||
samples: invertedSamples) { [weak self] success in
|
||||
UINotificationFeedbackGenerator().notificationOccurred((success ? .success : .error))
|
||||
self?.deleteRecordingAtURL(sourceURL)
|
||||
self?.deleteRecordingAtURL(destinationURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteRecordingAtURL(_ url: URL?) {
|
||||
guard let url = url else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
MXLog.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateTheme() {
|
||||
_voiceMessageToolbarView.update(theme: themeService.theme)
|
||||
}
|
||||
|
||||
@objc private func handleDisplayLinkTick() {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
|
||||
let shouldUpdateFromAudioPlayer = isInLockedMode && !(audioRecorder?.isRecording ?? false)
|
||||
|
||||
if shouldUpdateFromAudioPlayer {
|
||||
updateUIFromAudioPlayer()
|
||||
} else {
|
||||
updateUIFromAudioRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUIFromAudioRecorder() {
|
||||
let isRecording = audioRecorder?.isRecording ?? false
|
||||
|
||||
displayLink.isPaused = !isRecording
|
||||
|
||||
let requiredNumberOfSamples = _voiceMessageToolbarView.getRequiredNumberOfSamples()
|
||||
|
||||
if audioSamples.count != requiredNumberOfSamples {
|
||||
padSamplesArrayToSize(requiredNumberOfSamples)
|
||||
}
|
||||
|
||||
let sample = audioRecorder?.averagePowerForChannelNumber(0) ?? 0.0
|
||||
audioSamples.insert(sample, at: 0)
|
||||
audioSamples.removeLast()
|
||||
|
||||
let currentTime = audioRecorder?.currentTime ?? 0.0
|
||||
|
||||
if currentTime >= Constants.maximumAudioRecordingDuration {
|
||||
finishRecording()
|
||||
return
|
||||
}
|
||||
|
||||
var details = VoiceMessageToolbarViewDetails()
|
||||
details.state = (isRecording ? (isInLockedMode ? .lockedModeRecord : .record) : (isInLockedMode ? .lockedModePlayback : .idle))
|
||||
details.elapsedTime = VoiceMessageController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: currentTime))
|
||||
details.audioSamples = audioSamples
|
||||
|
||||
if isRecording {
|
||||
if currentTime >= Constants.maximumAudioRecordingDuration - Constants.maximumAudioRecordingLengthReachedThreshold {
|
||||
|
||||
if !self.notifiedRemainingTime {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
|
||||
notifiedRemainingTime = true
|
||||
|
||||
let remainingTime = ceil(Constants.maximumAudioRecordingDuration - currentTime)
|
||||
details.toastMessage = VectorL10n.voiceMessageRemainingRecordingTime(String(remainingTime))
|
||||
} else {
|
||||
details.toastMessage = (isInLockedMode ? VectorL10n.voiceMessageStopLockedModeRecording : VectorL10n.voiceMessageReleaseToSend)
|
||||
}
|
||||
}
|
||||
|
||||
_voiceMessageToolbarView.configureWithDetails(details)
|
||||
}
|
||||
|
||||
private func updateUIFromAudioPlayer() {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return
|
||||
}
|
||||
|
||||
displayLink.isPaused = !audioPlayer.isPlaying
|
||||
|
||||
let requiredNumberOfSamples = _voiceMessageToolbarView.getRequiredNumberOfSamples()
|
||||
if audioSamples.count != requiredNumberOfSamples && requiredNumberOfSamples > 0 {
|
||||
padSamplesArrayToSize(requiredNumberOfSamples)
|
||||
|
||||
waveformAnalyser = WaveformAnalyzer(audioAssetURL: temporaryFileURL)
|
||||
waveformAnalyser?.samples(count: requiredNumberOfSamples, completionHandler: { [weak self] samples in
|
||||
guard let samples = samples else {
|
||||
MXLog.error("Could not sample audio recording.")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.audioSamples = samples
|
||||
self?.updateUIFromAudioPlayer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var details = VoiceMessageToolbarViewDetails()
|
||||
details.state = (audioRecorder?.isRecording ?? false ? (isInLockedMode ? .lockedModeRecord : .record) : (isInLockedMode ? .lockedModePlayback : .idle))
|
||||
details.elapsedTime = VoiceMessageController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: (audioPlayer.isPlaying ? audioPlayer.currentTime : audioPlayer.duration)))
|
||||
details.audioSamples = audioSamples
|
||||
details.isPlaying = audioPlayer.isPlaying
|
||||
details.progress = (audioPlayer.isPlaying ? (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0) : 0.0)
|
||||
_voiceMessageToolbarView.configureWithDetails(details)
|
||||
}
|
||||
|
||||
private func padSamplesArrayToSize(_ size: Int) {
|
||||
let delta = size - audioSamples.count
|
||||
guard delta > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
audioSamples = audioSamples + [Float](repeating: 0.0, count: delta)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc public class VoiceMessageMediaServiceProvider: NSObject, VoiceMessageAudioPlayerDelegate, VoiceMessageAudioRecorderDelegate {
|
||||
|
||||
private let audioPlayers: NSMapTable<NSString, VoiceMessageAudioPlayer>
|
||||
private let audioRecorders: NSHashTable<VoiceMessageAudioRecorder>
|
||||
|
||||
// Retain currently playing audio player so it doesn't stop playing on timeline cell reusage
|
||||
private var currentlyPlayingAudioPlayer: VoiceMessageAudioPlayer?
|
||||
|
||||
@objc public static let sharedProvider = VoiceMessageMediaServiceProvider()
|
||||
|
||||
private override init() {
|
||||
audioPlayers = NSMapTable<NSString, VoiceMessageAudioPlayer>(valueOptions: .weakMemory)
|
||||
audioRecorders = NSHashTable<VoiceMessageAudioRecorder>(options: .weakMemory)
|
||||
}
|
||||
|
||||
@objc func audioPlayerForIdentifier(_ identifier: String) -> VoiceMessageAudioPlayer {
|
||||
if let audioPlayer = audioPlayers.object(forKey: identifier as NSString) {
|
||||
return audioPlayer
|
||||
}
|
||||
|
||||
let audioPlayer = VoiceMessageAudioPlayer()
|
||||
audioPlayer.registerDelegate(self)
|
||||
audioPlayers.setObject(audioPlayer, forKey: identifier as NSString)
|
||||
return audioPlayer
|
||||
}
|
||||
|
||||
@objc func audioRecorder() -> VoiceMessageAudioRecorder {
|
||||
let audioRecorder = VoiceMessageAudioRecorder()
|
||||
audioRecorder.registerDelegate(self)
|
||||
audioRecorders.add(audioRecorder)
|
||||
return audioRecorder
|
||||
}
|
||||
|
||||
@objc func stopAllServices() {
|
||||
stopAllServicesExcept(nil)
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
currentlyPlayingAudioPlayer = audioPlayer
|
||||
stopAllServicesExcept(audioPlayer)
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
if currentlyPlayingAudioPlayer == audioPlayer {
|
||||
currentlyPlayingAudioPlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioRecorderDelegate
|
||||
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder) {
|
||||
stopAllServicesExcept(audioRecorder)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func stopAllServicesExcept(_ service: AnyObject?) {
|
||||
for audioRecorder in audioRecorders.allObjects {
|
||||
if audioRecorder === service {
|
||||
continue
|
||||
}
|
||||
|
||||
audioRecorder.stopRecording()
|
||||
}
|
||||
|
||||
guard let audioPlayersEnumerator = audioPlayers.objectEnumerator() else {
|
||||
return
|
||||
}
|
||||
|
||||
for case let audioPlayer as VoiceMessageAudioPlayer in audioPlayersEnumerator {
|
||||
if audioPlayer === service {
|
||||
continue
|
||||
}
|
||||
|
||||
audioPlayer.stop()
|
||||
audioPlayer.unloadContent()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,213 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DSWaveformImage
|
||||
|
||||
enum VoiceMessagePlaybackControllerState {
|
||||
case stopped
|
||||
case playing
|
||||
case paused
|
||||
case error
|
||||
}
|
||||
|
||||
class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMessagePlaybackViewDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let elapsedTimeFormat = "m:ss"
|
||||
}
|
||||
|
||||
private let mediaServiceProvider: VoiceMessageMediaServiceProvider
|
||||
private let cacheManager: VoiceMessageAttachmentCacheManager
|
||||
|
||||
private var audioPlayer: VoiceMessageAudioPlayer?
|
||||
private var displayLink: CADisplayLink!
|
||||
private var samples: [Float] = []
|
||||
private var duration: TimeInterval = 0
|
||||
private var urlToLoad: URL?
|
||||
private var loading: Bool = false
|
||||
|
||||
private var state: VoiceMessagePlaybackControllerState = .stopped {
|
||||
didSet {
|
||||
updateUI()
|
||||
displayLink.isPaused = (state != .playing)
|
||||
}
|
||||
}
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = Constants.elapsedTimeFormat
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
|
||||
let playbackView: VoiceMessagePlaybackView
|
||||
|
||||
init(mediaServiceProvider: VoiceMessageMediaServiceProvider,
|
||||
cacheManager: VoiceMessageAttachmentCacheManager) {
|
||||
self.mediaServiceProvider = mediaServiceProvider
|
||||
self.cacheManager = cacheManager
|
||||
|
||||
playbackView = VoiceMessagePlaybackView.loadFromNib()
|
||||
playbackView.delegate = self
|
||||
|
||||
displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector)
|
||||
displayLink.isPaused = true
|
||||
displayLink.add(to: .current, forMode: .common)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: .themeServiceDidChangeTheme, object: nil)
|
||||
updateTheme()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
var attachment: MXKAttachment? {
|
||||
didSet {
|
||||
loadAttachmentData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessagePlaybackViewDelegate
|
||||
|
||||
func voiceMessagePlaybackViewDidRequestPlaybackToggle() {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return
|
||||
}
|
||||
|
||||
if audioPlayer.url != nil {
|
||||
if audioPlayer.isPlaying {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
} else if let url = urlToLoad {
|
||||
audioPlayer.loadContentFromURL(url)
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceMessagePlaybackViewDidChangeWidth() {
|
||||
loadAttachmentData()
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state = .playing
|
||||
}
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state = .paused
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state = .stopped
|
||||
}
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) {
|
||||
state = .error
|
||||
MXLog.error("Failed playing voice message with error: \(error)")
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
audioPlayer.seekToTime(0.0)
|
||||
state = .stopped
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@objc private func handleDisplayLinkTick() {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
var details = VoiceMessagePlaybackViewDetails()
|
||||
|
||||
details.playbackEnabled = (state != .error)
|
||||
details.playing = (state == .playing)
|
||||
details.samples = samples
|
||||
|
||||
switch state {
|
||||
case .stopped:
|
||||
details.currentTime = VoiceMessagePlaybackController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: self.duration))
|
||||
details.progress = 0.0
|
||||
default:
|
||||
if let audioPlayer = audioPlayer {
|
||||
details.currentTime = VoiceMessagePlaybackController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: audioPlayer.currentTime))
|
||||
details.progress = (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0)
|
||||
}
|
||||
}
|
||||
details.loading = self.loading
|
||||
|
||||
playbackView.configureWithDetails(details)
|
||||
}
|
||||
|
||||
private func loadAttachmentData() {
|
||||
guard let attachment = attachment else {
|
||||
return
|
||||
}
|
||||
|
||||
self.state = .stopped
|
||||
self.loading = true
|
||||
self.samples = []
|
||||
updateUI()
|
||||
|
||||
let requiredNumberOfSamples = playbackView.getRequiredNumberOfSamples()
|
||||
|
||||
cacheManager.loadAttachment(attachment, numberOfSamples: requiredNumberOfSamples) { [weak self] result in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let result):
|
||||
guard result.eventIdentifier == attachment.eventId else {
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid listening to old audio player delegates if the attachment for this playbackController/cell changes
|
||||
self.audioPlayer?.deregisterDelegate(self)
|
||||
|
||||
self.audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
|
||||
self.audioPlayer?.registerDelegate(self)
|
||||
|
||||
self.loading = false
|
||||
self.urlToLoad = result.url
|
||||
self.duration = result.duration
|
||||
self.samples = result.samples
|
||||
|
||||
if let audioPlayer = self.audioPlayer {
|
||||
if audioPlayer.isPlaying {
|
||||
self.state = .playing
|
||||
} else if audioPlayer.currentTime > 0 {
|
||||
self.state = .paused
|
||||
} else {
|
||||
self.state = .stopped
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
self.state = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateTheme() {
|
||||
playbackView.update(theme: ThemeService.shared().theme)
|
||||
}
|
||||
}
|
141
Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackView.swift
Normal file
|
@ -0,0 +1,141 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Reusable
|
||||
|
||||
protocol VoiceMessagePlaybackViewDelegate: AnyObject {
|
||||
func voiceMessagePlaybackViewDidRequestPlaybackToggle()
|
||||
func voiceMessagePlaybackViewDidChangeWidth()
|
||||
}
|
||||
|
||||
struct VoiceMessagePlaybackViewDetails {
|
||||
var currentTime: String = ""
|
||||
var progress = 0.0
|
||||
var samples: [Float] = []
|
||||
var playing: Bool = false
|
||||
var playbackEnabled = false
|
||||
var recording: Bool = false
|
||||
var loading: Bool = false
|
||||
}
|
||||
|
||||
class VoiceMessagePlaybackView: UIView, NibLoadable, Themable {
|
||||
|
||||
private enum Constants {
|
||||
static let backgroundCornerRadius: CGFloat = 12.0
|
||||
}
|
||||
|
||||
private var _waveformView: VoiceMessageWaveformView!
|
||||
private var currentTheme: Theme?
|
||||
|
||||
@IBOutlet private var backgroundView: UIView!
|
||||
@IBOutlet private var recordingIcon: UIView!
|
||||
@IBOutlet private var playButton: UIButton!
|
||||
@IBOutlet private var elapsedTimeLabel: UILabel!
|
||||
@IBOutlet private var waveformContainerView: UIView!
|
||||
|
||||
weak var delegate: VoiceMessagePlaybackViewDelegate?
|
||||
|
||||
var details: VoiceMessagePlaybackViewDetails?
|
||||
|
||||
var waveformView: UIView {
|
||||
return _waveformView
|
||||
}
|
||||
|
||||
override var bounds: CGRect {
|
||||
didSet {
|
||||
if oldValue.width != bounds.width {
|
||||
delegate?.voiceMessagePlaybackViewDidChangeWidth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
backgroundView.layer.cornerRadius = Constants.backgroundCornerRadius
|
||||
playButton.layer.cornerRadius = playButton.bounds.width / 2.0
|
||||
|
||||
_waveformView = VoiceMessageWaveformView(frame: waveformContainerView.bounds)
|
||||
waveformContainerView.vc_addSubViewMatchingParent(_waveformView)
|
||||
}
|
||||
|
||||
func configureWithDetails(_ details: VoiceMessagePlaybackViewDetails?) {
|
||||
guard let details = details else {
|
||||
return
|
||||
}
|
||||
|
||||
playButton.isEnabled = details.playbackEnabled
|
||||
playButton.setImage((details.playing ? Asset.Images.voiceMessagePauseButton.image : Asset.Images.voiceMessagePlayButton.image), for: .normal)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
// UIStackView doesn't respond well to re-setting hidden states https://openradar.appspot.com/22819594
|
||||
if playButton.isHidden != details.recording {
|
||||
playButton.isHidden = details.recording
|
||||
}
|
||||
|
||||
// UIStackView doesn't respond well to re-setting hidden states https://openradar.appspot.com/22819594
|
||||
if recordingIcon.isHidden != !details.recording {
|
||||
recordingIcon.isHidden = !details.recording
|
||||
}
|
||||
}
|
||||
|
||||
if details.loading {
|
||||
elapsedTimeLabel.text = "--:--"
|
||||
_waveformView.progress = 0
|
||||
_waveformView.samples = []
|
||||
_waveformView.alpha = 0.3
|
||||
} else {
|
||||
elapsedTimeLabel.text = details.currentTime
|
||||
_waveformView.progress = details.progress
|
||||
_waveformView.samples = details.samples
|
||||
_waveformView.alpha = 1.0
|
||||
}
|
||||
|
||||
self.details = details
|
||||
|
||||
guard let theme = currentTheme else {
|
||||
return
|
||||
}
|
||||
|
||||
self.backgroundColor = theme.colors.background
|
||||
playButton.backgroundColor = theme.colors.background
|
||||
playButton.tintColor = theme.colors.secondaryContent
|
||||
backgroundView.backgroundColor = theme.colors.quinaryContent
|
||||
_waveformView.primaryLineColor = theme.colors.quarterlyContent
|
||||
_waveformView.secondaryLineColor = theme.colors.secondaryContent
|
||||
elapsedTimeLabel.textColor = theme.colors.tertiaryContent
|
||||
}
|
||||
|
||||
func getRequiredNumberOfSamples() -> Int {
|
||||
_waveformView.setNeedsLayout()
|
||||
_waveformView.layoutIfNeeded()
|
||||
return _waveformView.requiredNumberOfSamples
|
||||
}
|
||||
|
||||
// MARK: - Themable
|
||||
|
||||
func update(theme: Theme) {
|
||||
currentTheme = theme
|
||||
configureWithDetails(details)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@IBAction private func onPlayButtonTap() {
|
||||
delegate?.voiceMessagePlaybackViewDidRequestPlaybackToggle()
|
||||
}
|
||||
}
|
89
Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackView.xib
Normal file
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="cGR-49-HWB" customClass="VoiceMessagePlaybackView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="427" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LPc-i8-8UC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="427" height="44"/>
|
||||
<color key="backgroundColor" red="0.8901960784313725" green="0.90980392156862744" blue="0.94117647058823528" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" priority="999" constant="44" id="RFF-Im-d7x"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="ZQ2-Ij-mYr">
|
||||
<rect key="frame" x="8" y="0.0" width="411" height="44"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_record_icon" translatesAutoresizingMaskIntoConstraints="NO" id="REB-gl-h0h">
|
||||
<rect key="frame" x="0.0" y="17" width="10" height="10"/>
|
||||
</imageView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="GL1-b8-dZK">
|
||||
<rect key="frame" x="14" y="6" width="32" height="32"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="32" id="5Pl-ej-HIg"/>
|
||||
<constraint firstAttribute="width" constant="32" id="dXM-KA-xzM"/>
|
||||
</constraints>
|
||||
<state key="normal" image="voice_message_play_button"/>
|
||||
<connections>
|
||||
<action selector="onPlayButtonTap" destination="cGR-49-HWB" eventType="touchUpInside" id="B5j-st-pUp"/>
|
||||
</connections>
|
||||
</button>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eAi-HM-Wvj">
|
||||
<rect key="frame" x="50" y="0.0" width="40" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="40" id="iuv-MD-XYg"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7Fl-yZ-dZB">
|
||||
<rect key="frame" x="94" y="7" width="317" height="30"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="7Fl-yZ-dZB" firstAttribute="height" secondItem="ZQ2-Ij-mYr" secondAttribute="height" constant="-14" id="PiL-fv-hP1"/>
|
||||
</constraints>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Ugy-Dx-gcs"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Ugy-Dx-gcs" firstAttribute="trailing" secondItem="LPc-i8-8UC" secondAttribute="trailing" id="2AH-VU-Kcc"/>
|
||||
<constraint firstAttribute="bottom" secondItem="ZQ2-Ij-mYr" secondAttribute="bottom" id="BSe-tM-f0V"/>
|
||||
<constraint firstItem="LPc-i8-8UC" firstAttribute="leading" secondItem="Ugy-Dx-gcs" secondAttribute="leading" id="FnY-Ab-FVL"/>
|
||||
<constraint firstItem="ZQ2-Ij-mYr" firstAttribute="top" secondItem="cGR-49-HWB" secondAttribute="top" id="KRu-5w-kGE"/>
|
||||
<constraint firstAttribute="bottom" secondItem="LPc-i8-8UC" secondAttribute="bottom" id="apf-b1-yIb"/>
|
||||
<constraint firstItem="ZQ2-Ij-mYr" firstAttribute="leading" secondItem="cGR-49-HWB" secondAttribute="leading" constant="8" id="fDO-rh-Jbl"/>
|
||||
<constraint firstAttribute="trailing" secondItem="ZQ2-Ij-mYr" secondAttribute="trailing" constant="8" id="fM3-nY-rDV"/>
|
||||
<constraint firstItem="LPc-i8-8UC" firstAttribute="top" secondItem="cGR-49-HWB" secondAttribute="top" id="zl5-Sf-qSF"/>
|
||||
</constraints>
|
||||
<nil key="simulatedTopBarMetrics"/>
|
||||
<nil key="simulatedBottomBarMetrics"/>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<connections>
|
||||
<outlet property="backgroundView" destination="LPc-i8-8UC" id="mfD-md-nTj"/>
|
||||
<outlet property="elapsedTimeLabel" destination="eAi-HM-Wvj" id="z70-aJ-O90"/>
|
||||
<outlet property="playButton" destination="GL1-b8-dZK" id="5u7-CG-d99"/>
|
||||
<outlet property="recordingIcon" destination="REB-gl-h0h" id="uL1-nI-bhF"/>
|
||||
<outlet property="waveformContainerView" destination="7Fl-yZ-dZB" id="f9u-wS-jvG"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="-1742.753623188406" y="-299.33035714285711"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="voice_message_play_button" width="12.5" height="15"/>
|
||||
<image name="voice_message_record_icon" width="10" height="10"/>
|
||||
</resources>
|
||||
</document>
|
398
Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift
Normal file
|
@ -0,0 +1,398 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Reusable
|
||||
|
||||
protocol VoiceMessageToolbarViewDelegate: AnyObject {
|
||||
func voiceMessageToolbarViewDidRequestRecordingStart(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestRecordingCancel(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestRecordingFinish(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestLockedModeRecording(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestPlaybackToggle(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestSend(_ toolbarView: VoiceMessageToolbarView)
|
||||
}
|
||||
|
||||
enum VoiceMessageToolbarViewUIState {
|
||||
case idle
|
||||
case record
|
||||
case lockedModeRecord
|
||||
case lockedModePlayback
|
||||
}
|
||||
|
||||
struct VoiceMessageToolbarViewDetails {
|
||||
var state: VoiceMessageToolbarViewUIState = .idle
|
||||
var elapsedTime: String = ""
|
||||
var audioSamples: [Float] = []
|
||||
var isPlaying: Bool = false
|
||||
var progress: Double = 0.0
|
||||
var toastMessage: String?
|
||||
}
|
||||
|
||||
class VoiceMessageToolbarView: PassthroughView, NibLoadable, Themable, UIGestureRecognizerDelegate, VoiceMessagePlaybackViewDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let longPressMinimumDuration: TimeInterval = 1.0
|
||||
static let animationDuration: TimeInterval = 0.25
|
||||
static let lockModeTransitionAnimationDuration: TimeInterval = 0.5
|
||||
static let panDirectionChangeThreshold: CGFloat = 20.0
|
||||
static let toastContainerCornerRadii: CGFloat = 8.0
|
||||
static let toastDisplayTimeout: TimeInterval = 5.0
|
||||
}
|
||||
|
||||
@IBOutlet private var backgroundView: UIView!
|
||||
|
||||
@IBOutlet private var recordingContainerView: UIView!
|
||||
|
||||
@IBOutlet private var recordButtonsContainerView: UIView!
|
||||
@IBOutlet private var primaryRecordButton: UIButton!
|
||||
@IBOutlet private var secondaryRecordButton: UIButton!
|
||||
|
||||
@IBOutlet private var recordingChromeContainerView: UIView!
|
||||
@IBOutlet private var recordingIndicatorView: UIView!
|
||||
|
||||
@IBOutlet private var elapsedTimeLabel: UILabel!
|
||||
|
||||
@IBOutlet private var slideToCancelContainerView: UIView!
|
||||
@IBOutlet private var slideToCancelLabel: UILabel!
|
||||
@IBOutlet private var slideToCancelChevron: UIImageView!
|
||||
@IBOutlet private var slideToCancelGradient: UIImageView!
|
||||
|
||||
@IBOutlet private var lockContainerView: UIView!
|
||||
@IBOutlet private var lockContainerBackgroundView: UIView!
|
||||
|
||||
@IBOutlet private var lockButtonsContainerView: UIView!
|
||||
@IBOutlet private var primaryLockButton: UIButton!
|
||||
@IBOutlet private var secondaryLockButton: UIButton!
|
||||
@IBOutlet private var lockChevron: UIView!
|
||||
|
||||
@IBOutlet private var lockedModeContainerView: UIView!
|
||||
@IBOutlet private var deleteButton: UIButton!
|
||||
@IBOutlet private var playbackViewContainerView: UIView!
|
||||
@IBOutlet private var sendButton: UIButton!
|
||||
|
||||
@IBOutlet private var toastNotificationContainerView: UIView!
|
||||
@IBOutlet private var toastNotificationLabel: UILabel!
|
||||
|
||||
private var playbackView: VoiceMessagePlaybackView!
|
||||
|
||||
private var cancelLabelToRecordButtonDistance: CGFloat = 0.0
|
||||
private var lockChevronToRecordButtonDistance: CGFloat = 0.0
|
||||
private var lockChevronToLockButtonDistance: CGFloat = 0.0
|
||||
private var panDirection: UISwipeGestureRecognizer.Direction?
|
||||
|
||||
private var details: VoiceMessageToolbarViewDetails?
|
||||
|
||||
private var currentTheme: Theme? {
|
||||
didSet {
|
||||
updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: VoiceMessageToolbarViewDelegate?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
lockContainerBackgroundView.layer.cornerRadius = lockContainerBackgroundView.bounds.width / 2.0
|
||||
lockButtonsContainerView.layer.cornerRadius = lockButtonsContainerView.bounds.width / 2.0
|
||||
toastNotificationContainerView.layer.cornerRadius = Constants.toastContainerCornerRadii
|
||||
|
||||
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||
longPressGesture.delegate = self
|
||||
longPressGesture.minimumPressDuration = Constants.longPressMinimumDuration
|
||||
recordButtonsContainerView.addGestureRecognizer(longPressGesture)
|
||||
|
||||
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
longPressGesture.delegate = self
|
||||
recordButtonsContainerView.addGestureRecognizer(panGesture)
|
||||
|
||||
playbackView = VoiceMessagePlaybackView.loadFromNib()
|
||||
playbackView.delegate = self
|
||||
playbackViewContainerView.vc_addSubViewMatchingParent(playbackView)
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleWaveformTap))
|
||||
playbackView.waveformView.addGestureRecognizer(tapGesture)
|
||||
|
||||
updateUIWithDetails(VoiceMessageToolbarViewDetails(), animated: false)
|
||||
}
|
||||
|
||||
func configureWithDetails(_ details: VoiceMessageToolbarViewDetails) {
|
||||
elapsedTimeLabel.text = details.elapsedTime
|
||||
|
||||
self.updateToastNotificationsWithDetails(details)
|
||||
self.updatePlaybackViewWithDetails(details)
|
||||
|
||||
if self.details?.state != details.state {
|
||||
switch details.state {
|
||||
case .record:
|
||||
var convertedFrame = self.convert(slideToCancelLabel.frame, from: slideToCancelContainerView)
|
||||
cancelLabelToRecordButtonDistance = recordButtonsContainerView.frame.minX - convertedFrame.maxX
|
||||
|
||||
convertedFrame = self.convert(lockChevron.frame, from: lockContainerView)
|
||||
lockChevronToRecordButtonDistance = recordButtonsContainerView.frame.midY + convertedFrame.maxY
|
||||
|
||||
lockChevronToLockButtonDistance = lockChevron.frame.minY - lockButtonsContainerView.frame.midY
|
||||
|
||||
startAnimatingRecordingIndicator()
|
||||
default:
|
||||
cancelDrag()
|
||||
}
|
||||
|
||||
if details.state == .lockedModeRecord && self.details?.state == .record {
|
||||
UIView.animate(withDuration: Constants.animationDuration) {
|
||||
self.lockButtonsContainerView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)
|
||||
} completion: { _ in
|
||||
self.updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
} else {
|
||||
updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
self.details = details
|
||||
}
|
||||
|
||||
func getRequiredNumberOfSamples() -> Int {
|
||||
return playbackView.getRequiredNumberOfSamples()
|
||||
}
|
||||
|
||||
// MARK: - Themable
|
||||
|
||||
func update(theme: Theme) {
|
||||
currentTheme = theme
|
||||
playbackView.update(theme: theme)
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessagePlaybackViewDelegate
|
||||
|
||||
func voiceMessagePlaybackViewDidRequestPlaybackToggle() {
|
||||
delegate?.voiceMessageToolbarViewDidRequestPlaybackToggle(self)
|
||||
}
|
||||
|
||||
func voiceMessagePlaybackViewDidChangeWidth() {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@objc private func handleLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) {
|
||||
switch gestureRecognizer.state {
|
||||
case UIGestureRecognizer.State.began:
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingStart(self)
|
||||
case UIGestureRecognizer.State.ended:
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
guard details?.state == .record && gestureRecognizer.state == .changed else {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = gestureRecognizer.translation(in: self)
|
||||
|
||||
if abs(translation.x) <= Constants.panDirectionChangeThreshold && abs(translation.y) <= Constants.panDirectionChangeThreshold {
|
||||
panDirection = nil
|
||||
} else if panDirection == nil {
|
||||
if abs(translation.x) >= abs(translation.y) {
|
||||
panDirection = .left
|
||||
} else {
|
||||
panDirection = .up
|
||||
}
|
||||
}
|
||||
|
||||
if panDirection == .left {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: min(translation.x, 0.0), y: 0.0)
|
||||
slideToCancelContainerView.transform = CGAffineTransform(translationX: min(translation.x + cancelLabelToRecordButtonDistance, 0.0), y: 0.0)
|
||||
|
||||
if abs(translation.x - recordButtonsContainerView.frame.width / 2.0) > self.bounds.width / 2.0 {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
|
||||
}
|
||||
} else if panDirection == .up {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: 0.0, y: min(0.0, translation.y))
|
||||
|
||||
let yTranslation = min(max(translation.y + lockChevronToRecordButtonDistance, -lockChevronToLockButtonDistance), 0.0)
|
||||
lockChevron.transform = CGAffineTransform(translationX: 0.0, y: yTranslation)
|
||||
|
||||
let transitionPercentage = abs(yTranslation) / lockChevronToLockButtonDistance
|
||||
|
||||
lockChevron.alpha = 1.0 - transitionPercentage
|
||||
secondaryRecordButton.alpha = 1.0 - transitionPercentage
|
||||
primaryLockButton.alpha = 1.0 - transitionPercentage
|
||||
lockContainerBackgroundView.alpha = 1.0 - transitionPercentage
|
||||
secondaryLockButton.alpha = transitionPercentage
|
||||
|
||||
if transitionPercentage >= 1.0 {
|
||||
self.delegate?.voiceMessageToolbarViewDidRequestLockedModeRecording(self)
|
||||
}
|
||||
|
||||
} else {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: min(0.0, translation.x), y: min(0.0, translation.y))
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelDrag() {
|
||||
recordButtonsContainerView.gestureRecognizers?.forEach { gestureRecognizer in
|
||||
gestureRecognizer.isEnabled = false
|
||||
gestureRecognizer.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUIWithDetails(_ details: VoiceMessageToolbarViewDetails?, animated: Bool) {
|
||||
guard let details = details else {
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: (animated ? Constants.animationDuration : 0.0), delay: 0.0, options: .beginFromCurrentState) {
|
||||
switch details.state {
|
||||
case .record:
|
||||
self.lockContainerBackgroundView.alpha = 1.0
|
||||
case .idle:
|
||||
self.lockContainerBackgroundView.alpha = 1.0
|
||||
self.primaryLockButton.alpha = 1.0
|
||||
self.secondaryLockButton.alpha = 0.0
|
||||
self.lockChevron.alpha = 1.0
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self.backgroundView.alpha = (details.state == .idle ? 0.0 : 1.0)
|
||||
self.primaryRecordButton.alpha = (details.state == .idle ? 1.0 : 0.0)
|
||||
self.secondaryRecordButton.alpha = (details.state == .record ? 1.0 : 0.0)
|
||||
self.recordingChromeContainerView.alpha = (details.state == .record ? 1.0 : 0.0)
|
||||
self.lockContainerView.alpha = (details.state == .record ? 1.0 : 0.0)
|
||||
self.lockedModeContainerView.alpha = (details.state == .lockedModePlayback || details.state == .lockedModeRecord ? 1.0 : 0.0)
|
||||
self.recordingContainerView.alpha = (details.state == .idle || details.state == .record ? 1.0 : 0.0)
|
||||
|
||||
guard let theme = self.currentTheme else {
|
||||
return
|
||||
}
|
||||
|
||||
self.backgroundView.backgroundColor = theme.colors.background
|
||||
self.slideToCancelGradient.tintColor = theme.colors.background
|
||||
|
||||
self.primaryRecordButton.tintColor = theme.colors.tertiaryContent
|
||||
self.slideToCancelLabel.textColor = theme.colors.secondaryContent
|
||||
self.slideToCancelChevron.tintColor = theme.colors.secondaryContent
|
||||
self.elapsedTimeLabel.textColor = theme.colors.secondaryContent
|
||||
|
||||
self.lockContainerBackgroundView.backgroundColor = theme.colors.navigation
|
||||
self.lockButtonsContainerView.backgroundColor = theme.colors.navigation
|
||||
|
||||
} completion: { _ in
|
||||
switch details.state {
|
||||
case .idle:
|
||||
self.secondaryRecordButton.transform = .identity
|
||||
self.slideToCancelContainerView.transform = .identity
|
||||
self.lockChevron.transform = .identity
|
||||
self.lockButtonsContainerView.transform = .identity
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toastIdleTimer: Timer?
|
||||
private var lastUIState: VoiceMessageToolbarViewUIState = .idle
|
||||
|
||||
private func updateToastNotificationsWithDetails(_ details: VoiceMessageToolbarViewDetails, animated: Bool = true) {
|
||||
|
||||
guard self.toastNotificationLabel.text != details.toastMessage || lastUIState != details.state else {
|
||||
return
|
||||
}
|
||||
|
||||
lastUIState = details.state
|
||||
|
||||
let shouldShowNotification = details.state != .idle && details.toastMessage != nil
|
||||
let requiredAlpha: CGFloat = shouldShowNotification ? 1.0 : 0.0
|
||||
|
||||
toastIdleTimer?.invalidate()
|
||||
toastIdleTimer = nil
|
||||
|
||||
if shouldShowNotification {
|
||||
self.toastNotificationLabel.text = details.toastMessage
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: (animated ? Constants.animationDuration : 0.0)) {
|
||||
self.toastNotificationContainerView.alpha = requiredAlpha
|
||||
}
|
||||
|
||||
if shouldShowNotification {
|
||||
toastIdleTimer = Timer.scheduledTimer(withTimeInterval: Constants.toastDisplayTimeout, repeats: false) { [weak self] timer in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.toastIdleTimer?.invalidate()
|
||||
self.toastIdleTimer = nil
|
||||
|
||||
UIView.animate(withDuration: Constants.animationDuration) {
|
||||
self.toastNotificationContainerView.alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePlaybackViewWithDetails(_ details: VoiceMessageToolbarViewDetails, animated: Bool = true) {
|
||||
UIView.animate(withDuration: (animated ? Constants.animationDuration : 0.0)) {
|
||||
var playbackViewDetails = VoiceMessagePlaybackViewDetails()
|
||||
playbackViewDetails.recording = (details.state == .record || details.state == .lockedModeRecord)
|
||||
playbackViewDetails.playing = details.isPlaying
|
||||
playbackViewDetails.progress = details.progress
|
||||
playbackViewDetails.currentTime = details.elapsedTime
|
||||
playbackViewDetails.samples = details.audioSamples
|
||||
playbackViewDetails.playbackEnabled = true
|
||||
self.playbackView.configureWithDetails(playbackViewDetails)
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimatingRecordingIndicator() {
|
||||
if self.details?.state != .record {
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: Constants.lockModeTransitionAnimationDuration) {
|
||||
if self.recordingIndicatorView.alpha > 0.0 {
|
||||
self.recordingIndicatorView.alpha = 0.0
|
||||
} else {
|
||||
self.recordingIndicatorView.alpha = 1.0
|
||||
}
|
||||
} completion: { [weak self] _ in
|
||||
self?.startAnimatingRecordingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction private func onTrashButtonTap(_ sender: UIBarItem) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
|
||||
}
|
||||
|
||||
@IBAction private func onSendButtonTap(_ sender: UIBarItem) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestSend(self)
|
||||
}
|
||||
|
||||
@objc private func handleWaveformTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
|
||||
}
|
||||
}
|
286
Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib
Normal file
|
@ -0,0 +1,286 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="VoiceMessageToolbarView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="FqE-3x-NQ9">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="XRB-CY-ijK" customClass="PassthroughView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="8fP-9K-WTa">
|
||||
<rect key="frame" x="492" y="-90" width="44" height="152"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kvc-OZ-peC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="152"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.95686274510000002" green="0.97647058819999999" blue="0.99215686270000003" alpha="1" colorSpace="custom" customColorSpace="calibratedRGB"/>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="C9P-A3-Vew">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="44"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="YF2-5s-q5S">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="44"/>
|
||||
<state key="normal" image="voice_message_lock_icon_unlocked"/>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="vm7-e1-VJ8">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="44"/>
|
||||
<state key="normal" image="voice_message_lock_icon_locked"/>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="YF2-5s-q5S" secondAttribute="trailing" id="3a5-Dn-gjn"/>
|
||||
<constraint firstAttribute="bottom" secondItem="vm7-e1-VJ8" secondAttribute="bottom" id="87x-rV-hNr"/>
|
||||
<constraint firstItem="vm7-e1-VJ8" firstAttribute="leading" secondItem="C9P-A3-Vew" secondAttribute="leading" id="BeS-hI-uDa"/>
|
||||
<constraint firstAttribute="width" secondItem="C9P-A3-Vew" secondAttribute="height" multiplier="1:1" id="fuO-oh-g8I"/>
|
||||
<constraint firstAttribute="bottom" secondItem="YF2-5s-q5S" secondAttribute="bottom" id="rMf-if-5c3"/>
|
||||
<constraint firstItem="YF2-5s-q5S" firstAttribute="leading" secondItem="C9P-A3-Vew" secondAttribute="leading" id="rZq-pO-0OY"/>
|
||||
<constraint firstAttribute="trailing" secondItem="vm7-e1-VJ8" secondAttribute="trailing" id="rwd-uO-nu6"/>
|
||||
<constraint firstItem="YF2-5s-q5S" firstAttribute="top" secondItem="C9P-A3-Vew" secondAttribute="top" id="xJC-WF-SKy"/>
|
||||
<constraint firstItem="vm7-e1-VJ8" firstAttribute="top" secondItem="C9P-A3-Vew" secondAttribute="top" id="xzW-aL-alz"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_lock_chevron" translatesAutoresizingMaskIntoConstraints="NO" id="c8y-xb-2nh">
|
||||
<rect key="frame" x="0.0" y="64" width="44" height="24"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="c8y-xb-2nh" secondAttribute="trailing" id="7HA-jr-fUD"/>
|
||||
<constraint firstItem="C9P-A3-Vew" firstAttribute="top" secondItem="8fP-9K-WTa" secondAttribute="top" id="7qH-6G-oAq"/>
|
||||
<constraint firstItem="c8y-xb-2nh" firstAttribute="centerY" secondItem="8fP-9K-WTa" secondAttribute="centerY" id="9x0-mO-M0V"/>
|
||||
<constraint firstAttribute="trailing" secondItem="C9P-A3-Vew" secondAttribute="trailing" id="DJV-ib-qiR"/>
|
||||
<constraint firstItem="C9P-A3-Vew" firstAttribute="leading" secondItem="8fP-9K-WTa" secondAttribute="leading" id="F2q-RQ-dgi"/>
|
||||
<constraint firstItem="c8y-xb-2nh" firstAttribute="leading" secondItem="8fP-9K-WTa" secondAttribute="leading" id="U4g-Vq-hJB"/>
|
||||
<constraint firstAttribute="width" constant="44" id="iwn-h5-ilH"/>
|
||||
<constraint firstAttribute="height" constant="152" id="li1-Bd-px2"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dyu-ha-046" customClass="PassthroughView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="6FH-4Q-Z5e">
|
||||
<rect key="frame" x="205" y="26" width="134.5" height="20.5"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="chevron.left" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="82A-vC-KEp">
|
||||
<rect key="frame" x="0.0" y="2" width="12.5" height="17"/>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Slide to cancel" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ydw-Nb-zP6">
|
||||
<rect key="frame" x="20.5" y="0.0" width="114" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_cancel_gradient" translatesAutoresizingMaskIntoConstraints="NO" id="BYJ-HN-opT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="217.5" height="72"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</imageView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="K6L-me-5EJ">
|
||||
<rect key="frame" x="20" y="11" width="64" height="50"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_record_icon" translatesAutoresizingMaskIntoConstraints="NO" id="miF-pM-B9J">
|
||||
<rect key="frame" x="0.0" y="0.0" width="10" height="50"/>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="QBp-TZ-h5s">
|
||||
<rect key="frame" x="14" y="0.0" width="50" height="50"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="K6L-me-5EJ" firstAttribute="leading" secondItem="dyu-ha-046" secondAttribute="leading" constant="20" id="0CB-EV-XDb"/>
|
||||
<constraint firstItem="BYJ-HN-opT" firstAttribute="top" secondItem="dyu-ha-046" secondAttribute="top" id="3Mq-1a-iKc"/>
|
||||
<constraint firstItem="BYJ-HN-opT" firstAttribute="width" secondItem="dyu-ha-046" secondAttribute="width" multiplier="0.4" id="4R3-5v-p6s"/>
|
||||
<constraint firstItem="K6L-me-5EJ" firstAttribute="centerY" secondItem="dyu-ha-046" secondAttribute="centerY" id="Eyq-fW-20D"/>
|
||||
<constraint firstItem="6FH-4Q-Z5e" firstAttribute="centerX" secondItem="dyu-ha-046" secondAttribute="centerX" id="IZ1-Dr-yrw"/>
|
||||
<constraint firstItem="6FH-4Q-Z5e" firstAttribute="centerY" secondItem="dyu-ha-046" secondAttribute="centerY" id="NAu-5j-4Yg"/>
|
||||
<constraint firstItem="BYJ-HN-opT" firstAttribute="leading" secondItem="dyu-ha-046" secondAttribute="leading" id="lXc-5e-Ssj"/>
|
||||
<constraint firstAttribute="bottom" secondItem="BYJ-HN-opT" secondAttribute="bottom" id="yNQ-wC-4iD"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7OQ-1F-5qT">
|
||||
<rect key="frame" x="488" y="10" width="52" height="52"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="BDj-Sw-VQ5">
|
||||
<rect key="frame" x="0.0" y="0.0" width="52" height="52"/>
|
||||
<state key="normal" image="voice_message_record_button_default"/>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rel-Fo-ROL">
|
||||
<rect key="frame" x="0.0" y="0.0" width="52" height="52"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<state key="normal" image="voice_message_record_button_recording"/>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="leading" secondItem="7OQ-1F-5qT" secondAttribute="leading" id="2Xv-EI-etf"/>
|
||||
<constraint firstAttribute="trailing" secondItem="BDj-Sw-VQ5" secondAttribute="trailing" id="2ZQ-3v-0W7"/>
|
||||
<constraint firstAttribute="height" constant="52" id="4XA-Gb-5NO"/>
|
||||
<constraint firstAttribute="trailing" secondItem="BDj-Sw-VQ5" secondAttribute="trailing" id="Dki-cT-7xX"/>
|
||||
<constraint firstAttribute="bottom" secondItem="BDj-Sw-VQ5" secondAttribute="bottom" id="fzv-iX-c1Y"/>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="top" secondItem="7OQ-1F-5qT" secondAttribute="top" id="mNa-EU-ZKQ"/>
|
||||
<constraint firstAttribute="bottom" secondItem="BDj-Sw-VQ5" secondAttribute="bottom" id="phX-gD-B2H"/>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="top" secondItem="7OQ-1F-5qT" secondAttribute="top" id="pv8-li-wP8"/>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="leading" secondItem="7OQ-1F-5qT" secondAttribute="leading" id="ynJ-4x-1jv"/>
|
||||
<constraint firstAttribute="width" constant="52" id="zPb-1B-JyA"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="dyu-ha-046" firstAttribute="leading" secondItem="XRB-CY-ijK" secondAttribute="leading" id="BoC-Ut-chI"/>
|
||||
<constraint firstAttribute="bottom" secondItem="dyu-ha-046" secondAttribute="bottom" id="U4h-FY-D3W"/>
|
||||
<constraint firstItem="8fP-9K-WTa" firstAttribute="bottom" secondItem="7OQ-1F-5qT" secondAttribute="bottom" id="X4v-7T-LgP"/>
|
||||
<constraint firstAttribute="trailing" secondItem="7OQ-1F-5qT" secondAttribute="trailing" constant="4" id="giC-4J-EUL"/>
|
||||
<constraint firstItem="dyu-ha-046" firstAttribute="top" secondItem="XRB-CY-ijK" secondAttribute="top" id="ra2-Me-23b"/>
|
||||
<constraint firstItem="8fP-9K-WTa" firstAttribute="centerX" secondItem="7OQ-1F-5qT" secondAttribute="centerX" id="xL5-g3-aHb"/>
|
||||
<constraint firstAttribute="trailing" secondItem="dyu-ha-046" secondAttribute="trailing" id="xME-WZ-OMX"/>
|
||||
<constraint firstItem="7OQ-1F-5qT" firstAttribute="centerY" secondItem="XRB-CY-ijK" secondAttribute="centerY" id="yLc-Ke-vBU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pkc-LT-lE6">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="wL2-0Z-cvF">
|
||||
<rect key="frame" x="8" y="0.0" width="528" height="72"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="U4V-EC-Ffy">
|
||||
<rect key="frame" x="0.0" y="14" width="44" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="44" id="rXL-fN-mn1"/>
|
||||
<constraint firstAttribute="height" constant="44" id="sMv-uS-G8f"/>
|
||||
</constraints>
|
||||
<color key="tintColor" red="0.55686274509803924" green="0.59999999999999998" blue="0.64313725490196072" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<state key="normal" image="room_context_menu_delete"/>
|
||||
<connections>
|
||||
<action selector="onTrashButtonTap:" destination="iN0-l3-epB" eventType="touchUpInside" id="G3W-VG-evO"/>
|
||||
</connections>
|
||||
</button>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="RWp-zw-zVq">
|
||||
<rect key="frame" x="52" y="14" width="424" height="44"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="44" id="H6t-Lp-spE"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="UuF-HN-cAU">
|
||||
<rect key="frame" x="484" y="14" width="44" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="44" id="HKq-XS-LDC"/>
|
||||
<constraint firstAttribute="height" constant="44" id="ZuT-pR-osp"/>
|
||||
</constraints>
|
||||
<state key="normal" image="send_icon"/>
|
||||
<connections>
|
||||
<action selector="onSendButtonTap:" destination="iN0-l3-epB" eventType="touchUpInside" id="8IQ-s2-AnY"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="wL2-0Z-cvF" firstAttribute="top" secondItem="pkc-LT-lE6" secondAttribute="top" id="2Na-3x-Ri6"/>
|
||||
<constraint firstAttribute="trailing" secondItem="wL2-0Z-cvF" secondAttribute="trailing" constant="8" id="7oK-QU-5uP"/>
|
||||
<constraint firstAttribute="bottom" secondItem="wL2-0Z-cvF" secondAttribute="bottom" id="IKw-iw-tWg"/>
|
||||
<constraint firstItem="wL2-0Z-cvF" firstAttribute="leading" secondItem="pkc-LT-lE6" secondAttribute="leading" constant="8" id="cG3-Fr-Auu"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="HDF-2Z-UHZ">
|
||||
<rect key="frame" x="260" y="-45" width="24" height="16"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="gZJ-ep-9Bz">
|
||||
<rect key="frame" x="12" y="8" width="0.0" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="12"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="0.090196078430000007" green="0.098039215690000001" blue="0.10980392160000001" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="gZJ-ep-9Bz" firstAttribute="leading" secondItem="HDF-2Z-UHZ" secondAttribute="leading" constant="12" id="OtO-8K-aVX"/>
|
||||
<constraint firstAttribute="trailing" secondItem="gZJ-ep-9Bz" secondAttribute="trailing" constant="12" id="avM-Fg-VOH"/>
|
||||
<constraint firstAttribute="bottom" secondItem="gZJ-ep-9Bz" secondAttribute="bottom" constant="8" id="cab-E0-Xdu"/>
|
||||
<constraint firstItem="gZJ-ep-9Bz" firstAttribute="top" secondItem="HDF-2Z-UHZ" secondAttribute="top" constant="8" id="fwT-X9-jWY"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="HDF-2Z-UHZ" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="JaY-uF-uSj"/>
|
||||
<constraint firstAttribute="trailing" secondItem="XRB-CY-ijK" secondAttribute="trailing" id="Utk-t1-anP"/>
|
||||
<constraint firstAttribute="trailing" secondItem="pkc-LT-lE6" secondAttribute="trailing" id="VNU-5V-O6I"/>
|
||||
<constraint firstAttribute="bottom" secondItem="XRB-CY-ijK" secondAttribute="bottom" id="VT1-7g-OYr"/>
|
||||
<constraint firstItem="XRB-CY-ijK" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="VWe-l9-ZqO"/>
|
||||
<constraint firstItem="pkc-LT-lE6" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="X9R-lc-F52"/>
|
||||
<constraint firstAttribute="top" secondItem="XRB-CY-ijK" secondAttribute="top" id="XRb-zW-xdf"/>
|
||||
<constraint firstAttribute="top" secondItem="HDF-2Z-UHZ" secondAttribute="top" constant="45" id="h5P-gd-sf3"/>
|
||||
<constraint firstItem="pkc-LT-lE6" firstAttribute="bottom" secondItem="iN0-l3-epB" secondAttribute="bottom" id="ppT-PL-6Jg"/>
|
||||
<constraint firstAttribute="top" secondItem="pkc-LT-lE6" secondAttribute="top" id="tEJ-94-MLM"/>
|
||||
</constraints>
|
||||
<nil key="simulatedTopBarMetrics"/>
|
||||
<nil key="simulatedBottomBarMetrics"/>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<connections>
|
||||
<outlet property="backgroundView" destination="FqE-3x-NQ9" id="RFR-SQ-s21"/>
|
||||
<outlet property="deleteButton" destination="U4V-EC-Ffy" id="Op3-oN-2vG"/>
|
||||
<outlet property="elapsedTimeLabel" destination="QBp-TZ-h5s" id="qC9-BQ-8RA"/>
|
||||
<outlet property="lockButtonsContainerView" destination="C9P-A3-Vew" id="ebu-OR-VXw"/>
|
||||
<outlet property="lockChevron" destination="c8y-xb-2nh" id="p6S-mB-C1U"/>
|
||||
<outlet property="lockContainerBackgroundView" destination="kvc-OZ-peC" id="ke4-gM-LQV"/>
|
||||
<outlet property="lockContainerView" destination="8fP-9K-WTa" id="mFH-Va-74i"/>
|
||||
<outlet property="lockedModeContainerView" destination="pkc-LT-lE6" id="bbY-iP-th3"/>
|
||||
<outlet property="playbackViewContainerView" destination="RWp-zw-zVq" id="X0h-z8-9CA"/>
|
||||
<outlet property="primaryLockButton" destination="YF2-5s-q5S" id="zsO-cM-wBY"/>
|
||||
<outlet property="primaryRecordButton" destination="BDj-Sw-VQ5" id="dg3-fG-Bym"/>
|
||||
<outlet property="recordButtonsContainerView" destination="7OQ-1F-5qT" id="HDQ-r9-2Tu"/>
|
||||
<outlet property="recordingChromeContainerView" destination="dyu-ha-046" id="u7O-Vb-T2W"/>
|
||||
<outlet property="recordingContainerView" destination="XRB-CY-ijK" id="czS-WC-dqS"/>
|
||||
<outlet property="recordingIndicatorView" destination="miF-pM-B9J" id="zNy-ms-awL"/>
|
||||
<outlet property="secondaryLockButton" destination="vm7-e1-VJ8" id="XDw-nX-Aef"/>
|
||||
<outlet property="secondaryRecordButton" destination="rel-Fo-ROL" id="KXM-gt-9hS"/>
|
||||
<outlet property="sendButton" destination="UuF-HN-cAU" id="bBT-hM-c9E"/>
|
||||
<outlet property="slideToCancelChevron" destination="82A-vC-KEp" id="Chg-EH-UBv"/>
|
||||
<outlet property="slideToCancelContainerView" destination="6FH-4Q-Z5e" id="qCc-rl-vQX"/>
|
||||
<outlet property="slideToCancelGradient" destination="BYJ-HN-opT" id="qbb-Q9-xSo"/>
|
||||
<outlet property="slideToCancelLabel" destination="Ydw-Nb-zP6" id="l4Y-Eg-Qwc"/>
|
||||
<outlet property="toastNotificationContainerView" destination="HDF-2Z-UHZ" id="8Ty-Gl-XnP"/>
|
||||
<outlet property="toastNotificationLabel" destination="gZJ-ep-9Bz" id="soa-bs-C37"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="10.144927536231885" y="456.69642857142856"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="chevron.left" catalog="system" width="96" height="128"/>
|
||||
<image name="room_context_menu_delete" width="24" height="24"/>
|
||||
<image name="send_icon" width="36" height="36"/>
|
||||
<image name="voice_message_cancel_gradient" width="104" height="47"/>
|
||||
<image name="voice_message_lock_chevron" width="24" height="24"/>
|
||||
<image name="voice_message_lock_icon_locked" width="24" height="24"/>
|
||||
<image name="voice_message_lock_icon_unlocked" width="16" height="16"/>
|
||||
<image name="voice_message_record_button_default" width="22" height="26.5"/>
|
||||
<image name="voice_message_record_button_recording" width="52" height="52"/>
|
||||
<image name="voice_message_record_icon" width="10" height="10"/>
|
||||
</resources>
|
||||
</document>
|
122
Riot/Modules/Room/VoiceMessages/VoiceMessageWaveformView.swift
Normal file
|
@ -0,0 +1,122 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class VoiceMessageWaveformView: UIView {
|
||||
|
||||
private let lineWidth: CGFloat = 2.0
|
||||
private let linePadding: CGFloat = 2.0
|
||||
private let renderingQueue: DispatchQueue = DispatchQueue(label: "io.element.VoiceMessageWaveformView.queue", qos: .userInitiated)
|
||||
|
||||
var samples: [Float] = [] {
|
||||
didSet {
|
||||
computeWaveForm()
|
||||
}
|
||||
}
|
||||
|
||||
var primaryLineColor = UIColor.lightGray {
|
||||
didSet {
|
||||
backgroundLayer.strokeColor = primaryLineColor.cgColor
|
||||
backgroundLayer.fillColor = primaryLineColor.cgColor
|
||||
}
|
||||
}
|
||||
var secondaryLineColor = UIColor.darkGray {
|
||||
didSet {
|
||||
progressLayer.strokeColor = secondaryLineColor.cgColor
|
||||
progressLayer.fillColor = secondaryLineColor.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private let backgroundLayer = CAShapeLayer()
|
||||
private let progressLayer = CAShapeLayer()
|
||||
|
||||
var progress = 0.0 {
|
||||
didSet {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
progressLayer.frame = CGRect(origin: self.bounds.origin, size: CGSize(width: self.bounds.width * CGFloat(self.progress), height: self.bounds.height))
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
var requiredNumberOfSamples: Int {
|
||||
return Int(self.bounds.size.width / (lineWidth + linePadding))
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setupAndAdd(backgroundLayer, with: primaryLineColor)
|
||||
setupAndAdd(progressLayer, with: secondaryLineColor)
|
||||
progressLayer.masksToBounds = true
|
||||
|
||||
computeWaveForm()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
backgroundLayer.frame = self.bounds
|
||||
progressLayer.frame = CGRect(origin: self.bounds.origin, size: CGSize(width: self.bounds.width * CGFloat(self.progress), height: self.bounds.height))
|
||||
computeWaveForm()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func computeWaveForm() {
|
||||
renderingQueue.async { [samples] in // Capture the current samples as a way to provide atomicity
|
||||
let path = UIBezierPath()
|
||||
|
||||
let drawMappingFactor = self.bounds.size.height
|
||||
let minimumGraphAmplitude: CGFloat = 1
|
||||
|
||||
var xOffset: CGFloat = self.lineWidth / 2
|
||||
var index = 0
|
||||
|
||||
while xOffset < self.bounds.width - self.lineWidth {
|
||||
let sample = CGFloat(index >= samples.count ? 1 : samples[index])
|
||||
let invertedDbSample = 1 - sample // sample is in dB, linearly normalized to [0, 1] (1 -> -50 dB)
|
||||
let drawingAmplitude = max(minimumGraphAmplitude, invertedDbSample * drawMappingFactor)
|
||||
|
||||
path.move(to: CGPoint(x: xOffset, y: self.bounds.midY - drawingAmplitude / 2))
|
||||
path.addLine(to: CGPoint(x: xOffset, y: self.bounds.midY + drawingAmplitude / 2))
|
||||
|
||||
xOffset += self.lineWidth + self.linePadding
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.backgroundLayer.path = path.cgPath
|
||||
self.progressLayer.path = path.cgPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupAndAdd(_ shapeLayer: CAShapeLayer, with color: UIColor) {
|
||||
shapeLayer.frame = self.bounds
|
||||
shapeLayer.strokeColor = color.cgColor
|
||||
shapeLayer.fillColor = color.cgColor
|
||||
shapeLayer.lineCap = .round
|
||||
shapeLayer.lineWidth = lineWidth
|
||||
self.layer.addSublayer(shapeLayer)
|
||||
}
|
||||
}
|
|
@ -142,7 +142,8 @@ enum
|
|||
|
||||
enum
|
||||
{
|
||||
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0
|
||||
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0,
|
||||
LABS_ENABLE_VOICE_MESSAGES = 1
|
||||
};
|
||||
|
||||
enum
|
||||
|
@ -487,6 +488,7 @@ TableViewSectionsDelegate>
|
|||
{
|
||||
Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS];
|
||||
[sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX];
|
||||
[sectionLabs addRowWithTag:LABS_ENABLE_VOICE_MESSAGES];
|
||||
sectionLabs.headerTitle = NSLocalizedStringFromTable(@"settings_labs", @"Vector", nil);
|
||||
if (sectionLabs.hasAnyRows)
|
||||
{
|
||||
|
@ -2263,6 +2265,17 @@ TableViewSectionsDelegate>
|
|||
|
||||
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableRingingForGroupCalls:) forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
cell = labelAndSwitchCell;
|
||||
} else if (row == LABS_ENABLE_VOICE_MESSAGES)
|
||||
{
|
||||
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
|
||||
|
||||
labelAndSwitchCell.mxkLabel.text = NSLocalizedStringFromTable(@"settings_labs_voice_messages", @"Vector", nil);
|
||||
labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableVoiceMessages;
|
||||
labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
|
||||
|
||||
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableVoiceMessages:) forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
cell = labelAndSwitchCell;
|
||||
}
|
||||
}
|
||||
|
@ -2789,7 +2802,7 @@ TableViewSectionsDelegate>
|
|||
}
|
||||
}
|
||||
|
||||
- (void)togglePushNotifications:(id)sender
|
||||
- (void)togglePushNotifications:(UISwitch *)sender
|
||||
{
|
||||
// Check first whether the user allow notification from device settings
|
||||
UIUserNotificationType currentUserNotificationTypes = UIApplication.sharedApplication.currentUserNotificationSettings.types;
|
||||
|
@ -2819,7 +2832,7 @@ TableViewSectionsDelegate>
|
|||
[self presentViewController:currentAlert animated:YES completion:nil];
|
||||
|
||||
// Keep off the switch
|
||||
((UISwitch*)sender).on = NO;
|
||||
sender.on = NO;
|
||||
}
|
||||
else if ([MXKAccountManager sharedManager].activeAccounts.count)
|
||||
{
|
||||
|
@ -2842,7 +2855,7 @@ TableViewSectionsDelegate>
|
|||
[[AppDelegate theDelegate] registerForRemoteNotificationsWithCompletion:^(NSError * error) {
|
||||
if (error)
|
||||
{
|
||||
[(UISwitch *)sender setOn:NO animated:YES];
|
||||
[sender setOn:NO animated:YES];
|
||||
[self stopActivityIndicator];
|
||||
}
|
||||
else
|
||||
|
@ -2858,49 +2871,42 @@ TableViewSectionsDelegate>
|
|||
}
|
||||
}
|
||||
|
||||
- (void)toggleCallKit:(id)sender
|
||||
- (void)toggleCallKit:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
[MXKAppSettings standardAppSettings].enableCallKit = switchButton.isOn;
|
||||
[MXKAppSettings standardAppSettings].enableCallKit = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)toggleStunServerFallback:(id)sender
|
||||
- (void)toggleStunServerFallback:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
RiotSettings.shared.allowStunServerFallback = switchButton.isOn;
|
||||
RiotSettings.shared.allowStunServerFallback = sender.isOn;
|
||||
|
||||
self.mainSession.callManager.fallbackSTUNServer = RiotSettings.shared.allowStunServerFallback ? BuildSettings.stunServerFallbackUrlString : nil;
|
||||
}
|
||||
|
||||
- (void)toggleAllowIntegrations:(id)sender
|
||||
- (void)toggleAllowIntegrations:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
MXSession *session = self.mainSession;
|
||||
[self startActivityIndicator];
|
||||
|
||||
|
||||
__block RiotSharedSettings *sharedSettings = [[RiotSharedSettings alloc] initWithSession:session];
|
||||
[sharedSettings setIntegrationProvisioningWithEnabled:switchButton.on success:^{
|
||||
[sharedSettings setIntegrationProvisioningWithEnabled:sender.isOn success:^{
|
||||
sharedSettings = nil;
|
||||
[self stopActivityIndicator];
|
||||
} failure:^(NSError * _Nullable error) {
|
||||
sharedSettings = nil;
|
||||
[switchButton setOn:!switchButton.on animated:YES];
|
||||
[sender setOn:!sender.isOn animated:YES];
|
||||
[self stopActivityIndicator];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)toggleShowDecodedContent:(id)sender
|
||||
- (void)toggleShowDecodedContent:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
RiotSettings.shared.showDecryptedContentInNotifications = switchButton.isOn;
|
||||
RiotSettings.shared.showDecryptedContentInNotifications = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)toggleLocalContactsSync:(id)sender
|
||||
- (void)toggleLocalContactsSync:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
if (switchButton.on)
|
||||
if (sender.on)
|
||||
{
|
||||
[MXKContactManager requestUserConfirmationForLocalContactsSyncInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
|
@ -2941,47 +2947,36 @@ TableViewSectionsDelegate>
|
|||
}
|
||||
}
|
||||
|
||||
- (void)toggleEnableRageShake:(id)sender
|
||||
- (void)toggleEnableRageShake:(UISwitch *)sender
|
||||
{
|
||||
if (sender && [sender isKindOfClass:UISwitch.class])
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.enableRageShake = switchButton.isOn;
|
||||
|
||||
[self updateSections];
|
||||
}
|
||||
RiotSettings.shared.enableRageShake = sender.isOn;
|
||||
|
||||
[self updateSections];
|
||||
}
|
||||
|
||||
- (void)toggleEnableRingingForGroupCalls:(UISwitch *)sender
|
||||
{
|
||||
if (sender)
|
||||
{
|
||||
RiotSettings.shared.enableRingingForGroupCalls = sender.isOn;
|
||||
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
RiotSettings.shared.enableRingingForGroupCalls = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)togglePinRoomsWithMissedNotif:(id)sender
|
||||
- (void)toggleEnableVoiceMessages:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = switchButton.on;
|
||||
RiotSettings.shared.enableVoiceMessages = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)togglePinRoomsWithUnread:(id)sender
|
||||
- (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.pinRoomsWithUnreadMessagesOnHome = switchButton.on;
|
||||
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)toggleCommunityFlair:(id)sender
|
||||
- (void)togglePinRoomsWithUnread:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:switchButton.tag inSection:groupsDataSource.joinedGroupsSection];
|
||||
RiotSettings.shared.pinRoomsWithUnreadMessagesOnHome = sender.on;
|
||||
}
|
||||
|
||||
- (void)toggleCommunityFlair:(UISwitch *)sender
|
||||
{
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:sender.tag inSection:groupsDataSource.joinedGroupsSection];
|
||||
id<MXKGroupCellDataStoring> groupCellData = [groupsDataSource cellDataAtIndex:indexPath];
|
||||
MXGroup *group = groupCellData.group;
|
||||
|
||||
|
@ -2991,7 +2986,7 @@ TableViewSectionsDelegate>
|
|||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
|
||||
[self.mainSession updateGroupPublicity:group isPublicised:switchButton.on success:^{
|
||||
[self.mainSession updateGroupPublicity:group isPublicised:sender.isOn success:^{
|
||||
|
||||
if (weakSelf)
|
||||
{
|
||||
|
@ -3007,7 +3002,7 @@ TableViewSectionsDelegate>
|
|||
[self stopActivityIndicator];
|
||||
|
||||
// Come back to previous state button
|
||||
[switchButton setOn:!switchButton.isOn animated:YES];
|
||||
[sender setOn:!sender.isOn animated:YES];
|
||||
|
||||
// Notify user
|
||||
[[AppDelegate theDelegate] showErrorAsAlert:error];
|
||||
|
@ -3653,16 +3648,9 @@ TableViewSectionsDelegate>
|
|||
animated:YES];
|
||||
}
|
||||
|
||||
- (void)toggleNSFWPublicRoomsFiltering:(id)sender
|
||||
- (void)toggleNSFWPublicRoomsFiltering:(UISwitch *)sender
|
||||
{
|
||||
if (sender && [sender isKindOfClass:UISwitch.class])
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.showNSFWPublicRooms = switchButton.isOn;
|
||||
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
RiotSettings.shared.showNSFWPublicRooms = sender.isOn;
|
||||
}
|
||||
|
||||
#pragma mark - TextField listener
|
||||
|
|
47
Riot/Utils/DelegateContainer.swift
Normal file
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Object container storing references weakly. Ideal for implementing simple multiple delegation.
|
||||
*/
|
||||
struct DelegateContainer {
|
||||
|
||||
private let hashTable: NSHashTable<AnyObject>
|
||||
|
||||
var delegates: [AnyObject] {
|
||||
return hashTable.allObjects
|
||||
}
|
||||
|
||||
init() {
|
||||
hashTable = NSHashTable(options: .weakMemory)
|
||||
}
|
||||
|
||||
func registerDelegate(_ delegate: AnyObject) {
|
||||
hashTable.add(delegate)
|
||||
}
|
||||
|
||||
func deregisterDelegate(_ delegate: AnyObject) {
|
||||
hashTable.remove(delegate)
|
||||
}
|
||||
|
||||
func notifyDelegatesWithBlock(_ block: (AnyObject) -> Void) {
|
||||
for delegate in hashTable.allObjects {
|
||||
block(delegate)
|
||||
}
|
||||
}
|
||||
}
|
32
Riot/Utils/PassthroughView.swift
Normal file
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/**
|
||||
UIView subclass that ignores touches on itself.
|
||||
*/
|
||||
class PassthroughView: UIView {
|
||||
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let hitTarget = super.hitTest(point, with: event)
|
||||
|
||||
guard hitTarget == self else {
|
||||
return hitTarget
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
37
Riot/Utils/WeakTarget.swift
Normal file
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Used to avoid retain cycles by creating a proxy that holds a weak reference to the original object.
|
||||
One example of that would be using CADisplayLink, which strongly retains its target, when manually invalidating it is unfeasable.
|
||||
*/
|
||||
class WeakTarget: NSObject {
|
||||
private(set) weak var target: AnyObject?
|
||||
let selector: Selector
|
||||
|
||||
static let triggerSelector = #selector(WeakTarget.handleTick(parameter:))
|
||||
|
||||
init(_ target: AnyObject, selector: Selector) {
|
||||
self.target = target
|
||||
self.selector = selector
|
||||
}
|
||||
|
||||
@objc private func handleTick(parameter: Any) {
|
||||
_ = self.target?.perform(self.selector, with: parameter)
|
||||
}
|
||||
}
|