From 478f5f671cd595676b05dcdea05ae52f0d33d625 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Wed, 7 Oct 2020 12:19:03 +0300 Subject: [PATCH] Added initial implementation of voice recording lock. --- Telegram/Resources/langs/lang.strings | 3 + .../SourceFiles/history/history_widget.cpp | 22 ++ Telegram/SourceFiles/history/history_widget.h | 2 + .../history_view_voice_record_bar.cpp | 229 +++++++++++++++++- .../controls/history_view_voice_record_bar.h | 35 ++- Telegram/SourceFiles/ui/chat/chat.style | 3 + 6 files changed, 278 insertions(+), 16 deletions(-) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index f10ddf6a6f..d6c0eac129 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -1341,6 +1341,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_broadcast_silent_ph" = "Silent broadcast..."; "lng_send_anonymous_ph" = "Send anonymously..."; "lng_record_cancel" = "Release outside this field to cancel"; +"lng_record_lock_cancel" = "Click outside of microphone button to cancel"; +"lng_record_lock_cancel_sure" = "Are you sure you want to stop recording and discard your voice message?"; +"lng_record_lock_discard" = "Discard"; "lng_will_be_notified" = "Members will be notified when you post"; "lng_wont_be_notified" = "Members will not be notified when you post"; "lng_willbe_history" = "Please select a chat to start messaging"; diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index 2cdfee1acc..dc7ec45716 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -717,6 +717,16 @@ void HistoryWidget::refreshTabbedPanel() { } void HistoryWidget::initVoiceRecordBar() { + { + auto scrollHeight = rpl::combine( + _scroll->topValue(), + _scroll->heightValue() + ) | rpl::map([=](int top, int height) { + return top + height; + }); + _voiceRecordBar->setLockBottom(std::move(scrollHeight)); + } + _voiceRecordBar->startRecordingRequests( ) | rpl::start_with_next([=] { const auto error = _peer @@ -756,6 +766,12 @@ void HistoryWidget::initVoiceRecordBar() { data.duration, action); }, lifetime()); + + _voiceRecordBar->lockShowStarts( + ) | rpl::start_with_next([=] { + updateHistoryDownVisibility(); + updateUnreadMentionsVisibility(); + }, lifetime()); } void HistoryWidget::initTabbedSelector() { @@ -4787,6 +4803,9 @@ void HistoryWidget::updateHistoryDownVisibility() { if (!_list || _firstLoadRequest) { return false; } + if (_voiceRecordBar->isLockPresent()) { + return false; + } if (!_history->loadedAtBottom() || _replyReturn) { return true; } @@ -4830,6 +4849,9 @@ void HistoryWidget::updateUnreadMentionsVisibility() { if (!showUnreadMentions || _firstLoadRequest) { return false; } + if (_voiceRecordBar->isLockPresent()) { + return false; + } if (!_history->getUnreadMentionsLoadedCount()) { return false; } diff --git a/Telegram/SourceFiles/history/history_widget.h b/Telegram/SourceFiles/history/history_widget.h index ecd664f6d9..b7956ec7ce 100644 --- a/Telegram/SourceFiles/history/history_widget.h +++ b/Telegram/SourceFiles/history/history_widget.h @@ -95,6 +95,7 @@ class ContactStatus; class Element; class PinnedTracker; namespace Controls { +class RecordLock; class VoiceRecordBar; } // namespace Controls } // namespace HistoryView @@ -111,6 +112,7 @@ class HistoryWidget final : public Window::AbstractSectionWidget { public: using FieldHistoryAction = Ui::InputField::HistoryAction; + using RecordLock = HistoryView::Controls::RecordLock; using VoiceRecordBar = HistoryView::Controls::VoiceRecordBar; HistoryWidget( diff --git a/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.cpp b/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.cpp index 02a5a73a9f..d942215658 100644 --- a/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.cpp +++ b/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.cpp @@ -8,12 +8,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/controls/history_view_voice_record_bar.h" #include "api/api_send_progress.h" +#include "base/event_filter.h" +#include "boxes/confirm_box.h" #include "core/application.h" #include "lang/lang_keys.h" #include "mainwindow.h" #include "media/audio/media_audio.h" #include "media/audio/media_audio_capture.h" #include "styles/style_chat.h" +#include "styles/style_layers.h" #include "ui/controls/send_button.h" #include "ui/text/format_values.h" #include "window/window_session_controller.h" @@ -25,6 +28,7 @@ namespace { using SendActionUpdate = VoiceRecordBar::SendActionUpdate; using VoiceToSend = VoiceRecordBar::VoiceToSend; +constexpr auto kLockDelay = crl::time(100); constexpr auto kRecordingUpdateDelta = crl::time(100); constexpr auto kAudioVoiceMaxLength = 100 * 60; // 100 minutes constexpr auto kMaxSamples = @@ -49,6 +53,77 @@ constexpr auto kPrecision = 10; } // namespace +class RecordLock final : public Ui::RpWidget { +public: + RecordLock(not_null parent); + + void requestPaintProgress(float64 progress); + void reset(); + + [[nodiscard]] rpl::producer<> locks() const; + [[nodiscard]] bool isLocked() const; + +private: + void init(); + + Ui::Animations::Simple _lockAnimation; + + rpl::variable _progress = 0.; +}; + +RecordLock::RecordLock(not_null parent) : RpWidget(parent) { + resize(st::historyRecordLockSize); + init(); +} + +void RecordLock::init() { + setAttribute(Qt::WA_TransparentForMouseEvents); + shownValue( + ) | rpl::start_with_next([=](bool shown) { + if (!shown) { + _lockAnimation.stop(); + _progress = 0.; + } + }, lifetime()); + + paintRequest( + ) | rpl::start_with_next([=](const QRect &clip) { + Painter p(this); + if (isLocked()) { + const auto color = anim::color( + Qt::red, + Qt::green, + _lockAnimation.value(1.)); + p.fillRect(clip, color); + return; + } + p.fillRect(clip, anim::color(Qt::blue, Qt::red, _progress.current())); + }, lifetime()); + + locks( + ) | rpl::start_with_next([=] { + const auto duration = st::historyRecordVoiceShowDuration * 3; + _lockAnimation.start([=] { update(); }, 0., 1., duration); + }, lifetime()); +} + +void RecordLock::requestPaintProgress(float64 progress) { + if (isHidden() || isLocked()) { + return; + } + _progress = progress; + update(); +} + +bool RecordLock::isLocked() const { + return _progress.current() == 1.; +} + +rpl::producer<> RecordLock::locks() const { + return _progress.changes( + ) | rpl::filter([=] { return isLocked(); }) | rpl::to_empty; +} + VoiceRecordBar::VoiceRecordBar( not_null parent, not_null controller, @@ -56,8 +131,8 @@ VoiceRecordBar::VoiceRecordBar( int recorderHeight) : RpWidget(parent) , _controller(controller) -, _wrap(std::make_unique(parent)) , _send(send) +, _lock(std::make_unique(parent)) , _cancelFont(st::historyRecordFont) , _recordingAnimation([=](crl::time now) { return recordingAnimationCallback(now); @@ -94,7 +169,7 @@ void VoiceRecordBar::updateControlsGeometry(QSize size) { + _durationRect.width() + ((_send->width() - st::historyRecordVoice.width()) / 2); const auto right = width() - _send->width(); - const auto width = _cancelFont->width(tr::lng_record_cancel(tr::now)); + const auto width = _cancelFont->width(cancelMessage()); _messageRect = QRect( left + (right - left - width) / 2, st::historyRecordTextTop, @@ -145,6 +220,44 @@ void VoiceRecordBar::init() { ) | rpl::start_with_next([=](bool value) { activeAnimate(value); }, lifetime()); + + _lockShowing.changes( + ) | rpl::start_with_next([=](bool show) { + const auto to = show ? 1. : 0.; + const auto from = show ? 0. : 1.; + const auto duration = st::historyRecordLockShowDuration; + _lock->show(); + auto callback = [=](auto value) { + const auto right = anim::interpolate( + -_lock->width(), + 0, + value); + _lock->moveToRight(right, _lock->y()); + if (value == 0. && !show) { + _lock->hide(); + } else if (value == 1. && show) { + computeAndSetLockProgress(QCursor::pos()); + } + }; + _showLockAnimation.start(std::move(callback), from, to, duration); + }, lifetime()); + + _lock->hide(); + _lock->locks( + ) | rpl::start_with_next([=] { + + updateControlsGeometry(rect().size()); + update(_messageRect); + + installClickOutsideFilter(); + + _send->clicks( + ) | rpl::filter([=] { + return _send->type() == Ui::SendButton::Type::Record; + }) | rpl::start_with_next([=] { + stop(true); + }, _recordingLifetime); + }, lifetime()); } void VoiceRecordBar::activeAnimate(bool active) { @@ -177,6 +290,14 @@ void VoiceRecordBar::visibilityAnimate(bool show, Fn &&callback) { _showAnimation.start(std::move(animationCallback), from, to, duration); } +void VoiceRecordBar::setLockBottom(rpl::producer &&bottom) { + std::move( + bottom + ) | rpl::start_with_next([=](int value) { + _lock->moveToLeft(_lock->x(), value - _lock->height()); + }, lifetime()); +} + void VoiceRecordBar::startRecording() { auto appearanceCallback = [=] { Expects(!_showAnimation.animating()); @@ -187,10 +308,17 @@ void VoiceRecordBar::startRecording() { return; } + const auto shown = _recordingLifetime.make_state(false); + _recording = true; instance()->start(); instance()->updated( ) | rpl::start_with_next_error([=](const Update &update) { + if (!(*shown) && !_showAnimation.animating()) { + // Show the lock widget after the first successful update. + *shown = true; + _lockShowing = true; + } recordUpdated(update.level, update.samples); }, [=] { stop(false); @@ -205,13 +333,20 @@ void VoiceRecordBar::startRecording() { _send->events( ) | rpl::filter([=](not_null e) { return isTypeRecord() + && !_lock->isLocked() && (e->type() == QEvent::MouseMove || e->type() == QEvent::MouseButtonRelease); }) | rpl::start_with_next([=](not_null e) { const auto type = e->type(); if (type == QEvent::MouseMove) { const auto mouse = static_cast(e.get()); - _inField = rect().contains(mapFromGlobal(mouse->globalPos())); + const auto localPos = mapFromGlobal(mouse->globalPos()); + _inField = rect().contains(localPos); + + if (_showLockAnimation.animating()) { + return; + } + computeAndSetLockProgress(mouse->globalPos()); } else if (type == QEvent::MouseButtonRelease) { stop(_inField.current()); } @@ -266,6 +401,7 @@ void VoiceRecordBar::stop(bool send) { _controller->widget()->setInnerFocus(); }; + _lockShowing = false; visibilityAnimate(false, std::move(disappearanceCallback)); } @@ -316,7 +452,7 @@ void VoiceRecordBar::drawMessage(Painter &p, float64 recordActive) { p.drawText( _messageRect.x(), _messageRect.y() + _cancelFont->ascent, - tr::lng_record_cancel(tr::now)); + cancelMessage()); } rpl::producer VoiceRecordBar::sendActionUpdates() const { @@ -340,10 +476,21 @@ rpl::producer VoiceRecordBar::recordingStateChanges() const { return _recording.changes(); } +rpl::producer VoiceRecordBar::lockShowStarts() const { + return _lockShowing.changes(); +} + +bool VoiceRecordBar::isLockPresent() const { + return _lockShowing.current(); +} + rpl::producer<> VoiceRecordBar::startRecordingRequests() const { return _send->events( ) | rpl::filter([=](not_null e) { - return isTypeRecord() && (e->type() == QEvent::MouseButtonPress); + return isTypeRecord() + && !_showAnimation.animating() + && !_lock->isLocked() + && (e->type() == QEvent::MouseButtonPress); }) | rpl::to_empty; } @@ -355,4 +502,76 @@ float64 VoiceRecordBar::activeAnimationRatio() const { return _activeAnimation.value(_inField.current() ? 1. : 0.); } +QString VoiceRecordBar::cancelMessage() const { + return _lock->isLocked() + ? tr::lng_record_lock_cancel(tr::now) + : tr::lng_record_cancel(tr::now); +} + +void VoiceRecordBar::computeAndSetLockProgress(QPoint globalPos) { + const auto localPos = mapFromGlobal(globalPos); + const auto lower = _lock->height(); + const auto higher = 0; + const auto progress = localPos.y() / (float64)(higher - lower); + _lock->requestPaintProgress(std::clamp(progress, 0., 1.)); +} + +void VoiceRecordBar::installClickOutsideFilter() { + const auto box = _recordingLifetime.make_state>(); + const auto showBox = [=] { + if (*box || _send->underMouse()) { + return; + } + auto sure = [=](Fn &&close) { + stop(false); + close(); + }; + *box = Ui::show(Box( + tr::lng_record_lock_cancel_sure(tr::now), + tr::lng_record_lock_discard(tr::now), + st::attentionBoxButton, + std::move(sure))); + }; + + const auto computeResult = [=](not_null e) { + using Result = base::EventFilterResult; + if (!_lock->isLocked()) { + return Result::Continue; + } + const auto type = e->type(); + const auto noBox = !(*box); + if (type == QEvent::KeyPress) { + if (noBox) { + return Result::Cancel; + } + const auto key = static_cast(e.get())->key(); + const auto cancelOrConfirmBox = (key == Qt::Key_Escape + || (key == Qt::Key_Enter || key == Qt::Key_Return)); + return cancelOrConfirmBox ? Result::Continue : Result::Cancel; + } else if (type == QEvent::ContextMenu || type == QEvent::Shortcut) { + return Result::Cancel; + } else if (type == QEvent::MouseButtonPress) { + return (noBox && !_send->underMouse()) + ? Result::Cancel + : Result::Continue; + } + return Result::Continue; + }; + + auto filterCallback = [=](not_null e) { + const auto result = computeResult(e); + if (result == base::EventFilterResult::Cancel) { + showBox(); + } + return result; + }; + + auto filter = base::install_event_filter( + QCoreApplication::instance(), + std::move(filterCallback)); + + _recordingLifetime.make_state>( + std::move(filter)); +} + } // namespace HistoryView::Controls diff --git a/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.h b/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.h index 8fdc0fc028..29ed0cab8f 100644 --- a/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.h +++ b/Telegram/SourceFiles/history/view/controls/history_view_voice_record_bar.h @@ -22,11 +22,20 @@ class SessionController; namespace HistoryView::Controls { +class RecordLock; + class VoiceRecordBar final : public Ui::RpWidget { public: using SendActionUpdate = Controls::SendActionUpdate; using VoiceToSend = Controls::VoiceToSend; + VoiceRecordBar( + not_null parent, + not_null controller, + std::shared_ptr send, + int recorderHeight); + ~VoiceRecordBar(); + void startRecording(); void finishAnimating(); @@ -34,15 +43,12 @@ public: [[nodiscard]] rpl::producer sendVoiceRequests() const; [[nodiscard]] rpl::producer recordingStateChanges() const; [[nodiscard]] rpl::producer<> startRecordingRequests() const; + [[nodiscard]] rpl::producer lockShowStarts() const; + + void setLockBottom(rpl::producer &&bottom); [[nodiscard]] bool isRecording() const; - - VoiceRecordBar( - not_null parent, - not_null controller, - std::shared_ptr send, - int recorderHeight); - ~VoiceRecordBar(); + [[nodiscard]] bool isLockPresent() const; private: void init(); @@ -58,23 +64,26 @@ private: void stopRecording(bool send); void visibilityAnimate(bool show, Fn &&callback); - void recordStopCallback(bool active); - void recordUpdateCallback(QPoint globalPos); - bool showRecordButton() const; void drawDuration(Painter &p); void drawRecording(Painter &p); void drawMessage(Painter &p, float64 recordActive); void updateOverStates(QPoint pos); + void installClickOutsideFilter(); + bool isTypeRecord() const; void activeAnimate(bool active); float64 activeAnimationRatio() const; + void computeAndSetLockProgress(QPoint globalPos); + + QString cancelMessage() const; + const not_null _controller; - const std::unique_ptr _wrap; const std::shared_ptr _send; + const std::unique_ptr _lock; rpl::event_stream _sendActionUpdates; rpl::event_stream _sendVoiceRequests; @@ -92,6 +101,10 @@ private: rpl::lifetime _recordingLifetime; + rpl::variable _lockShowing = false; + + Ui::Animations::Simple _showLockAnimation; + // This can animate for a very long time (like in music playing), // so it should be a Basic, not a Simple animation. Ui::Animations::Basic _recordingAnimation; diff --git a/Telegram/SourceFiles/ui/chat/chat.style b/Telegram/SourceFiles/ui/chat/chat.style index 625ef7a59b..1bf1b08bae 100644 --- a/Telegram/SourceFiles/ui/chat/chat.style +++ b/Telegram/SourceFiles/ui/chat/chat.style @@ -344,6 +344,9 @@ historyRecordDurationSkip: 12px; historyRecordDurationFg: historyComposeAreaFg; historyRecordTextTop: 14px; +historyRecordLockShowDuration: historyToDownDuration; +historyRecordLockSize: size(50px, 150px); + historySilentToggle: IconButton(historyBotKeyboardShow) { icon: icon {{ "send_control_silent_off", historyComposeIconFg }}; iconOver: icon {{ "send_control_silent_off", historyComposeIconFgOver }};