diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 1f71631980..a388a4d5e9 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -870,11 +870,25 @@ PRIVATE dialogs/suggestions/suggestion_userpic_setup.cpp dialogs/dialogs_widget.cpp dialogs/dialogs_widget.h + editor/audio_track_timeline.cpp + editor/audio_track_timeline.h editor/color_picker.cpp editor/color_picker.h editor/controllers/controllers.h editor/controllers/stickers_panel_controller.cpp editor/controllers/stickers_panel_controller.h + editor/editor_audio_timeline.cpp + editor/editor_audio_timeline.h + editor/editor_keys_legend.cpp + editor/editor_keys_legend.h + editor/editor_link_box.cpp + editor/editor_link_box.h + editor/editor_message_render.cpp + editor/editor_message_render.h + editor/editor_message_source.cpp + editor/editor_message_source.h + editor/editor_message_video.cpp + editor/editor_message_video.h editor/editor_paint.cpp editor/editor_paint.h editor/photo_editor.cpp @@ -885,8 +899,14 @@ PRIVATE editor/photo_editor_controls.h editor/photo_editor_layer_widget.cpp editor/photo_editor_layer_widget.h + editor/scene/scene_item_message.cpp + editor/scene/scene_item_message.h editor/scene/scene_item_sticker.cpp editor/scene/scene_item_sticker.h + editor/scene/scene_item_video.cpp + editor/scene/scene_item_video.h + editor/video/video_clip.cpp + editor/video/video_clip.h editor/video/video_editor.cpp editor/video/video_editor.h editor/video/video_editor_common.cpp @@ -897,8 +917,14 @@ PRIVATE editor/video/video_editor_quality.h editor/video/video_quality_slider.cpp editor/video/video_quality_slider.h + editor/video/video_segment_player.cpp + editor/video/video_segment_player.h editor/video/video_timeline.cpp editor/video/video_timeline.h + editor/video/video_timeline_seeker.cpp + editor/video/video_timeline_seeker.h + editor/video_item_timeline.cpp + editor/video_item_timeline.h export/export_manager.cpp export/export_manager.h export/view/export_view_content.cpp diff --git a/Telegram/Resources/animations/photo_editor/trim_shortest.tgs b/Telegram/Resources/animations/photo_editor/trim_shortest.tgs new file mode 100644 index 0000000000..6629b13395 Binary files /dev/null and b/Telegram/Resources/animations/photo_editor/trim_shortest.tgs differ diff --git a/Telegram/Resources/animations/sun_outline.tgs b/Telegram/Resources/animations/sun_outline.tgs new file mode 100644 index 0000000000..b21ef4c1f4 Binary files /dev/null and b/Telegram/Resources/animations/sun_outline.tgs differ diff --git a/Telegram/Resources/icons/photo_editor/keyboard.svg b/Telegram/Resources/icons/photo_editor/keyboard.svg new file mode 100644 index 0000000000..e1d8731da0 --- /dev/null +++ b/Telegram/Resources/icons/photo_editor/keyboard.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Telegram/Resources/icons/photo_editor/link.png b/Telegram/Resources/icons/photo_editor/link.png new file mode 100644 index 0000000000..949ebfce97 Binary files /dev/null and b/Telegram/Resources/icons/photo_editor/link.png differ diff --git a/Telegram/Resources/icons/photo_editor/link@2x.png b/Telegram/Resources/icons/photo_editor/link@2x.png new file mode 100644 index 0000000000..cb5834ab72 Binary files /dev/null and b/Telegram/Resources/icons/photo_editor/link@2x.png differ diff --git a/Telegram/Resources/icons/photo_editor/link@3x.png b/Telegram/Resources/icons/photo_editor/link@3x.png new file mode 100644 index 0000000000..da43c82ed7 Binary files /dev/null and b/Telegram/Resources/icons/photo_editor/link@3x.png differ diff --git a/Telegram/Resources/icons/photo_editor/widget_music.png b/Telegram/Resources/icons/photo_editor/widget_music.png new file mode 100644 index 0000000000..789aeac95a Binary files /dev/null and b/Telegram/Resources/icons/photo_editor/widget_music.png differ diff --git a/Telegram/Resources/icons/photo_editor/widget_music@2x.png b/Telegram/Resources/icons/photo_editor/widget_music@2x.png new file mode 100644 index 0000000000..3535d85dbf Binary files /dev/null and b/Telegram/Resources/icons/photo_editor/widget_music@2x.png differ diff --git a/Telegram/Resources/icons/photo_editor/widget_music@3x.png b/Telegram/Resources/icons/photo_editor/widget_music@3x.png new file mode 100644 index 0000000000..8f464aaca7 Binary files /dev/null and b/Telegram/Resources/icons/photo_editor/widget_music@3x.png differ diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 9ba7668f72..12fc0d0e21 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -3176,6 +3176,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_attach_failed" = "Failed"; "lng_attach_file" = "File"; "lng_attach_photo" = "Photo"; +"lng_attach_audio" = "Audio"; "lng_attach_camera" = "Camera"; "lng_attach_document" = "Document"; "lng_attach_photo_or_video" = "Photo or video"; @@ -5355,6 +5356,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_duration_played" = "{played} / {duration}"; "lng_date_and_duration" = "{date}, {duration}"; "lng_choose_image" = "Choose an image"; +"lng_choose_audio" = "Choose an audio file"; "lng_choose_file" = "Choose a file"; "lng_choose_files" = "Choose Files"; "lng_music_attach_choose_from_files" = "Choose from files"; @@ -7954,6 +7956,50 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_photo_editor_shape_arrow" = "Arrow"; "lng_photo_editor_shape_filled" = "Filled"; +"lng_photo_editor_audio_remove" = "Remove Audio"; +"lng_photo_editor_trim_shortest" = "Trim to shortest"; + +"lng_photo_editor_keys_tooltip" = "Keyboard shortcuts"; +"lng_photo_editor_keys_gesture" = "{modifier}-{gesture}"; +"lng_photo_editor_keys_gesture_drag" = "drag"; +"lng_photo_editor_keys_gesture_drag_edge" = "drag edge"; +"lng_photo_editor_keys_gesture_drag_handle" = "drag handle"; +"lng_photo_editor_keys_gesture_click" = "click"; +"lng_photo_editor_keys_gesture_wheel" = "wheel"; +"lng_photo_editor_keys_wheel" = "Wheel"; +"lng_photo_editor_keys_middle_drag" = "Middle drag"; +"lng_photo_editor_keys_double_click" = "Double click"; +"lng_photo_editor_keys_legend" = "Show these shortcuts"; +"lng_photo_editor_keys_crop" = "Crop"; +"lng_photo_editor_keys_crop_room" = "Zoom out for room around the photo"; +"lng_photo_editor_keys_crop_expand" = "Grow canvas past the photo"; +"lng_photo_editor_keys_fixed_zoom" = "Zoom picture inside the frame"; +"lng_photo_editor_keys_fixed_zoom_fine" = "Zoom in finer steps"; +"lng_photo_editor_keys_fixed_pan" = "Move picture inside the frame"; +"lng_photo_editor_keys_canvas" = "Canvas"; +"lng_photo_editor_keys_canvas_zoom" = "Zoom canvas at cursor"; +"lng_photo_editor_keys_canvas_pan" = "Pan zoomed canvas"; +"lng_photo_editor_keys_canvas_paste" = "Paste image, sticker, file or link"; +"lng_photo_editor_keys_canvas_undo" = "Undo"; +"lng_photo_editor_keys_canvas_redo" = "Redo"; +"lng_photo_editor_keys_shapes" = "Shapes"; +"lng_photo_editor_keys_shapes_proportions" = "Keep proportions or snap arrow angle"; +"lng_photo_editor_keys_shapes_instant" = "Place shape from the menu right away"; +"lng_photo_editor_keys_objects" = "Objects"; +"lng_photo_editor_keys_objects_snap" = "Snap to canvas guides"; +"lng_photo_editor_keys_objects_rotate" = "Rotate only, in 45° steps"; +"lng_photo_editor_keys_objects_stretch" = "Stretch shape freely"; +"lng_photo_editor_keys_objects_duplicate" = "Duplicate selection"; +"lng_photo_editor_keys_objects_flip" = "Flip selection horizontally"; +"lng_photo_editor_keys_objects_delete" = "Delete selection"; +"lng_photo_editor_keys_objects_text" = "Edit text"; +"lng_photo_editor_keys_timeline" = "Timeline"; +"lng_photo_editor_keys_timeline_zoom" = "Zoom timeline"; +"lng_photo_editor_keys_timeline_scroll" = "Scroll timeline"; +"lng_photo_editor_keys_timeline_window" = "Move trimmed range"; + +"lng_photo_editor_link_customize" = "Customize Link Text"; + "lng_voice_speed_slow" = "Slow"; "lng_voice_speed_normal" = "Normal"; "lng_voice_speed_medium" = "Medium"; diff --git a/Telegram/Resources/qrc/telegram/animations.qrc b/Telegram/Resources/qrc/telegram/animations.qrc index d160a9ba3d..2785cc7695 100644 --- a/Telegram/Resources/qrc/telegram/animations.qrc +++ b/Telegram/Resources/qrc/telegram/animations.qrc @@ -30,6 +30,7 @@ ../../animations/hours.tgs ../../animations/phone.tgs ../../animations/call_rate.tgs + ../../animations/sun_outline.tgs ../../animations/chat_link.tgs ../../animations/diamond.tgs ../../animations/collectible_username.tgs @@ -105,6 +106,7 @@ ../../animations/photo_editor/text_align.tgs ../../animations/photo_editor/eraser.tgs ../../animations/photo_editor/blur.tgs + ../../animations/photo_editor/trim_shortest.tgs ../../animations/swipe_action/archive.tgs ../../animations/swipe_action/unarchive.tgs diff --git a/Telegram/SourceFiles/boxes/send_files_box.cpp b/Telegram/SourceFiles/boxes/send_files_box.cpp index 27f298682d..10bc780e74 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.cpp +++ b/Telegram/SourceFiles/boxes/send_files_box.cpp @@ -1431,6 +1431,8 @@ void SendFilesBox::pushBlock(int from, int till) { show->session().saveSettings(); } *openedOnce = true; + const auto videos = (_limits & SendFilesAllow::Videos); + const auto animated = videos || (_limits & SendFilesAllow::Gifs); Editor::OpenWithPreparedFile( this, show, @@ -1439,7 +1441,8 @@ void SendFilesBox::pushBlock(int from, int till) { std::move(done), PhotoSideLimit(true), QSize(), - true); + animated, + videos); }; const auto replaceAttachment = [=, show = _show](int index) { applyBlockChanges(); diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index 3f55c335e1..b301638a17 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -154,6 +154,8 @@ EmojiPan { searchBackTextLeft: pixels; searchBackTextTop: pixels; photoButtonIcon: icon; + audioButtonIcon: icon; + linkButtonIcon: icon; colorAll: IconButton; colorAllLabel: FlatLabel; removeSet: IconButton; @@ -636,7 +638,8 @@ stickersPhotoButtonHeight: 36px; stickersPhotoButtonRadius: 8px; stickersPhotoButtonPadding: margins(6px, 0px, 12px, 0px); stickersPhotoButtonIconSkip: 4px; -stickersPhotoButtonFont: font(21px bold); +stickersPhotoButtonFont: font(14px bold); +stickersPhotoButtonSkip: 10px; mediaPreviewPhotoSkip: 48px; emojiPanColorAll: IconButton(stickerPanRemoveSet) { @@ -831,6 +834,8 @@ defaultEmojiPan: EmojiPan { searchBackTextLeft: 40px; searchBackTextTop: 10px; photoButtonIcon: icon {{ "photo_editor/camera-24x24", windowFg }}; + audioButtonIcon: icon {{ "photo_editor/widget_music", windowFg }}; + linkButtonIcon: icon {{ "limits/links", windowFg }}; colorAll: emojiPanColorAll; colorAllLabel: emojiPanColorAllLabel; removeSet: stickerPanRemoveSet; diff --git a/Telegram/SourceFiles/chat_helpers/compose/compose_features.h b/Telegram/SourceFiles/chat_helpers/compose/compose_features.h index 744104878b..0302308dfa 100644 --- a/Telegram/SourceFiles/chat_helpers/compose/compose_features.h +++ b/Telegram/SourceFiles/chat_helpers/compose/compose_features.h @@ -23,6 +23,8 @@ struct ComposeFeatures { bool stickersSettings : 1 = true; bool openStickerSets : 1 = true; bool photoButton : 1 = false; + bool audioButton : 1 = false; + bool linkButton : 1 = false; bool autocompleteHashtags : 1 = true; bool autocompleteMentions : 1 = true; bool autocompleteCommands : 1 = true; diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp index f928810107..c8d5551311 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp @@ -240,9 +240,8 @@ StickersListWidget::StickersListWidget( , _megagroupSetAbout(st::columnMinimalWidthThird - st::emojiScroll.width - st().headerLeft) -, _photoButtonBg(st::stickersPhotoButtonRadius, st().overBg) -, _photoButtonText(tr::lng_attach_photo(tr::now).toUpper()) -, _photoButtonTextWidth(st::stickersPhotoButtonFont->width(_photoButtonText)) +, _mediaButtonBg(st::stickersPhotoButtonRadius, st().overBg) +, _mediaButtons(MakeMediaButtons(_features, st())) , _addText(tr::lng_stickers_featured_add(tr::now)) , _addWidth(st::stickersTrendingAdd.style.font->width(_addText)) , _installedText(tr::lng_stickers_featured_installed(tr::now)) @@ -324,6 +323,14 @@ rpl::producer<> StickersListWidget::photoRequests() const { return _photoRequests.events(); } +rpl::producer<> StickersListWidget::audioRequests() const { + return _audioRequests.events(); +} + +rpl::producer<> StickersListWidget::linkRequests() const { + return _linkRequests.events(); +} + rpl::producer<> StickersListWidget::scrollUpdated() const { return _scrollUpdated.events(); } @@ -509,7 +516,7 @@ template bool StickersListWidget::enumerateSections(Callback callback) const { auto info = SectionInfo(); info.top = _search ? _search->height() : 0; - info.top += photoRowHeight() + searchShortcutsHeight(); + info.top += mediaButtonsRowHeight() + searchShortcutsHeight(); const auto &sets = shownSets(); for (auto i = 0; i != sets.size(); ++i) { auto &set = sets[i]; @@ -976,69 +983,135 @@ void StickersListWidget::startSearchSwapAnimation( } int StickersListWidget::searchShortcutsTop() const { - return (_search ? _search->height() : 0) + photoRowHeight(); + return (_search ? _search->height() : 0) + mediaButtonsRowHeight(); } -int StickersListWidget::photoRowHeight() const { - if (!_features.photoButton || _isMasks || _section == Section::Search) { +std::vector +StickersListWidget::MakeMediaButtons( + const ComposeFeatures &features, + const style::EmojiPan &st) { + auto result = std::vector(); + const auto add = [&]( + MediaButton::Kind kind, + const QString &text, + const style::icon *icon) { + auto button = MediaButton{ + .kind = kind, + .text = text.toUpper(), + .icon = icon, + }; + button.textWidth = st::stickersPhotoButtonFont->width(button.text); + result.push_back(std::move(button)); + }; + if (features.linkButton) { + add( + MediaButton::Kind::Link, + tr::lng_link_header_short(tr::now), + &st.linkButtonIcon); + } + if (features.photoButton) { + add( + MediaButton::Kind::Photo, + tr::lng_attach_photo(tr::now), + &st.photoButtonIcon); + } + if (features.audioButton) { + add( + MediaButton::Kind::Audio, + tr::lng_attach_audio(tr::now), + &st.audioButtonIcon); + } + return result; +} + +int StickersListWidget::mediaButtonsRowHeight() const { + if (_mediaButtons.empty() || _isMasks || _section == Section::Search) { return 0; } const auto &padding = st::stickersPhotoRowPadding; return padding.top() + st::stickersPhotoButtonHeight + padding.bottom(); } -QRect StickersListWidget::photoButtonRect() const { +int StickersListWidget::mediaButtonWidth(const MediaButton &button) const { const auto &padding = st::stickersPhotoButtonPadding; - const auto &icon = st().photoButtonIcon; - const auto buttonWidth = padding.left() - + icon.width() + return padding.left() + + button.icon->width() + st::stickersPhotoButtonIconSkip - + _photoButtonTextWidth + + button.textWidth + padding.right(); +} + +QRect StickersListWidget::mediaButtonRect(int index) const { + Expects(index >= 0 && index < int(_mediaButtons.size())); + + const auto skip = st::stickersPhotoButtonSkip; + auto full = -skip; + for (const auto &button : _mediaButtons) { + full += mediaButtonWidth(button) + skip; + } + auto left = (width() - full) / 2; + for (auto i = 0; i != index; ++i) { + left += mediaButtonWidth(_mediaButtons[i]) + skip; + } const auto top = (_search ? _search->height() : 0) + st::stickersPhotoRowPadding.top(); return QRect( - (width() - buttonWidth) / 2, + left, top, - buttonWidth, + mediaButtonWidth(_mediaButtons[index]), st::stickersPhotoButtonHeight); } -void StickersListWidget::paintPhotoButton(Painter &p, QRect clip) { - if (!photoRowHeight()) { - return; +int StickersListWidget::mediaButtonIndexAt(QPoint point) const { + if (!mediaButtonsRowHeight()) { + return -1; } - const auto rect = photoButtonRect(); - if (!rect.intersects(clip)) { - return; - } - _photoButtonBg.paint(p, myrtlrect(rect)); - if (_photoButtonRipple) { - _photoButtonRipple->paint(p, myrtlrect(rect).x(), rect.y(), width()); - if (_photoButtonRipple->empty()) { - _photoButtonRipple.reset(); + for (auto i = 0; i != int(_mediaButtons.size()); ++i) { + if (myrtlrect(mediaButtonRect(i)).contains(point)) { + return i; } } - const auto &padding = st::stickersPhotoButtonPadding; - const auto &icon = st().photoButtonIcon; - icon.paint( - p, - rect.x() + padding.left(), - rect.y() + (rect.height() - icon.height()) / 2, - width()); - const auto &font = st::stickersPhotoButtonFont; - const auto textLeft = rect.x() - + padding.left() - + icon.width() - + st::stickersPhotoButtonIconSkip; - p.setFont(font); - p.setPen(st().textFg); - p.drawTextLeft( - textLeft, - rect.y() + (rect.height() - font->height) / 2, - width(), - _photoButtonText, - _photoButtonTextWidth); + return -1; +} + +void StickersListWidget::paintMediaButtons(Painter &p, QRect clip) { + if (!mediaButtonsRowHeight()) { + return; + } + for (auto i = 0; i != int(_mediaButtons.size()); ++i) { + auto &button = _mediaButtons[i]; + const auto rect = mediaButtonRect(i); + if (!rect.intersects(clip)) { + continue; + } + _mediaButtonBg.paint(p, myrtlrect(rect)); + if (button.ripple) { + button.ripple->paint(p, myrtlrect(rect).x(), rect.y(), width()); + if (button.ripple->empty()) { + button.ripple.reset(); + } + } + const auto &padding = st::stickersPhotoButtonPadding; + const auto &icon = *button.icon; + icon.paint( + p, + rect.x() + padding.left(), + rect.y() + (rect.height() - icon.height()) / 2, + width()); + const auto &font = st::stickersPhotoButtonFont; + const auto textLeft = rect.x() + + padding.left() + + icon.width() + + st::stickersPhotoButtonIconSkip; + p.setFont(font); + p.setPen(st().textFg); + p.drawTextLeft( + textLeft, + rect.y() + (rect.height() - font->height) / 2, + width(), + button.text, + button.textWidth); + } } int StickersListWidget::searchShortcutsHeight() const { @@ -1624,7 +1697,7 @@ void StickersListWidget::paintStickers(Painter &p, QRect clip) { _paintAsPremium = session().premium(); _pathGradient->startFrame(0, width(), width() / 2); paintSearchShortcuts(p, clip); - paintPhotoButton(p, clip); + paintMediaButtons(p, clip); auto &sets = shownSets(); const auto selectedSticker = std::get_if(&_selected); @@ -2482,9 +2555,11 @@ void StickersListWidget::setPressed(OverState newPressed) { if (_megagroupSetButtonRipple) { _megagroupSetButtonRipple->lastStop(); } - } else if (std::get_if(&_pressed)) { - if (_photoButtonRipple) { - _photoButtonRipple->lastStop(); + } else if (const auto media = std::get_if(&_pressed)) { + if (media->index >= 0 && media->index < int(_mediaButtons.size())) { + if (const auto &ripple = _mediaButtons[media->index].ripple) { + ripple->lastStop(); + } } } _pressed = newPressed; @@ -2519,18 +2594,23 @@ void StickersListWidget::setPressed(OverState newPressed) { } _megagroupSetButtonRipple->add(mapFromGlobal(QCursor::pos()) - myrtlrect(megagroupSetButtonRectFinal()).topLeft()); - } else if (std::get_if(&_pressed)) { - if (!_photoButtonRipple) { + } else if (const auto media = std::get_if(&_pressed)) { + const auto index = media->index; + if (index < 0 || index >= int(_mediaButtons.size())) { + return; + } + auto &button = _mediaButtons[index]; + if (!button.ripple) { auto mask = Ui::RippleAnimation::RoundRectMask( - photoButtonRect().size(), + mediaButtonRect(index).size(), st::stickersPhotoButtonRadius); - _photoButtonRipple = std::make_unique( + button.ripple = std::make_unique( st().searchPackRipple, std::move(mask), - [this] { rtlupdate(photoButtonRect()); }); + [this, index] { rtlupdate(mediaButtonRect(index)); }); } - _photoButtonRipple->add(mapFromGlobal(QCursor::pos()) - - myrtlrect(photoButtonRect()).topLeft()); + button.ripple->add(mapFromGlobal(QCursor::pos()) + - myrtlrect(mediaButtonRect(index)).topLeft()); } } @@ -2865,8 +2945,19 @@ void StickersListWidget::mouseReleaseEvent(QMouseEvent *e) { if (std::get_if(&pressed)) { backToSearchResults(); return; - } else if (std::get_if(&pressed)) { - _photoRequests.fire({}); + } else if (const auto media = std::get_if( + &pressed)) { + const auto index = media->index; + if (index >= 0 && index < int(_mediaButtons.size())) { + const auto kind = _mediaButtons[index].kind; + if (kind == MediaButton::Kind::Photo) { + _photoRequests.fire({}); + } else if (kind == MediaButton::Kind::Audio) { + _audioRequests.fire({}); + } else if (kind == MediaButton::Kind::Link) { + _linkRequests.fire({}); + } + } return; } else if (auto shortcut = std::get_if(&pressed)) { toggleSearchShortcut(shortcut->index); @@ -3645,8 +3736,8 @@ void StickersListWidget::updateSelected() { } setSelected(newSelected); return; - } else if (photoRowHeight() && myrtlrect(photoButtonRect()).contains(p)) { - setSelected(OverPhotoButton{}); + } else if (const auto media = mediaButtonIndexAt(p); media >= 0) { + setSelected(OverMediaButton{ media }); return; } const auto &sets = shownSets(); @@ -3759,8 +3850,12 @@ void StickersListWidget::setSelected(OverState newSelected) { rtlupdate(searchBackRect()); } else if (std::get_if(&_selected)) { rtlupdate(megagroupSetButtonRectFinal()); - } else if (std::get_if(&_selected)) { - rtlupdate(photoButtonRect()); + } else if (const auto media = std::get_if( + &_selected)) { + if (media->index >= 0 + && media->index < int(_mediaButtons.size())) { + rtlupdate(mediaButtonRect(media->index)); + } } }; updateSelected(); diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h index 8244c43e4f..dd9d6d70c5 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h @@ -105,6 +105,8 @@ public: rpl::producer chosen() const; [[nodiscard]] rpl::producer<> photoRequests() const; + [[nodiscard]] rpl::producer<> audioRequests() const; + [[nodiscard]] rpl::producer<> linkRequests() const; rpl::producer<> scrollUpdated() const; rpl::producer choosingUpdated() const; @@ -233,11 +235,13 @@ private: return !(*this == other); } }; - struct OverPhotoButton { - inline bool operator==(OverPhotoButton other) const { - return true; + struct OverMediaButton { + int index = 0; + + inline bool operator==(OverMediaButton other) const { + return (index == other.index); } - inline bool operator!=(OverPhotoButton other) const { + inline bool operator!=(OverMediaButton other) const { return !(*this == other); } }; @@ -249,7 +253,7 @@ private: OverSearchShortcut, OverSearchBack, OverGroupAdd, - OverPhotoButton>; + OverMediaButton>; struct SectionInfo { int section = 0; @@ -320,9 +324,26 @@ private: void readVisibleFeatured(int visibleTop, int visibleBottom); void paintStickers(Painter &p, QRect clip); - void paintPhotoButton(Painter &p, QRect clip); - [[nodiscard]] int photoRowHeight() const; - [[nodiscard]] QRect photoButtonRect() const; + struct MediaButton { + enum class Kind { + Photo, + Audio, + Link, + }; + Kind kind = Kind::Photo; + QString text; + int textWidth = 0; + const style::icon *icon = nullptr; + std::unique_ptr ripple; + }; + [[nodiscard]] static std::vector MakeMediaButtons( + const ComposeFeatures &features, + const style::EmojiPan &st); + void paintMediaButtons(Painter &p, QRect clip); + [[nodiscard]] int mediaButtonsRowHeight() const; + [[nodiscard]] int mediaButtonWidth(const MediaButton &button) const; + [[nodiscard]] QRect mediaButtonRect(int index) const; + [[nodiscard]] int mediaButtonIndexAt(QPoint point) const; void paintMegagroupEmptySet(Painter &p, int y, bool buttonSelected); void paintSticker( Painter &p, @@ -507,10 +528,8 @@ private: QRect _megagroupSetButtonRect; std::unique_ptr _megagroupSetButtonRipple; - Ui::RoundRect _photoButtonBg; - QString _photoButtonText; - int _photoButtonTextWidth = 0; - std::unique_ptr _photoButtonRipple; + Ui::RoundRect _mediaButtonBg; + std::vector _mediaButtons; QString _addText; int _addWidth; @@ -551,6 +570,8 @@ private: rpl::event_stream _chosen; rpl::event_stream<> _photoRequests; + rpl::event_stream<> _audioRequests; + rpl::event_stream<> _linkRequests; rpl::event_stream<> _scrollUpdated; rpl::event_stream _choosingUpdated; diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp index 65fc07c80b..d19c389536 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp @@ -760,6 +760,14 @@ rpl::producer<> TabbedSelector::photoRequests() const { return hasStickersTab() ? stickers()->photoRequests() : rpl::never<>(); } +rpl::producer<> TabbedSelector::audioRequests() const { + return hasStickersTab() ? stickers()->audioRequests() : rpl::never<>(); +} + +rpl::producer<> TabbedSelector::linkRequests() const { + return hasStickersTab() ? stickers()->linkRequests() : rpl::never<>(); +} + auto TabbedSelector::choosingStickerUpdated() const -> rpl::producer{ return hasStickersTab() diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h index 91d286c6b4..72187a56e2 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h @@ -151,6 +151,8 @@ public: [[nodiscard]] rpl::producer photoChosen() const; [[nodiscard]] rpl::producer inlineResultChosen() const; [[nodiscard]] rpl::producer<> photoRequests() const; + [[nodiscard]] rpl::producer<> audioRequests() const; + [[nodiscard]] rpl::producer<> linkRequests() const; [[nodiscard]] rpl::producer<> cancelled() const; [[nodiscard]] rpl::producer<> checkForHide() const; diff --git a/Telegram/SourceFiles/core/core_settings.cpp b/Telegram/SourceFiles/core/core_settings.cpp index 83deb40e5b..1982dfc075 100644 --- a/Telegram/SourceFiles/core/core_settings.cpp +++ b/Telegram/SourceFiles/core/core_settings.cpp @@ -696,6 +696,9 @@ void Settings::addFromSerialized(const QByteArray &serialized) { for (auto i = 0; i != soundOverridesCount; ++i) { QString key, value; stream >> key >> value; + if (stream.status() != QDataStream::Ok) { + break; + } soundOverrides.emplace(key, value); } } @@ -719,6 +722,9 @@ void Settings::addFromSerialized(const QByteArray &serialized) { for (auto i = 0; i != dictionariesEnabledCount; ++i) { qint64 langId; stream >> langId; + if (stream.status() != QDataStream::Ok) { + break; + } dictionariesEnabled.emplace_back(langId); } } @@ -843,6 +849,9 @@ void Settings::addFromSerialized(const QByteArray &serialized) { for (auto i = 0; i != accountsOrderCount; ++i) { quint64 sessionUniqueId; stream >> sessionUniqueId; + if (stream.status() != QDataStream::Ok) { + break; + } accountsOrder.emplace_back(sessionUniqueId); } } @@ -872,6 +881,9 @@ void Settings::addFromSerialized(const QByteArray &serialized) { for (auto i = 0; i != skipTranslationLanguagesCount; ++i) { quint64 language; stream >> language; + if (stream.status() != QDataStream::Ok) { + break; + } skipTranslationLanguages.push_back({ QLocale::Language(language) }); @@ -911,9 +923,10 @@ void Settings::addFromSerialized(const QByteArray &serialized) { for (auto i = 0; i != count; ++i) { auto id = QString(); stream >> id; - if (stream.status() == QDataStream::Ok) { - recentEmojiSkip.emplace(id); + if (stream.status() != QDataStream::Ok) { + break; } + recentEmojiSkip.emplace(id); } } } @@ -1021,11 +1034,13 @@ void Settings::addFromSerialized(const QByteArray &serialized) { auto prefsCount = quint32(); stream >> prefsCount; auto prefs = base::flat_map(); - prefs.reserve(prefsCount); for (auto i = quint32(); i != prefsCount; ++i) { auto key = QByteArray(); auto value = QByteArray(); stream >> key >> value; + if (stream.status() != QDataStream::Ok) { + break; + } prefs.emplace(std::move(key), std::move(value)); } if (stream.status() == QDataStream::Ok) { diff --git a/Telegram/SourceFiles/data/data_chat_participant_status.cpp b/Telegram/SourceFiles/data/data_chat_participant_status.cpp index 7d862f0ac0..83a6fe123f 100644 --- a/Telegram/SourceFiles/data/data_chat_participant_status.cpp +++ b/Telegram/SourceFiles/data/data_chat_participant_status.cpp @@ -504,7 +504,9 @@ SendError FileRestrictionError( } switch (file.type) { case Type::Photo: - if (compress == true && photos) { + if (compress == true && videos && file.hasAudioEditScene()) { + return videos; + } else if (compress == true && photos) { return photos; } else if (const auto other = file.isSticker() ? stickers : files) { if ((compress == false || photos) && other) { diff --git a/Telegram/SourceFiles/data/data_document.cpp b/Telegram/SourceFiles/data/data_document.cpp index 567643f257..1129bc471c 100644 --- a/Telegram/SourceFiles/data/data_document.cpp +++ b/Telegram/SourceFiles/data/data_document.cpp @@ -1270,21 +1270,14 @@ void DocumentData::save( status = FileReady; auto reader = owner().streaming().sharedReader(this, origin, true); if (reader) { - _loader = std::make_unique( - &session(), - id, - _dc, - origin, - Data::DocumentCacheKey(_dc, id), - mediaKey(), + _loader = createStreamedDownloader( std::move(reader), + origin, + mediaKey(), toFile, - size, - locationType(), (saveToCache() ? LoadToCacheAsWell : LoadToFileOnly), fromCloud, - autoLoading, - cacheTag()); + autoLoading); } else if (hasWebLocation()) { _loader = std::make_unique( &session(), @@ -1655,6 +1648,32 @@ bool DocumentData::hasRemoteLocation() const { return (_dc != 0 && _access != 0); } +auto DocumentData::createStreamedDownloader( + std::shared_ptr reader, + Data::FileOrigin origin, + std::optional fileLocationKey, + const QString &toFile, + LoadToCacheSetting toCache, + LoadFromCloudSetting fromCloud, + bool autoLoading) const +-> std::unique_ptr { + return std::make_unique( + &session(), + id, + _dc, + origin, + Data::DocumentCacheKey(_dc, id), + fileLocationKey, + std::move(reader), + toFile, + size, + locationType(), + toCache, + fromCloud, + autoLoading, + cacheTag()); +} + bool DocumentData::useStreamingLoader() const { if (size <= 0) { return false; diff --git a/Telegram/SourceFiles/data/data_document.h b/Telegram/SourceFiles/data/data_document.h index 5806b03250..8a8614b21d 100644 --- a/Telegram/SourceFiles/data/data_document.h +++ b/Telegram/SourceFiles/data/data_document.h @@ -27,6 +27,7 @@ enum class NameType : uchar; } // namespace Core namespace Storage { +class StreamedFileDownloader; namespace Cache { struct Key; } // namespace Cache @@ -38,6 +39,7 @@ struct VideoQuality; namespace Media::Streaming { class Loader; +class Reader; } // namespace Media::Streaming namespace Data { @@ -301,6 +303,7 @@ public: [[nodiscard]] MediaKey mediaKey() const; [[nodiscard]] Storage::Cache::Key cacheKey() const; + [[nodiscard]] LocationType locationType() const; [[nodiscard]] uint8 cacheTag() const; [[nodiscard]] bool canBeStreamed() const; @@ -309,6 +312,15 @@ public: bool forceRemoteLoader) const -> std::unique_ptr; [[nodiscard]] bool useStreamingLoader() const; + [[nodiscard]] auto createStreamedDownloader( + std::shared_ptr reader, + Data::FileOrigin origin, + std::optional fileLocationKey, + const QString &toFile, + LoadToCacheSetting toCache, + LoadFromCloudSetting fromCloud, + bool autoLoading) const + -> std::unique_ptr; void setInappPlaybackFailed(); [[nodiscard]] bool inappPlaybackFailed() const; @@ -370,7 +382,6 @@ private: friend class Serialize::Document; - [[nodiscard]] LocationType locationType() const; void validateLottieSticker(); void setMaybeSupportsStreaming(bool supports); void setLoadedInMediaCacheLocation(); diff --git a/Telegram/SourceFiles/data/data_media_types.h b/Telegram/SourceFiles/data/data_media_types.h index 862c1ba7b1..8f1890c5e2 100644 --- a/Telegram/SourceFiles/data/data_media_types.h +++ b/Telegram/SourceFiles/data/data_media_types.h @@ -210,6 +210,8 @@ struct GiftCode { inline constexpr auto kTimeToLiveSingleView = crl::time(0x7FFFFFFF); +class MediaDice; + class Media { public: Media(not_null parent); diff --git a/Telegram/SourceFiles/editor/audio_track_timeline.cpp b/Telegram/SourceFiles/editor/audio_track_timeline.cpp new file mode 100644 index 0000000000..e51372ff9a --- /dev/null +++ b/Telegram/SourceFiles/editor/audio_track_timeline.cpp @@ -0,0 +1,80 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/audio_track_timeline.h" + +#include "editor/editor_audio_timeline.h" +#include "editor/photo_editor_common.h" +#include "styles/style_editor.h" + +namespace Editor { + +AudioTrackTimeline::AudioTrackTimeline(not_null parent) +: RpWidget(parent) { +} + +AudioTrackTimeline::~AudioTrackTimeline() = default; + +void AudioTrackTimeline::setTrack(std::shared_ptr track) { + if (!track) { + if (_timeline) { + _timeline->setPlaying(false); + } + return; + } else if (_track != track) { + _track = std::move(track); + _timeline = base::make_unique_q(this, _track); + _timeline->removeRequests( + ) | rpl::start_to_stream(_removeRequests, _timeline->lifetime()); + _timeline->trimChanges( + ) | rpl::on_next([=] { + _lengthChanges.fire(_track->length()); + }, _timeline->lifetime()); + _timeline->show(); + if (width() > 0) { + resizeToWidth(width()); + } + } + _timeline->setPlaying(true); +} + +rpl::producer AudioTrackTimeline::lengthChanges() const { + return _lengthChanges.events(); +} + +void AudioTrackTimeline::refreshTrim() { + if (_timeline) { + _timeline->refreshTrim(); + } +} + +void AudioTrackTimeline::commitPendingEdit() { + if (_timeline) { + _timeline->commitPendingEdit(); + } +} + +void AudioTrackTimeline::refreshVolume() { + if (_timeline) { + _timeline->refreshVolume(); + } +} + +rpl::producer<> AudioTrackTimeline::removeRequests() const { + return _removeRequests.events(); +} + +int AudioTrackTimeline::resizeGetHeight(int newWidth) { + const auto height = st::photoEditorButtonBarHeight; + if (_timeline) { + _timeline->resizeToWidth(std::max(newWidth, 1)); + _timeline->moveToLeft(0, (height - _timeline->height()) / 2); + } + return height; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/audio_track_timeline.h b/Telegram/SourceFiles/editor/audio_track_timeline.h new file mode 100644 index 0000000000..37844e8e8d --- /dev/null +++ b/Telegram/SourceFiles/editor/audio_track_timeline.h @@ -0,0 +1,42 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/unique_qptr.h" +#include "ui/rp_widget.h" + +namespace Editor { + +class AudioTimeline; +struct AudioTrack; + +class AudioTrackTimeline final : public Ui::RpWidget { +public: + explicit AudioTrackTimeline(not_null parent); + ~AudioTrackTimeline(); + + void setTrack(std::shared_ptr track); + void refreshTrim(); + void commitPendingEdit(); + void refreshVolume(); + + [[nodiscard]] rpl::producer<> removeRequests() const; + + [[nodiscard]] rpl::producer lengthChanges() const; + + int resizeGetHeight(int newWidth) override; + +private: + std::shared_ptr _track; + base::unique_qptr _timeline; + rpl::event_stream<> _removeRequests; + rpl::event_stream _lengthChanges; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/color_picker.cpp b/Telegram/SourceFiles/editor/color_picker.cpp index 7e29244eca..255583a2f6 100644 --- a/Telegram/SourceFiles/editor/color_picker.cpp +++ b/Telegram/SourceFiles/editor/color_picker.cpp @@ -15,6 +15,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/painter.h" #include "ui/rect.h" #include "ui/rp_widget.h" +#include "ui/ui_utility.h" #include "ui/widgets/color_editor.h" #include "styles/style_editor.h" @@ -293,6 +294,7 @@ ColorPicker::ColorPicker( , _sizeControlHoverArea(std::in_place, parent) , _sizeControl(std::in_place, parent) , _toolSelection(std::in_place, parent) +, _rowFade(std::in_place, parent) , _brush(savedBrushes[ToolIndex(savedTool)]) , _toolBrushes(savedBrushes) { _colorButton->resize(Size(st::photoEditorColorButtonSize)); @@ -328,6 +330,13 @@ ColorPicker::ColorPicker( -padding - half); p.drawEllipse(rect); }); + _rowFade->setAttribute(Qt::WA_TransparentForMouseEvents); + _rowFade->setAttribute(Qt::WA_TranslucentBackground, true); + _rowFade->setVisible(false); + _rowFade->paintOn([=](QPainter &p) { + p.setOpacity(_rowFadeAnimation.value(_rowFadeShown ? 1. : 0.)); + p.drawImage(0, 0, _rowFadeImage); + }); _toolButtons.push_back(base::make_unique_q( parent, @@ -636,7 +645,15 @@ void ColorPicker::paintSizeControl(QPainter &p) { p.drawEllipse(handleRect); } -void ColorPicker::setVisible(bool visible) { +void ColorPicker::setVisible(bool visible, anim::type animated) { + _rowFadeAnimation.stop(); + _rowFade->hide(); + const auto fade = (animated == anim::type::normal) + && !_paletteVisible + && (visible != _colorButton->isVisible()); + if (fade && !visible) { + _rowFadeImage = grabRow(); + } if (!visible) { _paletteVisible = false; _sizeDown.pressed = false; @@ -647,10 +664,21 @@ void ColorPicker::setVisible(bool visible) { _sizeControlPositionAnimation.stop(); _toolSelectionAnimation.stop(); } - _colorButton->setVisible(visible && !_paletteVisible); _paletteWrap->setVisible(visible && _paletteVisible); _sizeControlHoverArea->setVisible(visible); _sizeControl->setVisible(visible); + setRowVisible(visible); + if (!fade) { + return; + } else if (visible) { + _rowFadeImage = grabRow(); + setRowVisible(false); + } + startRowFade(visible); +} + +void ColorPicker::setRowVisible(bool visible) { + _colorButton->setVisible(visible && !_paletteVisible); const auto showTools = visible && !_paletteVisible && !_toolSelectionSuppressed; @@ -663,6 +691,57 @@ void ColorPicker::setVisible(bool visible) { } } +QRect ColorPicker::rowRect() const { + auto result = _colorButton->geometry(); + for (const auto &button : _toolButtons) { + result = result.united(button->geometry()); + } + return result.united(_toolSelection->geometry()); +} + +QImage ColorPicker::grabRow() const { + const auto rect = rowRect(); + const auto ratio = style::DevicePixelRatio(); + auto result = QImage( + rect.size() * ratio, + QImage::Format_ARGB32_Premultiplied); + result.setDevicePixelRatio(ratio); + result.fill(Qt::transparent); + auto p = QPainter(&result); + const auto draw = [&](not_null widget) { + if (widget->isVisible()) { + p.drawImage( + widget->pos() - rect.topLeft(), + Ui::GrabWidgetToImage(widget)); + } + }; + draw(_colorButton.get()); + for (const auto &button : _toolButtons) { + draw(button.get()); + } + draw(_toolSelection.get()); + return result; +} + +void ColorPicker::startRowFade(bool shown) { + _rowFadeShown = shown; + _rowFade->setGeometry(rowRect()); + _rowFade->raise(); + _rowFade->show(); + _rowFadeAnimation.start([=] { + _rowFade->update(); + if (!_rowFadeAnimation.animating()) { + _rowFade->hide(); + if (_rowFadeShown) { + setRowVisible(true); + } + } + }, + shown ? 0. : 1., + shown ? 1. : 0., + st::photoEditorBarAnimationDuration); +} + rpl::producer ColorPicker::saveBrushRequests() const { return _saveBrushRequests.events_starting_with_copy(_brush); } diff --git a/Telegram/SourceFiles/editor/color_picker.h b/Telegram/SourceFiles/editor/color_picker.h index 3d64431ce6..c8c1caa910 100644 --- a/Telegram/SourceFiles/editor/color_picker.h +++ b/Telegram/SourceFiles/editor/color_picker.h @@ -29,7 +29,9 @@ public: void moveLine(const QPoint &position); void setCanvasRect(const QRect &rect); - void setVisible(bool visible); + void setVisible( + bool visible, + anim::type animated = anim::type::instant); void setColor(const QColor &color); void setToolSelectionVisible(bool visible); bool preventHandleKeyPress() const; @@ -42,6 +44,10 @@ private: void rebuildPalette(); void updateToolButtonsGeometry(); void updateToolSelection(bool animated); + void setRowVisible(bool visible); + [[nodiscard]] QRect rowRect() const; + [[nodiscard]] QImage grabRow() const; + void startRowFade(bool shown); void setTool(Brush::Tool tool); void storeCurrentBrush(); void updateColorButtonColor(const QColor &color, bool animated); @@ -73,6 +79,9 @@ private: const base::unique_qptr _sizeControl; const base::unique_qptr _toolSelection; std::vector> _toolButtons; + const base::unique_qptr _rowFade; + QImage _rowFadeImage; + bool _rowFadeShown = false; struct { int y = 0; @@ -96,6 +105,7 @@ private: Ui::Animations::Simple _sizeControlPositionAnimation; Ui::Animations::Simple _colorButtonAnimation; Ui::Animations::Simple _toolSelectionAnimation; + Ui::Animations::Simple _rowFadeAnimation; rpl::event_stream _saveBrushRequests; rpl::event_stream<> _toolClicks; diff --git a/Telegram/SourceFiles/editor/controllers/controllers.h b/Telegram/SourceFiles/editor/controllers/controllers.h index f694fa6075..3a046615d5 100644 --- a/Telegram/SourceFiles/editor/controllers/controllers.h +++ b/Telegram/SourceFiles/editor/controllers/controllers.h @@ -11,16 +11,24 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/controllers/undo_controller.h" #include "ui/layers/show.h" +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + namespace Editor { struct Controllers final { Controllers( std::unique_ptr stickersPanelController, std::unique_ptr undoController, - std::shared_ptr show) + std::shared_ptr show, + std::shared_ptr layerShow, + std::shared_ptr sessionShow) : stickersPanelController(std::move(stickersPanelController)) , undoController(std::move(undoController)) - , show(std::move(show)) { + , show(std::move(show)) + , layerShow(std::move(layerShow)) + , sessionShow(std::move(sessionShow)) { } ~Controllers() { }; @@ -28,6 +36,8 @@ struct Controllers final { const std::unique_ptr stickersPanelController; const std::unique_ptr undoController; const std::shared_ptr show; + const std::shared_ptr layerShow; + const std::shared_ptr sessionShow; }; } // namespace Editor diff --git a/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp b/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp index 1c32ecfdba..63112443ab 100644 --- a/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp +++ b/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp @@ -15,10 +15,26 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_media_view.h" namespace Editor { +namespace { + +// Works around MSVC 14.44 ICE on bitfields set in a member-init list. +[[nodiscard]] ChatHelpers::ComposeFeatures PrepareFeatures(bool withAudio) { + return { + .megagroupSet = false, + .stickersSettings = false, + .openStickerSets = false, + .photoButton = true, + .audioButton = withAudio, + .linkButton = true, + }; +} + +} // namespace StickersPanelController::StickersPanelController( not_null panelContainer, - std::shared_ptr show) + std::shared_ptr show, + bool withAudio) : _stickersPanel( base::make_unique_q( panelContainer, @@ -30,12 +46,7 @@ StickersPanelController::StickersPanelController( .st = st::mediaviewEmojiPan, .level = Window::GifPauseReason::Layer, .mode = ChatHelpers::TabbedSelector::Mode::MediaEditor, - .features = { - .megagroupSet = false, - .stickersSettings = false, - .openStickerSets = false, - .photoButton = true, - }, + .features = PrepareFeatures(withAudio), }), })) { _stickersPanel->setDesiredHeightValues( @@ -57,6 +68,14 @@ rpl::producer<> StickersPanelController::photoRequests() const { return _stickersPanel->selector()->photoRequests(); } +rpl::producer<> StickersPanelController::audioRequests() const { + return _stickersPanel->selector()->audioRequests(); +} + +rpl::producer<> StickersPanelController::linkRequests() const { + return _stickersPanel->selector()->linkRequests(); +} + rpl::producer StickersPanelController::panelShown() const { return _stickersPanel->shownValue(); } diff --git a/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.h b/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.h index 91a8b8c793..641c3f18ad 100644 --- a/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.h +++ b/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.h @@ -31,11 +31,14 @@ public: StickersPanelController( not_null panelContainer, - std::shared_ptr show); + std::shared_ptr show, + bool withAudio); [[nodiscard]] auto stickerChosen() const -> rpl::producer>; [[nodiscard]] rpl::producer<> photoRequests() const; + [[nodiscard]] rpl::producer<> audioRequests() const; + [[nodiscard]] rpl::producer<> linkRequests() const; [[nodiscard]] rpl::producer panelShown() const; void setShowRequestChanges(rpl::producer &&showRequest); diff --git a/Telegram/SourceFiles/editor/editor.style b/Telegram/SourceFiles/editor/editor.style index d368bce23e..ed4188cea1 100644 --- a/Telegram/SourceFiles/editor/editor.style +++ b/Telegram/SourceFiles/editor/editor.style @@ -206,18 +206,96 @@ photoEditorCropPointSize: 10px; photoEditorCropMinSize: 20px; photoEditorItemHandleSize: 10px; +photoEditorStickyTrigger: 12px; +photoEditorStickyLineWidth: 2px; + +photoEditorKeysButton: IconButton(photoEditorRotateButton) { + icon: icon {{ "photo_editor/keyboard", photoEditorButtonIconFg }}; + iconOver: icon {{ "photo_editor/keyboard", photoEditorButtonIconFgOver }}; +} +photoEditorKeysButtonLeft: 16px; +photoEditorKeysMenuSkip: 6px; +photoEditorKeysMenu: PopupMenu(photoEditorCropRatioMenu) { + scrollPadding: margins(0px, 4px, 0px, 4px); + menu: Menu(mediaviewMenu) { + widthMin: 220px; + widthMax: 600px; + itemPadding: margins(14px, 8px, 14px, 8px); + arrow: photoEditorMenuSubmenuArrow; + } +} +photoEditorKeysTitleStyle: TextStyle(semiboldTextStyle) { + font: font(12px semibold); +} +photoEditorKeysTitleFg: groupCallMemberNotJoinedStatus; +photoEditorKeysTitleSkip: 6px; +photoEditorKeysRowSkip: 6px; +photoEditorKeysCapStyle: TextStyle(semiboldTextStyle) { + font: font(12px semibold); +} +photoEditorKeysCapHeight: 20px; +photoEditorKeysCapMinWidth: 22px; +photoEditorKeysCapPadding: 6px; +photoEditorKeysCapRadius: 4px; +photoEditorKeysCapSkip: 4px; +photoEditorKeysCapBg: groupCallMenuBgOver; +photoEditorKeysCapBorder: groupCallMemberNotJoinedStatus; +photoEditorKeysCapFg: groupCallMembersFg; +photoEditorKeysTextSkip: 12px; +photoEditorKeysTextWidth: 360px; +photoEditorKeysTextMinWidth: 100px; +photoEditorKeysTextFg: groupCallMembersFg; + +photoEditorAudioDiscSize: 48px; +photoEditorAudioDiscSkip: 12px; +photoEditorAudioDiscCoverSize: 30px; +photoEditorAudioDiscRing: 2px; +photoEditorAudioDiscRingFg: mediaviewControlFg; +photoEditorAudioDiscIcon: icon {{ "photo_editor/widget_music", photoEditorButtonIconFg }}; +photoEditorAudioDiscIconOver: icon {{ "photo_editor/widget_music", photoEditorButtonIconFgOver }}; +photoEditorMenuDelete: icon {{ "menu/delete", mediaviewMenuFg }}; +photoEditorMediaMenu: PopupMenu(photoEditorCropRatioMenu) { + scrollPadding: margins(0px, 3px, 0px, 8px); + menu: Menu(mediaviewMenu) { + widthMin: 210px; + arrow: photoEditorMenuSubmenuArrow; + } +} +photoEditorAudioVolumeItem: Menu(mediaviewMenu) { + itemBgOver: groupCallMenuBg; + arrow: photoEditorMenuSubmenuArrow; +} +photoEditorAudioVolumePadding: margins(15px, 6px, 17px, 5px); +photoEditorAudioVolumeMargin: margins(54px, 0px, 17px, 0px); + +photoEditorAudioTimelineBarWidth: 2px; +photoEditorAudioTimelineBarSkip: 3px; +photoEditorAudioTimelineBarMin: 1px; +photoEditorAudioTimelineBarRatio: 0.6; +photoEditorAudioTimelineBarOpacity: 0.4; +photoEditorAudioTimelineIcon: 13px; +photoEditorAudioTimelineIconSkip: 3px; +photoEditorAudioTimelineDotSkip: 10px; +photoEditorAudioTimelineDotRadius: 1px; +photoEditorAudioTimelineTextSkip: 12px; +photoEditorAudioTimelineFade: 8px; +photoEditorAudioTimelineAuthorFont: font(12px semibold); +photoEditorAudioTimelineFont: font(12px); +photoEditorTrimShortestIconSize: 24px; videoTimelineStripHeight: 54px; videoTimelineLabelHeight: 18px; // Wide enough to hold the cover dot without it touching either side. videoTimelineLabelSkip: 17px; -videoTimelineHeight: videoTimelineLabelHeight + videoTimelineLabelSkip + videoTimelineStripHeight; +videoTimelineTrimStripHeight: 32px; +videoTimelineTrimLabelSkip: 10px; videoTimelineDotSize: 5px; videoTimelineDotActiveSize: 9px; videoTimelineDotSkip: 2px; videoTimelineDotFg: callAnswerBg; videoTimelineRadius: 6px; videoTimelineHandleWidth: 11px; +videoTimelineHandleMinWidth: 4px; videoTimelineHandleGripWidth: 2px; videoTimelineHandleGripHeight: 16px; videoTimelineHandleHitSlop: 12px; @@ -230,9 +308,30 @@ videoTimelinePlaceholderBg: mediaviewPipPlaybackInactive; videoTimelineDurationFg: mediaviewCaptionFg; videoTimelineSizeFg: mediaviewCaptionFg; videoTimelineSizeSkip: 12px; +videoTimelineScrollHeight: 2px; +videoTimelineScrollSkip: 3px; +videoTimelineScrollMinWidth: 16px; +videoTimelineOverviewSelectionHeight: 3px; +videoTimelineOverviewFg: windowBgActive; +videoTimelineHintSkip: 4px; +videoTimelineHintWidth: 18px; +videoTimelineHintHeight: 20px; +videoTimelineHintRadius: 6px; +videoTimelineHintStroke: 2px; +videoTimelineHintArrowWidth: 5px; +videoTimelineHintArrowHeight: 10px; videoTimelineDurationStyle: TextStyle(defaultTextStyle) { font: font(12px semibold); } +videoTimelineDurationFieldSkip: 4px; +videoTimelineDurationField: InputField(defaultInputField) { + textBg: transparent; + textFg: videoTimelineDurationFg; + textMargins: margins(0px, 0px, 0px, 0px); + border: 0px; + borderActive: 0px; + style: videoTimelineDurationStyle; +} videoEditorGifButton: IconButton(photoEditorRotateButton) { icon: icon {{ "photo_editor/gif", photoEditorButtonIconFg }}; @@ -259,6 +358,7 @@ videoQualitySliderStyle: TextStyle(defaultTextStyle) { videoQualitySliderHeight: 32px; videoEditorTimelineTop: 10px; +photoEditorTimelineSkip: 6px; videoEditorHintSkip: 8px; videoEditorQualitySkip: 6px; videoEditorBarSkip: 12px; @@ -279,3 +379,19 @@ videoEditorPlayBadgeBg: mediaviewCaptionBg; videoEditorAbout: FlatLabel(photoEditorAbout) { } videoEditorAboutMargin: margins(10px, 10px, 10px, 0px); + +photoEditorMessageReferenceWidth: 600px; +photoEditorLinkPreviewMargin: margins(24px, 12px, 24px, 6px); +photoEditorLinkPreviewMaxHeight: 320px; +photoEditorLinkPreviewPadding: 12px; +photoEditorLinkPillIcon: icon {{ "photo_editor/link", windowFg }}; +photoEditorLinkThemeSize: 36px; +photoEditorLinkThemeIconSize: 28px; +photoEditorLinkThemeSkip: 6px; +photoEditorLinkThemeBg: mediaviewCaptionBg; +photoEditorLinkLoadingRadius: bubbleRadiusLarge; +photoEditorLinkLoadingPadding: margins(12px, 11px, 12px, 11px); +photoEditorLinkLoadingLine: 10px; +photoEditorLinkLoadingSkip: 8px; +photoEditorLinkLoadingQuote: 3px; +photoEditorLinkLoadingQuoteSkip: 8px; diff --git a/Telegram/SourceFiles/editor/editor_audio_disc_button.cpp b/Telegram/SourceFiles/editor/editor_audio_disc_button.cpp new file mode 100644 index 0000000000..511682df64 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_audio_disc_button.cpp @@ -0,0 +1,154 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_audio_disc_button.h" + +#include "editor/editor_audio_menu.h" +#include "editor/photo_editor_common.h" +#include "ui/image/image_prepare.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "ui/widgets/popup_menu.h" +#include "styles/style_editor.h" + +#include + +namespace Editor { +namespace { + +constexpr auto kSpinDuration = crl::time(4000); + +} // namespace + +AudioDiscButton::AudioDiscButton(QWidget *parent) +: RippleButton(parent, st::photoEditorRotateButton.ripple) { + resize(Size(st::photoEditorAudioDiscSize)); + _spin.init([=](crl::time now) { + update(); + return _active; + }); +} + +AudioDiscButton::~AudioDiscButton() = default; + +void AudioDiscButton::setTrack(std::shared_ptr track) { + if (_track == track) { + return; + } + _track = std::move(track); + if (!_track || _track->cover.isNull()) { + _cover = QImage(); + } else { + const auto ratio = style::DevicePixelRatio(); + const auto side = st::photoEditorAudioDiscCoverSize * ratio; + auto scaled = _track->cover.scaled( + side, + side, + Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + const auto crop = QRect( + (scaled.width() - side) / 2, + (scaled.height() - side) / 2, + side, + side); + _cover = Images::Circle(scaled.copy(crop)); + _cover.setDevicePixelRatio(ratio); + } + update(); +} + +void AudioDiscButton::setActive(bool active) { + if (_active == active) { + return; + } + _spinBase = angle(); + _active = active; + _spinStarted = crl::now(); + if (_active) { + _spin.start(); + } else { + _spin.stop(); + } + update(); +} + +rpl::producer<> AudioDiscButton::volumeChanges() const { + return _volumeChanges.events(); +} + +rpl::producer<> AudioDiscButton::removeRequests() const { + return _removeRequests.events(); +} + +float64 AudioDiscButton::angle() const { + if (!_active) { + return _spinBase; + } + const auto passed = crl::now() - _spinStarted; + return std::fmod(_spinBase + passed * 360. / kSpinDuration, 360.); +} + +void AudioDiscButton::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + const auto full = QRectF(rect()); + p.setPen(Qt::NoPen); + p.setBrush(st::roundedBg); + p.drawEllipse(full); + paintRipple(p, QPoint()); + if (_active) { + const auto ring = float64(st::photoEditorAudioDiscRing); + auto pen = QPen(st::photoEditorAudioDiscRingFg->c); + pen.setWidthF(ring); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawEllipse(full.marginsRemoved( + { ring / 2., ring / 2., ring / 2., ring / 2. })); + p.setPen(Qt::NoPen); + } + if (_cover.isNull()) { + const auto &icon = isOver() + ? st::photoEditorAudioDiscIconOver + : st::photoEditorAudioDiscIcon; + icon.paintInCenter(p, rect()); + return; + } + const auto cover = float64(st::photoEditorAudioDiscCoverSize); + p.save(); + p.translate(rect::center(full)); + p.rotate(angle()); + p.drawImage(QRectF(-cover / 2., -cover / 2., cover, cover), _cover); + p.restore(); +} + +void AudioDiscButton::contextMenuEvent(QContextMenuEvent *e) { + if (!_track) { + return; + } + _menu = CreateAudioMenu( + this, + _track, + [=] { _volumeChanges.fire({}); }, + [=] { _removeRequests.fire({}); }); + _menu->popup(e->globalPos()); + e->accept(); +} + +void AudioDiscButton::onStateChanged(State was, StateChangeSource source) { + RippleButton::onStateChanged(was, source); + update(); +} + +QImage AudioDiscButton::prepareRippleMask() const { + return Images::EllipseMask(size()); +} + +QPoint AudioDiscButton::prepareRippleStartPosition() const { + return mapFromGlobal(QCursor::pos()); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_audio_disc_button.h b/Telegram/SourceFiles/editor/editor_audio_disc_button.h new file mode 100644 index 0000000000..a72d4b46ea --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_audio_disc_button.h @@ -0,0 +1,57 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/unique_qptr.h" +#include "ui/effects/animations.h" +#include "ui/widgets/buttons.h" + +namespace Ui { +class PopupMenu; +} // namespace Ui + +namespace Editor { + +struct AudioTrack; + +class AudioDiscButton final : public Ui::RippleButton { +public: + explicit AudioDiscButton(QWidget *parent); + ~AudioDiscButton(); + + void setTrack(std::shared_ptr track); + void setActive(bool active); + + [[nodiscard]] rpl::producer<> volumeChanges() const; + [[nodiscard]] rpl::producer<> removeRequests() const; + +protected: + void paintEvent(QPaintEvent *e) override; + void contextMenuEvent(QContextMenuEvent *e) override; + void onStateChanged(State was, StateChangeSource source) override; + + QImage prepareRippleMask() const override; + QPoint prepareRippleStartPosition() const override; + +private: + [[nodiscard]] float64 angle() const; + + std::shared_ptr _track; + QImage _cover; + Ui::Animations::Basic _spin; + crl::time _spinStarted = 0; + float64 _spinBase = 0.; + bool _active = false; + + base::unique_qptr _menu; + rpl::event_stream<> _volumeChanges; + rpl::event_stream<> _removeRequests; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_audio_menu.cpp b/Telegram/SourceFiles/editor/editor_audio_menu.cpp new file mode 100644 index 0000000000..046e1d1cd9 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_audio_menu.cpp @@ -0,0 +1,228 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_audio_menu.h" + +#include "editor/photo_editor_common.h" +#include "lang/lang_keys.h" +#include "ui/effects/animation_value.h" +#include "ui/effects/cross_line.h" +#include "ui/paint/arcs.h" +#include "ui/widgets/continuous_sliders.h" +#include "ui/widgets/popup_menu.h" +#include "ui/color_int_conversion.h" +#include "ui/rect.h" +#include "styles/style_calls.h" +#include "styles/style_editor.h" + +namespace Editor { +namespace { + +constexpr auto kMuteDuration = crl::time(150); + +const auto kSpeakerThresholds = std::vector{ 0.05f, 0.25f, 0.75f }; + +constexpr auto kVolumeStickedValues + = std::array, 3>{{ + { 0.25, 0.02 }, + { 0.5, 0.02 }, + { 0.75, 0.02 }, + }}; + +} // namespace + +MenuVolumeItem::MenuVolumeItem( + not_null parent, + const style::Menu &st, + float64 volume) +: Ui::Menu::ItemBase(parent, st) +, _muted(volume <= 0.) +, _slider(base::make_unique_q( + this, + st::groupCallMenuVolumeSlider)) +, _dummyAction(new QAction(parent)) +, _st(st) +, _stCross(st::groupCallMuteCrossLine) +, _crossLineMute(std::make_unique(_stCross, true)) +, _arcs(std::make_unique( + st::groupCallSpeakerArcsAnimation, + kSpeakerThresholds, + volume, + Ui::Paint::ArcsAnimation::Direction::Right)) { + fitToMenuWidth(); + enableMouseSelecting(); + enableMouseSelecting(_slider.get()); + + _slider->setAlwaysDisplayMarker(true); + + sizeValue( + ) | rpl::on_next([=](const QSize &size) { + const auto &margin = st::photoEditorAudioVolumeMargin; + _itemRect = Rect(size) - st::photoEditorAudioVolumePadding; + _speakerRect = QRect(_itemRect.topLeft(), _stCross.icon.size()); + _arcPosition = rect::center(_speakerRect) + + QPoint(0, st::groupCallMenuSpeakerArcsSkip); + _slider->setGeometry( + margin.left(), + _speakerRect.y(), + size.width() - margin.left() - margin.right(), + _speakerRect.height()); + }, lifetime()); + + _slider->setValue(volume); + updateSliderColor(volume); + + paintRequest( + ) | rpl::on_next([=](const QRect &clip) { + auto p = QPainter(this); + const auto muteProgress = _crossLineAnimation.value(_muted ? 1. : 0.); + p.fillRect(clip, isSelected() ? _st.itemBgOver : _st.itemBg); + const auto mutePen = anim::color( + unmuteColor(), + muteColor(), + muteProgress); + _crossLineMute->paint( + p, + _speakerRect.topLeft(), + muteProgress, + (muteProgress > 0) ? std::make_optional(mutePen) : std::nullopt); + p.translate(_arcPosition); + _arcs->paint(p); + }, lifetime()); + + _slider->setChangeProgressCallback([=](float64 value) { + applyVolume(value); + }); + _slider->setAdjustCallback([=](float64 value) { + for (const auto &snap : kVolumeStickedValues) { + if (value > (snap.first - snap.second) + && value < (snap.first + snap.second)) { + return snap.first; + } + } + return value; + }); + + initArcsAnimation(); +} + +void MenuVolumeItem::initArcsAnimation() { + _arcsAnimation.init([=](crl::time now) { + _arcs->update(now); + update(_speakerRect); + }); + + _arcs->startUpdateRequests( + ) | rpl::on_next([=] { + if (!_arcsAnimation.animating()) { + _arcsAnimation.start(); + } + }, lifetime()); + + _arcs->stopUpdateRequests( + ) | rpl::on_next([=] { + _arcsAnimation.stop(); + }, lifetime()); +} + +void MenuVolumeItem::applyVolume(float64 value) { + const auto muted = (value <= 0.); + if (_muted != muted) { + _muted = muted; + _crossLineAnimation.start( + [=] { update(_speakerRect); }, + muted ? 0. : 1., + muted ? 1. : 0., + kMuteDuration); + } + _arcs->setValue(value); + updateSliderColor(value); + _changes.fire_copy(value); +} + +QColor MenuVolumeItem::unmuteColor() const { + return (isSelected() + ? _st.itemFgOver + : isEnabled() + ? _st.itemFg + : _st.itemFgDisabled)->c; +} + +QColor MenuVolumeItem::muteColor() const { + return (isSelected() + ? st::attentionButtonFgOver + : st::attentionButtonFg)->c; +} + +void MenuVolumeItem::updateSliderColor(float64 value) { + value = std::clamp(value, 0., 1.); + const auto colors = std::array{ { + Ui::ColorFromSerialized(0xF66464), + Ui::ColorFromSerialized(0xD0B738), + Ui::ColorFromSerialized(0x24CD80), + Ui::ColorFromSerialized(0x3BBCEC), + } }; + _slider->setColorOverrides({ + .activeFg = (value < 0.25) + ? anim::color(colors[0], colors[1], value / 0.25) + : (value < 0.5) + ? anim::color(colors[1], colors[2], (value - 0.25) / 0.25) + : anim::color(colors[2], colors[3], (value - 0.5) / 0.5), + }); +} + +not_null MenuVolumeItem::action() const { + return _dummyAction; +} + +bool MenuVolumeItem::isEnabled() const { + return true; +} + +int MenuVolumeItem::contentHeight() const { + return rect::m::sum::v(st::photoEditorAudioVolumePadding) + + _stCross.icon.height(); +} + +rpl::producer MenuVolumeItem::changes() const { + return _changes.events(); +} + +void AddVolumeAction( + not_null menu, + float64 volume, + Fn changed) { + auto item = base::make_unique_q( + menu->menu(), + st::photoEditorAudioVolumeItem, + volume); + item->changes( + ) | rpl::on_next(std::move(changed), item->lifetime()); + menu->addAction(std::move(item)); +} + +base::unique_qptr CreateAudioMenu( + not_null parent, + std::shared_ptr track, + Fn volumeChanged, + Fn remove) { + auto result = base::make_unique_q( + parent, + st::photoEditorMediaMenu); + AddVolumeAction(result.get(), track->volume, [=](float64 value) { + track->volume = value; + volumeChanged(); + }); + result->addSeparator(); + result->addAction( + tr::lng_photo_editor_audio_remove(tr::now), + std::move(remove), + &st::photoEditorMenuDelete); + return result; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_audio_menu.h b/Telegram/SourceFiles/editor/editor_audio_menu.h new file mode 100644 index 0000000000..1a643999bd --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_audio_menu.h @@ -0,0 +1,80 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/unique_qptr.h" +#include "ui/effects/animations.h" +#include "ui/widgets/menu/menu_item_base.h" + +namespace Ui { +class CrossLineAnimation; +class MediaSlider; +class PopupMenu; +namespace Paint { +class ArcsAnimation; +} // namespace Paint +} // namespace Ui + +namespace Editor { + +struct AudioTrack; + +class MenuVolumeItem final : public Ui::Menu::ItemBase { +public: + MenuVolumeItem( + not_null parent, + const style::Menu &st, + float64 volume); + + not_null action() const override; + bool isEnabled() const override; + + [[nodiscard]] rpl::producer changes() const; + +protected: + int contentHeight() const override; + +private: + void initArcsAnimation(); + void applyVolume(float64 value); + void updateSliderColor(float64 value); + + [[nodiscard]] QColor unmuteColor() const; + [[nodiscard]] QColor muteColor() const; + + QRect _itemRect; + QRect _speakerRect; + QPoint _arcPosition; + bool _muted = false; + + const base::unique_qptr _slider; + const not_null _dummyAction; + const style::Menu &_st; + const style::CrossLineAnimation &_stCross; + + const std::unique_ptr _crossLineMute; + Ui::Animations::Simple _crossLineAnimation; + const std::unique_ptr _arcs; + Ui::Animations::Basic _arcsAnimation; + + rpl::event_stream _changes; + +}; + +void AddVolumeAction( + not_null menu, + float64 volume, + Fn changed); + +[[nodiscard]] base::unique_qptr CreateAudioMenu( + not_null parent, + std::shared_ptr track, + Fn volumeChanged, + Fn remove); + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_audio_timeline.cpp b/Telegram/SourceFiles/editor/editor_audio_timeline.cpp new file mode 100644 index 0000000000..2539ca9f9c --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_audio_timeline.cpp @@ -0,0 +1,298 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_audio_timeline.h" + +#include "editor/editor_audio_menu.h" +#include "editor/photo_editor_common.h" +#include "editor/video/video_segment_player.h" +#include "editor/video/video_timeline_seeker.h" +#include "media/media_audio_waveform.h" +#include "ui/widgets/popup_menu.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "styles/style_editor.h" + +#include +#include +#include + +namespace Editor { +namespace { + +constexpr auto kWaveformBarDuration = crl::time(20); +constexpr auto kMaxWaveformBars = 16384; +constexpr auto kLoadedDuration = crl::time(600); + +[[nodiscard]] TrimTimelineDescriptor TrimDescriptor(const AudioTrack &track) { + return { + .duration = track.duration, + .from = track.from, + .till = track.till, + .trimOnly = true, + }; +} + +[[nodiscard]] QString TitleFor(const AudioTrack &track) { + return !track.title.isEmpty() + ? track.title + : !track.path.isEmpty() + ? QFileInfo(track.path).completeBaseName() + : QString(); +} + +} // namespace + +AudioTimeline::AudioTimeline( + not_null parent, + std::shared_ptr track) +: TrimTimeline(parent, TrimDescriptor(*track)) +, _track(std::move(track)) +, _title(TitleFor(*_track)) +, _performer(_track->performer) { + _track->from = from(); + _track->till = till(); + + trimChanges( + ) | rpl::on_next([=] { + _track->from = from(); + _track->till = till(); + }, lifetime()); + + loadWaveform(); +} + +AudioTimeline::~AudioTimeline() { + if (_waveformCancel) { + _waveformCancel->store(true); + } +} + +const std::shared_ptr &AudioTimeline::track() const { + return _track; +} + +void AudioTimeline::setPlaying(bool playing) { + if (playing == (_player != nullptr)) { + return; + } else if (!playing) { + _seeker = nullptr; + _player = nullptr; + return; + } + _player = std::make_unique( + _track->path, + _track->content, + SegmentPlayerOptions{ .audio = true, .volume = _track->volume }); + _seeker = std::make_unique(this, _player.get()); + _player->start(); +} + +void AudioTimeline::refreshTrim() { + setTrim(_track->from, _track->till); +} + +void AudioTimeline::refreshVolume() { + if (_player) { + _player->setVolume(_track->volume); + } +} + +rpl::producer<> AudioTimeline::removeRequests() const { + return _removeRequests.events(); +} + +void AudioTimeline::contextMenuEvent(QContextMenuEvent *e) { + _menu = CreateAudioMenu( + this, + _track, + [=] { refreshVolume(); }, + [=] { _removeRequests.fire({}); }); + _menu->popup(e->globalPos()); + e->accept(); +} + +void AudioTimeline::loadWaveform() { + const auto count = int(std::clamp( + duration() / kWaveformBarDuration, + crl::time(1), + crl::time(kMaxWaveformBars))); + const auto weak = base::make_weak(this); + _waveformCancel = std::make_shared>(false); + const auto cancel = _waveformCancel; + crl::async([=, path = _track->path, content = _track->content] { + auto result = std::make_shared( + Media::Audio::LoadWaveform(path, content, count, cancel)); + crl::on_main(weak, [=] { + if (result->empty()) { + return; + } + _waveform = result; + _waveformMax = 0; + for (const auto bar : result->bars) { + accumulate_max(_waveformMax, int(bar)); + } + _loadedAnimation.start( + [=] { update(); }, + 0., + 1., + kLoadedDuration); + update(); + }); + }); +} + +void AudioTimeline::paintStrip(QPainter &p, const QRect &strip) { + p.fillRect(strip, st::videoTimelinePlaceholderBg); + paintWaveform(p, strip); + paintText(p, strip); +} + +void AudioTimeline::paintWaveform(QPainter &p, const QRect &strip) { + if (!_waveform || _waveform->bars.empty() || !_waveformMax) { + return; + } + const auto loaded = _loadedAnimation.value(1.); + if (loaded <= 0.) { + return; + } + const auto &bars = _waveform->bars; + const auto count = int(bars.size()); + const auto slots = std::max( + strip.width() / st::photoEditorAudioTimelineBarSkip, + 1); + const auto spacing = strip.width() / float64(slots); + const auto from = float64(visibleFrom()); + const auto span = visibleSpan(); + const auto barDuration = float64(duration()) / count; + const auto barWidth = float64(st::photoEditorAudioTimelineBarWidth); + const auto minHeight = float64(st::photoEditorAudioTimelineBarMin); + const auto ratio = st::photoEditorAudioTimelineBarRatio; + auto color = st::videoTimelineFg->c; + color.setAlphaF(st::photoEditorAudioTimelineBarOpacity * loaded); + p.setPen(Qt::NoPen); + p.setBrush(color); + const auto bottom = float64(rect::bottom(strip)); + for (auto i = 0; i != slots; ++i) { + const auto slotFrom = (from + i * span / slots) / barDuration; + const auto slotTill = (from + (i + 1) * span / slots) / barDuration; + const auto first = std::clamp( + int(std::floor(slotFrom)), + 0, + count - 1); + const auto last = std::clamp( + int(std::ceil(slotTill)) - 1, + first, + count - 1); + auto peak = uint16(0); + for (auto j = first; j <= last; ++j) { + accumulate_max(peak, bars[j]); + } + const auto x = strip.x() + + i * spacing + + (spacing - barWidth) / 2.; + const auto height = std::max( + peak / float64(_waveformMax) * strip.height() * ratio, + minHeight); + p.drawRoundedRect( + QRectF(x, bottom - height, barWidth, height), + barWidth / 2., + barWidth / 2.); + } +} + +void AudioTimeline::paintText(QPainter &p, const QRect &strip) { + const auto left = std::max(xAt(from()), strip.x()); + const auto right = std::min(xAt(till()), rect::right(strip)); + const auto skip = st::photoEditorAudioTimelineTextSkip; + const auto available = right - left - 2 * skip; + const auto icon = st::photoEditorAudioTimelineIcon; + if (available <= icon) { + return; + } + const auto &authorFont = st::photoEditorAudioTimelineAuthorFont; + const auto &titleFont = st::photoEditorAudioTimelineFont; + const auto dotSkip = (_performer.isEmpty() || _title.isEmpty()) + ? 0 + : int(st::photoEditorAudioTimelineDotSkip); + const auto full = icon + + st::photoEditorAudioTimelineIconSkip + + (_performer.isEmpty() ? 0 : authorFont->width(_performer)) + + dotSkip + + (_title.isEmpty() ? 0 : titleFont->width(_title)); + const auto shown = std::min(full, available); + const auto x = (left + right) / 2 - shown / 2; + const auto centerY = rect::center(strip).y(); + if (full <= available) { + paintTextLine(p, x, centerY, shown); + return; + } + const auto ratio = style::DevicePixelRatio(); + auto image = QImage( + QSize(shown, strip.height()) * ratio, + QImage::Format_ARGB32_Premultiplied); + image.setDevicePixelRatio(ratio); + image.fill(Qt::transparent); + { + auto q = QPainter(&image); + auto hq = PainterHighQualityEnabler(q); + paintTextLine(q, 0, strip.height() / 2, shown); + const auto fade = float64(st::photoEditorAudioTimelineFade); + auto gradient = QLinearGradient(shown - fade, 0, shown, 0); + gradient.setColorAt(0., QColor(0, 0, 0, 255)); + gradient.setColorAt(1., QColor(0, 0, 0, 0)); + q.setCompositionMode(QPainter::CompositionMode_DestinationIn); + q.fillRect(QRectF(shown - fade, 0, fade, strip.height()), gradient); + } + p.drawImage(x, strip.y(), image); +} + +void AudioTimeline::paintTextLine( + QPainter &p, + int x, + int centerY, + int width) { + const auto icon = st::photoEditorAudioTimelineIcon; + const auto image = st::photoEditorAudioDiscIcon.instance( + st::videoTimelineFg->c); + p.setClipRect(x, centerY - height(), width, height() * 2); + p.setRenderHint(QPainter::SmoothPixmapTransform); + p.drawImage( + QRectF(x, centerY - icon / 2., icon, icon), + image); + x += icon + st::photoEditorAudioTimelineIconSkip; + p.setPen(st::videoTimelineFg); + if (!_performer.isEmpty()) { + const auto &font = st::photoEditorAudioTimelineAuthorFont; + p.setFont(font); + p.drawText( + QPointF(x, centerY - font->height / 2. + font->ascent), + _performer); + x += font->width(_performer); + } + if (!_performer.isEmpty() && !_title.isEmpty()) { + const auto dotSkip = st::photoEditorAudioTimelineDotSkip; + const auto radius = float64(st::photoEditorAudioTimelineDotRadius); + auto dot = st::videoTimelineFg->c; + dot.setAlphaF(dot.alphaF() / 2.); + p.setPen(Qt::NoPen); + p.setBrush(dot); + p.drawEllipse(QPointF(x + dotSkip / 2., centerY), radius, radius); + p.setPen(st::videoTimelineFg); + x += dotSkip; + } + if (!_title.isEmpty()) { + const auto &font = st::photoEditorAudioTimelineFont; + p.setFont(font); + p.drawText( + QPointF(x, centerY - font->height / 2. + font->ascent), + _title); + } + p.setClipping(false); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_audio_timeline.h b/Telegram/SourceFiles/editor/editor_audio_timeline.h new file mode 100644 index 0000000000..1503eab60b --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_audio_timeline.h @@ -0,0 +1,69 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/unique_qptr.h" +#include "editor/editor_trim_timeline.h" +#include "ui/effects/animations.h" + +namespace Media::Audio { +struct Waveform; +} // namespace Media::Audio + +namespace Ui { +class PopupMenu; +} // namespace Ui + +namespace Editor { + +class SegmentPlayer; +class TimelineSeeker; +struct AudioTrack; + +class AudioTimeline final : public TrimTimeline { +public: + AudioTimeline( + not_null parent, + std::shared_ptr track); + ~AudioTimeline(); + + [[nodiscard]] const std::shared_ptr &track() const; + + void setPlaying(bool playing); + void refreshTrim(); + void refreshVolume(); + + [[nodiscard]] rpl::producer<> removeRequests() const; + +private: + void paintStrip(QPainter &p, const QRect &strip) override; + void contextMenuEvent(QContextMenuEvent *e) override; + + void loadWaveform(); + void paintWaveform(QPainter &p, const QRect &strip); + void paintText(QPainter &p, const QRect &strip); + void paintTextLine(QPainter &p, int x, int centerY, int width); + + const std::shared_ptr _track; + const QString _title; + const QString _performer; + + std::unique_ptr _player; + std::unique_ptr _seeker; + + std::shared_ptr _waveform; + std::shared_ptr> _waveformCancel; + int _waveformMax = 0; + Ui::Animations::Simple _loadedAnimation; + + base::unique_qptr _menu; + rpl::event_stream<> _removeRequests; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_crop.cpp b/Telegram/SourceFiles/editor/editor_crop.cpp index 0a8556b6d5..67a23177e5 100644 --- a/Telegram/SourceFiles/editor/editor_crop.cpp +++ b/Telegram/SourceFiles/editor/editor_crop.cpp @@ -26,6 +26,7 @@ constexpr auto kEAll = Qt::TopEdge | Qt::LeftEdge | Qt::BottomEdge | Qt::RightEdge; +constexpr auto kMaxCanvasRatio = 2.; std::tuple RectEdges(const QRectF &r) { return { r.left(), r.top(), r.left() + r.width(), r.top() + r.height() }; @@ -92,7 +93,6 @@ Crop::Crop( , _pointSizeH(_pointSize / 2.) , _innerMargins(QMarginsF(_pointSizeH, _pointSizeH, _pointSizeH, _pointSizeH) .toMargins()) -, _offset(_innerMargins.left(), _innerMargins.top()) , _edgePointMargins(_pointSizeH, _pointSizeH, -_pointSizeH, -_pointSizeH) , _imageSize(imageSize) , _data(std::move(data)) @@ -125,6 +125,7 @@ Crop::Crop( void Crop::applyTransform( const QRect &geometry, + QPoint imagePosition, int angle, bool flipped, const QSizeF &scaledImageSize) { @@ -132,7 +133,9 @@ void Crop::applyTransform( return; } setGeometry(geometry); - _innerRect = QRectF(_offset, FlipSizeByRotation(scaledImageSize, angle)); + _innerRect = QRectF( + imagePosition, + FlipSizeByRotation(scaledImageSize, angle)); _ratio.w = scaledImageSize.width() / float64(_imageSize.width()); _ratio.h = scaledImageSize.height() / float64(_imageSize.height()); _flipped = flipped; @@ -153,25 +156,21 @@ void Crop::applyTransform( .scale(_ratio.w, _ratio.h) .mapRect(_cropOriginal) .translated( - -cropHolderRotated.x() + _offset.x(), - -cropHolderRotated.y() + _offset.y()); + imagePosition.x() - cropHolderRotated.x(), + imagePosition.y() - cropHolderRotated.y()); - auto adjusted = false; const auto min = float64(st::photoEditorCropMinSize); if ((cropPaint.width() < min) || (cropPaint.height() < min)) { + const auto bounds = _innerRect.united(cropPaint); cropPaint.setWidth(std::max( - std::min(min, _innerRect.width()), + std::min(min, bounds.width()), cropPaint.width())); cropPaint.setHeight(std::max( - std::min(min, _innerRect.height()), + std::min(min, bounds.height()), cropPaint.height())); - adjusted = true; + AdjustCropToInner(cropPaint, bounds); } - adjusted = AdjustCropToInner(cropPaint, _innerRect) || adjusted; setCropPaint(std::move(cropPaint)); - if (adjusted) { - convertCropPaintToOriginal(); - } } QPainterPath Crop::cropPath() const { @@ -282,14 +281,16 @@ void Crop::paintGrid(QPainter &p, float64 opacity) { } void Crop::setCropPaint(QRectF &&rect) { - AdjustCropToInner(rect, _innerRect); _cropPaint = std::move(rect); updateEdges(); + updatePainterPath(); +} - _painterPath.clear(); - _painterPath.addRect(_innerRect); - _painterPath.addPath(cropPath()); +void Crop::updatePainterPath() { + auto inner = QPainterPath(); + inner.addRect(_innerRect); + _painterPath = inner.subtracted(cropPath()); } void Crop::convertCropPaintToOriginal() { @@ -307,7 +308,7 @@ void Crop::convertCropPaintToOriginal() { const auto cropHolderRotated = matrix.mapRect(cropHolder); _cropOriginal = matrix - .mapRect(QRectF(_cropPaint).translated(-_offset)) + .mapRect(QRectF(_cropPaint).translated(-_innerRect.topLeft())) .translated( -cropHolderRotated.x(), -cropHolderRotated.y()); @@ -369,7 +370,13 @@ void Crop::mousePressEvent(QMouseEvent *e) { if (_data.fixedCrop && e->button() != Qt::LeftButton) { return; } - computeDownState(e->pos()); + const auto edge = mouseState(e->pos()); + if (edge) { + _dragChanges.fire(true); + } + const auto expanding = _expansionAllowed + && e->modifiers().testFlag(Qt::ControlModifier); + computeDownState(e->pos(), edge, expanding); if (_down.edge) { setGridVisible(true, false); } @@ -379,29 +386,43 @@ void Crop::mouseReleaseEvent(QMouseEvent *e) { if (_data.fixedCrop && e->button() != Qt::LeftButton) { return; } - const auto hadEdge = bool(_down.edge); - if (hadEdge) { - setGridVisible(false, true); + finishDrag(true); +} + +void Crop::hideEvent(QHideEvent *e) { + finishDrag(false); +} + +void Crop::finishDrag(bool animated) { + if (!_down.edge) { + return; } + setGridVisible(false, animated); clearDownState(); const auto was = saveCropRect(); convertCropPaintToOriginal(); + _dragChanges.fire(false); if (saveCropRect() != was) { _changes.fire({}); } } -void Crop::computeDownState(const QPoint &p) { - const auto edge = mouseState(p); - const auto &inner = _innerRect; +void Crop::computeDownState( + const QPoint &p, + Qt::Edges edge, + bool expanding) { const auto &crop = _cropPaint; - const auto &[iLeft, iTop, iRight, iBottom] = RectEdges(inner); + const auto bounds = (expanding && (edge != kEAll)) + ? expansionBounds() + : _innerRect.united(crop); + const auto &[iLeft, iTop, iRight, iBottom] = RectEdges(bounds); const auto &[cLeft, cTop, cRight, cBottom] = RectEdges(crop); _down = InfoAtDown{ .rect = crop, .edge = edge, .point = (p - PointOfEdge(edge, crop)), .cropRatio = (_cropOriginal.width() / _cropOriginal.height()), + .bounds = bounds, .borders = InfoAtDown::Borders{ .left = iLeft - cLeft, .right = iRight - cRight, @@ -419,15 +440,11 @@ void Crop::computeDownState(const QPoint &p) { auto &xSide = (hasLeft ? _down.borders.left : _down.borders.right); auto &ySide = (hasTop ? _down.borders.top : _down.borders.bottom); - const auto min = std::abs(std::min(xSign * xSide, ySign * ySide)); - const auto xIsMin = ((xSign * xSide) < (ySign * ySide)); - xSide = xSign * min; - ySide = ySign * min; - if (!xIsMin) { - xSide *= _down.cropRatio; - } else { - ySide /= _down.cropRatio; - } + const auto xRoom = std::min( + float64(xSign * xSide), + ySign * ySide * _down.cropRatio); + xSide = int(xSign * xRoom); + ySide = int(ySign * xRoom / _down.cropRatio); } } @@ -435,6 +452,20 @@ void Crop::clearDownState() { _down = InfoAtDown(); } +QRectF Crop::expansionBounds() const { + const auto united = _innerRect.united(_cropPaint); + const auto maxWidth = _innerRect.width() * kMaxCanvasRatio; + const auto maxHeight = _innerRect.height() * kMaxCanvasRatio; + const auto limit = QRectF( + QPointF( + std::min(united.right() - maxWidth, united.left()), + std::min(united.bottom() - maxHeight, united.top())), + QPointF( + std::max(united.left() + maxWidth, united.right()), + std::max(united.top() + maxHeight, united.bottom()))); + return (QRectF(rect()) - QMarginsF(_innerMargins)).intersected(limit); +} + void Crop::setGridVisible(bool visible, bool animated) { if ((_gridVisible == visible) && !_gridOpacityAnimation.animating()) { return; @@ -483,10 +514,18 @@ void Crop::performCrop(const QPoint &pos) { const auto minH = (_keepAspectRatio && cropRatio < 1.) ? (minSize / cropRatio) : float64(minSize); - const auto xMin = xFactor * int( - crop.width() - std::min(minW, crop.width())); - const auto yMin = yFactor * int( - crop.height() - std::min(minH, crop.height())); + const auto xInward = std::min( + crop.width() - minW, + hasLeft + ? (_innerRect.right() - minW - crop.left()) + : (crop.right() - minW - _innerRect.left())); + const auto yInward = std::min( + crop.height() - minH, + hasTop + ? (_innerRect.bottom() - minH - crop.top()) + : (crop.bottom() - minH - _innerRect.top())); + const auto xMin = xFactor * int(std::max(xInward, 0.)); + const auto yMin = yFactor * int(std::max(yInward, 0.)); const auto x = std::clamp( diff.x(), @@ -498,11 +537,17 @@ void Crop::performCrop(const QPoint &pos) { hasTop ? yMin : borders.bottom); return QPoint(x, y); }(); - setCropPaint(crop - QMargins( + auto result = crop - QMargins( hasLeft ? diff.x() : 0, hasTop ? diff.y() : 0, hasRight ? -diff.x() : 0, - hasBottom ? -diff.y() : 0)); + hasBottom ? -diff.y() : 0); + const auto &bounds = _down.bounds; + result.setLeft(std::max(result.left(), bounds.left())); + result.setTop(std::max(result.top(), bounds.top())); + result.setRight(std::min(result.right(), bounds.right())); + result.setBottom(std::min(result.bottom(), bounds.bottom())); + setCropPaint(std::move(result)); } void Crop::performMove(const QPoint &pos) { @@ -510,7 +555,9 @@ void Crop::performMove(const QPoint &pos) { const auto &b = _down.borders; const auto diffX = std::clamp(pos.x() - _down.point.x(), b.left, b.right); const auto diffY = std::clamp(pos.y() - _down.point.y(), b.top, b.bottom); - setCropPaint(inner.translated(diffX, diffY)); + auto result = inner.translated(diffX, diffY); + AdjustCropToInner(result, _down.bounds); + setCropPaint(std::move(result)); } void Crop::mouseMoveEvent(QMouseEvent *e) { @@ -555,8 +602,9 @@ void Crop::setAspectRatio(float64 ratio) { _keepAspectRatio = !free; if (!free) { - const auto maxW = _innerRect.width(); - const auto maxH = _innerRect.height(); + const auto bounds = _innerRect.united(_cropPaint); + const auto maxW = bounds.width(); + const auto maxH = bounds.height(); auto newW = maxW; auto newH = maxW / ratio; if (newH > maxH) { @@ -571,23 +619,29 @@ void Crop::setAspectRatio(float64 ratio) { newW, newH); - AdjustCropToInner(adjusted, _innerRect); + AdjustCropToInner(adjusted, bounds); setCropPaint(std::move(adjusted)); + const auto was = saveCropRect(); convertCropPaintToOriginal(); + if (saveCropRect() != was) { + _changes.fire({}); + } } else { updateEdges(); } update(); } +void Crop::setExpansionAllowed(bool allowed) { + _expansionAllowed = allowed; +} + void Crop::setCornersLevel(RoundedCornersLevel level) { if (_cornersLevel == level) { return; } _cornersLevel = level; - _painterPath.clear(); - _painterPath.addRect(_innerRect); - _painterPath.addPath(cropPath()); + updatePainterPath(); update(); } @@ -595,6 +649,10 @@ QRect Crop::paintRect() const { return _cropPaint.toRect(); } +QRect Crop::cropRect() const { + return _cropOriginal.toRect(); +} + QRect Crop::saveCropRect() { const auto savedCrop = _cropOriginal.toRect(); return (!savedCrop.topLeft().isNull() || (savedCrop.size() != _imageSize)) diff --git a/Telegram/SourceFiles/editor/editor_crop.h b/Telegram/SourceFiles/editor/editor_crop.h index b502d516e1..1125c86bda 100644 --- a/Telegram/SourceFiles/editor/editor_crop.h +++ b/Telegram/SourceFiles/editor/editor_crop.h @@ -26,22 +26,30 @@ public: void applyTransform( const QRect &geometry, + QPoint imagePosition, int angle, bool flipped, const QSizeF &scaledImageSize); [[nodiscard]] QRect saveCropRect(); + [[nodiscard]] QRect cropRect() const; [[nodiscard]] rpl::producer<> changes() const { return _changes.events(); } + [[nodiscard]] rpl::producer dragChanges() const { + return _dragChanges.events(); + } [[nodiscard]] QRect paintRect() const; + [[nodiscard]] QPainterPath cropPath() const; [[nodiscard]] style::margins cropMargins() const; void setAspectRatio(float64 ratio); void setCornersLevel(RoundedCornersLevel level); + void setExpansionAllowed(bool allowed); protected: void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; + void hideEvent(QHideEvent *e) override; private: struct InfoAtDown { @@ -49,6 +57,7 @@ private: Qt::Edges edge; QPoint point; float64 cropRatio = 0.; + QRectF bounds; struct Borders { int left = 0; @@ -61,25 +70,27 @@ private: void paintFrame(QPainter &p); void paintGrid(QPainter &p, float64 opacity); void setGridVisible(bool visible, bool animated); - [[nodiscard]] QPainterPath cropPath() const; void updateEdges(); + void updatePainterPath(); [[nodiscard]] QPoint pointOfEdge(Qt::Edges e) const; + [[nodiscard]] QRectF expansionBounds() const; void setCropPaint(QRectF &&rect); void convertCropPaintToOriginal(); - void computeDownState(const QPoint &p); + void computeDownState(const QPoint &p, Qt::Edges edge, bool expanding); void clearDownState(); [[nodiscard]] Qt::Edges mouseState(const QPoint &p); void performCrop(const QPoint &pos); void performMove(const QPoint &pos); + void finishDrag(bool animated); rpl::event_stream<> _changes; + rpl::event_stream _dragChanges; const int _pointSize; const float _pointSizeH; const style::margins _innerMargins; - const QPoint _offset; const QMarginsF _edgePointMargins; const QSize _imageSize; const EditorData _data; @@ -105,6 +116,7 @@ private: bool _gridVisible = false; bool _keepAspectRatio = false; + bool _expansionAllowed = false; RoundedCornersLevel _cornersLevel = RoundedCornersLevel::Large; diff --git a/Telegram/SourceFiles/editor/editor_keys_legend.cpp b/Telegram/SourceFiles/editor/editor_keys_legend.cpp new file mode 100644 index 0000000000..d77ce36b86 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_keys_legend.cpp @@ -0,0 +1,513 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_keys_legend.h" + +#include "base/platform/base_platform_info.h" +#include "lang/lang_keys.h" +#include "ui/effects/panel_animation.h" +#include "ui/painter.h" +#include "ui/qt_object_factory.h" +#include "ui/rect.h" +#include "ui/text/text.h" +#include "ui/ui_utility.h" +#include "ui/widgets/menu/menu.h" +#include "ui/widgets/menu/menu_item_base.h" +#include "ui/widgets/popup_menu.h" +#include "styles/style_editor.h" + +#include +#include + +namespace Editor { +namespace { + +struct Row { + std::vector keys; + QString text; +}; + +struct Section { + QString title; + std::vector rows; +}; + +[[nodiscard]] QString CtrlKey() { + return Platform::IsMac() ? QString(QChar(0x2318)) : u"Ctrl"_q; +} + +[[nodiscard]] QString ShiftKey() { + return Platform::IsMac() ? QString(QChar(0x21E7)) : u"Shift"_q; +} + +[[nodiscard]] QString DeleteKey() { + return Platform::IsMac() ? QString(QChar(0x232B)) : u"Del"_q; +} + +[[nodiscard]] QString Gesture( + const QString &modifier, + const QString &gesture) { + return tr::lng_photo_editor_keys_gesture( + tr::now, + lt_modifier, + modifier, + lt_gesture, + gesture); +} + +[[nodiscard]] QString DragEdge() { + return tr::lng_photo_editor_keys_gesture_drag_edge(tr::now); +} + +[[nodiscard]] QString DragHandle() { + return tr::lng_photo_editor_keys_gesture_drag_handle(tr::now); +} + +[[nodiscard]] QString Drag() { + return tr::lng_photo_editor_keys_gesture_drag(tr::now); +} + +[[nodiscard]] QString Click() { + return tr::lng_photo_editor_keys_gesture_click(tr::now); +} + +[[nodiscard]] QString Wheel() { + return tr::lng_photo_editor_keys_gesture_wheel(tr::now); +} + +[[nodiscard]] QString WheelKey() { + return tr::lng_photo_editor_keys_wheel(tr::now); +} + +[[nodiscard]] QString MiddleDragKey() { + return tr::lng_photo_editor_keys_middle_drag(tr::now); +} + +[[nodiscard]] QString DoubleClickKey() { + return tr::lng_photo_editor_keys_double_click(tr::now); +} + +[[nodiscard]] std::vector FixedCropRows() { + return { + { { WheelKey() }, tr::lng_photo_editor_keys_fixed_zoom(tr::now) }, + { + { Gesture(ShiftKey(), Wheel()) }, + tr::lng_photo_editor_keys_fixed_zoom_fine(tr::now), + }, + { + { MiddleDragKey() }, + tr::lng_photo_editor_keys_fixed_pan(tr::now), + }, + }; +} + +[[nodiscard]] Section CropSection(const KeysLegendContext &context) { + auto result = Section{ .title = tr::lng_photo_editor_keys_crop(tr::now) }; + if (context.fixedCrop) { + result.rows = FixedCropRows(); + } else { + result.rows = { + { { CtrlKey() }, tr::lng_photo_editor_keys_crop_room(tr::now) }, + { + { Gesture(CtrlKey(), DragEdge()) }, + tr::lng_photo_editor_keys_crop_expand(tr::now), + }, + }; + } + return result; +} + +[[nodiscard]] Section CanvasSection(const KeysLegendContext &context) { + auto result = Section{ + .title = tr::lng_photo_editor_keys_canvas(tr::now), + }; + if (context.fixedCrop) { + result.rows = FixedCropRows(); + } else { + result.rows = { + { + { WheelKey() }, + tr::lng_photo_editor_keys_canvas_zoom(tr::now), + }, + { + { MiddleDragKey() }, + tr::lng_photo_editor_keys_canvas_pan(tr::now), + }, + }; + } + result.rows.push_back({ + { CtrlKey(), u"V"_q }, + tr::lng_photo_editor_keys_canvas_paste(tr::now), + }); + result.rows.push_back({ + { CtrlKey(), u"Z"_q }, + tr::lng_photo_editor_keys_canvas_undo(tr::now), + }); + result.rows.push_back({ + { CtrlKey(), ShiftKey(), u"Z"_q }, + tr::lng_photo_editor_keys_canvas_redo(tr::now), + }); + return result; +} + +[[nodiscard]] Section ShapesSection() { + return { + .title = tr::lng_photo_editor_keys_shapes(tr::now), + .rows = { + { + { Gesture(ShiftKey(), Drag()) }, + tr::lng_photo_editor_keys_shapes_proportions(tr::now), + }, + { + { Gesture(ShiftKey(), Click()) }, + tr::lng_photo_editor_keys_shapes_instant(tr::now), + }, + }, + }; +} + +[[nodiscard]] Section ObjectsSection() { + return { + .title = tr::lng_photo_editor_keys_objects(tr::now), + .rows = { + { + { Gesture(ShiftKey(), Drag()) }, + tr::lng_photo_editor_keys_objects_snap(tr::now), + }, + { + { Gesture(ShiftKey(), DragHandle()) }, + tr::lng_photo_editor_keys_objects_rotate(tr::now), + }, + { + { Gesture(CtrlKey(), DragHandle()) }, + tr::lng_photo_editor_keys_objects_stretch(tr::now), + }, + { + { CtrlKey(), u"D"_q }, + tr::lng_photo_editor_keys_objects_duplicate(tr::now), + }, + { + { CtrlKey(), u"S"_q }, + tr::lng_photo_editor_keys_objects_flip(tr::now), + }, + { + { DeleteKey() }, + tr::lng_photo_editor_keys_objects_delete(tr::now), + }, + { + { DoubleClickKey() }, + tr::lng_photo_editor_keys_objects_text(tr::now), + }, + }, + }; +} + +[[nodiscard]] Section TimelineSection() { + return { + .title = tr::lng_photo_editor_keys_timeline(tr::now), + .rows = { + { + { WheelKey() }, + tr::lng_photo_editor_keys_timeline_zoom(tr::now), + }, + { + { Gesture(ShiftKey(), Wheel()) }, + tr::lng_photo_editor_keys_timeline_scroll(tr::now), + }, + { + { Gesture(ShiftKey(), Drag()) }, + tr::lng_photo_editor_keys_timeline_window(tr::now), + }, + }, + }; +} + +[[nodiscard]] std::vector
Sections( + const KeysLegendContext &context) { + auto result = (context.mode == PhotoEditorMode::Mode::Transform) + ? std::vector
{ CropSection(context) } + : std::vector
{ + CanvasSection(context), + ShapesSection(), + ObjectsSection(), + }; + if (context.timeline + && (context.mode == PhotoEditorMode::Mode::Paint)) { + result.push_back(TimelineSection()); + } + return result; +} + +[[nodiscard]] Section LegendSection() { + return { + .rows = { + { { u"?"_q }, tr::lng_photo_editor_keys_legend(tr::now) }, + }, + }; +} + +struct Cap { + QString text; + int width = 0; +}; + +[[nodiscard]] Cap MakeCap(const QString &text) { + const auto &st = st::photoEditorKeysCapStyle; + return { + .text = text, + .width = std::max( + st.font->width(text) + 2 * st::photoEditorKeysCapPadding, + st::photoEditorKeysCapMinWidth), + }; +} + +[[nodiscard]] int CapsWidth(const std::vector &caps) { + auto result = 0; + for (const auto &cap : caps) { + result += cap.width; + } + return result + (int(caps.size()) - 1) * st::photoEditorKeysCapSkip; +} + +class SectionItem final : public Ui::Menu::ItemBase { +public: + SectionItem( + not_null parent, + const style::Menu &st, + const Section §ion, + int keysWidth); + + not_null action() const override; + bool isEnabled() const override; + +private: + struct PreparedRow { + std::vector caps; + Ui::Text::String text; + }; + + int contentHeight() const override; + void paintEvent(QPaintEvent *e) override; + void paintCap(QPainter &p, const Cap &cap, int x, int y) const; + + const style::Menu &_st; + const int _keysWidth; + const Ui::Text::String _title; + std::vector _rows; + int _textWidth = 0; + const not_null _dummyAction; + +}; + +SectionItem::SectionItem( + not_null parent, + const style::Menu &st, + const Section §ion, + int keysWidth) +: ItemBase(parent, st) +, _st(st) +, _keysWidth(keysWidth) +, _title(st::photoEditorKeysTitleStyle, section.title) +, _dummyAction(Ui::CreateChild(parent.get())) { + setPointerCursor(false); + auto textWidth = 0; + for (const auto &row : section.rows) { + auto caps = std::vector(); + for (const auto &key : row.keys) { + caps.push_back(MakeCap(key)); + } + auto text = Ui::Text::String( + _st.itemStyle, + row.text, + kDefaultTextOptions, + st::photoEditorKeysTextMinWidth); + textWidth = std::max(textWidth, text.maxWidth()); + _rows.push_back({ std::move(caps), std::move(text) }); + } + const auto available = _st.widthMax + - rect::m::sum::h(_st.itemPadding) + - _keysWidth + - st::photoEditorKeysTextSkip; + _textWidth = std::max( + std::min({ textWidth, st::photoEditorKeysTextWidth, available }), + st::photoEditorKeysTextMinWidth); + setMinWidth(rect::m::sum::h(_st.itemPadding) + + _keysWidth + + st::photoEditorKeysTextSkip + + _textWidth); + parent->widthValue() | rpl::on_next([=](int width) { + resize(width, contentHeight()); + }, lifetime()); +} + +not_null SectionItem::action() const { + return _dummyAction; +} + +bool SectionItem::isEnabled() const { + return false; +} + +int SectionItem::contentHeight() const { + auto result = rect::m::sum::v(_st.itemPadding); + if (!_title.isEmpty()) { + result += _title.minHeight() + st::photoEditorKeysTitleSkip; + } + for (const auto &row : _rows) { + result += std::max( + st::photoEditorKeysCapHeight, + row.text.countHeight(_textWidth)); + } + return result + (int(_rows.size()) - 1) * st::photoEditorKeysRowSkip; +} + +void SectionItem::paintCap( + QPainter &p, + const Cap &cap, + int x, + int y) const { + const auto rect = QRect(x, y, cap.width, st::photoEditorKeysCapHeight); + const auto radius = st::photoEditorKeysCapRadius; + auto hq = PainterHighQualityEnabler(p); + p.setPen(QPen(st::photoEditorKeysCapBorder, st::lineWidth)); + p.setBrush(st::photoEditorKeysCapBg); + p.drawRoundedRect( + QRectF(rect).adjusted(.5, .5, -.5, -.5), + radius, + radius); + p.setPen(st::photoEditorKeysCapFg); + p.setFont(st::photoEditorKeysCapStyle.font); + p.drawText(rect, cap.text, style::al_center); +} + +void SectionItem::paintEvent(QPaintEvent *e) { + auto p = Painter(this); + p.fillRect(rect(), _st.itemBg); + + const auto &padding = _st.itemPadding; + const auto textLeft = padding.left() + + _keysWidth + + st::photoEditorKeysTextSkip; + auto top = padding.top(); + if (!_title.isEmpty()) { + p.setPen(st::photoEditorKeysTitleFg); + _title.draw( + p, + padding.left(), + top, + width() - rect::m::sum::h(padding)); + top += _title.minHeight() + st::photoEditorKeysTitleSkip; + } + + for (const auto &row : _rows) { + const auto textHeight = row.text.countHeight(_textWidth); + const auto rowHeight = std::max( + st::photoEditorKeysCapHeight, + textHeight); + auto capLeft = padding.left(); + const auto capTop = top + + (rowHeight - st::photoEditorKeysCapHeight) / 2; + for (const auto &cap : row.caps) { + paintCap(p, cap, capLeft, capTop); + capLeft += cap.width + st::photoEditorKeysCapSkip; + } + p.setPen(st::photoEditorKeysTextFg); + row.text.draw( + p, + textLeft, + top + (rowHeight - textHeight) / 2, + _textWidth); + top += rowHeight + st::photoEditorKeysRowSkip; + } +} + +void FillKeysLegend( + not_null menu, + const KeysLegendContext &context) { + auto sections = Sections(context); + sections.push_back(LegendSection()); + auto keysWidth = 0; + for (const auto §ion : sections) { + for (const auto &row : section.rows) { + auto caps = std::vector(); + for (const auto &key : row.keys) { + caps.push_back(MakeCap(key)); + } + keysWidth = std::max(keysWidth, CapsWidth(caps)); + } + } + for (const auto §ion : sections) { + if (section.title.isEmpty()) { + menu->addSeparator(); + } + menu->addAction(base::make_unique_q( + menu->menu(), + menu->st().menu, + section, + keysWidth)); + } +} + +} // namespace + +KeysLegendButton::KeysLegendButton( + not_null parent, + Fn context) +: IconButton(parent, st::photoEditorKeysButton) +, _context(std::move(context)) { + events( + ) | rpl::on_next([=](not_null event) { + if (event->type() == QEvent::Enter) { + Ui::Tooltip::Show(1000, this); + } else if (event->type() == QEvent::Leave) { + Ui::Tooltip::Hide(); + } + }, lifetime()); + setClickedCallback([=] { toggle(); }); +} + +void KeysLegendButton::toggle() { + if (_menu) { + _menu->hideMenu(); + } else { + showLegend(); + } +} + +QString KeysLegendButton::tooltipText() const { + return tr::lng_photo_editor_keys_tooltip(tr::now); +} + +QPoint KeysLegendButton::tooltipPos() const { + return QCursor::pos(); +} + +bool KeysLegendButton::tooltipWindowActive() const { + return Ui::AppInFocus() && Ui::InFocusChain(window()); +} + +void KeysLegendButton::showLegend() { + Ui::Tooltip::Hide(); + _menu = base::make_unique_q( + this, + st::photoEditorKeysMenu); + FillKeysLegend(_menu.get(), _context()); + const auto skip = st::photoEditorKeysMenuSkip; + const auto above = mapToGlobal(QPoint(0, -skip)); + const auto screen = QGuiApplication::screenAt(above); + const auto fitsAbove = !screen + || (above.y() - _menu->height() >= screen->availableGeometry().y()); + if (fitsAbove) { + _menu->setForcedOrigin(Ui::PanelAnimation::Origin::BottomLeft); + _menu->popup(above); + } else { + _menu->setForcedOrigin(Ui::PanelAnimation::Origin::TopLeft); + _menu->popup(mapToGlobal(QPoint(0, height()))); + } +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_keys_legend.h b/Telegram/SourceFiles/editor/editor_keys_legend.h new file mode 100644 index 0000000000..ff8261cf01 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_keys_legend.h @@ -0,0 +1,49 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/unique_qptr.h" +#include "editor/photo_editor_inner_common.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/tooltip.h" + +namespace Ui { +class PopupMenu; +} // namespace Ui + +namespace Editor { + +struct KeysLegendContext { + PhotoEditorMode::Mode mode = PhotoEditorMode::Mode::Transform; + bool fixedCrop = false; + bool timeline = false; +}; + +class KeysLegendButton final + : public Ui::IconButton + , public Ui::AbstractTooltipShower { +public: + KeysLegendButton( + not_null parent, + Fn context); + + void toggle(); + + QString tooltipText() const override; + QPoint tooltipPos() const override; + bool tooltipWindowActive() const override; + +private: + void showLegend(); + + const Fn _context; + base::unique_qptr _menu; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_layer_widget.cpp b/Telegram/SourceFiles/editor/editor_layer_widget.cpp index 6c6d43f1c8..9d96ea89fc 100644 --- a/Telegram/SourceFiles/editor/editor_layer_widget.cpp +++ b/Telegram/SourceFiles/editor/editor_layer_widget.cpp @@ -208,7 +208,25 @@ void LayerWidget::parentResized() { } void LayerWidget::keyPressEvent(QKeyEvent *e) { + forwardKeyEvent(e); +} + +void LayerWidget::keyReleaseEvent(QKeyEvent *e) { + forwardKeyEvent(e); +} + +void LayerWidget::forwardKeyEvent(not_null e) { + // Qt bubbles a key event ignored by the content back to its parent. + if (_forwardingKeyEvent) { + _keyEventIgnored = true; + e->accept(); + return; + } + _forwardingKeyEvent = true; + _keyEventIgnored = false; QGuiApplication::sendEvent(_content.get(), e); + _forwardingKeyEvent = false; + e->setAccepted(!_keyEventIgnored); } int LayerWidget::resizeGetHeight(int newWidth) { diff --git a/Telegram/SourceFiles/editor/editor_layer_widget.h b/Telegram/SourceFiles/editor/editor_layer_widget.h index ba8cb72bf2..4411d03659 100644 --- a/Telegram/SourceFiles/editor/editor_layer_widget.h +++ b/Telegram/SourceFiles/editor/editor_layer_widget.h @@ -34,7 +34,9 @@ public: private: bool eventHook(QEvent *e) override; void keyPressEvent(QKeyEvent *e) override; + void keyReleaseEvent(QKeyEvent *e) override; int resizeGetHeight(int newWidth) override; + void forwardKeyEvent(not_null e); void start(); void cacheBackground(); @@ -53,6 +55,8 @@ private: crl::time _lastAreaChangeTime = 0; bool _backgroundCaching = false; bool _backgroundNight = false; + bool _forwardingKeyEvent = false; + bool _keyEventIgnored = false; }; diff --git a/Telegram/SourceFiles/editor/editor_link_box.cpp b/Telegram/SourceFiles/editor/editor_link_box.cpp new file mode 100644 index 0000000000..5bfb20a616 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_link_box.cpp @@ -0,0 +1,1005 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_link_box.h" + +#include "api/api_single_message_search.h" +#include "base/qthelp_url.h" +#include "base/timer.h" +#include "chat_helpers/compose/compose_show.h" +#include "data/data_document.h" +#include "data/data_session.h" +#include "data/data_web_page.h" +#include "editor/editor_link_pill.h" +#include "editor/editor_message_render.h" +#include "history/history_item.h" +#include "history/view/controls/history_view_webpage_processor.h" +#include "lang/lang_keys.h" +#include "lottie/lottie_icon.h" +#include "main/main_session.h" +#include "ui/abstract_button.h" +#include "ui/chat/chat_theme.h" +#include "ui/effects/animations.h" +#include "ui/effects/glare.h" +#include "ui/effects/loading_element.h" +#include "ui/layers/generic_box.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "ui/rp_widget.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/fields/input_field.h" +#include "ui/wrap/slide_wrap.h" +#include "ui/wrap/vertical_layout.h" +#include "window/section_widget.h" +#include "window/themes/window_theme.h" +#include "window/themes/window_themes_embedded.h" +#include "styles/style_calls.h" +#include "styles/style_editor.h" +#include "styles/style_layers.h" + +namespace Editor { +namespace { + +constexpr auto kResolveDelay = crl::time(700); +constexpr auto kLoadingTitleRatio = 0.45; +constexpr auto kLoadingLineRatios = std::array{ 0.32, 0.58, 0.86 }; +constexpr auto kGlareTimeout = crl::time(1000); +constexpr auto kGlareDuration = crl::time(1000); + +[[nodiscard]] const style::palette &DefaultPalette(bool dark) { + static auto cache = std::array, 2>(); + auto &result = cache[dark ? 1 : 0]; + if (!result) { + result = std::make_unique(); + Window::Theme::PreparePaletteCallback(dark, std::nullopt)(*result); + } + return *result; +} + +class LoadingMessage final : public Ui::LoadingElement { +public: + LoadingMessage(const style::palette &palette, bool captionAbove); + + [[nodiscard]] bool captionAbove() const; + + [[nodiscard]] int height() const override; + void paint(QPainter &p, int width) override; + +private: + void paintCaption(QPainter &p, int inner); + void paintPreview(QPainter &p, int inner); + + const style::palette &_palette; + Ui::LoadingLine _line; + bool _captionAbove = true; + +}; + +LoadingMessage::LoadingMessage( + const style::palette &palette, + bool captionAbove) +: _palette(palette) +, _line( + st::photoEditorLinkLoadingLine, + st::photoEditorLinkLoadingSkip, + palette.windowBgRipple()->c) +, _captionAbove(captionAbove) { +} + +bool LoadingMessage::captionAbove() const { + return _captionAbove; +} + +int LoadingMessage::height() const { + const auto padding = st::photoEditorLinkLoadingPadding; + const auto lines = 1 + int(kLoadingLineRatios.size()); + return padding.top() + + lines * _line.height() + - st::photoEditorLinkLoadingSkip + + padding.bottom(); +} + +void LoadingMessage::paint(QPainter &p, int width) { + const auto radius = st::photoEditorLinkLoadingRadius; + p.setPen(Qt::NoPen); + p.setBrush(_palette.msgInBg()->c); + p.drawRoundedRect(QRect(0, 0, width, height()), radius, radius); + + const auto padding = st::photoEditorLinkLoadingPadding; + const auto inner = width - padding.left() - padding.right(); + p.translate(padding.left(), padding.top()); + if (_captionAbove) { + paintCaption(p, inner); + paintPreview(p, inner); + } else { + paintPreview(p, inner); + paintCaption(p, inner); + } +} + +void LoadingMessage::paintCaption(QPainter &p, int inner) { + _line.paint(p, int(inner * kLoadingTitleRatio)); + p.translate(0, _line.height()); +} + +void LoadingMessage::paintPreview(QPainter &p, int inner) { + const auto quote = st::photoEditorLinkLoadingQuote; + const auto quoteSkip = quote + st::photoEditorLinkLoadingQuoteSkip; + const auto line = _line.height(); + const auto lines = int(kLoadingLineRatios.size()); + p.setPen(Qt::NoPen); + p.setBrush(_palette.windowBgRipple()->c); + p.drawRoundedRect( + QRect(0, 0, quote, lines * line - st::photoEditorLinkLoadingSkip), + quote / 2., + quote / 2.); + p.translate(quoteSkip, 0); + for (const auto ratio : kLoadingLineRatios) { + _line.paint(p, int((inner - quoteSkip) * ratio)); + p.translate(0, line); + } + p.translate(-quoteSkip, 0); +} + +struct Resolved { + FullMsgId messageId; + WebPageData *webpage = nullptr; +}; + +class LinkResolver final : public base::has_weak_ptr { +public: + explicit LinkResolver(not_null session); + + void resolve(const QString &url); + void cancel(); + [[nodiscard]] rpl::producer resolved() const; + +private: + void resolveMessage(); + void resolveWebpage(); + void finish(Resolved result); + + const not_null _session; + Api::SingleMessageSearch _search; + HistoryView::Controls::WebpageResolver _webpages; + QString _url; + int _generation = 0; + rpl::event_stream _resolved; + rpl::lifetime _webpageLifetime; + +}; +LinkResolver::LinkResolver(not_null session) +: _session(session) +, _search(session) +, _webpages(session) { +} + +void LinkResolver::resolve(const QString &url) { + cancel(); + _url = url; + resolveMessage(); +} + +void LinkResolver::cancel() { + ++_generation; + _search.clear(); + _webpages.cancel(_url); + _webpageLifetime.destroy(); + _url = QString(); +} + +rpl::producer LinkResolver::resolved() const { + return _resolved.events(); +} + +void LinkResolver::resolveMessage() { + const auto generation = _generation; + const auto item = _search.lookup(_url, crl::guard(this, [=] { + if (generation == _generation) { + resolveMessage(); + } + })); + if (!item) { + return; + } else if (*item && CanRenderMessage(*item)) { + finish({ .messageId = MessageToRender(*item)->fullId() }); + } else { + resolveWebpage(); + } +} + +void LinkResolver::resolveWebpage() { + const auto url = _url; + const auto generation = _generation; + const auto finishWith = [=](WebPageData *page) { + if (generation == _generation) { + finish({ .webpage = page }); + } + }; + const auto waitForPending = [=](not_null page) { + _session->data().webPageUpdates( + ) | rpl::filter([=](not_null updated) { + return (updated == page) && !page->pendingTill; + }) | rpl::on_next([=] { + finishWith(page->failed ? nullptr : page.get()); + }, _webpageLifetime); + }; + _webpages.resolved( + ) | rpl::filter([=](const QString &link) { + return (link == url); + }) | rpl::on_next([=] { + const auto page = _webpages.lookup(url).value_or(nullptr); + if (page && page->pendingTill > 0) { + waitForPending(page); + } else { + finishWith(page); + } + }, _webpageLifetime); + _webpages.request(url, true); +} + +void LinkResolver::finish(Resolved result) { + _url = QString(); + _resolved.fire(std::move(result)); + ++_generation; +} + +constexpr auto kToDarkDuration = crl::time(450); +constexpr auto kToLightDuration = crl::time(320); + +class ThemeButton final : public Ui::AbstractButton { +public: + ThemeButton(QWidget *parent, bool dark); + + void setDark(bool dark); + +private: + void paintEvent(QPaintEvent *e) override; + + std::unique_ptr _icon; + bool _dark = false; + +}; + +ThemeButton::ThemeButton(QWidget *parent, bool dark) +: AbstractButton(parent) +, _icon(Lottie::MakeIcon({ + .name = u"sun_outline"_q, + .color = &st::groupCallMembersFg, + .sizeOverride = Size(st::photoEditorLinkThemeIconSize), +})) +, _dark(dark) { + setObjectName(u"photoEditorLinkThemeToggle"_q); + resize(st::photoEditorLinkThemeSize, st::photoEditorLinkThemeSize); + if (_icon->valid() && _dark) { + _icon->jumpTo(_icon->framesCount() - 1, [=] { update(); }); + } +} + +void ThemeButton::setDark(bool dark) { + if (_dark == dark) { + return; + } + _dark = dark; + if (_icon->valid()) { + _icon->animate( + [=] { update(); }, + _icon->frameIndex(), + dark ? (_icon->framesCount() - 1) : 0); + } + update(); +} + +void ThemeButton::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::photoEditorLinkThemeBg); + p.drawEllipse(rect()); + if (_icon->valid()) { + _icon->paintInCenter(p, rect()); + } +} + +class PreviewWidget final : public Ui::RpWidget { +public: + explicit PreviewWidget(QWidget *parent); + + void setSource(std::shared_ptr source, bool dark); + void setPill(const LinkPreview &link); + void setLoading(bool dark, bool captionAbove); + void clear(); + [[nodiscard]] rpl::producer<> themeToggles() const; + [[nodiscard]] rpl::producer<> sourceRemovals() const; + +private: + void paintEvent(QPaintEvent *e) override; + void resizeEvent(QResizeEvent *e) override; + void paintFrame(QPainter &p); + void paintContent(QPainter &p); + void paintLoading(QPainter &p, QRectF plate); + void paintGlare(QPainter &p, QRectF plate, int radius); + void clearLoading(); + void validateGlare(); + [[nodiscard]] QSize loadingSize() const; + [[nodiscard]] QImage snapshot(); + void beginTransition(bool radial, bool dark); + void scheduleRefresh(); + void refresh(); + [[nodiscard]] not_null background(bool dark); + [[nodiscard]] float64 contentScale() const; + [[nodiscard]] int targetHeight() const; + void applyHeight(); + [[nodiscard]] int innerWidth() const; + [[nodiscard]] bool hasContent() const; + + const not_null _theme; + std::array, 2> _backgrounds; + std::shared_ptr _source; + std::unique_ptr _renderer; + std::optional _pillLink; + std::optional _pill; + std::optional _loading; + QImage _image; + QSize _size; + QImage _from; + Ui::Animations::Simple _progress; + Ui::GlareEffect _glare; + rpl::event_stream<> _themeToggles; + rpl::event_stream<> _sourceRemovals; + rpl::lifetime _sourceLifetime; + int _heightFrom = 0; + int _heightTo = 0; + bool _radial = false; + bool _dark = false; + bool _refreshScheduled = false; + +}; + +PreviewWidget::PreviewWidget(QWidget *parent) +: RpWidget(parent) +, _theme(Ui::CreateChild(this, false)) { + _theme->hide(); + _theme->setClickedCallback([=] { _themeToggles.fire({}); }); + widthValue() | rpl::on_next([=] { + if (_pillLink) { + _pill.emplace( + *_pillLink, + LinkPill::DensityFor(innerWidth()), + innerWidth()); + _size = _pill->size().toSize(); + } else if (_loading) { + _size = loadingSize(); + validateGlare(); + } + applyHeight(); + }, lifetime()); +} + +rpl::producer<> PreviewWidget::themeToggles() const { + return _themeToggles.events(); +} + +rpl::producer<> PreviewWidget::sourceRemovals() const { + return _sourceRemovals.events(); +} + +int PreviewWidget::innerWidth() const { + return std::max(width() - 2 * st::photoEditorLinkPreviewPadding, 1); +} + +bool PreviewWidget::hasContent() const { + return !_size.isEmpty() + && (_loading || !_image.isNull() || _pill.has_value()); +} + +QSize PreviewWidget::loadingSize() const { + return QSize(innerWidth(), _loading->height()); +} + +void PreviewWidget::clearLoading() { + _loading.reset(); + _glare.animation.stop(); +} + +void PreviewWidget::validateGlare() { + _glare.width = _size.width(); + _glare.validate( + DefaultPalette(_dark).msgInBg()->c, + [=] { update(); }, + kGlareTimeout, + kGlareDuration); +} + +void PreviewWidget::setLoading(bool dark, bool captionAbove) { + if (_loading + && (_dark == dark) + && (_loading->captionAbove() == captionAbove)) { + return; + } + if (hasContent()) { + beginTransition(false, dark); + } + _dark = dark; + _loading.emplace(DefaultPalette(dark), captionAbove); + _sourceLifetime.destroy(); + _source = nullptr; + _renderer = nullptr; + _image = QImage(); + _pill.reset(); + _pillLink.reset(); + _size = loadingSize(); + _theme->hide(); + validateGlare(); + applyHeight(); + update(); +} + +void PreviewWidget::setSource( + std::shared_ptr source, + bool dark) { + if (hasContent()) { + beginTransition(_renderer && (_dark != dark), dark); + } + _dark = dark; + clearLoading(); + _pill.reset(); + _pillLink.reset(); + _sourceLifetime.destroy(); + _source = std::move(source); + _source->removed( + ) | rpl::on_next([=] { + _sourceRemovals.fire({}); + }, _sourceLifetime); + _renderer = std::make_unique(_source); + _renderer->setDark(dark); + _renderer->setRepaintCallback([=] { scheduleRefresh(); }); + _theme->setDark(dark); + _theme->show(); + refresh(); +} + +void PreviewWidget::setPill(const LinkPreview &link) { + if (hasContent()) { + beginTransition(false, _dark); + } + _dark = link.dark; + clearLoading(); + _sourceLifetime.destroy(); + _source = nullptr; + _renderer = nullptr; + _image = QImage(); + _pillLink = link; + _pill.emplace(link, LinkPill::DensityFor(innerWidth()), innerWidth()); + _size = _pill->size().toSize(); + _theme->hide(); + applyHeight(); + update(); +} + +void PreviewWidget::clear() { + if (hasContent()) { + beginTransition(false, _dark); + } + clearLoading(); + _sourceLifetime.destroy(); + _source = nullptr; + _renderer = nullptr; + _image = QImage(); + _pill.reset(); + _pillLink.reset(); + _size = QSize(); + _theme->hide(); + applyHeight(); + update(); +} + +void PreviewWidget::beginTransition(bool radial, bool dark) { + _from = snapshot(); + _heightFrom = height(); + _radial = radial; + _progress = {}; + _progress.start( + [=] { applyHeight(); update(); }, + 0., + 1., + (!radial + ? st::slideWrapDuration + : dark + ? kToDarkDuration + : kToLightDuration), + (!radial + ? anim::linear + : dark + ? anim::easeOutQuint + : anim::easeInCubic)); +} + +QImage PreviewWidget::snapshot() { + const auto ratio = style::DevicePixelRatio(); + auto result = QImage(size() * ratio, QImage::Format_ARGB32_Premultiplied); + result.setDevicePixelRatio(ratio); + result.fill(Qt::transparent); + auto p = QPainter(&result); + paintFrame(p); + return result; +} + +void PreviewWidget::scheduleRefresh() { + if (_refreshScheduled) { + return; + } + _refreshScheduled = true; + crl::on_main(this, [=] { + _refreshScheduled = false; + refresh(); + }); +} + +void PreviewWidget::refresh() { + if (!_renderer) { + return; + } + _image = _renderer->render(style::DevicePixelRatio()); + _size = _renderer->size(); + applyHeight(); + update(); +} + +not_null PreviewWidget::background(bool dark) { + auto &theme = _backgrounds[dark ? 1 : 0]; + if (!theme) { + theme = std::make_unique(); + theme->setBackground(Window::Theme::PrepareDefaultBackground(dark)); + theme->repaintBackgroundRequests( + ) | rpl::on_next([=] { update(); }, lifetime()); + } + return theme.get(); +} + +float64 PreviewWidget::contentScale() const { + return std::min({ + 1., + innerWidth() / float64(_size.width()), + st::photoEditorLinkPreviewMaxHeight / float64(_size.height()), + }); +} + +int PreviewWidget::targetHeight() const { + if (_size.isEmpty() || !width()) { + return 0; + } + const auto padding = st::photoEditorLinkPreviewPadding; + return int(std::ceil(_size.height() * contentScale())) + 2 * padding; +} + +void PreviewWidget::applyHeight() { + _heightTo = targetHeight(); + const auto animated = _progress.animating(); + const auto progress = animated ? _progress.value(1.) : 1.; + const auto shown = animated + ? anim::interpolate(_heightFrom, _heightTo, progress) + : _heightTo; + if (height() != shown) { + resize(width(), shown); + } + if (!animated && !_from.isNull()) { + _from = QImage(); + } +} + +void PreviewWidget::resizeEvent(QResizeEvent *e) { + const auto skip = st::photoEditorLinkThemeSkip; + _theme->moveToRight(skip, skip, width()); +} + +void PreviewWidget::paintContent(QPainter &p) { + auto hq = PainterHighQualityEnabler(p); + Window::SectionWidget::PaintBackground( + p, + background(_dark), + QSize(width(), height() * 3), + rect()); + if (!hasContent()) { + return; + } + const auto scale = contentScale(); + const auto size = QSizeF(_size) * scale; + const auto origin = QPointF( + (width() - size.width()) / 2., + st::photoEditorLinkPreviewPadding); + if (_loading) { + paintLoading(p, QRectF(origin, size)); + } else if (_pill) { + _pill->paint(p, origin, scale); + } else { + p.drawImage(QRectF(origin, size), _image); + } +} + +void PreviewWidget::paintLoading(QPainter &p, QRectF plate) { + p.save(); + p.translate(plate.topLeft()); + _loading->paint(p, _size.width()); + p.restore(); + paintGlare(p, plate, st::photoEditorLinkLoadingRadius); +} + +void PreviewWidget::paintGlare(QPainter &p, QRectF plate, int radius) { + if (!_glare.glare.birthTime) { + return; + } + const auto progress = _glare.progress(crl::now()); + if (progress < 0. || progress > 1.) { + return; + } + const auto width = float64(_glare.width); + const auto shift = plate.x() + - width + + (plate.width() + width * 2) * progress; + auto path = QPainterPath(); + path.addRoundedRect(plate, radius, radius); + p.save(); + p.setClipPath(path); + p.drawTiledPixmap( + QRectF(shift, plate.y(), width, plate.height()), + _glare.pixmap, + QPointF()); + p.restore(); +} + +void PreviewWidget::paintFrame(QPainter &p) { + auto clip = QPainterPath(); + clip.addRoundedRect(rect(), st::boxRadius, st::boxRadius); + p.setClipPath(clip); + paintContent(p); + if (!_progress.animating() || _from.isNull()) { + return; + } + const auto progress = _progress.value(1.); + auto hq = PainterHighQualityEnabler(p); + if (_radial) { + const auto full = std::hypot(width(), height()); + const auto radius = full * (_dark ? (1. - progress) : progress); + const auto center = QPointF(rect::center(_theme->geometry())); + auto circle = QPainterPath(); + circle.addEllipse(center, radius, radius); + p.setClipPath(_dark + ? clip.intersected(circle) + : clip.subtracted(circle)); + } else { + p.setOpacity(1. - progress); + } + p.drawImage(0, 0, _from); +} + +void PreviewWidget::paintEvent(QPaintEvent *e) { + if (!hasContent() && _from.isNull()) { + return; + } + auto p = QPainter(this); + paintFrame(p); +} + +[[nodiscard]] QString StripDoubledPrefix(const QString &text) { + const auto https = u"https://"_q; + const auto doubled = { + u"https://https://"_q, + u"https://http://"_q, + }; + for (const auto &prefix : doubled) { + if (text.startsWith(prefix, Qt::CaseInsensitive)) { + return text.mid(https.size()); + } + } + return QString(); +} + +[[nodiscard]] bool HasPhoto(WebPageData *webpage) { + return webpage + && webpage->hasLargeMedia + && (webpage->photo || webpage->document); +} + +[[nodiscard]] bool HasVideo(WebPageData *webpage) { + return webpage + && !webpage->photo + && webpage->document + && webpage->document->isVideoFile(); +} + +} // namespace + +object_ptr LinkBox(LinkBoxArgs &&args) { + return Box([args = std::move(args)](not_null box) { + const auto session = &args.show->session(); + struct State { + explicit State(not_null session) + : resolver(session) { + } + + LinkResolver resolver; + base::Timer timer; + std::optional resolved; + QString url; + rpl::variable valid = false; + rpl::variable loading = false; + rpl::variable captionAbove = true; + rpl::variable largePhoto = false; + rpl::variable video = false; + rpl::variable withoutPreview = false; + rpl::variable dark = false; + rpl::variable customName = false; + }; + const auto state = box->lifetime().make_state(session); + const auto &editing = args.editing; + state->dark = editing + ? editing->dark + : Window::Theme::IsNightMode(); + if (editing) { + state->captionAbove = editing->captionAbove; + state->largePhoto = editing->largePhoto; + state->withoutPreview = !editing->preview; + state->customName = !editing->name.isEmpty(); + } + + box->setTitle(editing + ? tr::lng_formatting_link_edit_title() + : tr::lng_formatting_link_create_title()); + box->setWidth(st::boxWideWidth); + + const auto url = box->addRow(object_ptr( + box, + st::groupCallField, + tr::lng_formatting_link_url(), + (editing + ? editing->url + : !args.url.isEmpty() + ? args.url + : u"https://"_q))); + box->setFocusCallback([=] { url->setFocusFast(); }); + + const auto preview = box->addRow( + object_ptr>( + box, + object_ptr(box)), + st::photoEditorLinkPreviewMargin); + preview->hide(anim::type::instant); + + const auto layout = box->verticalLayout(); + const auto options = layout->add( + object_ptr>( + box, + object_ptr(box))); + options->hide(anim::type::instant); + const auto optionsInner = options->entity(); + const auto addRow = [&](rpl::producer text) { + return optionsInner->add( + object_ptr>( + optionsInner, + object_ptr( + optionsInner, + std::move(text), + st::groupCallSettingsButton))); + }; + const auto above = addRow( + state->captionAbove.value() | rpl::map([](bool above) { + return above + ? tr::lng_link_move_up(tr::now) + : tr::lng_link_move_down(tr::now); + })); + const auto photo = addRow(rpl::combine( + state->largePhoto.value(), + state->video.value() + ) | rpl::map([](bool large, bool video) { + return large + ? (video + ? tr::lng_link_shrink_video(tr::now) + : tr::lng_link_shrink_photo(tr::now)) + : (video + ? tr::lng_link_enlarge_video(tr::now) + : tr::lng_link_enlarge_photo(tr::now)); + })); + const auto previewRow = addRow( + tr::lng_shortcuts_toggle_link_preview()); + previewRow->entity()->toggleOn( + state->withoutPreview.value() | rpl::map([](bool without) { + return !without; + })); + const auto customize = addRow( + tr::lng_photo_editor_link_customize()); + customize->entity()->toggleOn(state->customName.value()); + const auto nameWrap = optionsInner->add( + object_ptr>( + optionsInner, + object_ptr( + optionsInner, + st::groupCallField, + tr::lng_formatting_link_text(), + editing ? editing->name : QString()), + st::boxRowPadding)); + const auto name = nameWrap->entity(); + nameWrap->toggle(state->customName.current(), anim::type::instant); + + const auto currentLink = [=] { + return LinkPreview{ + .url = state->url, + .name = (state->customName.current() + ? name->getLastText().trimmed() + : QString()), + .captionAbove = state->captionAbove.current(), + .largePhoto = state->largePhoto.current(), + .preview = !state->withoutPreview.current(), + .dark = state->dark.current(), + }; + }; + const auto makeResult = [=] { + auto result = LinkBoxResult{ .dark = state->dark.current() }; + const auto &resolved = state->resolved; + if (!resolved) { + return result; + } else if (resolved->messageId) { + const auto item = session->data().message( + resolved->messageId); + if (item && CanRenderMessage(item)) { + result.message = std::make_shared(item); + return result; + } else if (item && !MessageForbidsRender(item)) { + auto link = currentLink(); + link.preview = false; + result.pill = std::move(link); + return result; + } + } + auto link = currentLink(); + const auto webpage = link.preview ? resolved->webpage : nullptr; + if (webpage) { + result.message = std::make_shared( + session, + std::move(link), + webpage); + } else { + result.pill = std::move(link); + } + return result; + }; + const auto refreshPreview = [=] { + const auto result = makeResult(); + const auto message = result.message && !result.message->link(); + const auto bubble = result.message && !message; + const auto loading = state->loading.current(); + const auto webpage = state->resolved + ? state->resolved->webpage + : nullptr; + if (result.message) { + preview->entity()->setSource(result.message, result.dark); + } else if (result.pill) { + preview->entity()->setPill(*result.pill); + } else if (loading) { + preview->entity()->setLoading( + state->dark.current(), + state->captionAbove.current()); + } else { + preview->entity()->clear(); + } + const auto shown = result.message || result.pill || loading; + preview->toggle(shown, anim::type::normal); + options->toggle(shown && !loading, anim::type::normal); + above->toggle(bubble, anim::type::normal); + photo->toggle(bubble && HasPhoto(webpage), anim::type::normal); + previewRow->toggle(webpage != nullptr, anim::type::normal); + customize->toggle(!message, anim::type::normal); + nameWrap->toggle( + !message && state->customName.current(), + anim::type::normal); + }; + + above->entity()->setClickedCallback([=] { + state->captionAbove = !state->captionAbove.current(); + refreshPreview(); + }); + photo->entity()->setClickedCallback([=] { + state->largePhoto = !state->largePhoto.current(); + refreshPreview(); + }); + previewRow->entity()->toggledChanges( + ) | rpl::on_next([=](bool shown) { + state->withoutPreview = !shown; + refreshPreview(); + }, previewRow->lifetime()); + preview->entity()->themeToggles( + ) | rpl::on_next([=] { + state->dark = !state->dark.current(); + refreshPreview(); + }, preview->lifetime()); + customize->entity()->toggledChanges( + ) | rpl::on_next([=](bool enabled) { + state->customName = enabled; + if (enabled) { + name->setFocusFast(); + } + refreshPreview(); + }, customize->lifetime()); + name->changes() | rpl::on_next([=] { + if (state->customName.current()) { + refreshPreview(); + } + }, name->lifetime()); + + const auto check = [=] { + const auto text = url->getLastText().trimmed(); + const auto stripped = StripDoubledPrefix(text); + if (!stripped.isEmpty()) { + InvokeQueued(url, [=] { url->setText(stripped); }); + return; + } + const auto validated = qthelp::validate_url(text); + state->valid = !validated.isEmpty(); + if (validated == state->url) { + return; + } + state->url = validated; + state->resolved = std::nullopt; + state->resolver.cancel(); + state->timer.cancel(); + state->loading = !validated.isEmpty(); + refreshPreview(); + if (state->loading.current()) { + state->timer.callOnce(kResolveDelay); + } + }; + state->timer.setCallback([=] { + state->resolver.resolve(state->url); + }); + state->resolver.resolved( + ) | rpl::on_next([=](Resolved resolved) { + state->loading = false; + state->resolved = resolved; + state->video = HasVideo(resolved.webpage); + refreshPreview(); + }, box->lifetime()); + url->changes() | rpl::on_next(check, url->lifetime()); + preview->entity()->sourceRemovals( + ) | rpl::on_next([=] { + state->url = QString(); + check(); + }, preview->lifetime()); + check(); + + const auto submit = [=] { + if (!state->valid.current() || state->loading.current()) { + return; + } + auto result = makeResult(); + if (!result.message && !result.pill) { + result.pill = currentLink(); + } + const auto done = args.done; + box->closeBox(); + done(std::move(result)); + }; + const auto button = box->addButton( + (editing + ? tr::lng_settings_save() + : tr::lng_formatting_link_create()), + submit); + rpl::combine( + state->valid.value(), + state->loading.value() + ) | rpl::on_next([=](bool valid, bool loading) { + button->setDisabled(!valid || loading); + }, button->lifetime()); + box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + + rpl::merge( + url->submits() | rpl::to_empty, + name->submits() | rpl::to_empty + ) | rpl::on_next(submit, box->lifetime()); + }); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_link_box.h b/Telegram/SourceFiles/editor/editor_link_box.h new file mode 100644 index 0000000000..ddfe7e7fb8 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_link_box.h @@ -0,0 +1,37 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "editor/editor_message_source.h" + +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + +namespace Ui { +class BoxContent; +} // namespace Ui + +namespace Editor { + +struct LinkBoxResult { + std::shared_ptr message; + std::optional pill; + bool dark = false; +}; + +struct LinkBoxArgs { + std::shared_ptr show; + QString url; + std::optional editing; + Fn done; +}; + +[[nodiscard]] object_ptr LinkBox(LinkBoxArgs &&args); + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_link_pill.cpp b/Telegram/SourceFiles/editor/editor_link_pill.cpp new file mode 100644 index 0000000000..9f53885dff --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_link_pill.cpp @@ -0,0 +1,135 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_link_pill.h" + +#include "ui/painter.h" +#include "styles/style_editor.h" + +namespace Editor { +namespace { + +constexpr auto kDpPerWidth = 360.; +constexpr auto kPaddingLeft = 4.; +constexpr auto kPaddingTop = 4.33; +constexpr auto kPaddingRight = 7.66; +constexpr auto kPaddingBottom = 3.; +constexpr auto kIconSize = 30.; +constexpr auto kIconPadding = 3.25; +constexpr auto kFontSize = 24.; +constexpr auto kRadiusRatio = 0.2; +constexpr auto kSemiTransparentAlpha = 0x4C; + +[[nodiscard]] QString WithoutScheme(const QString &url) { + auto result = url; + const auto scheme = result.indexOf(u"://"_q); + if (scheme >= 0) { + result = result.mid(scheme + 3); + } + while (result.endsWith('/')) { + result.chop(1); + } + return result; +} + +} // namespace + +LinkPill::LinkPill(const LinkPreview &link, float64 density, int maxWidth) +: _density(density) +, _font( + int(base::SafeRound(kFontSize * density)), + style::FontFlag::Bold, + QString()) { + const auto text = link.name.isEmpty() + ? WithoutScheme(link.url).toUpper() + : link.name; + const auto fixed = (kPaddingLeft + + kIconSize + + kIconPadding + + kPaddingRight) * _density; + const auto maxTextWidth = std::max(maxWidth - int(fixed), 1); + _text = _font->elided(text, maxTextWidth); + _textWidth = _font->width(_text); + _size = QSizeF( + fixed + _textWidth, + (kPaddingTop + kPaddingBottom) * _density + + std::max(kIconSize * _density, float64(_font->height))); + updateIcon(); +} + +QSizeF LinkPill::size() const { + return _size; +} + +LinkStyle LinkPill::style() const { + return _style; +} + +void LinkPill::setStyle(LinkStyle style) { + _style = style; + updateIcon(); +} + +QColor LinkPill::background() const { + switch (_style) { + case LinkStyle::Framed: + return QColor(255, 255, 255); + case LinkStyle::SemiTransparent: + return QColor(0, 0, 0, kSemiTransparentAlpha); + case LinkStyle::Opaque: + return QColor(0, 0, 0); + } + Unexpected("Link style in LinkPill::background."); +} + +QColor LinkPill::foreground() const { + return (_style == LinkStyle::Framed) + ? QColor(0x33, 0x91, 0xD4) + : QColor(255, 255, 255); +} + +float64 LinkPill::DensityFor(int canvasWidth) { + return canvasWidth / kDpPerWidth; +} + +void LinkPill::updateIcon() { + _icon = st::photoEditorLinkPillIcon.instance( + foreground(), + style::kScaleMax); +} + +void LinkPill::paint(QPainter &p, QPointF origin, float64 scale) const { + p.save(); + auto hq = PainterHighQualityEnabler(p); + p.translate(origin); + p.scale(scale, scale); + + const auto pill = QRectF(QPointF(), _size); + const auto radius = pill.height() * kRadiusRatio; + p.setPen(Qt::NoPen); + p.setBrush(background()); + p.drawRoundedRect(pill, radius, radius); + + const auto iconSide = kIconSize * _density; + const auto icon = QRectF( + kPaddingLeft * _density, + (pill.height() - iconSide) / 2., + iconSide, + iconSide); + p.drawImage(icon, _icon); + + p.setFont(_font); + p.setPen(foreground()); + p.drawText( + QPointF( + icon.x() + icon.width() + kIconPadding * _density, + (pill.height() - _font->height) / 2. + _font->ascent), + _text); + p.restore(); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_link_pill.h b/Telegram/SourceFiles/editor/editor_link_pill.h new file mode 100644 index 0000000000..66f2427d60 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_link_pill.h @@ -0,0 +1,58 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +namespace Editor { + +struct LinkPreview { + QString url; + QString name; + bool captionAbove = true; + bool largePhoto = false; + bool preview = true; + bool dark = false; + + friend inline bool operator==( + const LinkPreview &, + const LinkPreview &) = default; +}; + +enum class LinkStyle : uchar { + Framed, + SemiTransparent, + Opaque, +}; + +class LinkPill final { +public: + LinkPill(const LinkPreview &link, float64 density, int maxWidth); + + [[nodiscard]] static float64 DensityFor(int canvasWidth); + + [[nodiscard]] QSizeF size() const; + [[nodiscard]] LinkStyle style() const; + void setStyle(LinkStyle style); + + void paint(QPainter &p, QPointF origin, float64 scale) const; + +private: + [[nodiscard]] QColor background() const; + [[nodiscard]] QColor foreground() const; + void updateIcon(); + + float64 _density = 1.; + style::font _font; + QString _text; + int _textWidth = 0; + QSizeF _size; + QImage _icon; + LinkStyle _style = LinkStyle::Framed; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_message_render.cpp b/Telegram/SourceFiles/editor/editor_message_render.cpp new file mode 100644 index 0000000000..efcf2d78a4 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_message_render.cpp @@ -0,0 +1,397 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_message_render.h" + +#include "data/data_cloud_file.h" +#include "data/data_document.h" +#include "data/data_peer.h" +#include "data/data_session.h" +#include "editor/editor_message_source.h" +#include "editor/editor_message_video.h" +#include "history/history_item.h" +#include "history/history_item_components.h" +#include "history/view/history_view_element.h" +#include "history/view/media/history_view_media.h" +#include "main/main_session.h" +#include "ui/cached_round_corners.h" +#include "ui/chat/chat_style.h" +#include "ui/chat/chat_theme.h" +#include "ui/effects/path_shift_gradient.h" +#include "ui/empty_userpic.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "window/themes/window_theme.h" +#include "window/themes/window_themes_embedded.h" +#include "styles/style_chat.h" + +namespace Editor { +namespace { + +[[nodiscard]] QRect OpaqueBounds(const QImage &image) { + Expects(image.format() == QImage::Format_ARGB32_Premultiplied); + + const auto width = image.width(); + const auto height = image.height(); + const auto rowHasContent = [&](int y) { + const auto row = reinterpret_cast( + image.constScanLine(y)); + for (auto x = 0; x != width; ++x) { + if (row[x]) { + return true; + } + } + return false; + }; + auto top = 0; + while (top != height && !rowHasContent(top)) { + ++top; + } + if (top == height) { + return QRect(); + } + auto bottom = height - 1; + while (bottom > top && !rowHasContent(bottom)) { + --bottom; + } + auto left = width; + auto right = -1; + for (auto y = top; y <= bottom; ++y) { + const auto row = reinterpret_cast( + image.constScanLine(y)); + auto x = 0; + while (x < left && !row[x]) { + ++x; + } + left = std::min(left, x); + x = width - 1; + while (x > right && !row[x]) { + --x; + } + right = std::max(right, x); + } + return QRect(left, top, right - left + 1, bottom - top + 1); +} + +[[nodiscard]] std::unique_ptr MakeChatTheme( + std::optional dark, + rpl::lifetime &lifetime) { + if (!dark) { + return Window::Theme::DefaultChatThemeOn(lifetime); + } + return std::make_unique(Ui::ChatThemeDescriptor{ + .preparePalette = Window::Theme::PreparePaletteCallback( + *dark, + std::nullopt), + .basedOnDark = *dark, + }); +} + +[[nodiscard]] float64 CornerRadius( + Ui::BubbleCornerRounding corner, + int ratio) { + using Corner = Ui::BubbleCornerRounding; + using Radius = Ui::CachedCornerRadius; + return (corner == Corner::Small) + ? Ui::CachedCornerRadiusValue(Radius::BubbleSmall) * ratio + : (corner == Corner::Large) + ? Ui::CachedCornerRadiusValue(Radius::BubbleLarge) * ratio + : 0.; +} + +[[nodiscard]] QPainterPath HolePath( + QRect rect, + Ui::BubbleRounding rounding, + int ratio) { + const auto topLeft = CornerRadius(rounding.topLeft, ratio); + const auto topRight = CornerRadius(rounding.topRight, ratio); + const auto bottomLeft = CornerRadius(rounding.bottomLeft, ratio); + const auto bottomRight = CornerRadius(rounding.bottomRight, ratio); + const auto left = float64(rect.x()); + const auto top = float64(rect.y()); + const auto right = float64(rect.x() + rect.width()); + const auto bottom = float64(rect.y() + rect.height()); + const auto corner = [](float64 x, float64 y, float64 radius) { + return QRectF(x, y, 2 * radius, 2 * radius); + }; + auto result = QPainterPath(); + result.moveTo(left + topLeft, top); + result.lineTo(right - topRight, top); + result.arcTo(corner(right - 2 * topRight, top, topRight), 90, -90); + result.lineTo(right, bottom - bottomRight); + result.arcTo( + corner( + right - 2 * bottomRight, + bottom - 2 * bottomRight, + bottomRight), + 0, + -90); + result.lineTo(left + bottomLeft, bottom); + result.arcTo(corner(left, bottom - 2 * bottomLeft, bottomLeft), 270, -90); + result.lineTo(left, top + topLeft); + result.arcTo(corner(left, top, topLeft), 180, -90); + result.closeSubpath(); + return result; +} + +} // namespace + +class MessageRenderer::Delegate final + : public HistoryView::DefaultElementDelegate { +public: + explicit Delegate(not_null gradient) + : _gradient(gradient) { + } + +private: + HistoryView::Context elementContext() override { + return HistoryView::Context::MediaEditor; + } + bool elementAnimationsPaused() override { + return true; + } + not_null elementPathShiftGradient() override { + return _gradient; + } + + const not_null _gradient; + +}; + +MessageRenderer::MessageRenderer(std::shared_ptr source) +: _source(std::move(source)) +, _theme(Window::Theme::DefaultChatThemeOn(_lifetime)) +, _style( + std::make_unique( + _source->session().colorIndicesValue())) +, _pathGradient( + HistoryView::MakePathShiftGradient(_style.get(), [=] { repaint(); })) +, _delegate(std::make_unique(_pathGradient.get())) { + _style->apply(_theme.get()); + + const auto session = &_source->session(); + const auto data = &session->data(); + data->viewRepaintRequest( + ) | rpl::filter([=](const Data::RequestViewRepaint &request) { + return (request.view == _element.get()); + }) | rpl::on_next([=] { + repaint(); + }, _lifetime); + + data->viewResizeRequest( + ) | rpl::filter([=](not_null view) { + return (view == _element.get()); + }) | rpl::on_next([=] { + _layoutDirty = true; + repaint(); + }, _lifetime); + + data->itemViewRefreshRequest( + ) | rpl::filter([=](not_null item) { + return (item == _source->item()); + }) | rpl::on_next([=] { + _recreate = true; + repaint(); + }, _lifetime); + + data->itemDataChanges( + ) | rpl::filter([=](not_null item) { + return (item == _source->item()); + }) | rpl::on_next([=] { + if (_element) { + _element->itemDataChanged(); + _layoutDirty = true; + repaint(); + } + }, _lifetime); + + session->downloaderTaskFinished( + ) | rpl::on_next([=] { + repaint(); + }, _lifetime); + + style::PaletteChanged( + ) | rpl::on_next([=] { + _layoutDirty = true; + repaint(); + }, _lifetime); + + _source->removed( + ) | rpl::on_next([=] { + _element = nullptr; + }, _lifetime); + + createView(); +} + +MessageRenderer::~MessageRenderer() = default; + +void MessageRenderer::setRepaintCallback(Fn callback) { + _repaint = std::move(callback); +} + +void MessageRenderer::setDark(std::optional dark) { + if (_dark == dark) { + return; + } + _dark = dark; + _theme = MakeChatTheme(dark, _lifetime); + _style->apply(_theme.get()); + _layoutDirty = true; + repaint(); +} + +bool MessageRenderer::ready() const { + return (_element != nullptr); +} + +void MessageRenderer::createView() { + const auto item = _source->item(); + _element = item ? item->createView(_delegate.get()) : nullptr; + _layoutDirty = true; +} + +void MessageRenderer::repaint() { + _base = QImage(); + if (_repaint) { + _repaint(); + } +} + +void MessageRenderer::layout() { + if (base::take(_recreate)) { + createView(); + } + if (!_layoutDirty || !_element) { + return; + } + _layoutDirty = false; + _element->initDimensions(); + _width = st::msgMargin.left() + + st::msgMaxWidth + + st::msgMargin.right() + + (_element->hasFromPhoto() ? st::msgPhotoSkip : 0); + _element->resizeGetHeight(_width); + _base = renderFull(1); + _bounds = OpaqueBounds(_base).marginsAdded(Margins(1)) & _base.rect(); +} + +QSize MessageRenderer::size() { + layout(); + return _bounds.size(); +} + +QRect MessageRenderer::mediaRect() { + layout(); + return _bounds.isEmpty() + ? QRect() + : elementMediaRect().translated(-_bounds.topLeft()); +} + +QRect MessageRenderer::elementMediaRect() const { + const auto media = _element ? _element->media() : nullptr; + return media + ? media->contentRectForReactions().translated( + _element->mediaTopLeft()) + : QRect(); +} + +QImage MessageRenderer::videoMask(int ratio) { + Expects(ratio > 0); + + layout(); + const auto video = _source->video(); + const auto rect = elementMediaRect(); + if (!video || rect.isEmpty()) { + return QImage(); + } + auto mask = QImage( + rect.size() * ratio, + QImage::Format_ARGB32_Premultiplied); + mask.fill(Qt::transparent); + auto p = QPainter(&mask); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + if (video->document()->isVideoMessage()) { + p.drawEllipse(mask.rect()); + } else { + p.drawPath(HolePath( + mask.rect(), + _element->media()->adjustedBubbleRounding(), + ratio)); + } + return mask; +} + +QImage MessageRenderer::render(int ratio) { + Expects(ratio > 0); + + layout(); + if (!_element || _bounds.isEmpty()) { + return QImage(); + } + const auto full = (ratio == 1 && !_base.isNull()) + ? _base + : renderFull(ratio); + const auto bounds = QRect( + _bounds.topLeft() * ratio, + _bounds.size() * ratio); + auto result = full.copy(bounds); + result.setDevicePixelRatio(ratio); + return result; +} + +QImage MessageRenderer::renderFull(int ratio) { + const auto full = QSize(_width, _element->height()); + auto result = QImage( + full * ratio, + QImage::Format_ARGB32_Premultiplied); + result.setDevicePixelRatio(ratio); + result.fill(Qt::transparent); + + auto p = Painter(&result); + PainterHighQualityEnabler hq(p); + const auto rect = Rect(full); + auto context = _theme->preparePaintContext( + _style.get(), + rect, + rect, + rect, + true); + context.outbg = _element->hasOutLayout(); + _element->draw(p, context); + paintUserpic(p, full.width(), full.height()); + return result; +} + +void MessageRenderer::paintUserpic(Painter &p, int width, int height) { + if (!_element->displayFromPhoto()) { + return; + } + const auto item = _element->data(); + const auto left = st::historyPhotoLeft; + const auto top = height - _element->marginBottom() - st::msgPhotoSize; + const auto size = st::msgPhotoSize; + if (const auto from = _element->displayFrom()) { + from->paintUserpicLeft(p, _userpic, left, top, width, size); + } else if (const auto info = item->displayHiddenSenderInfo()) { + if (info->customUserpic.empty()) { + info->emptyUserpic.paintCircle(p, left, top, width, size); + } else if (!info->paintCustomUserpic( + p, + _userpic, + left, + top, + width, + size)) { + info->customUserpic.load(&_source->session(), item->fullId()); + } + } +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_message_render.h b/Telegram/SourceFiles/editor/editor_message_render.h new file mode 100644 index 0000000000..9602786dbe --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_message_render.h @@ -0,0 +1,70 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "ui/userpic_view.h" + +class Painter; + +namespace HistoryView { +class Element; +} // namespace HistoryView + +namespace Ui { +class ChatTheme; +class ChatStyle; +class PathShiftGradient; +} // namespace Ui + +namespace Editor { + +class MessageSource; + +class MessageRenderer final { +public: + explicit MessageRenderer(std::shared_ptr source); + ~MessageRenderer(); + + void setRepaintCallback(Fn callback); + void setDark(std::optional dark); + + [[nodiscard]] bool ready() const; + [[nodiscard]] QSize size(); + [[nodiscard]] QRect mediaRect(); + [[nodiscard]] QImage videoMask(int ratio); + [[nodiscard]] QImage render(int ratio); + +private: + class Delegate; + + void createView(); + void layout(); + void repaint(); + [[nodiscard]] QImage renderFull(int ratio); + [[nodiscard]] QRect elementMediaRect() const; + void paintUserpic(Painter &p, int width, int height); + + rpl::lifetime _lifetime; + const std::shared_ptr _source; + std::unique_ptr _theme; + const std::unique_ptr _style; + const std::unique_ptr _pathGradient; + const std::unique_ptr _delegate; + std::unique_ptr _element; + Fn _repaint; + Ui::PeerUserpicView _userpic; + QImage _base; + QRect _bounds; + std::optional _dark; + int _width = 0; + bool _layoutDirty = true; + bool _recreate = false; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_message_source.cpp b/Telegram/SourceFiles/editor/editor_message_source.cpp new file mode 100644 index 0000000000..3c8fede225 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_message_source.cpp @@ -0,0 +1,172 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_message_source.h" + +#include "base/unixtime.h" +#include "data/data_groups.h" +#include "data/data_media_types.h" +#include "data/data_peer.h" +#include "data/data_session.h" +#include "data/data_web_page.h" +#include "editor/editor_message_video.h" +#include "history/history.h" +#include "history/history_item.h" +#include "history/view/history_view_fake_items.h" +#include "main/main_session.h" + +namespace Editor { +namespace { + +[[nodiscard]] TextWithEntities LinkText(const LinkPreview &link) { + auto result = TextWithEntities{ + link.name.isEmpty() ? link.url : link.name, + }; + result.entities.push_back(link.name.isEmpty() + ? EntityInText(EntityType::Url, 0, result.text.size()) + : EntityInText( + EntityType::CustomUrl, + 0, + result.text.size(), + link.url)); + return result; +} + +[[nodiscard]] MTPMessageMedia LinkMedia( + const LinkPreview &link, + WebPageData *webpage) { + if (!webpage) { + return MTP_messageMediaEmpty(); + } + using Flag = MTPDmessageMediaWebPage::Flag; + return MTP_messageMediaWebPage( + MTP_flags(link.largePhoto + ? Flag::f_force_large_media + : Flag::f_force_small_media), + MTP_webPagePending( + MTP_flags(0), + MTP_long(webpage->id), + MTPstring(), + MTP_int(0))); +} + +[[nodiscard]] not_null LinkHistory( + not_null session) { + const auto peerId = HistoryView::GenerateUser( + session->data().history(PeerData::kServiceNotificationsId), + u"Link"_q); + const auto result = session->data().history(peerId); + result->peer->setBarSettings(PeerBarSettings()); + return result; +} + +[[nodiscard]] not_null MakeLinkItem( + not_null session, + const LinkPreview &link, + WebPageData *webpage) { + const auto history = LinkHistory(session); + return history->makeMessage( + { + .id = history->nextNonHistoryEntryId(), + .flags = (MessageFlag::FakeHistoryItem + | MessageFlag::HasFromId + | (link.captionAbove + ? MessageFlag() + : MessageFlag::InvertMedia)), + .from = session->userPeerId(), + .date = base::unixtime::now(), + }, + LinkText(link), + LinkMedia(link, webpage)); +} + +} // namespace + +bool MessageForbidsRender(not_null item) { + return item->isSponsored() + || item->forbidsSaving() + || !item->history()->peer->allowsForwarding(); +} + +bool CanRenderMessage(not_null item) { + const auto media = item->media(); + return !MessageForbidsRender(item) + && !item->isEphemeral() + && !item->showSimilarChannels() + && !item->isLegacyMessage() + && !dynamic_cast(media); +} + +not_null MessageToRender(not_null item) { + const auto group = item->history()->owner().groups().find(item); + return group ? group->items.front() : item; +} + +MessageSource::MessageSource(not_null item) +: _session(&item->history()->session()) +, _item(item) { + if (MessageVideo::Find(item)) { + _video = std::make_unique(item); + } + watchRemoval(); +} + +MessageSource::MessageSource( + not_null session, + LinkPreview link, + WebPageData *webpage) +: _session(session) +, _link(std::move(link)) +, _webpage(webpage) +, _item(MakeLinkItem(session, *_link, webpage)) +, _owned(true) { + watchRemoval(); +} + +MessageSource::~MessageSource() { + if (_owned) { + if (const auto item = base::take(_item)) { + item->destroy(); + } + } +} + +void MessageSource::watchRemoval() { + _session->data().itemRemoved( + ) | rpl::filter([=](not_null item) { + return (item == _item); + }) | rpl::on_next([=] { + _item = nullptr; + _removed.fire({}); + }, _lifetime); +} + +Main::Session &MessageSource::session() const { + return *_session; +} + +HistoryItem *MessageSource::item() const { + return _item; +} + +const std::optional &MessageSource::link() const { + return _link; +} + +WebPageData *MessageSource::webpage() const { + return _webpage; +} + +MessageVideo *MessageSource::video() const { + return _video.get(); +} + +rpl::producer<> MessageSource::removed() const { + return _removed.events(); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_message_source.h b/Telegram/SourceFiles/editor/editor_message_source.h new file mode 100644 index 0000000000..da899b0403 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_message_source.h @@ -0,0 +1,58 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "editor/editor_link_pill.h" + +class HistoryItem; +struct WebPageData; + +namespace Main { +class Session; +} // namespace Main + +namespace Editor { + +class MessageVideo; + +[[nodiscard]] bool MessageForbidsRender(not_null item); +[[nodiscard]] bool CanRenderMessage(not_null item); +[[nodiscard]] not_null MessageToRender( + not_null item); + +class MessageSource final { +public: + explicit MessageSource(not_null item); + MessageSource( + not_null session, + LinkPreview link, + WebPageData *webpage); + ~MessageSource(); + + [[nodiscard]] Main::Session &session() const; + [[nodiscard]] HistoryItem *item() const; + [[nodiscard]] const std::optional &link() const; + [[nodiscard]] WebPageData *webpage() const; + [[nodiscard]] MessageVideo *video() const; + [[nodiscard]] rpl::producer<> removed() const; + +private: + void watchRemoval(); + + const not_null _session; + const std::optional _link; + WebPageData *_webpage = nullptr; + HistoryItem *_item = nullptr; + std::unique_ptr _video; + bool _owned = false; + rpl::event_stream<> _removed; + rpl::lifetime _lifetime; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_message_video.cpp b/Telegram/SourceFiles/editor/editor_message_video.cpp new file mode 100644 index 0000000000..50568eadb4 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_message_video.cpp @@ -0,0 +1,206 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_message_video.h" + +#include "core/mime_type.h" +#include "data/data_document.h" +#include "data/data_document_media.h" +#include "data/data_file_origin.h" +#include "data/data_groups.h" +#include "data/data_media_types.h" +#include "data/data_session.h" +#include "data/data_streaming.h" +#include "editor/video/video_clip.h" +#include "history/history.h" +#include "history/history_item.h" +#include "main/main_session.h" +#include "media/media_video_encode.h" +#include "storage/storage_media_prepare.h" +#include "storage/streamed_file_downloader.h" +#include "ui/image/image_prepare.h" + +#include + +namespace Editor { +namespace { + +constexpr auto kMaxExtensionLength = 5; + +[[nodiscard]] QString VideoExtension(not_null document) { + const auto extension = Core::FileExtension(document->filename()); + const auto valid = !extension.isEmpty() + && (extension.size() <= kMaxExtensionLength) + && ranges::all_of(extension, [](QChar ch) { + return ch.isLetterOrNumber(); + }); + return valid ? extension : u"mp4"_q; +} + +[[nodiscard]] QString TempVideoPath(not_null document) { + auto temp = QTemporaryFile( + Media::Encode::TempFileTemplate(VideoExtension(document))); + if (!temp.open()) { + return QString(); + } + temp.setAutoRemove(false); + return temp.fileName(); +} + +} // namespace + +DocumentData *MessageVideo::Find(not_null item) { + const auto media = item->media(); + const auto document = media ? media->document() : nullptr; + if (!document + || media->webpage() + || item->history()->owner().groups().find(item)) { + return nullptr; + } + const auto video = document->isVideoFile() + || document->isGifv() + || document->isVideoMessage(); + const auto fits = (document->size > 0) + && (document->size <= Images::kReadBytesLimit); + return (video && fits) ? document : nullptr; +} + +MessageVideo::MessageVideo(not_null item) +: _document(Find(item)) +, _itemId(item->fullId()) { +} + +MessageVideo::~MessageVideo() { + _lifetime.destroy(); + _loader = nullptr; + if (!_tempPath.isEmpty()) { + QFile::remove(_tempPath); + } +} + +not_null MessageVideo::document() const { + return _document; +} + +void MessageVideo::load() { + if (_source || _loader || _waiting || _reading || _failed) { + return; + } + _media = _document->createMediaView(); + if (_media->loaded(true)) { + const auto path = _document->filepath(true); + readSource(path, path.isEmpty() ? _media->bytes() : QByteArray()); + return; + } else if (_document->loading()) { + waitForLoader(); + return; + } + const auto origin = Data::FileOrigin(_itemId); + auto reader = _document->owner().streaming().sharedReader( + _document, + origin, + true); + const auto path = reader ? TempVideoPath(_document) : QString(); + if (path.isEmpty()) { + fail(); + return; + } + _tempPath = path; + _loader = _document->createStreamedDownloader( + std::move(reader), + origin, + std::nullopt, + path, + LoadToFileOnly, + LoadFromCloudOrLocal, + false); + _loader->updates( + ) | rpl::on_next_error_done([=] { + _changes.fire({}); + }, [=](FileLoader::Error) { + crl::on_main(base::make_weak(this), [=] { fail(); }); + }, [=] { + readSource(_tempPath, QByteArray()); + }, _lifetime); + _loader->start(); + _changes.fire({}); +} + +bool MessageVideo::loading() const { + return (_loader != nullptr) || _waiting || _reading; +} + +float64 MessageVideo::progress() const { + return _loader + ? _loader->currentProgress() + : _waiting + ? _document->progress() + : _reading + ? 1. + : 0.; +} + +std::shared_ptr MessageVideo::source() const { + return _source; +} + +rpl::producer<> MessageVideo::changes() const { + return _changes.events(); +} + +void MessageVideo::waitForLoader() { + _waiting = true; + const auto weak = base::make_weak(this); + const auto lifetime = std::make_shared(); + _document->session().downloaderTaskFinished( + ) | rpl::on_next_done([=] { + const auto strong = weak.get(); + if (strong && strong->_document->loading()) { + return; + } + lifetime->destroy(); + if (strong) { + strong->_waiting = false; + strong->load(); + } + }, [=] { + lifetime->destroy(); + }, *lifetime); + _changes.fire({}); +} + +void MessageVideo::readSource( + const QString &path, + const QByteArray &content) { + _reading = true; + const auto done = crl::guard(this, [=]( + Storage::PhotoEditorMedia &&media) { + _reading = false; + _loader = nullptr; + if (!media.video()) { + fail(); + return; + } + _source = std::make_shared(VideoClipSource{ + .path = std::move(media.videoPath), + .content = std::move(media.videoContent), + .thumbnail = std::move(media.image), + .duration = media.videoDuration, + .hasAudio = media.videoHasAudio, + }); + _changes.fire({}); + }); + Storage::ReadPhotoEditorMediaAsync(path, content, done); +} + +void MessageVideo::fail() { + _failed = true; + _loader = nullptr; + _changes.fire({}); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_message_video.h b/Telegram/SourceFiles/editor/editor_message_video.h new file mode 100644 index 0000000000..7624fb411a --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_message_video.h @@ -0,0 +1,57 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/weak_ptr.h" + +class DocumentData; +class FileLoader; +class HistoryItem; + +namespace Data { +class DocumentMedia; +} // namespace Data + +namespace Editor { + +struct VideoClipSource; + +class MessageVideo final : public base::has_weak_ptr { +public: + [[nodiscard]] static DocumentData *Find(not_null item); + + explicit MessageVideo(not_null item); + ~MessageVideo(); + + [[nodiscard]] not_null document() const; + void load(); + [[nodiscard]] bool loading() const; + [[nodiscard]] float64 progress() const; + [[nodiscard]] std::shared_ptr source() const; + [[nodiscard]] rpl::producer<> changes() const; + +private: + void waitForLoader(); + void readSource(const QString &path, const QByteArray &content); + void fail(); + + const not_null _document; + const FullMsgId _itemId; + std::shared_ptr _media; + std::unique_ptr _loader; + std::shared_ptr _source; + QString _tempPath; + bool _waiting = false; + bool _reading = false; + bool _failed = false; + rpl::event_stream<> _changes; + rpl::lifetime _lifetime; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index 1caa7702f9..0dba93cbbb 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -7,25 +7,40 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "editor/editor_paint.h" +#include "apiwrap.h" #include "base/platform/base_platform_haptic.h" +#include "base/qthelp_url.h" +#include "chat_helpers/compose/compose_show.h" #include "core/file_utilities.h" #include "core/mime_type.h" +#include "data/data_peer.h" +#include "data/data_session.h" #include "editor/controllers/controllers.h" +#include "editor/editor_link_box.h" +#include "editor/editor_message_render.h" +#include "editor/editor_message_source.h" #include "editor/scene/scene_item_canvas.h" #include "editor/scene/scene_item_image.h" +#include "editor/scene/scene_item_link.h" +#include "editor/scene/scene_item_message.h" #include "editor/scene/scene_item_shape.h" #include "editor/scene/scene_item_sticker.h" #include "editor/scene/scene_item_text.h" #include "editor/scene/scene_item_video.h" #include "editor/scene/scene.h" +#include "history/history.h" +#include "history/history_item.h" #include "lang/lang_keys.h" #include "lottie/lottie_single_player.h" +#include "main/main_session.h" #include "platform/platform_file_utilities.h" #include "storage/storage_media_prepare.h" #include "ui/boxes/confirm_box.h" #include "ui/chat/attach/attach_prepare.h" #include "ui/rect.h" #include "ui/ui_utility.h" +#include "window/themes/window_theme.h" +#include "styles/style_editor.h" #include #include @@ -40,6 +55,28 @@ constexpr auto kMaxBrush = 25.; constexpr auto kMinBrush = 1.; constexpr auto kShapeSizeRatio = 2. / 5.; constexpr auto kMediaSizeRatio = 1. / 2.; +constexpr auto kImageMaxSizeRatio = 4.; + +constexpr auto kMessageMaxWidthRatio = 0.88; +constexpr auto kMessageMaxHeightRatio = 0.7; +constexpr auto kMessagesCascadeRatio = 1. / 20.; + +[[nodiscard]] bool IsForwardMimeData(not_null data) { + return data->hasFormat(u"application/x-td-forward"_q); +} + +[[nodiscard]] QString MimeLinkUrl(not_null data) { + const auto urls = Core::ReadMimeUrls(data); + const auto text = (urls.size() == 1 && !urls.front().isLocalFile()) + ? urls.front().toString() + : urls.isEmpty() + ? Core::ReadMimeText(data).trimmed() + : QString(); + if (text.isEmpty() || text.contains('\n') || text.contains(' ')) { + return QString(); + } + return qthelp::validate_url(text); +} [[nodiscard]] float64 BrushSize(const Brush &brush) { return kMinBrush + float64(kMaxBrush - kMinBrush) * brush.sizeRatio; @@ -90,7 +127,8 @@ Paint::Paint( , _viewport(_view->viewport()) , _imageSize(imageSize) , _fixedCrop(data.fixedCrop) -, _composeAnimated(data.composeAnimated) { +, _composeAnimated(data.composeAnimated) +, _composeSound(data.composeSound) { Expects(modifications.paint != nullptr); _scene->setBlurSource(std::move(blurSource)); @@ -176,6 +214,10 @@ Paint::Paint( controllers->stickersPanelController->stickerChosen( ) | rpl::map_to(ShowRequest::HideAnimated), controllers->stickersPanelController->photoRequests( + ) | rpl::map_to(ShowRequest::HideAnimated), + controllers->stickersPanelController->audioRequests( + ) | rpl::map_to(ShowRequest::HideAnimated), + controllers->stickersPanelController->linkRequests( ) | rpl::map_to(ShowRequest::HideAnimated))); controllers->stickersPanelController->stickerChosen( @@ -189,6 +231,16 @@ Paint::Paint( ) | rpl::on_next([=] { choosePhotoFile(); }, lifetime()); + + controllers->stickersPanelController->audioRequests( + ) | rpl::on_next([=] { + chooseAudioFile(); + }, lifetime()); + + controllers->stickersPanelController->linkRequests( + ) | rpl::on_next([=] { + chooseLink(QString()); + }, lifetime()); } _scene->pendingShapeStates( @@ -235,7 +287,7 @@ bool Paint::zoomSceneItems(float64 wheelDelta, bool fine) { } bool Paint::zoomSceneItemsByFactor(float64 factor) { - const auto center = rect::center(_scene->sceneRect()); + const auto center = rect::center(_scene->canvasRect()); auto applied = false; for (const auto &item : _scene->items()) { const auto raw = item.get(); @@ -378,28 +430,37 @@ Paint::~Paint() { } void Paint::updateViewGeometry() { - if (_imageGeometry.isEmpty()) { + if (_canvasGeometry.isEmpty()) { return; } const auto target = (_transform.userZoom - kMinCanvasZoom) > kZoomEpsilon ? _outerGeometry - : _imageGeometry; + : _canvasGeometry; if (geometry() != target) { setGeometry(target); } _view->setGeometry(rect()); } -void Paint::applyTransform(QRect geometry, int angle, bool flipped) { - if (geometry.isEmpty()) { +void Paint::applyTransform( + QRect geometry, + QRect canvasGeometry, + QRectF canvas, + int angle, + bool flipped) { + if (geometry.isEmpty() || canvasGeometry.isEmpty()) { return; } - _imageGeometry = geometry; + _canvasGeometry = canvasGeometry; + _canvas = canvas; _outerGeometry = parentWidget() ? parentWidget()->rect() : geometry; + _view->setSceneRect((canvas == _scene->sceneRect()) ? QRectF() : canvas); + _scene->setCanvasRect(canvas); const auto center = (_transform.fitZoom <= 0.) || _view->viewport()->rect().isEmpty() - ? rect::center(_scene->sceneRect()) + || (_transform.userZoom == kMinCanvasZoom) + ? rect::center(canvas) : _view->mapToScene(_view->viewport()->rect().center()); const auto size = geometry.size(); @@ -431,7 +492,7 @@ void Paint::applyTransform(QRect geometry, int angle, bool flipped) { std::shared_ptr Paint::saveScene() const { _scene->save(SaveState::Save); - return _scene->items().empty() + return (_scene->items().empty() && !_scene->audio()) ? nullptr : _scene; } @@ -510,9 +571,18 @@ void Paint::disarmShapeTool() { } bool Paint::handleKeyPress(not_null e) { - if ((e->key() == Qt::Key_Escape) && _scene->hasPendingShape()) { + const auto key = e->key(); + if ((key == Qt::Key_Escape) && _scene->hasPendingShape()) { disarmShapeTool(); return true; + } else if (!_scene->audioSelected()) { + return false; + } else if (key == Qt::Key_Escape) { + _scene->setAudioSelected(false); + return true; + } else if ((key == Qt::Key_Delete) || (key == Qt::Key_Backspace)) { + removeAudio(); + return true; } return false; } @@ -521,6 +591,50 @@ void Paint::clearSelection() { _scene->clearSelection(); } +void Paint::removeAudio() { + _scene->setAudio(nullptr); +} + +void Paint::setAudioSelected(bool selected) { + _scene->setAudioSelected(selected); +} + +bool Paint::canEqualizeDurations() const { + return _scene->canEqualizeDurations(); +} + +void Paint::matchDurations(crl::time duration) { + _scene->matchDurations(duration); +} + +bool Paint::durationsLinked() const { + return _scene->durationsLinked(); +} + +void Paint::setDurationsLinked(bool linked) { + _scene->setDurationsLinked(linked); +} + +rpl::producer<> Paint::durationsLinkChanges() const { + return _scene->durationsLinkChanges(); +} + +std::shared_ptr Paint::audio() const { + return _scene->audio(); +} + +bool Paint::audioSelected() const { + return _scene->audioSelected(); +} + +rpl::producer<> Paint::audioChanges() const { + return _scene->audioChanges(); +} + +rpl::producer Paint::audioSelectedChanges() const { + return _scene->audioSelectedChanges(); +} + void Paint::applyTextPrefs(const TextPrefs &prefs) { _scene->applyTextPrefs(prefs); } @@ -557,6 +671,11 @@ rpl::producer Paint::shapeItemSelections() const { return _scene->shapeItemSelections(); } +auto Paint::videoClipSelections() const +-> rpl::producer> { + return _scene->videoClipSelections(); +} + rpl::producer<> Paint::shapeItemDeselections() const { return _scene->shapeItemDeselections(); } @@ -566,12 +685,25 @@ rpl::producer Paint::shapeToolStates() const { } bool Paint::canHandleMimeData(const QMimeData *data) const { - return data - && !_textEditing.current() - && Storage::ValidatePhotoEditorMediaDragData(data, _composeAnimated); + if (!data || _textEditing.current()) { + return false; + } else if (session() + && (IsForwardMimeData(data) || !MimeLinkUrl(data).isEmpty())) { + return true; + } + return Storage::ValidatePhotoEditorMediaDragData( + data, + _composeAnimated, + _composeSound); } void Paint::handleMimeData(const QMimeData *data) { + if (IsForwardMimeData(data)) { + if (const auto session = Paint::session()) { + addMessages(session->data().takeMimeForwardIds()); + } + return; + } const auto urls = Core::ReadMimeUrls(data); if (urls.size() == 1 && urls.front().isLocalFile()) { readMediaFile( @@ -579,22 +711,168 @@ void Paint::handleMimeData(const QMimeData *data) { QByteArray()); } else if (auto read = Core::ReadMimeImage(data)) { addMedia({ .image = std::move(read.image) }); + } else if (const auto url = MimeLinkUrl(data); !url.isEmpty()) { + chooseLink(url); } else { addMedia({}); } } +Main::Session *Paint::session() const { + const auto &show = _controllers->sessionShow; + return show ? &show->session() : nullptr; +} + +void Paint::addMessages(const MessageIdsList &ids) { + const auto session = Paint::session(); + if (!session) { + return; + } + auto added = base::flat_set>(); + auto forbidden = (HistoryItem*)nullptr; + auto index = 0; + for (const auto &id : ids) { + const auto item = session->data().message(id); + if (!item) { + continue; + } + const auto render = MessageToRender(item); + if (!added.emplace(render).second) { + continue; + } else if (MessageForbidsRender(render)) { + forbidden = render; + continue; + } else if (!CanRenderMessage(render)) { + if (render->hasDirectLink()) { + addLinkItem({ + .url = session->api().exportDirectMessageLink( + render, + false), + .preview = false, + }); + } + continue; + } + addMessageItem(std::make_shared(render), index++); + } + if (forbidden) { + _controllers->show->showBox(Ui::MakeInformBox( + forbidden->history()->peer->isBroadcast() + ? tr::lng_error_noforwards_channel() + : tr::lng_error_noforwards_group())); + } +} + +void Paint::addMessageItem( + std::shared_ptr source, + int index, + std::optional dark, + std::optional position) { + auto renderer = std::make_unique(source); + renderer->setDark(dark); + auto data = messageItemData(renderer->size()); + const auto scene = _scene->sceneRect().size(); + const auto shift = int(std::min(scene.width(), scene.height()) + * kMessagesCascadeRatio) * index; + data.x = position ? int(position->x()) : (data.x + shift); + data.y = position ? int(position->y()) : (data.y + shift); + const auto item = std::make_shared( + std::move(source), + std::move(renderer), + std::move(data), + dark, + MessageVideoOptions{ + .play = _composeAnimated, + .sound = _composeSound, + }); + item->setEditCallback(crl::guard(this, [=]( + not_null item) { + const auto &link = item->source()->link(); + chooseLink(link ? link->url : QString(), item); + })); + addMediaItem(item); +} + +void Paint::addLinkItem(LinkPreview link, std::optional position) { + auto data = itemBaseData(); + data.size = std::max( + int(std::ceil(ItemLink::MakePill(link, _imageSize).size().width())), + 1); + if (position) { + data.x = int(position->x()); + data.y = int(position->y()); + } + const auto item = std::make_shared(std::move(link), data); + item->setEditCallback(crl::guard(this, [=](not_null item) { + chooseLink(item->link().url, item); + })); + addMediaItem(item); +} + +void Paint::chooseLink(const QString &url, ItemBase *editing) { + const auto &show = _controllers->sessionShow; + if (!show) { + return; + } + auto link = std::optional(); + if (!editing) { + } else if (editing->type() == ItemMessage::Type) { + const auto item = static_cast(editing); + link = item->source()->link(); + if (link) { + link->dark = item->dark().value_or( + Window::Theme::IsNightMode()); + } + } else if (editing->type() == ItemLink::Type) { + link = static_cast(editing)->link(); + } + const auto weak = editing + ? std::weak_ptr(_scene->itemShared(editing)) + : std::weak_ptr(); + _controllers->layerShow->showBox(LinkBox({ + .show = show, + .url = url, + .editing = link, + .done = crl::guard(this, [=](LinkBoxResult &&result) { + applyLinkResult(std::move(result), weak); + }), + })); +} + +void Paint::applyLinkResult( + LinkBoxResult &&result, + std::weak_ptr editing) { + const auto strong = editing.lock(); + const auto raw = (strong && strong->isNormalStatus()) + ? strong.get() + : nullptr; + if (raw && result.message && raw->type() == ItemMessage::Type) { + const auto item = static_cast(raw); + item->setSource(std::move(result.message)); + item->setDark(result.dark); + return; + } else if (raw && result.pill && raw->type() == ItemLink::Type) { + static_cast(raw)->setLink(std::move(*result.pill)); + return; + } + const auto position = raw + ? std::make_optional(raw->scenePos()) + : std::nullopt; + if (raw) { + _scene->removeItem(strong); + } + if (result.message) { + addMessageItem(std::move(result.message), 0, result.dark, position); + } else if (result.pill) { + addLinkItem(std::move(*result.pill), position); + } +} + void Paint::readMediaFile(const QString &path, const QByteArray &content) { - const auto done = crl::guard(this, [=]( + Storage::ReadPhotoEditorMediaAsync(path, content, crl::guard(this, [=]( Storage::PhotoEditorMedia &&media) { addMedia(std::move(media)); - }); - crl::async([=] { - auto media = Storage::ReadPhotoEditorMedia(path, content); - crl::on_main([=, media = std::move(media)]() mutable { - done(std::move(media)); - }); - }); + })); } void Paint::choosePhotoFile() { @@ -615,9 +893,52 @@ void Paint::choosePhotoFile() { crl::guard(this, callback)); } +void Paint::chooseAudioFile() { + const auto callback = [=](FileDialog::OpenResult &&result) { + if (result.paths.isEmpty() && result.remoteContent.isEmpty()) { + return; + } + readAudioFile( + result.paths.isEmpty() ? QString() : result.paths.front(), + result.remoteContent); + }; + FileDialog::GetOpenPath( + this, + tr::lng_choose_audio(tr::now), + FileDialog::AudioFilesFilter(), + crl::guard(this, callback)); +} + +void Paint::readAudioFile(const QString &path, const QByteArray &content) { + const auto done = crl::guard(this, [=](AudioTrack &&track) { + addAudio(std::move(track)); + }); + crl::async([=] { + auto track = Storage::ReadPhotoEditorAudio(path, content); + crl::on_main([=, track = std::move(track)]() mutable { + done(std::move(track)); + }); + }); +} + +void Paint::addAudio(AudioTrack &&track) { + if (track.empty() || (track.duration <= 0)) { + _controllers->show->showBox( + Ui::MakeInformBox(tr::lng_edit_media_invalid_file())); + return; + } + disarmShapeTool(); + _scene->setAudio(std::make_shared(std::move(track))); + _scene->setAudioSelected(true); +} + void Paint::addMedia(Storage::PhotoEditorMedia &&media) { const auto &image = media.image; - if (!media + if (!media.audio.empty() && _composeSound) { + addAudio(std::move(media.audio)); + return; + } else if (!media + || !media.audio.empty() || (media.video() && !_composeAnimated) || !Ui::ValidateThumbDimensions(image.width(), image.height())) { _controllers->show->showBox( @@ -633,11 +954,12 @@ void Paint::addMedia(Storage::PhotoEditorMedia &&media) { void Paint::addVideoItem(Storage::PhotoEditorMedia &&media) { const auto data = mediaItemData(media.image.size()); addMediaItem(std::make_shared( - std::make_shared(ItemVideo::Source{ + std::make_shared(VideoClipSource{ .path = std::move(media.videoPath), .content = std::move(media.videoContent), .thumbnail = std::move(media.image), .duration = media.videoDuration, + .hasAudio = media.videoHasAudio && _composeSound, }), data)); } @@ -651,7 +973,8 @@ void Paint::addImageItem(QImage &&image) { Qt::KeepAspectRatio, Qt::SmoothTransformation); } - const auto data = mediaItemData(image.size()); + auto data = mediaItemData(image.size()); + data.maxSizeRatio = kImageMaxSizeRatio; addMediaItem(std::make_shared( Ui::PixmapFromImage(std::move(image)), data)); @@ -666,6 +989,34 @@ void Paint::addMediaItem(std::shared_ptr item) { _view->setFocus(); } +void Paint::setCanvasBackground( + const Media::Encode::CanvasBackground &background) { + _background = background; +} + +void Paint::setCropRect(QRectF crop) { + _cropRect = crop; +} + +void Paint::paintCanvas(QPainter &p) const { + const auto image = QRectF(Rect(_imageSize)); + if (_view->geometry().isEmpty() + || !_background.valid() + || image.contains(_canvas)) { + return; + } + const auto transform = _view->viewportTransform(); + const auto imageDisplay = transform.mapRect(image).toRect(); + const auto cropDisplay = transform.mapRect(_cropRect).toAlignedRect(); + p.save(); + p.translate(pos()); + p.setClipRegion( + QRegion(rect()) - QRegion(imageDisplay), + Qt::IntersectClip); + Media::Encode::PaintCanvasBackground(p, cropDisplay, _background); + p.restore(); +} + void Paint::paintImage(QPainter &p, const QPixmap &image) const { if (_view->geometry().isEmpty()) { return; @@ -687,7 +1038,7 @@ void Paint::resetView() { _transform.userZoom = kMinCanvasZoom; updateViewGeometry(); applyViewTransform(); - _view->centerOn(rect::center(_scene->sceneRect())); + _view->centerOn(rect::center(_canvas)); if (const auto parent = parentWidget()) { parent->update(geometry()); } @@ -696,20 +1047,38 @@ void Paint::resetView() { ItemBase::Data Paint::itemBaseData() const { const auto s = _scene->sceneRect().toRect().size(); const auto size = std::min(s.width(), s.height()) / 2; - const auto x = s.width() / 2; - const auto y = s.height() / 2; + const auto center = rect::center(_scene->canvasRect().toRect()); return ItemBase::Data{ .initialZoom = _transform.zoom, .zPtr = _scene->lastZ(), .size = size, - .x = x, - .y = y, + .x = center.x(), + .y = center.y(), .flipped = _transform.flipped, .rotation = -_transform.angle, .imageSize = _imageSize, }; } +ItemBase::Data Paint::messageItemData(QSize bubbleSize) const { + auto result = itemBaseData(); + if (bubbleSize.isEmpty()) { + return result; + } + const auto scene = _scene->sceneRect().size(); + const auto scale = scene.width() + / float64(st::photoEditorMessageReferenceWidth); + const auto width = bubbleSize.width() * scale; + const auto height = bubbleSize.height() * scale; + const auto fit = std::min({ + 1., + scene.width() * kMessageMaxWidthRatio / width, + scene.height() * kMessageMaxHeightRatio / height, + }); + result.size = std::max(int(std::ceil(width * fit)), 1); + return result; +} + ItemBase::Data Paint::mediaItemData(QSize mediaSize) const { auto result = itemBaseData(); if (mediaSize.isEmpty()) { diff --git a/Telegram/SourceFiles/editor/editor_paint.h b/Telegram/SourceFiles/editor/editor_paint.h index d82b04d89a..3491c329e7 100644 --- a/Telegram/SourceFiles/editor/editor_paint.h +++ b/Telegram/SourceFiles/editor/editor_paint.h @@ -8,17 +8,23 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "base/timer.h" +#include "data/data_types.h" #include "ui/rp_widget.h" #include "ui/effects/animations.h" #include "editor/photo_editor_common.h" #include "editor/photo_editor_inner_common.h" #include "editor/scene/scene_item_base.h" +#include "media/media_video_canvas.h" class QGraphicsItem; class QGraphicsView; class QKeyEvent; +namespace Main { +class Session; +} // namespace Main + namespace Storage { struct PhotoEditorMedia; } // namespace Storage @@ -26,7 +32,11 @@ struct PhotoEditorMedia; namespace Editor { struct Controllers; +struct LinkBoxResult; +struct LinkPreview; +class MessageSource; class Scene; +class VideoClip; // Paint control. class Paint final : public Ui::RpWidget { @@ -43,7 +53,12 @@ public: [[nodiscard]] std::shared_ptr saveScene() const; void restoreScene(); - void applyTransform(QRect geometry, int angle, bool flipped); + void applyTransform( + QRect geometry, + QRect canvasGeometry, + QRectF canvas, + int angle, + bool flipped); void applyBrush(const Brush &brush); void cancel(); void keepResult(); @@ -54,6 +69,17 @@ public: void armShapeTool(ShapeType shape, const Brush &brush, bool fill); void disarmShapeTool(); void clearSelection(); + void removeAudio(); + void setAudioSelected(bool selected); + [[nodiscard]] bool canEqualizeDurations() const; + void matchDurations(crl::time duration); + [[nodiscard]] bool durationsLinked() const; + void setDurationsLinked(bool linked); + [[nodiscard]] rpl::producer<> durationsLinkChanges() const; + [[nodiscard]] std::shared_ptr audio() const; + [[nodiscard]] bool audioSelected() const; + [[nodiscard]] rpl::producer<> audioChanges() const; + [[nodiscard]] rpl::producer audioSelectedChanges() const; void applyTextPrefs(const TextPrefs &prefs); void setTextColor(const QColor &color); void setSelectedTextColor(const QColor &color); @@ -69,9 +95,15 @@ public: [[nodiscard]] rpl::producer shapeItemSelections() const; [[nodiscard]] rpl::producer<> shapeItemDeselections() const; [[nodiscard]] rpl::producer shapeToolStates() const; + [[nodiscard]] auto videoClipSelections() const + -> rpl::producer>; [[nodiscard]] bool canHandleMimeData(const QMimeData *data) const; void handleMimeData(const QMimeData *data); + void setCanvasBackground( + const Media::Encode::CanvasBackground &background); + void setCropRect(QRectF crop); + void paintCanvas(QPainter &p) const; void paintImage(QPainter &p, const QPixmap &image) const; void resetView(); @@ -92,14 +124,32 @@ private: bool undid = false; }; + [[nodiscard]] Main::Session *session() const; ItemBase::Data itemBaseData() const; ItemBase::Data mediaItemData(QSize mediaSize) const; + ItemBase::Data messageItemData(QSize bubbleSize) const; void addMediaItem(std::shared_ptr item); + void addMessages(const MessageIdsList &ids); + void addMessageItem( + std::shared_ptr source, + int index = 0, + std::optional dark = std::nullopt, + std::optional position = std::nullopt); + void addLinkItem( + LinkPreview link, + std::optional position = std::nullopt); + void chooseLink(const QString &url, ItemBase *editing = nullptr); + void applyLinkResult( + LinkBoxResult &&result, + std::weak_ptr editing); void addMedia(Storage::PhotoEditorMedia &&media); void readMediaFile(const QString &path, const QByteArray &content); void addImageItem(QImage &&image); void addVideoItem(Storage::PhotoEditorMedia &&media); void choosePhotoFile(); + void chooseAudioFile(); + void readAudioFile(const QString &path, const QByteArray &content); + void addAudio(AudioTrack &&track); void applyViewTransform(); void bakeTextScales(); @@ -112,8 +162,12 @@ private: const QSize _imageSize; const bool _fixedCrop = false; const bool _composeAnimated = false; - QRect _imageGeometry; + const bool _composeSound = false; + QRect _canvasGeometry; QRect _outerGeometry; + QRectF _canvas; + QRectF _cropRect; + Media::Encode::CanvasBackground _background; struct { int angle = 0; diff --git a/Telegram/SourceFiles/editor/editor_trim_timeline.cpp b/Telegram/SourceFiles/editor/editor_trim_timeline.cpp new file mode 100644 index 0000000000..c94f1e3661 --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_trim_timeline.cpp @@ -0,0 +1,1205 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/editor_trim_timeline.h" + +#include "base/event_filter.h" +#include "ui/widgets/fields/masked_input_field.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "ui/text/format_values.h" +#include "ui/ui_utility.h" +#include "styles/style_editor.h" + +#include +#include +#include + +namespace Editor { +namespace { + +constexpr auto kZoomStep = 1.2; +constexpr auto kZoomSnap = 0.001; +constexpr auto kMinVisibleSpan = crl::time(2000); +constexpr auto kMinSelection = crl::time(100); +constexpr auto kCompactHandleSpans = 3; +constexpr auto kEdgeScrollFrame = crl::time(16); +constexpr auto kEdgeScrollSpeed = 9.; // Per second per pixel of overshoot. +constexpr auto kEdgeScrollMaxSpeed = 3.; // Strip widths per second. +constexpr auto kScrollIndicatorOpacity = 0.5; +constexpr auto kScrollIndicatorFadeZoom = 0.25; +constexpr auto kOverviewTrackOpacity = 0.18; +constexpr auto kHintBgOpacityBoost = 1.6; +constexpr auto kHintDuration = crl::time(150); +constexpr auto kScrollToSelectionDuration = crl::time(250); + +constexpr auto kStampPrecision = 3; + +[[nodiscard]] QString Stamp(crl::time value, int precision = 1) { + const auto digits = QString::number(value % 1000).rightJustified( + kStampPrecision, + '0'); + return Ui::FormatDurationText(int(value / 1000)) + + '.' + + digits.left(precision); +} + +[[nodiscard]] bool IsRangeDash(QChar ch) { + return (ch == '-') || (ch == QChar(0x2013)) || (ch == QChar(0x2014)); +} + +[[nodiscard]] bool IsStampChar(QChar ch) { + return (ch >= '0' && ch <= '9') + || (ch == ':') + || (ch == '.') + || (ch == ',') + || (ch == ' ') + || IsRangeDash(ch); +} + +[[nodiscard]] std::optional ParseStamp(QStringView text) { + const auto parts = text.split(':'); + if (parts.isEmpty() || parts.size() > 3) { + return std::nullopt; + } + auto seconds = crl::time(0); + for (auto i = 0; i + 1 < parts.size(); ++i) { + auto ok = false; + const auto value = parts[i].toInt(&ok); + if (!ok || value < 0) { + return std::nullopt; + } + seconds = seconds * 60 + value; + } + const auto last = parts.back(); + const auto dot = std::max(last.indexOf('.'), last.indexOf(',')); + const auto whole = (dot >= 0) ? last.left(dot) : last; + const auto fraction = (dot >= 0) ? last.mid(dot + 1) : QStringView(); + if (whole.isEmpty() && fraction.isEmpty()) { + return std::nullopt; + } + auto ok = true; + const auto wholeValue = whole.isEmpty() ? 0 : whole.toInt(&ok); + if (!ok || wholeValue < 0) { + return std::nullopt; + } + const auto digits = fraction.left(kStampPrecision); + const auto fractionValue = digits.isEmpty() ? 0 : digits.toInt(&ok); + if (!ok || fractionValue < 0) { + return std::nullopt; + } + auto milliseconds = crl::time(fractionValue); + for (auto i = digits.size(); i < kStampPrecision; ++i) { + milliseconds *= 10; + } + return (seconds * 60 + wholeValue) * 1000 + milliseconds; +} + +[[nodiscard]] QStringList SplitStamps(const QString &text) { + auto result = QStringList(); + auto current = QString(); + for (const auto ch : text) { + if (!ch.isSpace() && !IsRangeDash(ch)) { + current.append(ch); + } else if (!current.isEmpty()) { + result.push_back(base::take(current)); + } + } + if (!current.isEmpty()) { + result.push_back(current); + } + return result; +} + +[[nodiscard]] int HandleWidth(int selectionWidth) { + const auto full = st::videoTimelineHandleWidth; + const auto compact = st::videoTimelineHandleMinWidth; + const auto threshold = full * kCompactHandleSpans; + if (selectionWidth >= threshold) { + return full; + } + return compact + int(base::SafeRound( + (full - compact) * std::max(selectionWidth, 0) / float64(threshold))); +} + +class DurationInput final : public Ui::MaskedInputField { +public: + DurationInput(QWidget *parent, const QString &value); + +protected: + void correctValue( + const QString &was, + int wasCursor, + QString &now, + int &nowCursor) override; + +}; + +DurationInput::DurationInput(QWidget *parent, const QString &value) +: MaskedInputField( + parent, + st::videoTimelineDurationField, + rpl::single(QString()), + value) { +} + +void DurationInput::correctValue( + const QString &was, + int wasCursor, + QString &now, + int &nowCursor) { + auto result = QString(); + result.reserve(now.size()); + auto cursor = nowCursor; + for (auto i = 0; i != now.size(); ++i) { + const auto ch = now[i]; + const auto digit = ch.digitValue(); + if (digit >= 0) { + result.append(QChar('0' + digit)); + } else if (IsStampChar(ch)) { + result.append(ch); + } else if (i < nowCursor) { + --cursor; + } + } + if (result != now) { + setCorrectedText(now, nowCursor, result, cursor); + } +} + +} // namespace + +TrimTimeline::TrimTimeline( + not_null parent, + TrimTimelineDescriptor descriptor) +: RpWidget(parent) +, _duration(std::max(descriptor.duration, crl::time(1))) +, _maxDuration((descriptor.maxDuration > 0) + ? std::min(descriptor.maxDuration, _duration) + : _duration) +, _minDuration(std::min(descriptor.minDuration, _maxDuration)) +, _trimOnly(descriptor.trimOnly) +, _from(0) +, _till(_maxDuration) +, _cover(0) +, _edgeScrollAnimation([=](crl::time now) { return edgeScrollStep(now); }) { + setMouseTracking(true); + + _from = std::clamp( + descriptor.from, + crl::time(0), + std::max(_duration - _minDuration, crl::time(0))); + const auto limit = std::min(_from + _maxDuration, _duration); + _till = (descriptor.till > _from) + ? std::min(descriptor.till, limit) + : limit; + _cover = std::clamp(descriptor.cover, _from, _till); + const auto &font = st::videoTimelineDurationStyle.font; + const auto separator = QString::fromUtf8(" – "); + if (_trimOnly) { + _labelWidth = font->width( + Stamp(_duration) + separator + Stamp(_duration)); + } + const auto widest = Stamp(_duration, kStampPrecision) + + separator + + Stamp(_duration, kStampPrecision); + _durationFieldWidth = font->width(widest) + + st::videoTimelineDurationFieldSkip; + + sizeValue( + ) | rpl::on_next([=] { + updateDurationFieldGeometry(); + updateHints(); + }, lifetime()); +} + +int TrimTimeline::resizeGetHeight(int newWidth) { + const auto playhead = st::videoTimelinePlayheadOverflow + + st::videoTimelinePlayheadOutline; + if (_trimOnly) { + return playhead * 2 + st::videoTimelineTrimStripHeight; + } + return st::videoTimelineLabelHeight + + st::videoTimelineLabelSkip + + st::videoTimelineStripHeight + + playhead; +} + +QRect TrimTimeline::stripRect() const { + const auto handle = st::videoTimelineHandleWidth; + if (_trimOnly) { + const auto reserved = _durationField + ? _durationFieldWidth + : _labelWidth; + const auto label = reserved + st::videoTimelineTrimLabelSkip; + return QRect( + handle, + st::videoTimelinePlayheadOverflow + + st::videoTimelinePlayheadOutline, + std::max(width() - handle * 2 - label, 1), + st::videoTimelineTrimStripHeight); + } + const auto top = st::videoTimelineLabelHeight + + st::videoTimelineLabelSkip; + return QRect( + handle, + top, + std::max(width() - handle * 2, 1), + st::videoTimelineStripHeight); +} + +QRect TrimTimeline::labelRect() const { + if (_trimOnly) { + return QRect(width() - _labelWidth, 0, _labelWidth, height()); + } + return QRect(0, 0, width(), st::videoTimelineLabelHeight); +} + +TrimTimeline::DurationLabel TrimTimeline::durationLabel() const { + const auto text = (_till - _from >= _duration) + ? Stamp(_till - _from) + : (Stamp(_from) + QString::fromUtf8(" – ") + Stamp(_till)); + const auto label = labelRect(); + const auto &font = st::videoTimelineDurationStyle.font; + const auto width = font->width(text); + const auto top = label.y() + (label.height() - font->height) / 2; + if (_trimOnly) { + return { + .text = text, + .rect = QRect( + rect::right(label) - width, + top, + width, + font->height), + }; + } + + const auto sizeWidth = _sizeLabel.isEmpty() + ? 0 + : font->width(_sizeLabel); + const auto skip = st::videoTimelineSizeSkip; + const auto sizeShown = sizeWidth + && (width + skip + sizeWidth <= label.width()); + const auto available = sizeShown + ? (label.width() - sizeWidth - skip) + : label.width(); + const auto shown = (width <= available) + ? text + : font->elided(text, available); + const auto shownWidth = std::min(width, available); + const auto strip = stripRect(); + const auto visibleLeft = std::max(xAt(_from), strip.x()); + const auto visibleRight = std::min(xAt(_till), rect::right(strip)); + const auto center = (visibleLeft + visibleRight) / 2; + const auto x = std::clamp( + center - shownWidth / 2, + label.x(), + label.x() + std::max(available - shownWidth, 0)); + return { + .text = shown, + .rect = QRect(x, top, shownWidth, font->height), + .sizeShown = sizeShown, + }; +} + +QRect TrimTimeline::durationHitRect() const { + const auto label = labelRect(); + if (_trimOnly) { + return label; + } + const auto text = durationLabel().rect; + return QRect(text.x(), label.y(), text.width(), label.height()); +} + +QRect TrimTimeline::durationFieldRect() const { + const auto label = labelRect(); + const auto &font = st::videoTimelineDurationStyle.font; + const auto top = label.y() + (label.height() - font->height) / 2; + const auto height = font->height; + const auto width = _durationFieldWidth; + if (_trimOnly) { + return QRect(rect::right(label) - width, top, width, height); + } + const auto painted = durationLabel(); + const auto available = painted.sizeShown + ? (label.width() + - font->width(_sizeLabel) + - st::videoTimelineSizeSkip) + : label.width(); + const auto center = rect::center(painted.rect).x(); + const auto x = std::clamp( + center - width / 2, + label.x(), + label.x() + std::max(available - width, 0)); + return QRect(x, top, width, height); +} + +QString TrimTimeline::durationEditText() const { + return Stamp(_from, kStampPrecision) + + QString::fromUtf8(" – ") + + Stamp(_till, kStampPrecision); +} + +void TrimTimeline::updateDurationFieldGeometry() { + if (_durationField) { + _durationField->setGeometry(durationFieldRect()); + } +} + +void TrimTimeline::editDuration() { + if (_durationField) { + return; + } + _scrollAnimation.stop(); + _durationFocusReturn = QApplication::focusWidget(); + _durationField = base::make_unique_q( + this, + durationEditText()); + const auto field = _durationField.get(); + field->setAlignment(Qt::AlignVCenter + | (_trimOnly ? Qt::AlignRight : Qt::AlignHCenter)); + field->setGeometry(durationFieldRect()); + field->show(); + field->selectAll(); + field->setFocus(); + base::install_event_filter(field, [=](not_null e) { + const auto type = e->type(); + if (type == QEvent::KeyPress) { + const auto key = static_cast(e.get())->key(); + if (key == Qt::Key_Escape) { + finishDurationEdit(false, true); + return base::EventFilterResult::Cancel; + } else if (key == Qt::Key_Return || key == Qt::Key_Enter) { + finishDurationEdit(true, true); + return base::EventFilterResult::Cancel; + } + } else if (type == QEvent::FocusOut) { + const auto reason = static_cast(e.get())->reason(); + if (reason != Qt::PopupFocusReason + && reason != Qt::ActiveWindowFocusReason) { + finishDurationEdit(true, false); + } + } + return base::EventFilterResult::Continue; + }); + updateHints(); + update(); +} + +void TrimTimeline::finishDurationEdit(bool apply, bool restoreFocus) { + const auto field = _durationField.release(); + if (!field) { + return; + } + const auto text = field->text(); + const auto focusReturn = base::take(_durationFocusReturn).data(); + if (restoreFocus && focusReturn && focusReturn->isVisible()) { + focusReturn->setFocus(); + } + field->hide(); + field->deleteLater(); + if (apply) { + applyDurationText(text); + } + updateHints(); + update(); +} + +void TrimTimeline::commitPendingEdit() { + finishDurationEdit(true, false); +} + +void TrimTimeline::applyDurationText(const QString &text) { + const auto parts = SplitStamps(text); + if (parts.isEmpty() || parts.size() > 2) { + return; + } + const auto till = ParseStamp(parts.back()); + const auto from = (parts.size() > 1) + ? ParseStamp(parts.front()) + : std::optional(_from); + if (!from || !till) { + return; + } + setTrim(std::min(*from, *till), std::max(*from, *till)); + if (selectionHiddenLeft() || selectionHiddenRight()) { + scrollToSelection(); + } +} + +void TrimTimeline::moveWindowTo(crl::time center) { + const auto span = _till - _from; + const auto half = span / 2; + const auto from = std::clamp( + center - half, + crl::time(0), + std::max(_duration - span, crl::time(0))); + if (_from == from) { + return; + } + _from = from; + _till = from + span; + setCover(std::clamp(_cover, _from, _till), true); + _trimChanges.fire_copy(_from); + updateHints(); +} + +float64 TrimTimeline::visibleSpan() const { + return _duration / _zoom; +} + +crl::time TrimTimeline::visibleFrom() const { + return crl::time(base::SafeRound(_visibleFrom)); +} + +crl::time TrimTimeline::visibleTill() const { + return std::min( + crl::time(base::SafeRound(_visibleFrom + visibleSpan())), + _duration); +} + +crl::time TrimTimeline::timeAt(int x) const { + const auto strip = stripRect(); + if (strip.width() <= 0) { + return 0; + } + const auto shift = std::clamp(x - strip.x(), 0, strip.width()); + return std::clamp( + crl::time(base::SafeRound( + _visibleFrom + shift * visibleSpan() / strip.width())), + crl::time(0), + _duration); +} + +int TrimTimeline::xAt(crl::time time) const { + const auto strip = stripRect(); + const auto clamped = std::clamp(time, crl::time(0), _duration); + return strip.x() + int(base::SafeRound( + (clamped - _visibleFrom) * strip.width() / visibleSpan())); +} + +bool TrimTimeline::draggingHead() const { + return (_grab == Grab::Head); +} + +void TrimTimeline::setTrim(crl::time from, crl::time till) { + const auto minimum = minSelection(); + from = std::clamp( + from, + crl::time(0), + std::max(_duration - minimum, crl::time(0))); + const auto limit = std::min(from + _maxDuration, _duration); + till = std::clamp(till, std::min(from + minimum, limit), limit); + if (_from == from && _till == till) { + return; + } + _from = from; + _till = till; + setCover(std::clamp(_cover, _from, _till), true); + _trimChanges.fire_copy(_from); + if (_durationField) { + _durationField->setText(durationEditText()); + } + updateHints(); + update(); +} + +void TrimTimeline::setPlaybackPosition(crl::time position) { + const auto clamped = std::clamp(position, _from, _till); + if (_playback == clamped) { + return; + } + _playback = clamped; + update(); +} + +void TrimTimeline::setSizeLabel(const QString &text) { + if (_sizeLabel == text) { + return; + } + _sizeLabel = text; + update(); +} + +float64 TrimTimeline::maxZoom() const { + return std::max(_duration / float64(kMinVisibleSpan), 1.); +} + +bool TrimTimeline::setVisibleRange(float64 zoom, float64 from) { + zoom = std::clamp(zoom, 1., maxZoom()); + if (zoom < 1. + kZoomSnap) { + zoom = 1.; + } + const auto span = _duration / zoom; + from = (zoom > 1.) ? std::clamp(from, 0., _duration - span) : 0.; + if (_zoom == zoom && _visibleFrom == from) { + return false; + } + _zoom = zoom; + _visibleFrom = from; + visibleRangeChanged(); + if (grabMovesSelection()) { + applyGrab(_dragPosition); + } + updateDurationFieldGeometry(); + updateHints(); + update(); + return true; +} + +void TrimTimeline::zoomBy(float64 factor, int anchorX) { + _scrollAnimation.stop(); + const auto strip = stripRect(); + if (strip.width() <= 0 || factor <= 0.) { + return; + } + const auto shift = std::clamp(anchorX - strip.x(), 0, strip.width()); + const auto anchor = _visibleFrom + shift * visibleSpan() / strip.width(); + const auto zoom = std::clamp(_zoom * factor, 1., maxZoom()); + const auto span = _duration / zoom; + setVisibleRange(zoom, anchor - shift * span / strip.width()); +} + +bool TrimTimeline::scrollBy(float64 pixels) { + _scrollAnimation.stop(); + const auto strip = stripRect(); + if (strip.width() <= 0 || _zoom <= 1.) { + return false; + } + return setVisibleRange( + _zoom, + _visibleFrom + pixels * visibleSpan() / strip.width()); +} + +void TrimTimeline::updateEdgeScroll(QPoint position) { + const auto strip = stripRect(); + const auto x = position.x(); + const auto right = rect::right(strip); + _edgeOvershoot = (x < strip.x()) + ? (x - strip.x()) + : (x > right) + ? (x - right) + : 0; + const auto active = _edgeOvershoot + && (_zoom > 1.) + && grabMovesSelection(); + if (!active) { + _edgeScrollAnimation.stop(); + } else if (!_edgeScrollAnimation.animating()) { + _edgeScrollLast = crl::now(); + _edgeScrollAnimation.start(); + } +} + +bool TrimTimeline::edgeScrollStep(crl::time now) { + const auto dt = std::min(now - _edgeScrollLast, kEdgeScrollFrame); + _edgeScrollLast = now; + if (dt <= 0) { + return true; + } + const auto strip = stripRect(); + const auto seconds = dt / 1000.; + const auto limit = std::max( + strip.width() * kEdgeScrollMaxSpeed * seconds, + 1.); + const auto pixels = std::clamp( + _edgeOvershoot * kEdgeScrollSpeed * seconds, + -limit, + limit); + return !grabClamped(pixels > 0.) && scrollBy(pixels); +} + +bool TrimTimeline::grabClamped(bool forward) const { + const auto minimum = minSelection(); + switch (_grab) { + case Grab::Left: + return forward ? (_from >= _till - minimum) : (_from <= 0); + case Grab::Right: + return forward ? (_till >= _duration) : (_till <= _from + minimum); + case Grab::Head: + return forward ? (_cover >= _till) : (_cover <= _from); + case Grab::Window: + return forward ? (_till >= _duration) : (_from <= 0); + case Grab::None: + case Grab::Scroll: + case Grab::Hint: + case Grab::Label: return true; + } + return true; +} + +void TrimTimeline::visibleRangeChanged() { +} + +bool TrimTimeline::selectionHiddenLeft() const { + return (_zoom > 1.) && (xAt(_till) <= stripRect().x()); +} + +bool TrimTimeline::selectionHiddenRight() const { + const auto strip = stripRect(); + return (_zoom > 1.) && (xAt(_from) >= rect::right(strip)); +} + +QRect TrimTimeline::hintRect(bool left) const { + const auto strip = stripRect(); + const auto size = QSize( + st::videoTimelineHintWidth, + st::videoTimelineHintHeight); + const auto skip = st::videoTimelineHintSkip; + const auto x = left + ? (strip.x() + skip) + : (rect::right(strip) - skip - size.width()); + return QRect( + QPoint(x, strip.y() + (strip.height() - size.height()) / 2), + size); +} + +void TrimTimeline::updateHints() { + const auto toggle = [&]( + Ui::Animations::Simple &animation, + bool &shown, + bool now) { + if (shown == now) { + return; + } + shown = now; + animation.start( + [=] { update(); }, + now ? 0. : 1., + now ? 1. : 0., + kHintDuration); + }; + toggle(_hintLeft, _hintLeftShown, selectionHiddenLeft()); + toggle(_hintRight, _hintRightShown, selectionHiddenRight()); +} + +void TrimTimeline::scrollToSelection() { + const auto span = visibleSpan(); + const auto target = (_till - _from <= span) + ? ((_from + _till) / 2. - span / 2.) + : float64(_from); + const auto from = std::clamp(target, 0., _duration - span); + _scrollAnimation.start( + [=] { setVisibleRange(_zoom, _scrollAnimation.value(from)); }, + _visibleFrom, + from, + kScrollToSelectionDuration, + anim::easeOutCubic); +} + +bool TrimTimeline::grabMovesSelection() const { + return (_grab == Grab::Left) + || (_grab == Grab::Right) + || (_grab == Grab::Head) + || (_grab == Grab::Window); +} + +TrimTimeline::Grab TrimTimeline::grabAt( + QPoint position, + Qt::KeyboardModifiers modifiers) const { + if (!_durationField && durationHitRect().contains(position)) { + return Grab::Label; + } else if (modifiers & (Qt::ShiftModifier | Qt::AltModifier)) { + return Grab::Window; + } + const auto strip = stripRect(); + const auto slop = st::videoTimelineHandleHitSlop; + const auto x = position.x(); + const auto stripLeft = strip.x() - st::videoTimelineHandleWidth; + const auto stripRight = strip.x() + + strip.width() + + st::videoTimelineHandleWidth; + if (x < stripLeft - slop || x > stripRight + slop) { + return Grab::None; + } else if (_hintLeftShown && hintRect(true).contains(position)) { + return Grab::Hint; + } else if (_hintRightShown && hintRect(false).contains(position)) { + return Grab::Hint; + } + const auto left = xAt(_from); + const auto right = xAt(_till); + const auto handle = HandleWidth(right - left); + const auto stripEnd = rect::right(strip); + const auto leftShown = (left >= strip.x()) && (left <= stripEnd); + const auto rightShown = (right >= strip.x()) && (right <= stripEnd); + + const auto span = std::max(right - left, 1); + const auto inside = std::min(int(slop), span / 3); + if (leftShown && x >= left - handle - slop && x <= left + inside) { + return Grab::Left; + } else if (rightShown + && x <= right + handle + slop + && x >= right - inside) { + return Grab::Right; + } else if (x > left && x < right) { + return Grab::Head; + } else if (_zoom > 1.) { + return Grab::Scroll; + } + return Grab::None; +} + +crl::time TrimTimeline::minSelection() const { + return std::clamp( + std::max(_minDuration, kMinSelection), + crl::time(0), + _maxDuration); +} + +void TrimTimeline::updateCursor(Grab grab) { + setCursor((grab == Grab::None) + ? style::cur_default + : (grab == Grab::Label) + ? style::cur_text + : (grab == Grab::Hint) + ? style::cur_pointer + : (grab != Grab::Scroll) + ? style::cur_sizehor + : (_grab == Grab::Scroll) + ? Qt::ClosedHandCursor + : Qt::OpenHandCursor); +} + +void TrimTimeline::mousePressEvent(QMouseEvent *e) { + const auto position = e->pos(); + if (_durationField) { + finishDurationEdit(true, true); + return; + } else if (_grab != Grab::None) { + return; + } else if (e->button() == Qt::MiddleButton) { + if (_zoom <= 1.) { + return; + } + _scrollAnimation.stop(); + _grab = Grab::Scroll; + _grabButton = e->button(); + _dragPosition = position; + updateCursor(_grab); + return; + } else if (e->button() != Qt::LeftButton) { + return; + } + _grab = grabAt(position, e->modifiers()); + _grabButton = e->button(); + _dragPosition = position; + if (_grab == Grab::None) { + return; + } + _scrollAnimation.stop(); + if (_grab == Grab::Scroll) { + updateCursor(_grab); + return; + } else if (_grab == Grab::Hint) { + _hintGrabLeft = hintRect(true).contains(position); + updateCursor(_grab); + return; + } else if (_grab == Grab::Label) { + updateCursor(_grab); + return; + } else if (_grab == Grab::Left) { + _grabShift = position.x() - xAt(_from); + } else if (_grab == Grab::Right) { + _grabShift = position.x() - xAt(_till); + } else if (_grab == Grab::Window) { + _grabShift = position.x() - (xAt(_from) + xAt(_till)) / 2; + } else { + _grabShift = 0; + } + updateCursor(_grab); + if (_grab == Grab::Head) { + headGrabChanged(true); + } + _draggingChanges.fire(true); + applyGrab(position); +} + +void TrimTimeline::mouseMoveEvent(QMouseEvent *e) { + const auto position = e->pos(); + if (_grab != Grab::None && !(e->buttons() & _grabButton)) { + releaseGrab(); + } + if (_grab == Grab::None) { + updateCursor(grabAt(position, e->modifiers())); + return; + } else if (_grab == Grab::Scroll) { + scrollBy(_dragPosition.x() - position.x()); + _dragPosition = position; + return; + } else if (_grab == Grab::Hint || _grab == Grab::Label) { + return; + } + _dragPosition = position; + applyGrab(position); + updateEdgeScroll(position); +} + +void TrimTimeline::mouseReleaseEvent(QMouseEvent *e) { + if (_grab == Grab::None || e->button() != _grabButton) { + return; + } + const auto hintClicked = (_grab == Grab::Hint) + && hintRect(_hintGrabLeft).contains(e->pos()) + && (_hintGrabLeft ? _hintLeftShown : _hintRightShown); + const auto labelClicked = (_grab == Grab::Label) + && durationHitRect().contains(e->pos()); + releaseGrab(); + if (hintClicked) { + scrollToSelection(); + } else if (labelClicked) { + editDuration(); + } + updateCursor(grabAt(e->pos(), e->modifiers())); +} + +void TrimTimeline::releaseGrab() { + if (_grab == Grab::None) { + return; + } + const auto wasHead = (_grab == Grab::Head); + const auto wasSelection = grabMovesSelection(); + _grab = Grab::None; + _grabButton = Qt::NoButton; + _grabShift = 0; + _edgeScrollAnimation.stop(); + if (wasHead) { + headGrabChanged(false); + } + if (wasSelection) { + _draggingChanges.fire(false); + } + update(); +} + +void TrimTimeline::wheelEvent(QWheelEvent *e) { + const auto angle = e->angleDelta(); + const auto delta = Ui::ScrollDeltaF(e); + const auto locked = _wheelDirectionLock.update(e->phase(), delta); + const auto horizontal = locked + ? (*locked == Qt::Horizontal) + : (std::abs(angle.x()) > std::abs(angle.y())); + if (horizontal || e->modifiers().testFlag(Qt::ShiftModifier)) { + scrollBy(-(horizontal ? delta.x() : delta.y())); + } else if (angle.y()) { + const auto steps = angle.y() + / float64(QWheelEvent::DefaultDeltasPerStep); + zoomBy(std::pow(kZoomStep, steps), int(e->position().x())); + } + e->accept(); +} + +bool TrimTimeline::eventHook(QEvent *e) { + const auto type = e->type(); + if (type == QEvent::ContextMenu || type == QEvent::Hide) { + releaseGrab(); + } else if (type == QEvent::NativeGesture) { + const auto gesture = static_cast(e); + const auto type = gesture->gestureType(); + if (type == Qt::ZoomNativeGesture) { + const auto global = gesture->globalPosition().toPoint(); + zoomBy(1. + gesture->value(), mapFromGlobal(global).x()); + return true; + } else if (type == Qt::SmartZoomNativeGesture) { + setVisibleRange(1., 0.); + return true; + } + } + return RpWidget::eventHook(e); +} + +void TrimTimeline::leaveEventHook(QEvent *e) { + if (_grab == Grab::None) { + setCursor(style::cur_default); + } +} + +void TrimTimeline::applyGrab(QPoint position) { + const auto x = position.x() - _grabShift; + const auto at = ((_grab == Grab::Left) && (x == xAt(_from))) + ? _from + : ((_grab == Grab::Right) && (x == xAt(_till))) + ? _till + : timeAt(x); + const auto minimum = minSelection(); + switch (_grab) { + case Grab::Left: { + const auto highest = std::max(_till - minimum, crl::time(0)); + _from = std::clamp(at, crl::time(0), highest); + if (_till - _from > _maxDuration) { + _till = _from + _maxDuration; + } + setCover(std::clamp(_cover, _from, _till), true); + _trimChanges.fire_copy(_from); + updateHints(); + } break; + case Grab::Right: { + const auto lowest = std::min(_from + minimum, _duration); + _till = std::clamp(at, lowest, _duration); + if (_till - _from > _maxDuration) { + _from = _till - _maxDuration; + } + setCover(std::clamp(_cover, _from, _till), true); + _trimChanges.fire_copy(_till); + updateHints(); + } break; + case Grab::Head: { + setCover(std::clamp(at, _from, _till), true); + } break; + case Grab::Window: { + moveWindowTo(at); + } break; + case Grab::None: + case Grab::Scroll: + case Grab::Hint: return; + } + update(); +} + +void TrimTimeline::setCover(crl::time cover, bool notify) { + if (_cover == cover) { + return; + } + _cover = cover; + _playback = cover; + if (notify) { + _coverChanges.fire_copy(_cover); + } + update(); +} + +void TrimTimeline::paintOverlay(QPainter &p) { +} + +void TrimTimeline::headGrabChanged(bool grabbed) { +} + +void TrimTimeline::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + const auto strip = stripRect(); + if (strip.isEmpty()) { + return; + } + auto path = QPainterPath(); + const auto radius = st::videoTimelineRadius; + path.addRoundedRect(QRectF(strip), radius, radius); + p.setClipPath(path); + paintStrip(p, strip); + p.setClipping(false); + + paintSelection(p, strip); + p.setClipPath(path); + paintOverview(p, strip); + p.setClipping(false); + paintHead(p, strip); + paintHints(p); + paintDuration(p); + paintOverlay(p); +} + +void TrimTimeline::paintSelection(QPainter &p, const QRect &strip) { + const auto left = xAt(_from); + const auto right = xAt(_till); + const auto radius = st::videoTimelineRadius; + const auto stripLeft = strip.x(); + const auto stripRight = rect::right(strip); + const auto dimLeft = std::clamp(left, stripLeft, stripRight); + const auto dimRight = std::clamp(right, stripLeft, stripRight); + + if (dimLeft > stripLeft) { + p.fillRect( + QRect(stripLeft, strip.y(), dimLeft - stripLeft, strip.height()), + st::videoTimelineDimBg); + } + if (dimRight < stripRight) { + p.fillRect( + QRect(dimRight, strip.y(), stripRight - dimRight, strip.height()), + st::videoTimelineDimBg); + } + + const auto handle = HandleWidth(right - left); + const auto border = st::videoTimelineHandleGripWidth; + const auto outer = QRectF( + left - handle, + strip.y(), + (right - left) + handle * 2, + strip.height()); + auto frame = QPainterPath(); + frame.addRoundedRect(outer, radius, radius); + auto inner = QPainterPath(); + inner.addRect(QRectF( + left, + strip.y() + border, + std::max(right - left, 0), + std::max(strip.height() - border * 2, 0))); + + const auto clipLeft = (left >= stripLeft) + ? (stripLeft - handle) + : stripLeft; + const auto clipRight = (right <= stripRight) + ? (stripRight + handle) + : stripRight; + p.setClipRect(clipLeft, 0, clipRight - clipLeft, height()); + p.setPen(Qt::NoPen); + p.setBrush(st::videoTimelineFg); + p.drawPath(frame.subtracted(inner)); + + const auto gripWidth = st::videoTimelineHandleGripWidth; + const auto gripHeight = std::min( + int(st::videoTimelineHandleGripHeight), + strip.height() / 2); + const auto gripY = strip.y() + (strip.height() - gripHeight) / 2; + p.setBrush(st::videoTimelineDimBg); + for (const auto x : { left - handle + (handle - gripWidth) / 2, + right + (handle - gripWidth) / 2 }) { + p.drawRoundedRect( + QRectF(x, gripY, gripWidth, gripHeight), + gripWidth / 2., + gripWidth / 2.); + } + p.setClipping(false); +} + +void TrimTimeline::paintOverview(QPainter &p, const QRect &strip) { + const auto shown = std::clamp( + (_zoom - 1.) / kScrollIndicatorFadeZoom, + 0., + 1.); + if (shown <= 0.) { + return; + } + const auto span = visibleSpan(); + const auto height = st::videoTimelineScrollHeight; + const auto top = strip.y() + + strip.height() + - st::videoTimelineScrollSkip + - height; + const auto radius = height / 2.; + p.setPen(Qt::NoPen); + + auto track = st::videoTimelineFg->c; + track.setAlphaF(track.alphaF() * kOverviewTrackOpacity * shown); + p.setBrush(track); + p.drawRoundedRect( + QRectF(strip.x(), top, strip.width(), height), + radius, + radius); + + const auto minWidth = std::min( + int(st::videoTimelineScrollMinWidth), + strip.width()); + const auto width = std::clamp( + strip.width() * span / _duration, + float64(minWidth), + float64(strip.width())); + const auto ratio = _visibleFrom / std::max(_duration - span, 1.); + const auto left = strip.x() + (strip.width() - width) * ratio; + auto thumb = st::videoTimelineFg->c; + thumb.setAlphaF(thumb.alphaF() * kScrollIndicatorOpacity * shown); + p.setBrush(thumb); + p.drawRoundedRect(QRectF(left, top, width, height), radius, radius); + + const auto scale = strip.width() / float64(_duration); + const auto selectionHeight = st::videoTimelineOverviewSelectionHeight; + const auto selectionLeft = strip.x() + _from * scale; + const auto selectionRight = strip.x() + _till * scale; + auto selection = st::videoTimelineOverviewFg->c; + selection.setAlphaF(selection.alphaF() * shown); + p.setBrush(selection); + p.drawRoundedRect( + QRectF( + selectionLeft, + top + (height - selectionHeight) / 2., + std::max(selectionRight - selectionLeft, 1. * selectionHeight), + selectionHeight), + selectionHeight / 2., + selectionHeight / 2.); +} + +void TrimTimeline::paintHints(QPainter &p) { + const auto paint = [&](bool left, float64 shown) { + if (shown <= 0.) { + return; + } + const auto hint = QRectF(hintRect(left)); + const auto radius = float64(st::videoTimelineHintRadius); + auto bg = st::videoTimelineDimBg->c; + bg.setAlphaF(std::min(bg.alphaF() * kHintBgOpacityBoost, 1.) * shown); + p.setPen(Qt::NoPen); + p.setBrush(bg); + p.drawRoundedRect(hint, radius, radius); + + auto fg = st::videoTimelineFg->c; + fg.setAlphaF(fg.alphaF() * shown); + p.setPen(QPen( + fg, + st::videoTimelineHintStroke, + Qt::SolidLine, + Qt::RoundCap, + Qt::RoundJoin)); + p.setBrush(Qt::NoBrush); + const auto arrowWidth = st::videoTimelineHintArrowWidth; + const auto arrowHeight = st::videoTimelineHintArrowHeight; + const auto center = rect::center(hint); + const auto tip = center.x() + (left ? -arrowWidth : arrowWidth) / 2.; + const auto base = center.x() + (left ? arrowWidth : -arrowWidth) / 2.; + auto path = QPainterPath(); + path.moveTo(base, center.y() - arrowHeight / 2.); + path.lineTo(tip, center.y()); + path.lineTo(base, center.y() + arrowHeight / 2.); + p.drawPath(path); + }; + paint(true, _hintLeft.value(_hintLeftShown ? 1. : 0.)); + paint(false, _hintRight.value(_hintRightShown ? 1. : 0.)); +} + +void TrimTimeline::paintHead(QPainter &p, const QRect &strip) { + const auto width = st::videoTimelinePlayheadWidth; + const auto outline = st::videoTimelinePlayheadOutline; + const auto overflow = st::videoTimelinePlayheadOverflow; + const auto x = std::clamp( + xAt((_playback >= 0) ? _playback : _cover) - width / 2., + 1. * xAt(_from), + 1. * std::max(xAt(_till) - width, xAt(_from))); + const auto head = QRectF( + x, + strip.y() - overflow, + width, + strip.height() + overflow * 2); + const auto full = head.marginsAdded( + { 1. * outline, 1. * outline, 1. * outline, 1. * outline }); + p.setClipRect( + strip.x() - outline, + 0, + strip.width() + outline * 2, + height()); + p.setPen(Qt::NoPen); + p.setBrush(st::videoTimelineDimBg); + p.drawRoundedRect(full, width / 2. + outline, width / 2. + outline); + p.setBrush(st::videoTimelineFg); + p.drawRoundedRect(head, width / 2., width / 2.); + p.setClipping(false); +} + +void TrimTimeline::paintDuration(QPainter &p) { + const auto label = durationLabel(); + p.setFont(st::videoTimelineDurationStyle.font); + if (label.sizeShown) { + p.setPen(st::videoTimelineSizeFg); + p.drawText( + labelRect(), + Qt::AlignVCenter | Qt::AlignRight, + _sizeLabel); + } + if (_durationField) { + return; + } + p.setPen(st::videoTimelineDurationFg); + p.drawText(label.rect, Qt::AlignVCenter | Qt::AlignLeft, label.text); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_trim_timeline.h b/Telegram/SourceFiles/editor/editor_trim_timeline.h new file mode 100644 index 0000000000..d7fb20ea0f --- /dev/null +++ b/Telegram/SourceFiles/editor/editor_trim_timeline.h @@ -0,0 +1,207 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/unique_qptr.h" +#include "base/weak_ptr.h" +#include "ui/effects/animations.h" +#include "ui/rp_widget.h" +#include "ui/ui_utility.h" + +namespace Ui { +class MaskedInputField; +} // namespace Ui + +namespace Editor { + +struct TrimTimelineDescriptor { + crl::time duration = 0; + + crl::time maxDuration = 0; + crl::time minDuration = 0; + + // A zero |till| means the whole allowed window is selected. + crl::time from = 0; + crl::time till = 0; + crl::time cover = 0; + + bool trimOnly = false; +}; + +class TrimTimeline + : public Ui::RpWidget + , public base::has_weak_ptr { +public: + TrimTimeline( + not_null parent, + TrimTimelineDescriptor descriptor); + + [[nodiscard]] crl::time from() const { + return _from; + } + [[nodiscard]] crl::time till() const { + return _till; + } + [[nodiscard]] crl::time cover() const { + return _cover; + } + [[nodiscard]] crl::time playbackPosition() const { + return _playback; + } + [[nodiscard]] float64 zoom() const { + return _zoom; + } + [[nodiscard]] crl::time visibleFrom() const; + [[nodiscard]] crl::time visibleTill() const; + + [[nodiscard]] rpl::producer trimChanges() const { + return _trimChanges.events(); + } + [[nodiscard]] rpl::producer coverChanges() const { + return _coverChanges.events(); + } + [[nodiscard]] rpl::producer draggingChanges() const { + return _draggingChanges.events(); + } + + void setTrim(crl::time from, crl::time till); + void setPlaybackPosition(crl::time position); + void commitPendingEdit(); + + void setSizeLabel(const QString &text); + + [[nodiscard]] bool draggingHead() const; + + [[nodiscard]] int resizeGetHeight(int newWidth) override; + +protected: + [[nodiscard]] crl::time duration() const { + return _duration; + } + [[nodiscard]] bool trimOnly() const { + return _trimOnly; + } + [[nodiscard]] QRect stripRect() const; + [[nodiscard]] crl::time timeAt(int x) const; + [[nodiscard]] int xAt(crl::time time) const; + [[nodiscard]] float64 visibleSpan() const; + + virtual void paintStrip(QPainter &p, const QRect &strip) = 0; + virtual void paintOverlay(QPainter &p); + virtual void headGrabChanged(bool grabbed); + virtual void visibleRangeChanged(); + + void paintEvent(QPaintEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + void mouseReleaseEvent(QMouseEvent *e) override; + void wheelEvent(QWheelEvent *e) override; + void leaveEventHook(QEvent *e) override; + bool eventHook(QEvent *e) override; + +private: + enum class Grab { + None, + Left, + Right, + Head, + + Window, + Scroll, + Hint, + Label, + }; + struct DurationLabel { + QString text; + QRect rect; + bool sizeShown = false; + }; + + [[nodiscard]] QRect labelRect() const; + [[nodiscard]] DurationLabel durationLabel() const; + [[nodiscard]] QRect durationHitRect() const; + [[nodiscard]] QRect durationFieldRect() const; + [[nodiscard]] QString durationEditText() const; + void updateDurationFieldGeometry(); + void editDuration(); + void finishDurationEdit(bool apply, bool restoreFocus); + void applyDurationText(const QString &text); + [[nodiscard]] crl::time minSelection() const; + void moveWindowTo(crl::time center); + [[nodiscard]] Grab grabAt( + QPoint position, + Qt::KeyboardModifiers modifiers) const; + + [[nodiscard]] float64 maxZoom() const; + bool setVisibleRange(float64 zoom, float64 from); + void zoomBy(float64 factor, int anchorX); + bool scrollBy(float64 pixels); + void updateEdgeScroll(QPoint position); + bool edgeScrollStep(crl::time now); + [[nodiscard]] bool grabClamped(bool forward) const; + + [[nodiscard]] bool selectionHiddenLeft() const; + [[nodiscard]] bool selectionHiddenRight() const; + [[nodiscard]] QRect hintRect(bool left) const; + void updateHints(); + void scrollToSelection(); + + [[nodiscard]] bool grabMovesSelection() const; + void applyGrab(QPoint position); + void releaseGrab(); + void setCover(crl::time cover, bool notify); + void updateCursor(Grab grab); + void paintSelection(QPainter &p, const QRect &strip); + void paintOverview(QPainter &p, const QRect &strip); + void paintHints(QPainter &p); + void paintHead(QPainter &p, const QRect &strip); + void paintDuration(QPainter &p); + + const crl::time _duration = 0; + const crl::time _maxDuration = 0; + const crl::time _minDuration = 0; + const bool _trimOnly = false; + + crl::time _from = 0; + crl::time _till = 0; + crl::time _cover = 0; + // Negative means nothing played yet; zero is a real position. + crl::time _playback = -1; + + float64 _zoom = 1.; + float64 _visibleFrom = 0.; + + QString _sizeLabel; + int _labelWidth = 0; + int _durationFieldWidth = 0; + base::unique_qptr _durationField; + QPointer _durationFocusReturn; + + Grab _grab = Grab::None; + Qt::MouseButton _grabButton = Qt::NoButton; + int _grabShift = 0; + QPoint _dragPosition; + int _edgeOvershoot = 0; + crl::time _edgeScrollLast = 0; + Ui::Animations::Basic _edgeScrollAnimation; + Ui::ScrollDirectionLock _wheelDirectionLock; + + bool _hintLeftShown = false; + bool _hintRightShown = false; + bool _hintGrabLeft = false; + Ui::Animations::Simple _hintLeft; + Ui::Animations::Simple _hintRight; + Ui::Animations::Simple _scrollAnimation; + + rpl::event_stream _trimChanges; + rpl::event_stream _coverChanges; + rpl::event_stream _draggingChanges; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index fc3ed5a129..88a070e39e 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -15,7 +15,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/photo_editor_controls.h" #include "window/window_controller.h" #include "window/window_session_controller.h" +#include "ui/layers/layer_manager.h" #include "ui/layers/layer_widget.h" +#include "styles/style_calls.h" #include "styles/style_editor.h" namespace Editor { @@ -25,6 +27,14 @@ constexpr auto kPrecision = 100000; constexpr auto kBrushesVersion = -2; constexpr auto kDefaultBrushSizeRatio = 0.9; +[[nodiscard]] std::unique_ptr MakeLayers( + not_null parent) { + auto result = std::make_unique(parent); + result->setStyleOverrides(&st::groupCallBox, &st::groupCallLayerBox); + result->setHideByBackgroundClick(true); + return result; +} + [[nodiscard]] int ToolIndex(Brush::Tool tool) { switch (tool) { case Brush::Tool::Pen: return 0; @@ -274,14 +284,18 @@ PhotoEditor::PhotoEditor( EditorData data) : RpWidget(parent) , _modifications(std::move(modifications)) +, _layers(MakeLayers(this)) , _controllers(std::make_shared( sessionShow ? std::make_unique( this, - std::move(sessionShow)) + sessionShow, + data.composeSound) : nullptr, std::make_unique(), - show)) + show, + _layers->uiShow(), + sessionShow)) , _content(base::make_unique_q( this, photo, @@ -308,6 +322,13 @@ PhotoEditor::PhotoEditor( _modifications.cropType = data.cropType; _modifications.cropMode = data.cropMode; + events( + ) | rpl::on_next([=](not_null e) { + if (e->type() == QEvent::WindowDeactivate) { + _content->setExpansionRoom(false); + } + }, lifetime()); + sizeValue( ) | rpl::on_next([=](const QSize &size) { if (size.isEmpty()) { @@ -316,6 +337,73 @@ PhotoEditor::PhotoEditor( _content->setGeometry(rect() - st::photoEditorContentMargins); }, lifetime()); + _content->videoClipSelections( + ) | rpl::on_next([=](std::shared_ptr clip) { + _videoClipSelected = (clip != nullptr); + updateColorPickerVisibility(anim::type::normal); + _controls->setVideoClip(std::move(clip)); + _controls->setTrimShortestAvailable( + _content->canEqualizeDurations()); + _controls->setTrimShortestActive( + _content->durationsLinked(), + anim::type::instant); + }, lifetime()); + + rpl::merge( + _content->audioSelectedChanges(), + _content->audioChanges() | rpl::map([=] { + return _content->audioSelected(); + }) + ) | rpl::on_next([=](bool selected) { + _audioSelected = selected; + updateColorPickerVisibility(anim::type::normal); + _controls->setAudioTrack(selected ? _content->audio() : nullptr); + _controls->setTrimShortestAvailable( + _content->canEqualizeDurations()); + _controls->setTrimShortestActive( + _content->durationsLinked(), + anim::type::instant); + }, lifetime()); + + _content->audioVolumeChanges( + ) | rpl::on_next([=] { + _controls->refreshAudioVolume(); + }, lifetime()); + + _content->durationsLinkChanges( + ) | rpl::on_next([=] { + _controls->setTrimShortestAvailable( + _content->canEqualizeDurations()); + _controls->setTrimShortestActive( + _content->durationsLinked(), + anim::type::instant); + _controls->refreshTimelines(); + }, lifetime()); + + _controls->audioRemoveRequests( + ) | rpl::on_next([=] { + _content->removeAudio(); + }, lifetime()); + + _controls->trimShortestRequests( + ) | rpl::on_next([=] { + const auto linked = !_content->durationsLinked(); + _content->setDurationsLinked(linked); + _controls->setTrimShortestActive(linked, anim::type::normal); + _controls->refreshTimelines(); + }, lifetime()); + + _controls->trimLengthChanges( + ) | rpl::on_next([=](crl::time length) { + if (!_content->durationsLinked() || _matchingDurations) { + return; + } + _matchingDurations = true; + _content->matchDurations(length); + _controls->refreshTimelines(); + _matchingDurations = false; + }, lifetime()); + _content->innerRect( ) | rpl::on_next([=](QRect inner) { if (inner.isEmpty()) { @@ -336,7 +424,8 @@ PhotoEditor::PhotoEditor( _controls->colorLineShownValue( ) | rpl::on_next([=](bool shown) { - _colorPicker->setVisible(shown); + _colorLineShown = shown; + updateColorPickerVisibility(anim::type::instant); }, _controls->lifetime()); _mode.value( @@ -424,6 +513,7 @@ PhotoEditor::PhotoEditor( _controls->doneRequests( ) | rpl::on_next([=] { + _controls->commitTimelineEdits(); const auto mode = _mode.current().mode; if (mode == PhotoEditorMode::Mode::Paint) { _mode = PhotoEditorMode{ @@ -560,11 +650,27 @@ PhotoEditor::PhotoEditor( } void PhotoEditor::keyPressEvent(QKeyEvent *e) { + _content->setExpansionRoom(e->modifiers().testFlag(Qt::ControlModifier)); if (!_colorPicker->preventHandleKeyPress()) { _content->handleKeyPress(e) || _controls->handleKeyPress(e); } } +void PhotoEditor::keyReleaseEvent(QKeyEvent *e) { + _content->setExpansionRoom(e->modifiers().testFlag(Qt::ControlModifier)); +} + +void PhotoEditor::updateColorPickerVisibility(anim::type animated) { + const auto painting + = (_mode.current().mode == PhotoEditorMode::Mode::Paint); + _colorPicker->setVisible( + painting + && _colorLineShown + && !_videoClipSelected + && !_audioSelected, + animated); +} + void PhotoEditor::save() { _content->save(_modifications); _done.fire_copy(_modifications); diff --git a/Telegram/SourceFiles/editor/photo_editor.h b/Telegram/SourceFiles/editor/photo_editor.h index 50b4013d23..d8cd7e9500 100644 --- a/Telegram/SourceFiles/editor/photo_editor.h +++ b/Telegram/SourceFiles/editor/photo_editor.h @@ -16,6 +16,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include namespace Ui { +class LayerManager; class LayerWidget; class Show; } // namespace Ui @@ -57,9 +58,12 @@ public: private: void keyPressEvent(QKeyEvent *e) override; + void keyReleaseEvent(QKeyEvent *e) override; + void updateColorPickerVisibility(anim::type animated); PhotoModifications _modifications; + const std::unique_ptr _layers; const std::shared_ptr _controllers; base::unique_qptr _content; @@ -77,6 +81,10 @@ private: bool _textItemSelected = false; bool _textEditing = false; bool _shapeItemSelected = false; + bool _videoClipSelected = false; + bool _audioSelected = false; + bool _matchingDurations = false; + bool _colorLineShown = false; rpl::event_stream _done; rpl::event_stream<> _cancel; diff --git a/Telegram/SourceFiles/editor/photo_editor_common.cpp b/Telegram/SourceFiles/editor/photo_editor_common.cpp index 9f76cd7154..fb9183feaa 100644 --- a/Telegram/SourceFiles/editor/photo_editor_common.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_common.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene.h" #include "editor/scene/scene_item_animated.h" +#include "editor/video/video_clip.h" #include "ui/painter.h" #include "ui/userpic_view.h" @@ -18,7 +19,36 @@ namespace { constexpr auto kAnimatedMaxSide = 854; constexpr auto kAnimatedFps = 30.; constexpr auto kAnimatedMinDuration = crl::time(1000); -constexpr auto kAnimatedMaxDuration = crl::time(3000); + +[[nodiscard]] QImage ExpandCanvas( + const QImage &image, + QRect crop, + Scene *scene = nullptr) { + auto result = QImage(crop.size(), QImage::Format_ARGB32_Premultiplied); + result.fill(Qt::transparent); + + auto p = Painter(&result); + p.setCompositionMode(QPainter::CompositionMode_Source); + p.drawImage(-crop.topLeft(), image); + if (scene) { + p.setCompositionMode(QPainter::CompositionMode_SourceOver); + PainterHighQualityEnabler hq(p); + scene->render(&p, QRectF(result.rect()), QRectF(crop)); + } + return result; +} + +void FillCanvasBackground( + QImage &canvas, + const Media::Encode::CanvasBackground &background, + QRect keep = QRect()) { + auto p = QPainter(&canvas); + p.setCompositionMode(QPainter::CompositionMode_DestinationOver); + if (!keep.isEmpty()) { + p.setClipRegion(QRegion(canvas.rect()) - QRegion(keep)); + } + Media::Encode::PaintCanvasBackground(p, canvas.rect(), background); +} } // namespace @@ -76,7 +106,9 @@ QImage ImageModified(QImage image, const PhotoModifications &mods) { if (!mods) { return image; } - if (mods.paint) { + const auto expanded = mods.crop.isValid() + && !image.rect().contains(mods.crop); + if (mods.paint && !expanded) { if (image.format() != QImage::Format_ARGB32_Premultiplied) { image = image.convertToFormat( QImage::Format_ARGB32_Premultiplied); @@ -87,7 +119,9 @@ QImage ImageModified(QImage image, const PhotoModifications &mods) { mods.paint->render(&p, image.rect()); } - auto cropped = mods.crop.isValid() + auto cropped = expanded + ? ExpandCanvas(image, mods.crop, mods.paint.get()) + : mods.crop.isValid() ? image.copy(mods.crop) : image; QTransform transform; @@ -97,7 +131,18 @@ QImage ImageModified(QImage image, const PhotoModifications &mods) { if (mods.angle) { transform.rotate(mods.angle); } - return cropped.transformed(transform); + auto result = cropped.transformed(transform); + if (expanded) { + const auto matrix = QImage::trueMatrix( + transform, + cropped.width(), + cropped.height()); + FillCanvasBackground( + result, + Media::Encode::DominantCanvasBackground(image), + matrix.mapRect(QRect(-mods.crop.topLeft(), image.size()))); + } + return result; } Media::Encode::Job ComposeAnimatedJob( @@ -141,9 +186,8 @@ Media::Encode::Job ComposeAnimatedJob( target.width() / rotated.width(), target.height() / rotated.height()); - const auto bake = [&](const QImage &source) { - auto cropped = source.copy(crop); - return cropped.transformed(transform, Qt::SmoothTransformation) + const auto bake = [&](const QImage &canvas) { + return canvas.transformed(transform, Qt::SmoothTransformation) .scaled( target, Qt::IgnoreAspectRatio, @@ -171,13 +215,13 @@ Media::Encode::Job ComposeAnimatedJob( item->setVisible(true); } auto layer = QImage( - image.size(), + crop.size(), QImage::Format_ARGB32_Premultiplied); layer.fill(Qt::transparent); { auto p = Painter(&layer); PainterHighQualityEnabler hq(p); - scene->render(&p, layer.rect()); + scene->render(&p, QRectF(layer.rect()), QRectF(crop)); } for (const auto item : normal) { item->setVisible(true); @@ -187,8 +231,9 @@ Media::Encode::Job ComposeAnimatedJob( }; auto longest = crl::time(0); + auto music = std::vector(); for (const auto item : normal) { - const auto animated = dynamic_cast(item); + const auto animated = item->asAnimated(); if (!animated || !animated->animated()) { run.push_back(item); continue; @@ -199,21 +244,47 @@ Media::Encode::Job ComposeAnimatedJob( continue; } flushRun(); + const auto clip = item->videoClip(); + const auto loop = animated->loopDuration(); + if (clip && (loop > 0)) { + entity.till = entity.from + loop; + } + if (clip && clip->sounding()) { + const auto trim = clip->trim(); + music.push_back({ + .bytes = entity.bytes, + .from = trim.from, + .till = trim.from + loop, + .volume = clip->volume(), + .loop = true, + }); + } job.overlay.push_back(std::move(entity)); - const auto duration = animated->loopDuration(); - longest = std::max( - longest, - duration ? duration : kAnimatedMaxDuration); + longest = std::max(longest, loop); } flushRun(); + if (const auto audio = scene->audio()) { + if (audio->volume > 0.) { + music.push_back({ + .path = audio->path, + .bytes = audio->content, + .from = audio->from, + .till = (audio->till > audio->from) ? audio->till : 0, + .volume = audio->volume, + }); + } + longest = std::max(longest, audio->length()); + } + auto base = bake(ExpandCanvas(image, crop)); + FillCanvasBackground( + base, + Media::Encode::DominantCanvasBackground(image)); job.source = Media::Encode::StillSource{ - .base = bake(image), - .duration = std::clamp( - longest, - kAnimatedMinDuration, - kAnimatedMaxDuration), + .base = std::move(base), + .duration = std::max(longest, kAnimatedMinDuration), .fps = kAnimatedFps, + .music = std::move(music), }; job.silentLoop = true; return job; diff --git a/Telegram/SourceFiles/editor/photo_editor_common.h b/Telegram/SourceFiles/editor/photo_editor_common.h index 3cb60f3c3a..71cec380ad 100644 --- a/Telegram/SourceFiles/editor/photo_editor_common.h +++ b/Telegram/SourceFiles/editor/photo_editor_common.h @@ -7,12 +7,34 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once +#include "media/media_video_canvas.h" #include "media/media_video_encode.h" namespace Editor { class Scene; +struct AudioTrack { + QString path; + QByteArray content; + QString title; + QString performer; + QImage cover; + crl::time duration = 0; + crl::time from = 0; + // Zero means the end of the track. + crl::time till = 0; + float64 volume = 1.; + + [[nodiscard]] bool empty() const { + return path.isEmpty() && content.isEmpty(); + } + [[nodiscard]] crl::time length() const { + const auto end = (till > from) ? till : duration; + return std::max(end - from, crl::time(0)); + } +}; + enum class RoundedCornersLevel { Large, Medium, @@ -45,6 +67,7 @@ struct EditorData { bool fixedCrop = false; bool forOtherUser = false; bool composeAnimated = false; + bool composeSound = false; }; struct PhotoModifications { diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index a7666d649c..ec98d10b7e 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -7,11 +7,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "editor/photo_editor_content.h" +#include "editor/editor_audio_disc_button.h" #include "editor/editor_crop.h" #include "editor/editor_paint.h" #include "history/history_drag_area.h" #include "media/view/media_view_pip.h" #include "storage/storage_media_prepare.h" +#include "ui/effects/animation_value_f.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "styles/style_editor.h" #include #include @@ -20,6 +25,23 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include namespace Editor { +namespace { + +constexpr auto kExpansionRoomRatio = 0.7; +constexpr auto kLayoutSmoothTau = 60.; +constexpr auto kLayoutMaxFrameDelta = crl::time(64); +constexpr auto kLayoutEpsilon = 0.5; + +[[nodiscard]] float64 MaxDistance(const QRectF &a, const QRectF &b) { + return std::max({ + std::abs(a.x() - b.x()), + std::abs(a.y() - b.y()), + std::abs(a.width() - b.width()), + std::abs(a.height() - b.height()), + }); +} + +} // namespace using Media::View::FlipSizeByRotation; using Media::View::RotatedRect; @@ -57,53 +79,94 @@ PhotoEditorContent::PhotoEditorContent( modifications, _photoSize, std::move(data))) +, _audioDisc(base::make_unique_q(this)) , _photo(std::move(photo)) -, _modifications(modifications) { +, _background(Media::Encode::DominantCanvasBackground(_photo->original())) +, _modifications(modifications) +, _canvas(QRect(QPoint(), _photoSize) | modifications.crop) { + _crop->setExpansionAllowed(!_fixedCrop); + _paint->setCanvasBackground(_background); + _paint->setCropRect(QRectF(_crop->cropRect())); + _layoutAnimation.init([=](crl::time now) { + return layoutAnimationStep(now); + }); + _crop->events( + ) | rpl::on_next([=](not_null e) { + const auto type = e->type(); + if (type == QEvent::MouseMove + || type == QEvent::MouseButtonRelease) { + const auto mouse = static_cast(e.get()); + setExpansionRoom( + mouse->modifiers().testFlag(Qt::ControlModifier)); + } + }, _crop->lifetime()); + _crop->changes( + ) | rpl::on_next([=] { + updateCanvas(); + }, lifetime()); + _crop->dragChanges( + ) | rpl::on_next([=](bool dragging) { + _dragging = dragging; + if (dragging && _layoutAnimation.animating()) { + _layoutAnimation.stop(); + applyLayout(_layoutTarget); + } else if (!dragging) { + updateRoom(); + } + }, lifetime()); + + _audioDisc->hide(); + _audioDisc->setClickedCallback([=] { + _paint->setAudioSelected(!_paint->audioSelected()); + }); + _audioDisc->removeRequests( + ) | rpl::on_next([=] { + _paint->removeAudio(); + }, _audioDisc->lifetime()); + _paint->audioChanges( + ) | rpl::on_next([=] { + updateAudioDisc(); + }, lifetime()); + _paint->audioSelectedChanges( + ) | rpl::on_next([=](bool selected) { + _audioDisc->setActive(selected); + }, lifetime()); + rpl::merge( + _innerRect.value() | rpl::to_empty, + sizeValue() | rpl::to_empty + ) | rpl::on_next([=] { + updateAudioDiscGeometry(); + }, lifetime()); rpl::combine( _modifications.value(), - sizeValue() + sizeValue(), + _canvas.value(), + _room.value() ) | rpl::on_next([=]( - const PhotoModifications &mods, const QSize &size) { + const PhotoModifications &mods, + const QSize &size, + const QRect &canvas, + bool room) { if (size.isEmpty()) { return; } - const auto imageSizeF = [&] { - const auto rotatedSize - = FlipSizeByRotation(size, mods.angle); - const auto m = _crop->cropMargins(); - const auto sizeForCrop = rotatedSize - - QSize(m.left() + m.right(), m.top() + m.bottom()); - const auto originalSize = QSizeF(_photoSize); - if ((originalSize.width() > sizeForCrop.width()) - || (originalSize.height() > sizeForCrop.height())) { - return originalSize.scaled( - sizeForCrop, - Qt::KeepAspectRatio); - } - return originalSize; - }(); - const auto imageSize = QSize(imageSizeF.width(), imageSizeF.height()); - _imageRect = QRect( - QPoint(-imageSize.width() / 2, -imageSize.height() / 2), - imageSize); - - _imageMatrix.reset(); - _imageMatrix.translate(size.width() / 2, size.height() / 2); - if (mods.flipped) { - _imageMatrix.scale(-1, 1); + const auto target = layoutTarget(mods, size, canvas, room); + const auto animate = _animateLayout + && !_dragging + && !anim::Disabled() + && !_imageRectF.isEmpty() + && (target != _imageRectF); + if (!animate) { + _layoutAnimation.stop(); + applyLayout(target); + return; + } + _layoutTarget = target; + if (!_layoutAnimation.animating()) { + _layoutLastFrame = crl::now(); + _layoutAnimation.start(); } - _imageMatrix.rotate(mods.angle); - - const auto geometry = _imageMatrix.mapRect(_imageRect); - _crop->applyTransform( - geometry + _crop->cropMargins(), - mods.angle, - mods.flipped, imageSizeF); - _crop->setCornersLevel(mods.cornersLevel); - _paint->applyTransform(geometry, mods.angle, mods.flipped); - - _innerRect = geometry; }, lifetime()); paintRequest( @@ -112,10 +175,18 @@ PhotoEditorContent::PhotoEditorContent( p.fillRect(clip, Qt::transparent); if (_mode.mode == PhotoEditorMode::Mode::Paint) { + _paint->paintCanvas(p); _paint->paintImage(p, _photo->pix(_photoSize)); } else { + paintCanvasFill(p); p.setTransform(_imageMatrix); - p.drawPixmap(_imageRect, _photo->pix(_imageRect.size())); + const auto size = _layoutAnimation.animating() + ? _layoutTarget.size().toSize() + : _imageRect.size(); + p.setRenderHint( + QPainter::SmoothPixmapTransform, + (size != _imageRect.size())); + p.drawPixmap(_imageRect, _photo->pixSingle(size)); } }, lifetime()); @@ -164,6 +235,143 @@ PhotoEditorContent::PhotoEditorContent( } } +QRectF PhotoEditorContent::layoutTarget( + const PhotoModifications &mods, + QSize size, + QRect canvas, + bool room) const { + const auto m = _crop->cropMargins(); + const auto full = FlipSizeByRotation(size, mods.angle) + - QSize(m.left() + m.right(), m.top() + m.bottom()); + const auto fit = room + ? (QSizeF(full) * kExpansionRoomRatio) + : QSizeF(full); + const auto scale = std::min({ + 1., + fit.width() / canvas.width(), + fit.height() / canvas.height(), + }); + const auto canvasSize = QSizeF(canvas.size()) * scale; + return QRectF( + QPointF(-canvasSize.width() / 2., -canvasSize.height() / 2.) + - QPointF(canvas.topLeft()) * scale, + QSizeF(_photoSize) * scale); +} + +void PhotoEditorContent::applyLayout(QRectF imageRect) { + const auto size = this->size(); + if (size.isEmpty() || imageRect.isEmpty()) { + return; + } + const auto &mods = _modifications.current(); + const auto canvas = _canvas.current(); + const auto scale = imageRect.width() / _photoSize.width(); + const auto rounded = [](float64 value) { + return int(base::SafeRound(value)); + }; + _imageRectF = imageRect; + _imageRect = QRect( + QPoint(rounded(imageRect.x()), rounded(imageRect.y())), + QSize(rounded(imageRect.width()), rounded(imageRect.height()))); + const auto canvasRect = QRect( + _imageRect.topLeft() + QPoint( + rounded(canvas.x() * scale), + rounded(canvas.y() * scale)), + QSize( + rounded(canvas.width() * scale), + rounded(canvas.height() * scale))); + + _imageMatrix.reset(); + _imageMatrix.translate(size.width() / 2, size.height() / 2); + if (mods.flipped) { + _imageMatrix.scale(-1, 1); + } + _imageMatrix.rotate(mods.angle); + + const auto geometry = _imageMatrix.mapRect(_imageRect); + _crop->applyTransform( + rect(), + geometry.topLeft(), + mods.angle, + mods.flipped, + imageRect.size()); + _crop->setCornersLevel(mods.cornersLevel); + const auto canvasGeometry = _imageMatrix.mapRect(canvasRect); + _paint->applyTransform( + geometry, + canvasGeometry, + QRectF(canvas), + mods.angle, + mods.flipped); + + _innerRect = canvasGeometry; + update(); +} + +bool PhotoEditorContent::layoutAnimationStep(crl::time now) { + const auto delta = std::clamp( + now - _layoutLastFrame, + crl::time(0), + kLayoutMaxFrameDelta); + _layoutLastFrame = now; + if (delta <= 0) { + return true; + } + const auto ratio = 1. - std::exp(-float64(delta) / kLayoutSmoothTau); + const auto next = anim::interpolatedRectF( + _imageRectF, + _layoutTarget, + ratio); + const auto finished = MaxDistance(next, _layoutTarget) < kLayoutEpsilon; + applyLayout(finished ? _layoutTarget : next); + return !finished; +} + +void PhotoEditorContent::updateCanvas() { + _paint->setCropRect(QRectF(_crop->cropRect())); + _animateLayout = true; + _canvas = QRect(QPoint(), _photoSize) | _crop->cropRect(); + _animateLayout = false; +} + +void PhotoEditorContent::updateRoom() { + if (_dragging) { + return; + } + _animateLayout = true; + _room = _roomRequested + && !_fixedCrop + && (_mode.mode == PhotoEditorMode::Mode::Transform); + _animateLayout = false; +} + +void PhotoEditorContent::setExpansionRoom(bool room) { + _roomRequested = room; + updateRoom(); +} + +void PhotoEditorContent::paintCanvasFill(QPainter &p) const { + if (Rect(_photoSize).contains(_crop->cropRect())) { + return; + } + auto image = QPainterPath(); + image.addRect(_imageMatrix.mapRect(QRectF(_imageRect))); + const auto fill = _crop->cropPath() + .translated(_crop->pos()) + .subtracted(image); + if (fill.isEmpty()) { + return; + } + p.save(); + auto hq = PainterHighQualityEnabler(p); + p.setClipPath(fill); + Media::Encode::PaintCanvasBackground( + p, + _crop->paintRect().translated(_crop->pos()), + _background); + p.restore(); +} + void PhotoEditorContent::applyModifications( PhotoModifications modifications) { _modifications = std::move(modifications); @@ -206,9 +414,38 @@ void PhotoEditorContent::applyMode(const PhotoEditorMode &mode) { _paint->keepResult(); } _mode = mode; + updateCanvas(); + updateRoom(); + updateAudioDisc(); update(); } +void PhotoEditorContent::updateAudioDiscGeometry() { + const auto inner = _innerRect.current(); + const auto skip = st::photoEditorAudioDiscSkip; + const auto size = _audioDisc->width(); + const auto right = rect::right(inner); + const auto bottom = rect::bottom(inner); + if (width() - right >= size + skip) { + _audioDisc->moveToLeft(right + skip, bottom - size); + } else if (height() - bottom >= size + skip) { + _audioDisc->moveToLeft(right - size, bottom + skip); + } else { + _audioDisc->moveToLeft(right - skip - size, bottom - skip - size); + } +} + +void PhotoEditorContent::updateAudioDisc() { + const auto audio = _paint->audio(); + const auto shown = (audio != nullptr) + && (_mode.mode == PhotoEditorMode::Mode::Paint); + _audioDisc->setTrack(audio); + _audioDisc->setVisible(shown); + if (shown) { + _audioDisc->raise(); + } +} + void PhotoEditorContent::applyAspectRatio(float64 ratio) { _crop->setAspectRatio(ratio); } @@ -283,10 +520,59 @@ rpl::producer PhotoEditorContent::shapeItemSelections() const { return _paint->shapeItemSelections(); } +auto PhotoEditorContent::videoClipSelections() const +-> rpl::producer> { + return _paint->videoClipSelections(); +} + rpl::producer<> PhotoEditorContent::shapeItemDeselections() const { return _paint->shapeItemDeselections(); } +rpl::producer<> PhotoEditorContent::audioChanges() const { + return _paint->audioChanges(); +} + +rpl::producer PhotoEditorContent::audioSelectedChanges() const { + return _paint->audioSelectedChanges(); +} + +rpl::producer<> PhotoEditorContent::audioVolumeChanges() const { + return _audioDisc->volumeChanges(); +} + +bool PhotoEditorContent::audioSelected() const { + return _paint->audioSelected(); +} + +std::shared_ptr PhotoEditorContent::audio() const { + return _paint->audio(); +} + +void PhotoEditorContent::removeAudio() { + _paint->removeAudio(); +} + +bool PhotoEditorContent::canEqualizeDurations() const { + return _paint->canEqualizeDurations(); +} + +void PhotoEditorContent::matchDurations(crl::time duration) { + _paint->matchDurations(duration); +} + +bool PhotoEditorContent::durationsLinked() const { + return _paint->durationsLinked(); +} + +void PhotoEditorContent::setDurationsLinked(bool linked) { + _paint->setDurationsLinked(linked); +} + +rpl::producer<> PhotoEditorContent::durationsLinkChanges() const { + return _paint->durationsLinkChanges(); +} + rpl::producer PhotoEditorContent::shapeToolStates() const { return _paint->shapeToolStates(); } diff --git a/Telegram/SourceFiles/editor/photo_editor_content.h b/Telegram/SourceFiles/editor/photo_editor_content.h index c8d4e1b319..c724c1a5f4 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.h +++ b/Telegram/SourceFiles/editor/photo_editor_content.h @@ -8,14 +8,18 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "ui/rp_widget.h" +#include "ui/effects/animations.h" #include "editor/photo_editor_common.h" #include "editor/photo_editor_inner_common.h" +#include "media/media_video_canvas.h" #include "ui/image/image.h" namespace Editor { +class AudioDiscButton; class Crop; +class VideoClip; class Paint; struct Controllers; @@ -50,7 +54,21 @@ public: [[nodiscard]] rpl::producer<> shapeItemDeselections() const; [[nodiscard]] rpl::producer shapeToolStates() const; [[nodiscard]] rpl::producer<> paintModeRequests() const; + [[nodiscard]] auto videoClipSelections() const + -> rpl::producer>; + [[nodiscard]] rpl::producer<> audioChanges() const; + [[nodiscard]] rpl::producer audioSelectedChanges() const; + [[nodiscard]] rpl::producer<> audioVolumeChanges() const; + [[nodiscard]] std::shared_ptr audio() const; + [[nodiscard]] bool audioSelected() const; + void removeAudio(); + [[nodiscard]] bool canEqualizeDurations() const; + void matchDurations(crl::time duration); + [[nodiscard]] bool durationsLinked() const; + void setDurationsLinked(bool linked); + [[nodiscard]] rpl::producer<> durationsLinkChanges() const; void applyAspectRatio(float64 ratio); + void setExpansionRoom(bool room); void save(PhotoModifications &modifications); bool handleKeyPress(not_null e); @@ -64,22 +82,45 @@ public: } private: + void updateCanvas(); + void updateRoom(); + [[nodiscard]] QRectF layoutTarget( + const PhotoModifications &mods, + QSize size, + QRect canvas, + bool room) const; + void applyLayout(QRectF imageRect); + bool layoutAnimationStep(crl::time now); + void paintCanvasFill(QPainter &p) const; + void updateAudioDisc(); + void updateAudioDiscGeometry(); const QSize _photoSize; const bool _fixedCrop = false; const bool _composeAnimated = false; const base::unique_qptr _paint; const base::unique_qptr _crop; + const base::unique_qptr _audioDisc; const std::shared_ptr _photo; + const Media::Encode::CanvasBackground _background; rpl::variable _innerRect; rpl::variable _modifications; + rpl::variable _canvas; + rpl::variable _room; rpl::event_stream _keyPresses; rpl::event_stream<> _paintModeRequests; QRect _imageRect; + QRectF _imageRectF; + QRectF _layoutTarget; + Ui::Animations::Basic _layoutAnimation; + crl::time _layoutLastFrame = 0; QTransform _imageMatrix; PhotoEditorMode _mode; + bool _roomRequested = false; + bool _dragging = false; + bool _animateLayout = false; }; diff --git a/Telegram/SourceFiles/editor/photo_editor_controls.cpp b/Telegram/SourceFiles/editor/photo_editor_controls.cpp index 8bce67d790..b0adeaf6d9 100644 --- a/Telegram/SourceFiles/editor/photo_editor_controls.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_controls.cpp @@ -7,8 +7,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "editor/photo_editor_controls.h" +#include "editor/audio_track_timeline.h" #include "editor/controllers/controllers.h" +#include "editor/editor_keys_legend.h" +#include "editor/video_item_timeline.h" #include "lang/lang_keys.h" +#include "lottie/lottie_icon.h" #include "ui/effects/round_checkbox.h" #include "ui/image/image_prepare.h" #include "ui/qt_object_factory.h" @@ -18,9 +22,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/menu/menu_action.h" #include "ui/widgets/menu/menu_multiline_action.h" #include "ui/widgets/popup_menu.h" +#include "ui/widgets/tooltip.h" #include "ui/wrap/fade_wrap.h" #include "ui/painter.h" #include "ui/rect.h" +#include "ui/ui_utility.h" #include "styles/style_editor.h" #include "styles/style_media_player.h" // mediaPlayerMenuCheck @@ -470,6 +476,74 @@ ButtonBar::ButtonBar( }, lifetime()); } +class TrimShortestButton final + : public Ui::IconButton + , public Ui::AbstractTooltipShower { +public: + explicit TrimShortestButton(QWidget *parent) + : IconButton(parent, st::photoEditorRotateButton) + , _icon(Lottie::MakeIcon({ + .path = u":/animations/photo_editor_trim_shortest.tgs"_q, + .color = &st::photoEditorButtonIconFg, + .sizeOverride = QSize( + st::photoEditorTrimShortestIconSize, + st::photoEditorTrimShortestIconSize), + })) { + events( + ) | rpl::on_next([=](not_null event) { + if (event->type() == QEvent::Enter) { + Ui::Tooltip::Show(1000, this); + } else if (event->type() == QEvent::Leave) { + Ui::Tooltip::Hide(); + } + }, lifetime()); + } + + void setActive(bool active, anim::type animated) { + if (_active == active) { + return; + } + _active = active; + const auto last = _icon->framesCount() - 1; + const auto repaint = [=] { update(); }; + if (animated == anim::type::instant) { + _icon->jumpTo(active ? last : 0, repaint); + } else { + _icon->animate(repaint, active ? 0 : last, active ? last : 0); + } + } + QString tooltipText() const override { + return tr::lng_photo_editor_trim_shortest(tr::now); + } + QPoint tooltipPos() const override { + return QCursor::pos(); + } + bool tooltipWindowActive() const override { + return Ui::AppInFocus() && Ui::InFocusChain(window()); + } + +protected: + void paintEvent(QPaintEvent *e) override { + auto p = Painter(this); + paintRipple(p, st().rippleAreaPosition); + const auto last = std::max(_icon->framesCount() - 1, 1); + const auto progress = _icon->frameIndex() / float64(last); + const auto idle = anim::color( + st::photoEditorButtonIconFg, + st::photoEditorButtonIconFgOver, + iconOverOpacity()); + _icon->paintInCenter(p, rect(), anim::color( + idle, + st::photoEditorButtonIconFgActive, + progress)); + } + +private: + const std::unique_ptr _icon; + bool _active = false; + +}; + class TextToolButton final : public Ui::AbstractButton { public: TextToolButton(not_null parent) @@ -511,6 +585,7 @@ PhotoEditorControls::PhotoEditorControls( : RpWidget(parent) , _imageSize(imageSize) , _originalRatio(data.originalRatio) +, _fixedCrop(data.fixedCrop) , _bg(st::roundedBg) , _buttonHeight(st::photoEditorButtonBarHeight) , _transformButtons(base::make_unique_q(this, _bg)) @@ -589,9 +664,35 @@ PhotoEditorControls::PhotoEditorControls( _buttonHeight, st::photoEditorEdgeButtonBg, st::mediaviewTextLinkFg, - st::photoEditorRotateButton.ripple)) { + st::photoEditorRotateButton.ripple)) +, _videoTimeline(base::make_unique_q>( + this, + object_ptr(this))) +, _audioTimeline(base::make_unique_q>( + this, + object_ptr(this))) +, _trimShortest(base::make_unique_q>( + this, + object_ptr(this))) +, _keysLegend(base::make_unique_q(this, [=] { + return KeysLegendContext{ + .mode = _mode.current().mode, + .fixedCrop = _fixedCrop, + .timeline = _videoTimelineShown || _audioTimelineShown, + }; +})) { _shapesFilled = shapesFilled; + _videoTimeline->hide(anim::type::instant); + _videoTimeline->setDuration(st::photoEditorBarAnimationDuration); + _audioTimeline->hide(anim::type::instant); + _audioTimeline->setDuration(st::photoEditorBarAnimationDuration); + _trimShortest->hide(anim::type::instant); + _trimShortest->setDuration(st::photoEditorBarAnimationDuration); + _paintTopButtons->geometryValue( + ) | rpl::on_next([=] { + updateTimelinesGeometry(); + }, lifetime()); _shapesButton->setClickedCallback([=] { if (_shapeToolActive) { _shapeRequests.fire({ .action = ShapeRequest::Action::Cancel }); @@ -630,6 +731,10 @@ PhotoEditorControls::PhotoEditorControls( (size.width() - current->width()) / 2, buttonsTop); + _keysLegend->moveToLeft(st::photoEditorKeysButtonLeft, buttonsTop); + _keysLegend->setVisible( + current->x() >= _keysLegend->x() + _keysLegend->width()); + if (_about) { const auto &margin = st::photoEditorAboutMargin; const auto skip = st::photoEditorCropPointSize; @@ -680,6 +785,8 @@ PhotoEditorControls::PhotoEditorControls( _paintBottomButtons->shownValue() | rpl::to_empty, _paintTopButtons->geometryValue() | rpl::to_empty, _paintTopButtons->shownValue() | rpl::to_empty, + _keysLegend->geometryValue() | rpl::to_empty, + _keysLegend->shownValue() | rpl::to_empty, std::move(aboutChanges) ) | rpl::on_next([=] { updateInputMask(); @@ -1128,6 +1235,7 @@ void PhotoEditorControls::updateInputMask() { add(_transformButtons); add(_paintBottomButtons); add(_paintTopButtons); + add(_keysLegend); if (_about && !_about->isHidden()) { const auto geometry = _about->geometry() & visibleRect; if (!geometry.isEmpty()) { @@ -1160,7 +1268,117 @@ rpl::producer PhotoEditorControls::colorLineShownValue() const { return _paintTopButtons->shownValue(); } +void PhotoEditorControls::setVideoClip(std::shared_ptr clip) { + const auto shown = (clip != nullptr); + _videoTimelineShown = shown; + updateTrimShortest(); + if (shown) { + _videoTimeline->entity()->setClip(std::move(clip)); + updateTimelineGeometry(_videoTimeline.get()); + } + _videoTimeline->toggle(shown, anim::type::normal); + if (!shown) { + _videoTimeline->entity()->setClip(nullptr); + } +} + +void PhotoEditorControls::setAudioTrack(std::shared_ptr track) { + const auto shown = (track != nullptr); + _audioTimelineShown = shown; + updateTrimShortest(); + _audioTimeline->entity()->setTrack(std::move(track)); + if (shown) { + updateTimelineGeometry(_audioTimeline.get()); + } + _audioTimeline->toggle(shown, anim::type::normal); +} + +void PhotoEditorControls::setTrimShortestAvailable(bool available) { + if (_trimShortestAvailable == available) { + return; + } + _trimShortestAvailable = available; + updateTrimShortest(); +} + +void PhotoEditorControls::setTrimShortestActive( + bool active, + anim::type animated) { + const auto button = static_cast( + _trimShortest->entity()); + button->setActive(active, animated); +} + +void PhotoEditorControls::refreshTimelines() { + _videoTimeline->entity()->refreshTrim(); + _audioTimeline->entity()->refreshTrim(); +} + +void PhotoEditorControls::refreshAudioVolume() { + _audioTimeline->entity()->refreshVolume(); +} + +void PhotoEditorControls::commitTimelineEdits() { + _videoTimeline->entity()->commitPendingEdit(); + _audioTimeline->entity()->commitPendingEdit(); +} + +rpl::producer<> PhotoEditorControls::trimShortestRequests() const { + return _trimShortest->entity()->clicks() | rpl::to_empty; +} + +rpl::producer PhotoEditorControls::trimLengthChanges() const { + return rpl::merge( + _videoTimeline->entity()->lengthChanges(), + _audioTimeline->entity()->lengthChanges()); +} + +rpl::producer<> PhotoEditorControls::audioRemoveRequests() const { + return _audioTimeline->entity()->removeRequests(); +} + +void PhotoEditorControls::updateTrimShortest() { + const auto shown = _trimShortestAvailable + && (_videoTimelineShown || _audioTimelineShown); + if (shown == _trimShortest->toggled()) { + return; + } + _trimShortest->toggle(shown, anim::type::normal); + if (shown || _videoTimelineShown || _audioTimelineShown) { + updateTimelinesGeometry(); + } +} + +void PhotoEditorControls::updateTimelinesGeometry() { + updateTimelineGeometry(_videoTimeline.get()); + updateTimelineGeometry(_audioTimeline.get()); +} + +void PhotoEditorControls::updateTimelineGeometry( + not_null timeline) { + const auto bar = _paintTopButtons->geometry(); + const auto skip = st::photoEditorTimelineSkip; + const auto left = _undoButton->width() + skip; + const auto trimShortest = _trimShortest->toggled() + ? (_trimShortest->width() + skip) + : 0; + const auto width = bar.width() + - left + - trimShortest + - _redoButton->width() + - skip; + timeline->resizeToWidth(std::max(width, 1)); + timeline->moveToLeft(bar.x() + left, bar.y()); + _trimShortest->moveToLeft( + bar.x() + left + std::max(width, 1) + skip, + bar.y()); +} + bool PhotoEditorControls::handleKeyPress(not_null e) const { + if ((e->key() == Qt::Key_Question) || (e->text() == u"?"_q)) { + _keysLegend->toggle(); + return true; + } _keyPresses.fire(std::move(e)); return true; } diff --git a/Telegram/SourceFiles/editor/photo_editor_controls.h b/Telegram/SourceFiles/editor/photo_editor_controls.h index e6e32bf028..e7241a19db 100644 --- a/Telegram/SourceFiles/editor/photo_editor_controls.h +++ b/Telegram/SourceFiles/editor/photo_editor_controls.h @@ -25,8 +25,13 @@ class FadeWrap; namespace Editor { +class AudioTrackTimeline; class EdgeButton; class ButtonBar; +class VideoClip; +class KeysLegendButton; +class VideoItemTimeline; +struct AudioTrack; struct Controllers; struct EditorData; @@ -60,6 +65,16 @@ public: void applyMode(const PhotoEditorMode &mode); void setShapeToolActive(bool active); + void setVideoClip(std::shared_ptr clip); + void setAudioTrack(std::shared_ptr track); + void setTrimShortestAvailable(bool available); + void setTrimShortestActive(bool active, anim::type animated); + void refreshTimelines(); + void refreshAudioVolume(); + void commitTimelineEdits(); + [[nodiscard]] rpl::producer<> trimShortestRequests() const; + [[nodiscard]] rpl::producer trimLengthChanges() const; + [[nodiscard]] rpl::producer<> audioRemoveRequests() const; private: void showAnimated( @@ -67,11 +82,15 @@ private: anim::type animated = anim::type::normal); void showShapesMenu(); void updateInputMask(); + void updateTimelinesGeometry(); + void updateTimelineGeometry(not_null timeline); + void updateTrimShortest(); int bottomButtonsTop() const; const QSize _imageSize; const float64 _originalRatio = 0.; + const bool _fixedCrop = false; const style::color &_bg; const int _buttonHeight; const base::unique_qptr _transformButtons; @@ -96,6 +115,10 @@ private: const base::unique_qptr _textButton; const base::unique_qptr _shapesButton; const base::unique_qptr _paintDone; + const base::unique_qptr> _videoTimeline; + const base::unique_qptr> _audioTimeline; + const base::unique_qptr> _trimShortest; + const base::unique_qptr _keysLegend; base::unique_qptr _ratioMenu; base::unique_qptr _cornersMenu; @@ -107,6 +130,9 @@ private: bool _shapesFilled = false; bool _shapeToolActive = false; bool _keepOriginalRatio = false; + bool _trimShortestAvailable = false; + bool _videoTimelineShown = false; + bool _audioTimelineShown = false; Ui::Animations::Simple _toggledBarAnimation; diff --git a/Telegram/SourceFiles/editor/photo_editor_inner_common.h b/Telegram/SourceFiles/editor/photo_editor_inner_common.h index fab3da4208..477ce41b3d 100644 --- a/Telegram/SourceFiles/editor/photo_editor_inner_common.h +++ b/Telegram/SourceFiles/editor/photo_editor_inner_common.h @@ -93,4 +93,13 @@ enum class SaveState { Keep, }; +struct VideoTrim { + crl::time from = 0; + crl::time till = 0; + + friend inline bool operator==( + const VideoTrim &, + const VideoTrim &) = default; +}; + } // namespace Editor diff --git a/Telegram/SourceFiles/editor/photo_editor_layer_widget.cpp b/Telegram/SourceFiles/editor/photo_editor_layer_widget.cpp index 799d68cfe9..a87262db19 100644 --- a/Telegram/SourceFiles/editor/photo_editor_layer_widget.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_layer_widget.cpp @@ -29,7 +29,8 @@ void OpenWithPreparedFile( Fn &&doneCallback, int sideLimit, QSize exactSize, - bool composeAnimated) { + bool composeAnimated, + bool composeSound) { using ImageInfo = Ui::PreparedFileInformation::Image; const auto image = std::get_if(&file->information->media); if (!image) { @@ -72,6 +73,7 @@ void OpenWithPreparedFile( .exactSize = exactSize, .keepAspectRatio = keepRatio, .composeAnimated = composeAnimated, + .composeSound = composeSound, }); const auto raw = editor.get(); auto layer = std::make_unique(parent, std::move(editor)); diff --git a/Telegram/SourceFiles/editor/photo_editor_layer_widget.h b/Telegram/SourceFiles/editor/photo_editor_layer_widget.h index 2ac7b2c324..ab6f436a31 100644 --- a/Telegram/SourceFiles/editor/photo_editor_layer_widget.h +++ b/Telegram/SourceFiles/editor/photo_editor_layer_widget.h @@ -37,7 +37,8 @@ void OpenWithPreparedFile( Fn &&doneCallback, int sideLimit = 0, QSize exactSize = {}, - bool composeAnimated = false); + bool composeAnimated = false, + bool composeSound = false); void PrepareProfilePhoto( not_null parent, diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index 3a1587bb31..6aa77677fd 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -7,14 +7,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "editor/scene/scene.h" +#include "editor/photo_editor_common.h" #include "editor/scene/scene_item_animated.h" #include "editor/scene/scene_item_canvas.h" #include "editor/scene/scene_item_line.h" #include "editor/scene/scene_item_shape.h" #include "editor/scene/scene_item_text.h" #include "editor/scene/scene_text_editing.h" +#include "editor/video/video_clip.h" #include "ui/image/image_prepare.h" #include "ui/painter.h" +#include "ui/rect.h" #include "ui/rp_widget.h" #include "styles/style_editor.h" @@ -22,9 +25,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include namespace Editor { -namespace { - -using ItemPtr = std::shared_ptr; class ItemAction : public NumberedItem { public: @@ -33,6 +33,10 @@ public: virtual void apply() = 0; virtual void revert() = 0; + ItemAction *asAction() override { + return this; + } + QRectF boundingRect() const override { return QRectF(); } @@ -71,6 +75,10 @@ private: } _saved, _keeped; }; +namespace { + +using ItemPtr = std::shared_ptr; + class ItemEraser final : public ItemAction { public: struct Target { @@ -152,16 +160,48 @@ bool SkipMouseEvent(not_null event) { constexpr auto kShapeDragThreshold = 4.; constexpr auto kShapeSnapAngle = 45.; constexpr auto kDraftShapeOpacity = 0.5; +constexpr auto kStickyGuideDuration = crl::time(150); +constexpr auto kItemsBaseZ = 9000.; } // namespace +class Scene::StickyGuidesItem final : public QGraphicsItem { +public: + explicit StickyGuidesItem(not_null scene) + : _scene(scene) { + setAcceptedMouseButtons(Qt::NoButton); + setZValue(kItemsBaseZ - 1.); + } + + QRectF boundingRect() const override { + return _scene->canvasRect() + Margins(_scene->stickyGuideMargin()); + } + + void paint( + QPainter *p, + const QStyleOptionGraphicsItem *, + QWidget *) override { + _scene->paintStickyGuide(*p, Qt::Horizontal); + _scene->paintStickyGuide(*p, Qt::Vertical); + } + + void updateGeometry() { + prepareGeometryChange(); + } + +private: + const not_null _scene; + +}; + Scene::Scene(const QRectF &rect) : QGraphicsScene(rect) , _canvas(std::make_shared()) -, _lastZ(std::make_shared(9000.)) +, _lastZ(std::make_shared(kItemsBaseZ)) +, _stickyGuides(std::make_unique(this)) , _textEdit(std::make_unique(this)) { QGraphicsScene::addItem(_canvas.get()); - _canvas->clearPixmap(); + QGraphicsScene::addItem(_stickyGuides.get()); _canvas->grabContentRequests( ) | rpl::on_next([=](ItemCanvas::Content &&content) { @@ -242,6 +282,7 @@ Scene::Scene(const QRectF &rect) } const auto canvasVisible = _canvas->isVisible(); _canvas->setVisible(false); + _stickyGuides->setVisible(false); { auto p = QPainter(&source); render( @@ -250,6 +291,7 @@ Scene::Scene(const QRectF &rect) QRectF(captureRect), Qt::IgnoreAspectRatio); } + _stickyGuides->setVisible(true); _canvas->setVisible(canvasVisible); auto blurred = Images::BlurLargeImage( std::move(source), @@ -297,6 +339,9 @@ Scene::Scene(const QRectF &rect) &QGraphicsScene::selectionChanged, [=] { const auto selected = selectedItems(); + if (!selected.empty()) { + setAudioSelected(false); + } auto *textItem = (ItemText*)(nullptr); auto *shapeItem = (ItemShape*)(nullptr); if (selected.size() == 1) { @@ -322,9 +367,28 @@ Scene::Scene(const QRectF &rect) _shapeItemDeselections.fire({}); } } + refreshVideoClipSelection(); }); } +void Scene::refreshVideoClipSelection() { + const auto selected = selectedItems(); + auto clip = std::shared_ptr(); + if (selected.size() == 1) { + if (const auto item = itemShared(selected.front())) { + if (const auto raw = item->videoClip()) { + clip = std::shared_ptr(item, raw); + } + } + } + if (clip.get() == _selectedVideoClip) { + return; + } + _selectedVideoClip = clip.get(); + updateVideoClipsSound(); + _videoClipSelections.fire(std::move(clip)); +} + void Scene::cancelDrawing() { _textEdit->finishEditing(false); _canvas->cancelDrawing(); @@ -339,12 +403,15 @@ void Scene::addItem(ItemPtr item) { return; } item->setNumber(_itemNumber++); - if (item->scene() != this) { - QGraphicsScene::addItem(item.get()); - } const auto raw = item.get(); _items.push_back(std::move(item)); _itemsByPointer.emplace(raw, _items.back()); + if (raw->scene() != this) { + QGraphicsScene::addItem(raw); + } + if (raw->videoClip()) { + checkDurationsLink(); + } _addsItem.fire({}); } @@ -360,10 +427,21 @@ void Scene::removeItem(not_null item) { void Scene::removeItem(const ItemPtr &item) { item->setStatus(NumberedItem::Status::Removed); + if (item->videoClip()) { + checkDurationsLink(); + } _removesItem.fire({}); } +void Scene::videoClipChanged(not_null item) { + checkDurationsLink(); + if (item->isSelected()) { + refreshVideoClipSelection(); + } +} + void Scene::mousePressEvent(QGraphicsSceneMouseEvent *event) { + setAudioSelected(false); if (_shapeTool.pending) { if (event->button() == Qt::LeftButton) { event->accept(); @@ -389,12 +467,116 @@ void Scene::mousePressEvent(QGraphicsSceneMouseEvent *event) { QGraphicsScene::mousePressEvent(event); capturePlacements(); - if (SkipMouseEvent(event) || !sceneRect().contains(event->scenePos())) { + if (SkipMouseEvent(event) + || !_canvas->drawableRect().contains(event->scenePos())) { return; } _canvas->handleMousePressEvent(event); } +void Scene::setCanvasRect(const QRectF &rect) { + if (_canvasRect == rect) { + return; + } + _stickyGuides->updateGeometry(); + _canvasRect = rect; + _canvas->setCanvasRect(canvasRect()); +} + +QRectF Scene::canvasRect() const { + return _canvasRect.isNull() ? sceneRect() : _canvasRect; +} + +void Scene::setStickyGuides( + std::optional x, + std::optional y) { + setStickyGuide(Qt::Horizontal, x); + setStickyGuide(Qt::Vertical, y); +} + +void Scene::setStickyGuide( + Qt::Orientation orientation, + std::optional position) { + auto &guide = (orientation == Qt::Horizontal) + ? _stickyGuideX + : _stickyGuideY; + const auto shown = position.has_value(); + if (!shown && !guide.shown) { + return; + } + const auto was = stickyGuideRect(orientation); + if (shown) { + guide.position = *position; + } + if (guide.shown != shown) { + guide.shown = shown; + guide.animation.start( + [=] { _stickyGuides->update(stickyGuideRect(orientation)); }, + shown ? 0. : 1., + shown ? 1. : 0., + kStickyGuideDuration); + } + _stickyGuides->update(was); + _stickyGuides->update(stickyGuideRect(orientation)); +} + +void Scene::hideStickyGuides() { + for (const auto guide : { &_stickyGuideX, &_stickyGuideY }) { + guide->animation.stop(); + guide->shown = false; + } + _stickyGuides->update(); +} + +float64 Scene::stickyGuideMargin() const { + const auto zoom = (_currentZoom > 0.) ? _currentZoom : 1.; + return st::photoEditorStickyLineWidth / zoom; +} + +QRectF Scene::stickyGuideRect(Qt::Orientation orientation) const { + const auto &guide = (orientation == Qt::Horizontal) + ? _stickyGuideX + : _stickyGuideY; + const auto canvas = canvasRect(); + const auto margin = stickyGuideMargin(); + return (orientation == Qt::Horizontal) + ? QRectF( + guide.position - margin, + canvas.top(), + margin * 2, + canvas.height()) + : QRectF( + canvas.left(), + guide.position - margin, + canvas.width(), + margin * 2); +} + +void Scene::paintStickyGuide( + QPainter &p, + Qt::Orientation orientation) const { + const auto &guide = (orientation == Qt::Horizontal) + ? _stickyGuideX + : _stickyGuideY; + const auto opacity = guide.animation.value(guide.shown ? 1. : 0.); + if (opacity <= 0.) { + return; + } + const auto canvas = canvasRect(); + auto color = QColor(Qt::white); + color.setAlphaF(opacity); + p.setPen(QPen(color, stickyGuideMargin())); + if (orientation == Qt::Horizontal) { + p.drawLine( + QPointF(guide.position, canvas.top()), + QPointF(guide.position, canvas.bottom())); + } else { + p.drawLine( + QPointF(canvas.left(), guide.position), + QPointF(canvas.right(), guide.position)); + } +} + void Scene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (_shapeTool.dragging && (event->button() == Qt::LeftButton)) { event->accept(); @@ -436,6 +618,7 @@ void Scene::setPendingShape(std::optional pending) { _textEdit->finishEditing(true); clearSelection(); clearFocus(); + setAudioSelected(false); } if (was != now) { _pendingShapeStates.fire_copy(now); @@ -659,6 +842,11 @@ rpl::producer<> Scene::shapeItemDeselections() const { return _shapeItemDeselections.events(); } +auto Scene::videoClipSelections() const +-> rpl::producer> { + return _videoClipSelections.events(); +} + void Scene::setBlurSource(Fn source) { _blurSource = std::move(source); } @@ -687,7 +875,7 @@ std::vector Scene::items( bool Scene::hasAnimatedItems() const { for (const auto &item : _items) { const auto animated = item->isNormalStatus() - ? dynamic_cast(item.get()) + ? item->asAnimated() : nullptr; if (animated && animated->animated() && animated->hasContent()) { return true; @@ -696,14 +884,177 @@ bool Scene::hasAnimatedItems() const { return false; } +bool Scene::hasAnimatedResult() const { + return (_audio != nullptr) || hasAnimatedItems(); +} + +void Scene::updateVideoClipsSound() { + const auto apply = [&](bool enabled) { + for (const auto &item : _items) { + const auto clip = item->videoClip(); + if (clip && ((clip == _selectedVideoClip) == enabled)) { + clip->setSoundEnabled(enabled); + } + } + }; + apply(false); + apply(true); +} + +bool Scene::hasSoundResult() const { + if (_audio && (_audio->volume > 0.)) { + return true; + } + for (const auto &item : _items) { + const auto clip = item->videoClip(); + if (clip + && item->isNormalStatus() + && clip->animated() + && clip->sounding()) { + return true; + } + } + return false; +} + void Scene::releaseAnimations() { for (const auto &item : _items) { - if (const auto animated = dynamic_cast(item.get())) { + if (const auto animated = item->asAnimated()) { animated->releasePlayers(); } } } +void Scene::setAudio(std::shared_ptr audio) { + if (audio && audio->empty()) { + audio = nullptr; + } + if (_audio == audio) { + return; + } + _audio = std::move(audio); + if (!_audio) { + setAudioSelected(false); + } + _audioChanges.fire({}); + checkDurationsLink(); +} + +std::shared_ptr Scene::audio() const { + return _audio; +} + +rpl::producer<> Scene::audioChanges() const { + return _audioChanges.events(); +} + +void Scene::setAudioSelected(bool selected) { + if (selected && !_audio) { + return; + } else if (_audioSelected == selected) { + return; + } + if (selected) { + setPendingShape(std::nullopt); + _textEdit->finishEditing(true); + clearSelection(); + clearFocus(); + } + _audioSelected = selected; + _audioSelectedChanges.fire_copy(selected); +} + +bool Scene::audioSelected() const { + return _audioSelected; +} + +rpl::producer Scene::audioSelectedChanges() const { + return _audioSelectedChanges.events(); +} + +bool Scene::canEqualizeDurations() const { + if (!_audio) { + return false; + } + for (const auto &item : _items) { + if (item->isNormalStatus() && item->videoClip()) { + return true; + } + } + return false; +} + +void Scene::equalizeDurations() { + auto shortest = _audio ? _audio->length() : crl::time(0); + for (const auto &item : _items) { + const auto clip = item->isNormalStatus() + ? item->videoClip() + : nullptr; + if (clip) { + const auto loop = clip->loopDuration(); + if (loop > 0 && (!shortest || loop < shortest)) { + shortest = loop; + } + } + } + matchDurations(shortest); +} + +void Scene::matchDurations(crl::time duration) { + auto clips = std::vector(); + auto shortest = duration; + if (_audio) { + shortest = std::min(shortest, _audio->duration - _audio->from); + } + for (const auto &item : _items) { + const auto clip = item->isNormalStatus() + ? item->videoClip() + : nullptr; + const auto full = clip ? clip->duration() : crl::time(0); + if (full > 0) { + shortest = std::min(shortest, full - clip->trim().from); + clips.push_back(clip); + } + } + if (shortest <= 0) { + return; + } + for (const auto clip : clips) { + const auto from = clip->trim().from; + clip->setTrim({ from, from + shortest }); + } + if (_audio) { + _audio->till = _audio->from + shortest; + } +} + +bool Scene::durationsLinked() const { + return _durationsLinked; +} + +void Scene::setDurationsLinked(bool linked) { + if (_durationsLinked == linked) { + return; + } + _durationsLinked = linked; + if (linked) { + equalizeDurations(); + } +} + +rpl::producer<> Scene::durationsLinkChanges() const { + return _durationsLinkChanges.events(); +} + +void Scene::checkDurationsLink() { + if (_durationsLinked && !canEqualizeDurations()) { + _durationsLinked = false; + } else if (_durationsLinked) { + equalizeDurations(); + } + _durationsLinkChanges.fire({}); +} + std::shared_ptr Scene::lastZ() const { return _lastZ; } @@ -720,6 +1071,7 @@ float64 Scene::currentZoom() const { void Scene::updateZoom(float64 zoom) { _currentZoom = zoom; _canvas->updateZoom(zoom); + _stickyGuides->updateGeometry(); for (const auto &item : items()) { if (item->type() >= ItemBase::Type) { static_cast(item.get())->updateZoom(zoom); @@ -744,10 +1096,13 @@ void Scene::performUndo() { return item->isNormalStatus() && item->undoable(); }); if (it != filtered.end()) { - if (const auto action = dynamic_cast(it->get())) { + if (const auto action = (*it)->asAction()) { action->revert(); } (*it)->setStatus(NumberedItem::Status::Undid); + if ((*it)->videoClip()) { + checkDurationsLink(); + } } } @@ -756,10 +1111,13 @@ void Scene::performRedo() { const auto it = ranges::find_if(filtered, &NumberedItem::isUndidStatus); if (it != filtered.end()) { - if (const auto action = dynamic_cast(it->get())) { + if (const auto action = (*it)->asAction()) { action->apply(); } (*it)->setStatus(NumberedItem::Status::Normal); + if ((*it)->videoClip()) { + checkDurationsLink(); + } } } @@ -805,6 +1163,11 @@ void Scene::removeIf(Fn proj) { // Scene loses ownership of an item. // It seems for some reason this line causes a crash. =( // QGraphicsScene::removeItem(item.get()); + item->setSelected(false); + item->setVisible(false); + if (const auto animated = item->asAnimated()) { + animated->releasePlayers(); + } } else { copy.push_back(item); } @@ -842,8 +1205,16 @@ void Scene::save(SaveState state) { for (const auto &item : _items) { item->save(state); } + auto &saved = (state == SaveState::Keep) ? _keptAudio : _savedAudio; + saved = _audio ? std::make_shared(*_audio) : nullptr; + auto &savedLinked = (state == SaveState::Keep) + ? _keptDurationsLinked + : _savedDurationsLinked; + savedLinked = _durationsLinked; + setAudioSelected(false); clearSelection(); cancelDrawing(); + hideStickyGuides(); } void Scene::restore(SaveState state) { @@ -854,6 +1225,15 @@ void Scene::restore(SaveState state) { for (const auto &item : _items) { item->restore(state); } + const auto &saved = (state == SaveState::Keep) + ? _keptAudio + : _savedAudio; + setAudioSelected(false); + setAudio(saved ? std::make_shared(*saved) : nullptr); + _durationsLinked = (state == SaveState::Keep) + ? _keptDurationsLinked + : _savedDurationsLinked; + checkDurationsLink(); clearSelection(); cancelDrawing(); } @@ -873,6 +1253,7 @@ Scene::~Scene() { QGraphicsScene::removeItem(pending.get()); } QGraphicsScene::removeItem(_canvas.get()); + QGraphicsScene::removeItem(_stickyGuides.get()); for (const auto &item : items()) { QGraphicsScene::removeItem(item.get()); } diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index 50712836d3..5a7f2b68a7 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include +#include "ui/effects/animations.h" #include @@ -25,6 +26,8 @@ class ItemShape; class ItemText; class NumberedItem; class TextEditController; +class VideoClip; +struct AudioTrack; class Scene final : public QGraphicsScene { public: @@ -42,6 +45,9 @@ public: Scene(const QRectF &rect); ~Scene(); + void setCanvasRect(const QRectF &rect); + [[nodiscard]] QRectF canvasRect() const; + void setStickyGuides(std::optional x, std::optional y); void applyBrush(const QColor &color, float64 size, Brush::Tool tool); void setBlurSource(Fn source); void setTextDefaults( @@ -61,10 +67,26 @@ public: [[nodiscard]] std::vector items( Qt::SortOrder order = Qt::DescendingOrder) const; [[nodiscard]] bool hasAnimatedItems() const; + [[nodiscard]] bool hasAnimatedResult() const; + [[nodiscard]] bool hasSoundResult() const; void releaseAnimations(); + + void setAudio(std::shared_ptr audio); + [[nodiscard]] std::shared_ptr audio() const; + [[nodiscard]] rpl::producer<> audioChanges() const; + void setAudioSelected(bool selected); + [[nodiscard]] bool audioSelected() const; + [[nodiscard]] rpl::producer audioSelectedChanges() const; + [[nodiscard]] bool canEqualizeDurations() const; + void equalizeDurations(); + void matchDurations(crl::time duration); + [[nodiscard]] bool durationsLinked() const; + void setDurationsLinked(bool linked); + [[nodiscard]] rpl::producer<> durationsLinkChanges() const; void addItem(ItemPtr item); void removeItem(not_null item); void removeItem(const ItemPtr &item); + void videoClipChanged(not_null item); [[nodiscard]] rpl::producer<> addsItem() const; [[nodiscard]] rpl::producer<> removesItem() const; @@ -90,6 +112,8 @@ public: [[nodiscard]] rpl::producer textEditStates() const; [[nodiscard]] rpl::producer shapeItemSelections() const; [[nodiscard]] rpl::producer<> shapeItemDeselections() const; + [[nodiscard]] auto videoClipSelections() const + -> rpl::producer>; [[nodiscard]] bool hasUndo() const; [[nodiscard]] bool hasRedo() const; @@ -106,13 +130,29 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; private: + class StickyGuidesItem; struct CapturedPlacement { std::shared_ptr item; ItemBase::Placement placement; }; + struct StickyGuide { + Ui::Animations::Simple animation; + float64 position = 0.; + bool shown = false; + }; void removeIf(Fn proj); + void setStickyGuide( + Qt::Orientation orientation, + std::optional position); + void hideStickyGuides(); + [[nodiscard]] float64 stickyGuideMargin() const; + [[nodiscard]] QRectF stickyGuideRect(Qt::Orientation orientation) const; + void paintStickyGuide(QPainter &p, Qt::Orientation orientation) const; void capturePlacements(); + void refreshVideoClipSelection(); + void updateVideoClipsSound(); + void checkDurationsLink(); void commitPlacements(); void startShapeDrawing(const QPointF &position); void updateShapeDrawing( @@ -126,12 +166,16 @@ private: const std::shared_ptr _canvas; const std::shared_ptr _lastZ; + const std::unique_ptr _stickyGuides; const std::unique_ptr _textEdit; Fn _blurSource; std::vector _items; std::unordered_map _itemsByPointer; std::vector _capturedPlacements; + QRectF _canvasRect; + StickyGuide _stickyGuideX; + StickyGuide _stickyGuideY; float64 _lastLineZ = 0.; float64 _currentZoom = 1.; @@ -152,8 +196,20 @@ private: rpl::event_stream _shapeItemSelections; rpl::event_stream<> _shapeItemDeselections; rpl::event_stream _pendingShapeStates; + rpl::event_stream> _videoClipSelections; + rpl::event_stream<> _audioChanges; + rpl::event_stream _audioSelectedChanges; + rpl::event_stream<> _durationsLinkChanges; ItemText *_selectedTextItem = nullptr; ItemShape *_selectedShapeItem = nullptr; + VideoClip *_selectedVideoClip = nullptr; + std::shared_ptr _audio; + std::shared_ptr _keptAudio; + std::shared_ptr _savedAudio; + bool _audioSelected = false; + bool _durationsLinked = false; + bool _keptDurationsLinked = false; + bool _savedDurationsLinked = false; rpl::lifetime _lifetime; }; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_animated.cpp b/Telegram/SourceFiles/editor/scene/scene_item_animated.cpp index 1207db249c..3819b6d3db 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_animated.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_animated.cpp @@ -13,12 +13,20 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Editor { +ItemAnimated *ItemAnimated::asAnimated() { + return this; +} + +VideoTrim ItemAnimated::trim() const { + return {}; +} + Media::Encode::AnimatedEntity ItemAnimated::animatedEntity( const QTransform &sceneToCanvas) const { const auto composed = QTransform().scale(flipped() ? -1. : 1., 1.) * sceneTransform() * sceneToCanvas; - const auto inner = contentRect(); + const auto inner = entityRect(); const auto m11 = composed.m11(); const auto m12 = composed.m12(); const auto m21 = composed.m21(); @@ -30,6 +38,7 @@ Media::Encode::AnimatedEntity ItemAnimated::animatedEntity( : (std::atan2(m12, m11) * 180. / M_PI); const auto size = inner.size() * scale; const auto center = composed.map(inner.center()); + const auto segment = trim(); return { .kind = entityKind(), .bytes = content(), @@ -38,9 +47,19 @@ Media::Encode::AnimatedEntity ItemAnimated::animatedEntity( size), .rotation = rotation, .flipped = mirrored, + .from = segment.from, + .till = segment.till, }; } +QRectF ItemAnimated::entityRect() const { + return contentRect(); +} + +QRectF ItemAnimated::visibleRect() const { + return fittedRect(_paintedFrameSize); +} + void ItemAnimated::paintFrame( QPainter *p, const QImage &frame, @@ -49,13 +68,9 @@ void ItemAnimated::paintFrame( if (frame.isNull()) { return; } - const auto rect = contentRect(); + _paintedFrameSize = frame.size(); const auto ratio = style::DevicePixelRatio(); - const auto fitted = QSizeF(frame.size()) - .scaled(rect.size(), Qt::KeepAspectRatio); - const auto resultRect = QRectF(rect.topLeft(), fitted).translated( - (rect.width() - fitted.width()) / 2., - (rect.height() - fitted.height()) / 2.); + const auto resultRect = visibleRect(); if (live) { p->save(); p->setRenderHint(QPainter::SmoothPixmapTransform); @@ -68,7 +83,7 @@ void ItemAnimated::paintFrame( p->restore(); return; } - auto pixelSize = (fitted * ratio).toSize(); + auto pixelSize = (resultRect.size() * ratio).toSize(); if (pixelSize.width() > frame.width()) { pixelSize = frame.size(); } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_animated.h b/Telegram/SourceFiles/editor/scene/scene_item_animated.h index a46ce95e2a..caf1862749 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_animated.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_animated.h @@ -20,14 +20,18 @@ public: [[nodiscard]] virtual bool hasContent() const = 0; [[nodiscard]] virtual QByteArray content() const = 0; [[nodiscard]] virtual crl::time loopDuration() const = 0; + [[nodiscard]] virtual VideoTrim trim() const; virtual void releasePlayers() = 0; + ItemAnimated *asAnimated() override; - [[nodiscard]] Media::Encode::AnimatedEntity animatedEntity( + [[nodiscard]] virtual Media::Encode::AnimatedEntity animatedEntity( const QTransform &sceneToCanvas) const; protected: [[nodiscard]] virtual Media::Encode::AnimatedEntity::Kind entityKind() const = 0; + [[nodiscard]] virtual QRectF entityRect() const; + [[nodiscard]] QRectF visibleRect() const override; void paintFrame( QPainter *p, @@ -42,6 +46,7 @@ private: QSize size; bool flipped = false; } _preview; + QSize _paintedFrameSize; }; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_base.cpp b/Telegram/SourceFiles/editor/scene/scene_item_base.cpp index bfdfab3e2c..ec77bd15fe 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_base.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_base.cpp @@ -12,7 +12,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/popup_menu.h" #include "ui/painter.h" #include "styles/style_editor.h" -#include "styles/style_media_view.h" #include "styles/style_menu_icons.h" #include @@ -30,15 +29,38 @@ constexpr auto kSnapAngle = 45.; const auto kDuplicateSequence = QKeySequence("ctrl+d"); const auto kFlipSequence = QKeySequence("ctrl+s"); const auto kDeleteSequence = QKeySequence("delete"); +const auto kBackspaceSequence = QKeySequence("backspace"); constexpr auto kMinSizeRatio = 0.05; -constexpr auto kMaxSizeRatio = 1.00; + +constexpr auto kStickyDuration = crl::time(150); +constexpr auto kStickyLines = 5; auto Normalized(float64 angle) { return angle + ((std::abs(angle) < 360) ? 0 : (-360 * (angle < 0 ? -1 : 1))); } +[[nodiscard]] float64 StickyStart( + const QRectF &rect, + Qt::Orientation orientation) { + return (orientation == Qt::Horizontal) ? rect.left() : rect.top(); +} + +[[nodiscard]] float64 StickyLength( + const QRectF &rect, + Qt::Orientation orientation) { + return (orientation == Qt::Horizontal) ? rect.width() : rect.height(); +} + +[[nodiscard]] float64 StickyLine( + const QRectF &canvas, + Qt::Orientation orientation, + int line) { + return StickyStart(canvas, orientation) + + StickyLength(canvas, orientation) * line / (kStickyLines - 1); +} + } // namespace int NumberedItem::type() const { @@ -69,6 +91,18 @@ bool NumberedItem::isRemovedStatus() const { return _status == Status::Removed; } +ItemAction *NumberedItem::asAction() { + return nullptr; +} + +ItemAnimated *NumberedItem::asAnimated() { + return nullptr; +} + +VideoClip *NumberedItem::videoClip() { + return nullptr; +} + void NumberedItem::save(SaveState state) { } @@ -97,6 +131,7 @@ bool NumberedItem::undoable() const { ItemBase::ItemBase(Data data) : _lastZ(data.zPtr) , _imageSize(data.imageSize) +, _maxSizeRatio(data.maxSizeRatio) , _contentMargins(data.contentMargins) , _horizontalSize(data.size) { setFlags(QGraphicsItem::ItemIsMovable @@ -122,6 +157,21 @@ QRectF ItemBase::innerRect() const { return QRectF(-hSize / 2, -vSize / 2, hSize, vSize); } +QRectF ItemBase::fittedRect(QSizeF size) const { + const auto rect = contentRect(); + if (size.isEmpty()) { + return rect; + } + const auto fitted = size.scaled(rect.size(), Qt::KeepAspectRatio); + return QRectF(rect.topLeft(), fitted).translated( + (rect.width() - fitted.width()) / 2., + (rect.height() - fitted.height()) / 2.); +} + +QRectF ItemBase::visibleRect() const { + return contentRect(); +} + void ItemBase::paint( QPainter *p, const QStyleOptionGraphicsItem *option, @@ -180,6 +230,7 @@ void ItemBase::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { : angle); } else { QGraphicsItem::mouseMoveEvent(event); + updateSticky(event->modifiers().testFlag(Qt::ShiftModifier)); } } @@ -205,6 +256,9 @@ void ItemBase::mousePressEvent(QGraphicsSceneMouseEvent *event) { setCursor(Qt::ClosedHandCursor); } else { QGraphicsItem::mousePressEvent(event); + if (event->button() == Qt::LeftButton) { + startStickyDrag(); + } } } @@ -212,10 +266,221 @@ void ItemBase::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if ((event->button() == Qt::LeftButton) && isHandling()) { _handle = HandleType::None; } else { + if (event->button() == Qt::LeftButton) { + finishStickyDrag(); + } QGraphicsItem::mouseReleaseEvent(event); } } +ItemBase::StickyAxis &ItemBase::stickyAxis(Qt::Orientation orientation) { + return (orientation == Qt::Horizontal) ? _stickyX : _stickyY; +} + +const ItemBase::StickyAxis &ItemBase::stickyAxis( + Qt::Orientation orientation) const { + return (orientation == Qt::Horizontal) ? _stickyX : _stickyY; +} + +void ItemBase::startStickyDrag() { + resetStickyAxis(Qt::Horizontal); + resetStickyAxis(Qt::Vertical); + _stickyDrag = { .raw = pos(), .active = true }; + notifyStickyGuides(); +} + +void ItemBase::resetStickyAxis(Qt::Orientation orientation) { + auto &axis = stickyAxis(orientation); + axis.animation.stop(); + axis.current = axis.last = Sticky(); +} + +void ItemBase::updateSticky(bool enabled) { + if (!_stickyDrag.active) { + return; + } + _stickyDrag.raw = pos(); + _stickyDrag.others.clear(); + if (const auto s = scene()) { + for (const auto item : s->selectedItems()) { + if ((item != this) + && (item->flags() & QGraphicsItem::ItemIsMovable)) { + _stickyDrag.others.emplace_back(item, item->pos()); + } + } + } + applyStickyState(enabled); +} + +void ItemBase::applyStickyState(bool enabled) { + if (!_stickyDrag.active) { + return; + } + for (const auto orientation : { Qt::Horizontal, Qt::Vertical }) { + applySticky( + orientation, + enabled ? computeSticky(orientation) : Sticky()); + } + applyStickyPosition(); +} + +void ItemBase::finishStickyDrag() { + if (!_stickyDrag.active) { + return; + } + for (const auto orientation : { Qt::Horizontal, Qt::Vertical }) { + stickyAxis(orientation).animation.stop(); + } + applyStickyPosition(); + _stickyDrag = {}; + resetStickyAxis(Qt::Horizontal); + resetStickyAxis(Qt::Vertical); + notifyStickyGuides(); +} + +void ItemBase::applySticky(Qt::Orientation orientation, Sticky sticky) { + auto &axis = stickyAxis(orientation); + if (axis.current == sticky) { + return; + } + const auto to = sticky.valid(); + const auto continues = !to || (sticky == axis.last); + const auto from = continues + ? axis.animation.value(axis.current.valid() ? 1. : 0.) + : 0.; + axis.current = sticky; + if (to) { + axis.last = axis.current; + } + axis.animation = {}; + axis.animation.start( + [=] { applyStickyPosition(); }, + from, + to ? 1. : 0., + kStickyDuration, + anim::easeOutCubic); + notifyStickyGuides(); +} + +void ItemBase::applyStickyPosition() { + if (!_stickyDrag.active) { + return; + } + const auto offset = QPointF( + stickyOffset(Qt::Horizontal), + stickyOffset(Qt::Vertical)); + setPos(_stickyDrag.raw + offset); + for (const auto &[item, raw] : _stickyDrag.others) { + item->setPos(raw + offset); + } +} + +void ItemBase::notifyStickyGuides() { + if (const auto owner = static_cast(scene())) { + owner->setStickyGuides( + stickyGuide(Qt::Horizontal), + stickyGuide(Qt::Vertical)); + } +} + +QRectF ItemBase::stickyBounds() const { + return mapToScene(visibleRect()).boundingRect().translated( + _stickyDrag.raw - pos()); +} + +ItemBase::Sticky ItemBase::computeSticky(Qt::Orientation orientation) const { + struct Candidate { + Sticky sticky; + float64 shift = 0.; + int priority = 0; + }; + const auto priority = [](const Sticky &sticky) { + const auto anchor = (sticky.anchor == StickyAnchor::Center) ? 0 : 1; + const auto line = (sticky.line * 2 == kStickyLines - 1) + ? 0 + : (sticky.line == 0 || sticky.line == kStickyLines - 1) + ? 1 + : 2; + return anchor * 3 + line; + }; + auto candidates = std::vector(); + candidates.reserve(kStickyLines * 3); + for (auto line = 0; line != kStickyLines; ++line) { + for (const auto anchor : { + StickyAnchor::Start, + StickyAnchor::Center, + StickyAnchor::End }) { + const auto sticky = Sticky{ line, anchor }; + candidates.push_back({ + .sticky = sticky, + .shift = stickyShift(orientation, sticky), + .priority = priority(sticky), + }); + } + } + ranges::sort(candidates, ranges::less(), &Candidate::priority); + auto kept = std::vector(); + auto result = Sticky(); + auto best = _scaledStickyTrigger; + for (const auto &candidate : candidates) { + if (std::abs(candidate.shift) > _scaledStickyTrigger) { + continue; + } + const auto overlaps = ranges::any_of(kept, [&](float64 shift) { + return std::abs(shift - candidate.shift) + < _scaledStickyTrigger * 2; + }); + if (overlaps) { + continue; + } + kept.push_back(candidate.shift); + if (std::abs(candidate.shift) <= best) { + best = std::abs(candidate.shift); + result = candidate.sticky; + } + } + return result; +} + +float64 ItemBase::stickyShift( + Qt::Orientation orientation, + Sticky sticky) const { + const auto owner = static_cast(scene()); + if (!owner || !sticky.valid()) { + return 0.; + } + const auto bounds = stickyBounds(); + const auto start = StickyStart(bounds, orientation); + const auto length = StickyLength(bounds, orientation); + const auto anchor = (sticky.anchor == StickyAnchor::Start) + ? start + : (sticky.anchor == StickyAnchor::Center) + ? (start + length / 2.) + : (start + length); + return StickyLine(owner->canvasRect(), orientation, sticky.line) - anchor; +} + +float64 ItemBase::stickyOffset(Qt::Orientation orientation) const { + const auto &axis = stickyAxis(orientation); + const auto stuck = axis.current.valid(); + const auto sticky = stuck ? axis.current : axis.last; + if (!sticky.valid()) { + return 0.; + } + const auto progress = axis.animation.value(stuck ? 1. : 0.); + return stickyShift(orientation, sticky) * progress; +} + +std::optional ItemBase::stickyGuide( + Qt::Orientation orientation) const { + const auto owner = static_cast(scene()); + const auto sticky = stickyAxis(orientation).current; + if (!owner || !sticky.valid()) { + return std::nullopt; + } + return StickyLine(owner->canvasRect(), orientation, sticky.line); +} + void ItemBase::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { if (scene()) { scene()->clearSelection(); @@ -238,17 +503,23 @@ void ItemBase::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { _menu = base::make_unique_q( nullptr, - st::mediaviewPopupMenu); + st::photoEditorMediaMenu); + fillContextMenu(_menu.get()); + if (!_menu->empty()) { + _menu->addSeparator(); + } add( tr::lng_photo_editor_menu_delete, kDeleteSequence, [=] { actionDelete(); }, &st::mediaMenuIconDelete); - add( - tr::lng_photo_editor_menu_flip, - kFlipSequence, - [=] { actionFlip(); }, - &st::mediaMenuIconFlip); + if (flippable()) { + add( + tr::lng_photo_editor_menu_flip, + kFlipSequence, + [=] { actionFlip(); }, + &st::mediaMenuIconFlip); + } add( tr::lng_photo_editor_menu_duplicate, kDuplicateSequence, @@ -258,6 +529,9 @@ void ItemBase::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { _menu->popup(event->screenPos()); } +void ItemBase::fillContextMenu(not_null menu) { +} + void ItemBase::performForSelectedItems(Action action) { if (const auto s = scene()) { for (const auto item : s->selectedItems()) { @@ -268,8 +542,14 @@ void ItemBase::performForSelectedItems(Action action) { } } +bool ItemBase::flippable() const { + return true; +} + void ItemBase::actionFlip() { - setFlip(!flipped()); + if (flippable()) { + setFlip(!flipped()); + } } void ItemBase::actionDelete() { @@ -298,8 +578,25 @@ void ItemBase::raiseToTop() { setZValue((*_lastZ)++); } +bool ItemBase::sceneEvent(QEvent *event) { + if (event->type() == QEvent::UngrabMouse) { + _handle = HandleType::None; + finishStickyDrag(); + } + return NumberedItem::sceneEvent(event); +} + +void ItemBase::keyReleaseEvent(QKeyEvent *e) { + if (e->key() == Qt::Key_Shift) { + applyStickyState(e->modifiers().testFlag(Qt::ShiftModifier)); + } + NumberedItem::keyReleaseEvent(e); +} + void ItemBase::keyPressEvent(QKeyEvent *e) { - if (e->key() == Qt::Key_Escape) { + if (e->key() == Qt::Key_Shift) { + applyStickyState(e->modifiers().testFlag(Qt::ShiftModifier)); + } else if (e->key() == Qt::Key_Escape) { if (const auto s = scene()) { s->clearSelection(); s->clearFocus(); @@ -318,7 +615,7 @@ void ItemBase::handleActionKey(not_null e) { }; if (matches(kDuplicateSequence)) { performForSelectedItems(&ItemBase::actionDuplicate); - } else if (matches(kDeleteSequence)) { + } else if (matches(kDeleteSequence) || matches(kBackspaceSequence)) { performForSelectedItems(&ItemBase::actionDelete); } else if (matches(kFlipSequence)) { performForSelectedItems(&ItemBase::actionFlip); @@ -450,6 +747,7 @@ int ItemBase::type() const { void ItemBase::updateZoom(float64 zoom) { _scaledHandleSize = st::photoEditorItemHandleSize / zoom; + _scaledStickyTrigger = st::photoEditorStickyTrigger / zoom; _scaledInnerMargins = QMarginsF( _scaledHandleSize, _scaledHandleSize, @@ -461,7 +759,7 @@ void ItemBase::updateZoom(float64 zoom) { _imageSize.height()); _sizeLimits = { .min = std::max(int(maxSide * kMinSizeRatio), 1), - .max = std::max(int(maxSide * kMaxSizeRatio), 1), + .max = std::max(int(maxSide * _maxSizeRatio), 1), }; _horizontalSize = std::clamp( _horizontalSize, @@ -505,6 +803,7 @@ ItemBase::Data ItemBase::generateData() const { .flipped = flipped(), .rotation = int(rotation()), .imageSize = _imageSize, + .maxSizeRatio = _maxSizeRatio, .contentMargins = _contentMargins, }; } @@ -562,6 +861,9 @@ void ItemBase::restore(SaveState state) { } const auto &saved = (state == SaveState::Keep) ? _keeped : _saved; applyData(saved.data); + if (const auto owner = static_cast(scene())) { + updateZoom(owner->currentZoom()); + } setZValue(saved.zValue); setStatus(saved.status); } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_base.h b/Telegram/SourceFiles/editor/scene/scene_item_base.h index 065d99fe9c..d3f946383f 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_base.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_base.h @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unique_qptr.h" #include "editor/photo_editor_inner_common.h" +#include "ui/effects/animations.h" #include @@ -22,6 +23,10 @@ class PopupMenu; namespace Editor { +class ItemAction; +class ItemAnimated; +class VideoClip; + class NumberedItem : public QGraphicsItem { public: enum class Status { @@ -46,6 +51,10 @@ public: void setUndoable(bool undoable); [[nodiscard]] bool undoable() const; + [[nodiscard]] virtual ItemAction *asAction(); + [[nodiscard]] virtual ItemAnimated *asAnimated(); + [[nodiscard]] virtual VideoClip *videoClip(); + virtual void save(SaveState state); virtual void restore(SaveState state); virtual bool hasState(SaveState state) const; @@ -68,6 +77,7 @@ public: bool flipped = false; int rotation = 0; QSize imageSize; + float64 maxSizeRatio = 1.; bool contentMargins = true; }; @@ -118,9 +128,13 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override; void keyPressEvent(QKeyEvent *e) override; + void keyReleaseEvent(QKeyEvent *e) override; + bool sceneEvent(QEvent *event) override; + virtual void fillContextMenu(not_null menu); using Action = void(ItemBase::*)(); void performForSelectedItems(Action action); + [[nodiscard]] virtual bool flippable() const; virtual void actionFlip(); void actionDelete(); void actionDuplicate(); @@ -128,6 +142,8 @@ protected: QRectF contentRect() const; QRectF innerRect() const; + [[nodiscard]] QRectF fittedRect(QSizeF size) const; + [[nodiscard]] virtual QRectF visibleRect() const; float64 size() const; float64 horizontalSize() const; float64 verticalSize() const; @@ -148,6 +164,33 @@ protected: virtual void performFlip(); virtual std::shared_ptr duplicate(Data data) const = 0; private: + enum class StickyAnchor { + Start, + Center, + End, + }; + struct Sticky { + int line = -1; + StickyAnchor anchor = StickyAnchor::Start; + + [[nodiscard]] bool valid() const { + return line >= 0; + } + friend inline bool operator==( + const Sticky &, + const Sticky &) = default; + }; + struct StickyAxis { + Sticky current; + Sticky last; + Ui::Animations::Simple animation; + }; + struct StickyDrag { + std::vector> others; + QPointF raw; + bool active = false; + }; + HandleType handleType(const QPointF &pos) const; QRectF rightHandleRect() const; QRectF leftHandleRect() const; @@ -156,11 +199,32 @@ private: void updatePens(QPen pen); void handleActionKey(not_null e); + [[nodiscard]] StickyAxis &stickyAxis(Qt::Orientation orientation); + [[nodiscard]] const StickyAxis &stickyAxis( + Qt::Orientation orientation) const; + void startStickyDrag(); + void updateSticky(bool enabled); + void applyStickyState(bool enabled); + void finishStickyDrag(); + void resetStickyAxis(Qt::Orientation orientation); + void applySticky(Qt::Orientation orientation, Sticky sticky); + void applyStickyPosition(); + void notifyStickyGuides(); + [[nodiscard]] QRectF stickyBounds() const; + [[nodiscard]] Sticky computeSticky(Qt::Orientation orientation) const; + [[nodiscard]] float64 stickyShift( + Qt::Orientation orientation, + Sticky sticky) const; + [[nodiscard]] float64 stickyOffset(Qt::Orientation orientation) const; + [[nodiscard]] std::optional stickyGuide( + Qt::Orientation orientation) const; + Data generateData() const; void applyData(const Data &data); const std::shared_ptr _lastZ; const QSize _imageSize; + const float64 _maxSizeRatio; const bool _contentMargins; struct { @@ -183,7 +247,11 @@ private: int max = 0; } _sizeLimits; float64 _scaledHandleSize = 1.0; + float64 _scaledStickyTrigger = 0.; QMarginsF _scaledInnerMargins; + StickyAxis _stickyX; + StickyAxis _stickyY; + StickyDrag _stickyDrag; float64 _horizontalSize = 0; float64 _verticalSize = 0; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp b/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp index 130e65864b..d62cd65f07 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp @@ -40,17 +40,42 @@ ItemCanvas::ItemCanvas() { setAcceptedMouseButtons({}); } -void ItemCanvas::clearPixmap() { +void ItemCanvas::setCanvasRect(const QRectF &rect) { + prepareGeometryChange(); + _canvasRect = rect; + releasePixmap(); + update(); +} + +QRectF ItemCanvas::canvasRect() const { + return _canvasRect.isNull() ? scene()->sceneRect() : _canvasRect; +} + +QRectF ItemCanvas::drawableRect() const { + return (_brushData.tool == Brush::Tool::Blur) + ? scene()->sceneRect() + : canvasRect(); +} + +void ItemCanvas::releasePixmap() { _hq = nullptr; _p = nullptr; + _pixmap = QPixmap(); +} +void ItemCanvas::preparePixmap() { + releasePixmap(); + + const auto canvas = canvasRect(); _pixmap = QPixmap( - (scene()->sceneRect().size() * style::DevicePixelRatio()).toSize()); + (canvas.size() * style::DevicePixelRatio()).toSize()); _pixmap.setDevicePixelRatio(style::DevicePixelRatio()); _pixmap.fill(Qt::transparent); _p = std::make_unique(&_pixmap); _hq = std::make_unique(*_p); + _p->translate(-canvas.topLeft()); + _p->setClipRect(drawableRect()); _p->setPen(Qt::NoPen); _p->setBrush(_brushData.color); } @@ -59,7 +84,6 @@ void ItemCanvas::applyBrush(const QColor &color, float size, Brush::Tool tool) { _brushData.color = color; _brushData.size = size; _brushData.tool = tool; - _p->setBrush(color); const auto width = strokeWidth(1.0); const auto extra = (tool == Brush::Tool::Arrow) ? arrowHeadLength() @@ -69,31 +93,37 @@ void ItemCanvas::applyBrush(const QColor &color, float size, Brush::Tool tool) { } QRectF ItemCanvas::boundingRect() const { - return scene()->sceneRect(); + return canvasRect(); } void ItemCanvas::computeContentRect(const QPointF &p) { if (!scene() || !IsValidPoint(p)) { return; } - const auto sceneSize = scene()->sceneRect().size(); - const auto contentLeft = std::max(0., _contentRect.x()); - const auto contentTop = std::max(0., _contentRect.y()); + const auto canvas = drawableRect(); + const auto contentLeft = std::max(canvas.left(), _contentRect.x()); + const auto contentTop = std::max(canvas.top(), _contentRect.y()); const auto contentRight = contentLeft + _contentRect.width(); const auto contentBottom = contentTop + _contentRect.height(); _contentRect = QRectF( QPointF( - std::clamp(p.x() - _brushMargins.left(), 0., contentLeft), - std::clamp(p.y() - _brushMargins.top(), 0., contentTop)), + std::clamp( + p.x() - _brushMargins.left(), + canvas.left(), + contentLeft), + std::clamp( + p.y() - _brushMargins.top(), + canvas.top(), + contentTop)), QPointF( std::clamp( p.x() + _brushMargins.right(), - std::min(contentRight, sceneSize.width()), - sceneSize.width()), + std::min(contentRight, canvas.right()), + canvas.right()), std::clamp( p.y() + _brushMargins.bottom(), - std::min(contentBottom, sceneSize.height()), - sceneSize.height()))); + std::min(contentBottom, canvas.bottom()), + canvas.bottom()))); } std::vector ItemCanvas::smoothStroke( @@ -356,6 +386,7 @@ void ItemCanvas::handleMousePressEvent( _drawing = false; return; } + preparePixmap(); _rectToUpdate = QRectF(); _contentRect = QRectF(); _currentStroke.clear(); @@ -404,11 +435,12 @@ void ItemCanvas::handleMouseReleaseEvent( drawArrowHead(); update(_rectToUpdate); if ((_currentStroke.size() >= 2) && _contentRect.isValid()) { + const auto local = _contentRect.translated(-canvasRect().topLeft()); const auto scaledContentRect = QRectF( - _contentRect.x() * style::DevicePixelRatio(), - _contentRect.y() * style::DevicePixelRatio(), - _contentRect.width() * style::DevicePixelRatio(), - _contentRect.height() * style::DevicePixelRatio()); + local.x() * style::DevicePixelRatio(), + local.y() * style::DevicePixelRatio(), + local.width() * style::DevicePixelRatio(), + local.height() * style::DevicePixelRatio()); _grabContentRequests.fire({ .pixmap = _pixmap.copy(scaledContentRect.toRect()), .position = _contentRect.topLeft(), @@ -420,7 +452,7 @@ void ItemCanvas::handleMouseReleaseEvent( _lastRenderedIndex = 0; _lastPointTime = 0; _currentPath = QPainterPath(); - clearPixmap(); + releasePixmap(); update(); } @@ -429,18 +461,19 @@ void ItemCanvas::paint( const QStyleOptionGraphicsItem *, QWidget *) { p->fillRect(_rectToUpdate, Qt::transparent); + const auto origin = canvasRect().topLeft(); if (_brushData.tool == Brush::Tool::Eraser) { p->save(); p->setOpacity(st::photoEditorEraserPreviewOpacity); - p->drawPixmap(0, 0, _pixmap); + p->drawPixmap(origin, _pixmap); p->restore(); } else if (_brushData.tool == Brush::Tool::Blur) { p->save(); p->setOpacity(st::photoEditorBlurPreviewOpacity); - p->drawPixmap(0, 0, _pixmap); + p->drawPixmap(origin, _pixmap); p->restore(); } else { - p->drawPixmap(0, 0, _pixmap); + p->drawPixmap(origin, _pixmap); } _rectToUpdate = QRectF(); } @@ -469,7 +502,7 @@ void ItemCanvas::cancelDrawing() { _lastPointTime = 0; _currentPath = QPainterPath(); _contentRect = QRectF(); - clearPixmap(); + releasePixmap(); update(); } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_canvas.h b/Telegram/SourceFiles/editor/scene/scene_item_canvas.h index 1c319fd696..808000ba48 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_canvas.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_canvas.h @@ -29,7 +29,9 @@ public: ~ItemCanvas(); void applyBrush(const QColor &color, float size, Brush::Tool tool); - void clearPixmap(); + void setCanvasRect(const QRectF &rect); + [[nodiscard]] QRectF canvasRect() const; + [[nodiscard]] QRectF drawableRect() const; void cancelDrawing(); void updateZoom(float64 zoom); @@ -60,6 +62,8 @@ private: int64 time = 0; }; + void preparePixmap(); + void releasePixmap(); void computeContentRect(const QPointF &p); void addStrokePoint(const QPointF &point, int64 time); void drawIncrementalStroke(); @@ -85,6 +89,7 @@ private: QRectF _rectToUpdate; QRectF _contentRect; + QRectF _canvasRect; QMarginsF _brushMargins; QPointF _lastPoint; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_image.cpp b/Telegram/SourceFiles/editor/scene/scene_item_image.cpp index 9508668af0..27b821794b 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_image.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_image.cpp @@ -26,16 +26,15 @@ void ItemImage::paint( QPainter *p, const QStyleOptionGraphicsItem *option, QWidget *w) { - const auto rect = contentRect(); - const auto pixmapSize = QSizeF(_pixmap.size() / style::DevicePixelRatio()) - .scaled(rect.size(), Qt::KeepAspectRatio); - const auto resultRect = QRectF(rect.topLeft(), pixmapSize).translated( - (rect.width() - pixmapSize.width()) / 2., - (rect.height() - pixmapSize.height()) / 2.); - p->drawPixmap(resultRect.toRect(), _pixmap); + p->setRenderHint(QPainter::SmoothPixmapTransform); + p->drawPixmap(visibleRect().toRect(), _pixmap); ItemBase::paint(p, option, w); } +QRectF ItemImage::visibleRect() const { + return fittedRect(_pixmap.size() / style::DevicePixelRatio()); +} + void ItemImage::performFlip() { _pixmap = _pixmap.transformed(QTransform().scale(-1, 1)); update(); diff --git a/Telegram/SourceFiles/editor/scene/scene_item_image.h b/Telegram/SourceFiles/editor/scene/scene_item_image.h index 320370ea27..b92f3f7197 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_image.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_image.h @@ -21,6 +21,7 @@ public: const QStyleOptionGraphicsItem *option, QWidget *widget) override; protected: + [[nodiscard]] QRectF visibleRect() const override; void performFlip() override; std::shared_ptr duplicate(ItemBase::Data data) const override; private: diff --git a/Telegram/SourceFiles/editor/scene/scene_item_link.cpp b/Telegram/SourceFiles/editor/scene/scene_item_link.cpp new file mode 100644 index 0000000000..44ab2e11b2 --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_link.cpp @@ -0,0 +1,181 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/scene/scene_item_link.h" + +#include "lang/lang_keys.h" +#include "ui/widgets/popup_menu.h" +#include "styles/style_media_player.h" +#include "styles/style_menu_icons.h" + +#include + +namespace Editor { +namespace { + +constexpr auto kMaxWidthRatio = 0.9; + +[[nodiscard]] LinkStyle NextStyle(LinkStyle style) { + switch (style) { + case LinkStyle::Framed: return LinkStyle::SemiTransparent; + case LinkStyle::SemiTransparent: return LinkStyle::Opaque; + case LinkStyle::Opaque: return LinkStyle::Framed; + } + Unexpected("Link style in NextStyle."); +} + +[[nodiscard]] LinkStyle PreviousStyle(LinkStyle style) { + switch (style) { + case LinkStyle::Framed: return LinkStyle::Opaque; + case LinkStyle::SemiTransparent: return LinkStyle::Framed; + case LinkStyle::Opaque: return LinkStyle::SemiTransparent; + } + Unexpected("Link style in PreviousStyle."); +} + +} // namespace + +ItemLink::ItemLink(LinkPreview link, ItemBase::Data data) +: ItemBase(data) +, _link(std::move(link)) +, _imageSize(data.imageSize) +, _pill(MakePill(_link, _imageSize)) { + setAspectRatio(_pill.size().height() / _pill.size().width()); +} + +LinkPill ItemLink::MakePill(const LinkPreview &link, QSize imageSize) { + return LinkPill( + link, + LinkPill::DensityFor(imageSize.width()), + int(imageSize.width() * kMaxWidthRatio)); +} + +int ItemLink::type() const { + return Type; +} + +const LinkPreview &ItemLink::link() const { + return _link; +} + +void ItemLink::setLink(LinkPreview link) { + _link = std::move(link); + const auto style = _pill.style(); + const auto scale = size() / _pill.size().width(); + _pill = MakePill(_link, _imageSize); + _pill.setStyle(style); + applyStretch( + _pill.size().width() * scale, + _pill.size().height() * scale, + false); + update(); +} + +LinkStyle ItemLink::style() const { + return _pill.style(); +} + +void ItemLink::setStyle(LinkStyle style) { + _pill.setStyle(style); + update(); +} + +void ItemLink::nextStyle() { + setStyle(NextStyle(_pill.style())); +} + +void ItemLink::setEditCallback(EditCallback callback) { + _edit = std::move(callback); +} + +QRectF ItemLink::visibleRect() const { + return fittedRect(_pill.size()); +} + +bool ItemLink::flippable() const { + return false; +} + +void ItemLink::paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *w) { + const auto rect = visibleRect(); + _pill.paint(*p, rect.topLeft(), rect.width() / _pill.size().width()); + ItemBase::paint(p, option, w); +} + +void ItemLink::mousePressEvent(QGraphicsSceneMouseEvent *event) { + _wasSelected = isSelected() && (event->button() == Qt::LeftButton); + _styleCycled = false; + ItemBase::mousePressEvent(event); +} + +void ItemLink::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { + const auto clicked = _wasSelected + && (event->button() == Qt::LeftButton) + && !isHandling() + && !dragThresholdPassed(event); + ItemBase::mouseReleaseEvent(event); + if (clicked) { + _styleCycled = true; + nextStyle(); + } +} + +void ItemLink::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { + if (base::take(_styleCycled)) { + setStyle(PreviousStyle(_pill.style())); + } + _wasSelected = false; + if (_edit) { + _edit(this); + } else { + ItemBase::mouseDoubleClickEvent(event); + } +} + +void ItemLink::fillContextMenu(not_null menu) { + const auto current = _pill.style(); + const auto addStyle = [&]( + const QString &text, + LinkStyle style, + const style::icon *icon) { + const auto checked = (current == style); + menu->addAction( + text, + [=] { setStyle(style); }, + checked ? &st::mediaPlayerMenuCheck : icon); + }; + addStyle( + tr::lng_photo_editor_text_style_framed(tr::now), + LinkStyle::Framed, + &st::mediaMenuIconTextStyleFramed); + addStyle( + tr::lng_photo_editor_text_style_semi_transparent(tr::now), + LinkStyle::SemiTransparent, + &st::mediaMenuIconTextStyleSemiTransparent); + addStyle( + tr::lng_photo_editor_text_style_opaque(tr::now), + LinkStyle::Opaque, + &st::mediaMenuIconTextStyleOpaque); + if (_edit) { + menu->addAction( + tr::lng_menu_formatting_link_edit(tr::now), + [=] { _edit(this); }, + &st::mediaMenuIconEdit); + } +} + +std::shared_ptr ItemLink::duplicate(ItemBase::Data data) const { + auto result = std::make_shared(_link, std::move(data)); + result->_pill.setStyle(_pill.style()); + result->_edit = _edit; + return result; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_link.h b/Telegram/SourceFiles/editor/scene/scene_item_link.h new file mode 100644 index 0000000000..c5981f924f --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_link.h @@ -0,0 +1,59 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "editor/editor_link_pill.h" +#include "editor/scene/scene_item_base.h" + +namespace Editor { + +class ItemLink final : public ItemBase { +public: + enum { Type = ItemBase::Type + 6 }; + + using EditCallback = Fn)>; + + ItemLink(LinkPreview link, ItemBase::Data data); + + [[nodiscard]] static LinkPill MakePill( + const LinkPreview &link, + QSize imageSize); + + void paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *widget) override; + int type() const override; + + [[nodiscard]] const LinkPreview &link() const; + void setLink(LinkPreview link); + [[nodiscard]] LinkStyle style() const; + void setStyle(LinkStyle style); + void nextStyle(); + void setEditCallback(EditCallback callback); + +protected: + [[nodiscard]] QRectF visibleRect() const override; + [[nodiscard]] bool flippable() const override; + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; + void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + void fillContextMenu(not_null menu) override; + std::shared_ptr duplicate(ItemBase::Data data) const override; + +private: + LinkPreview _link; + const QSize _imageSize; + LinkPill _pill; + EditCallback _edit; + bool _wasSelected = false; + bool _styleCycled = false; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_message.cpp b/Telegram/SourceFiles/editor/scene/scene_item_message.cpp new file mode 100644 index 0000000000..7c66bdfc3c --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_message.cpp @@ -0,0 +1,499 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/scene/scene_item_message.h" + +#include "data/data_document.h" +#include "editor/editor_audio_menu.h" +#include "editor/editor_message_render.h" +#include "editor/editor_message_source.h" +#include "editor/editor_message_video.h" +#include "editor/scene/scene.h" +#include "editor/video/video_segment_player.h" +#include "lang/lang_keys.h" +#include "ui/effects/radial_animation.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "ui/widgets/popup_menu.h" +#include "window/themes/window_theme.h" +#include "styles/style_chat.h" +#include "styles/style_menu_icons.h" + +#include +#include + +namespace Editor { +namespace { + +constexpr auto kMaxRatio = 8; +constexpr auto kPlaybackFrameSide = 1024; + +[[nodiscard]] bool OnMainThread() { + return QThread::currentThread() == QCoreApplication::instance()->thread(); +} + +[[nodiscard]] int RatioFor(float64 width, int logicalWidth) { + return std::clamp(int(std::ceil(width / logicalWidth)), 1, kMaxRatio); +} + +[[nodiscard]] QRect CoverRect(QRect hole, QSize video) { + if (video.isEmpty() || hole.isEmpty()) { + return hole; + } + const auto scale = std::max( + hole.width() / float64(video.width()), + hole.height() / float64(video.height())); + const auto size = QSize( + int(std::ceil(video.width() * scale)), + int(std::ceil(video.height() * scale))); + return QRect( + rect::center(hole) - QPoint(size.width() / 2, size.height() / 2), + size); +} + +[[nodiscard]] QSize PlaybackFrameSize(QSize frames) { + const auto ratio = style::DevicePixelRatio(); + const auto side = std::max(frames.width(), frames.height()); + const auto fit = std::min(1., kPlaybackFrameSide / float64(side)); + return QSize( + std::max(int(std::round(frames.width() * fit / ratio)), 1), + std::max(int(std::round(frames.height() * fit / ratio)), 1)); +} + +} // namespace + +ItemMessage::ItemMessage( + std::shared_ptr source, + std::unique_ptr renderer, + ItemBase::Data data, + std::optional dark, + MessageVideoOptions video) +: ItemAnimated(std::move(data)) +, _source(std::move(source)) +, _renderer(std::move(renderer)) +, _videoOptions(video) +, _dark(dark) { + attachRenderer(); + watchVideo(); +} + +ItemMessage::~ItemMessage() = default; + +void ItemMessage::attachRenderer() { + _renderer->setDark(_dark); + _renderer->setRepaintCallback([=] { scheduleRefresh(); }); + setImage(_renderer->render(1), 1); + updateSize(); +} + +void ItemMessage::scheduleRefresh() { + if (_refreshScheduled) { + return; + } + _refreshScheduled = true; + crl::on_main(crl::guard(this, [=] { + _refreshScheduled = false; + refresh(); + })); +} + +void ItemMessage::refresh() { + if (!_renderer->ready()) { + return; + } + const auto ratio = std::max(_ratio, 1); + auto image = _renderer->render(ratio); + if (image.isNull()) { + return; + } + setImage(std::move(image), ratio); + updateSize(); + update(); +} + +void ItemMessage::setImage(QImage image, int ratio) { + _image = std::move(image); + _image.setDevicePixelRatio(1.); + _ratio = _image.isNull() ? 0 : ratio; + _mediaRect = _renderer->mediaRect(); + _mask = QImage(); + _composite = QImage(); +} + +void ItemMessage::updateSize() { + const auto size = _renderer->size(); + if (size.isEmpty() || (size == _size)) { + return; + } + _size = size; + setAspectRatio(_size.height() / float64(_size.width())); +} + +int ItemMessage::neededRatio(not_null p) const { + if (_size.isEmpty()) { + return 1; + } + const auto &transform = p->worldTransform(); + const auto device = p->device(); + const auto scale = std::hypot(transform.m11(), transform.m12()) + * (device ? device->devicePixelRatio() : 1.); + return RatioFor(visibleRect().width() * scale, _size.width()); +} + +void ItemMessage::ensureRatio(int ratio) { + if (ratio <= _ratio || !_renderer->ready() || !OnMainThread()) { + return; + } + auto image = _renderer->render(ratio); + if (!image.isNull()) { + setImage(std::move(image), ratio); + } +} + +void ItemMessage::watchVideo() { + _videoLifetime.destroy(); + const auto video = _videoOptions.play ? _source->video() : nullptr; + if (!video) { + return; + } + video->changes( + ) | rpl::on_next([=] { + checkVideo(); + }, _videoLifetime); + video->load(); + checkVideo(); +} + +void ItemMessage::checkVideo() { + const auto video = _source->video(); + if (!video) { + return; + } else if (!isNormalStatus()) { + _radial = nullptr; + return; + } else if (!_clip) { + if (auto source = video->source()) { + createClip(std::move(source)); + } + } + const auto loading = !_clip && video->loading(); + if (loading && !_radial) { + _radial = std::make_unique([=](crl::time now) { + updateRadial(now); + }); + _radial->start(video->progress()); + } else if (loading) { + _radial->update(video->progress(), false, crl::now()); + } else { + _radial = nullptr; + } + update(); +} + +void ItemMessage::updateRadial(crl::time now) { + const auto video = _source->video(); + if (!_radial || !video) { + return; + } + const auto loading = !_clip && video->loading(); + _radial->update(video->progress(), !loading, now); + update(); +} + +void ItemMessage::createClip(std::shared_ptr source) { + auto copy = std::make_shared(*source); + copy->hasAudio = copy->hasAudio && _videoOptions.sound; + _clip = std::make_unique(std::move(copy), [=] { + checkCutout(); + update(); + }); + if (_playersReleased) { + _clip->stop(); + } + notifyVideoClipChanged(); +} + +void ItemMessage::checkCutout() { + if (_cutout || !_clip || !_clip->player()->ready()) { + return; + } + _cutout = true; + update(); +} + +void ItemMessage::notifyVideoClipChanged() { + if (const auto owner = static_cast(scene())) { + owner->videoClipChanged(this); + } +} + +QRect ItemMessage::holeRect() const { + return QRect(_mediaRect.topLeft() * _ratio, _mediaRect.size() * _ratio); +} + +QRect ItemMessage::framesRect() const { + const auto hole = holeRect(); + if (!_clip) { + return hole; + } + const auto thumbnail = _clip->source()->thumbnail.size(); + const auto video = _source->video(); + return CoverRect( + hole, + (!thumbnail.isEmpty() + ? thumbnail + : video + ? video->document()->dimensions + : QSize())); +} + +Media::Encode::AnimatedEntity::Cutout ItemMessage::cutout() const { + const auto hole = holeRect(); + if (_mask.isNull()) { + _mask = _renderer->videoMask(std::max(_ratio, 1)); + } + return { + .picture = _image, + .mask = _mask, + .hole = hole, + .frames = framesRect(), + }; +} + +const QImage &ItemMessage::composeFrame() { + Expects(_clip != nullptr); + + const auto hole = holeRect(); + const auto frame = hole.isEmpty() + ? QImage() + : _clip->frame(PlaybackFrameSize(framesRect().size())); + if (frame.isNull()) { + return _composite.isNull() ? _image : _composite; + } + return Media::Encode::ComposeCutout(cutout(), frame, _composite); +} + +void ItemMessage::paintLoading(QPainter *p) const { + if (_mediaRect.isEmpty() || _size.isEmpty()) { + return; + } + const auto rect = visibleRect(); + const auto scale = rect.width() / _size.width(); + const auto center = rect.topLeft() + + rect::center(QRectF(_mediaRect)) * scale; + const auto side = st::msgFileLayout.thumbSize * scale; + const auto line = st::msgFileRadialLine * scale; + const auto inner = QRectF( + center.x() - side / 2. + line, + center.y() - side / 2. + line, + side - 2 * line, + side - 2 * line); + PainterHighQualityEnabler hq(*p); + _radial->draw(*p, inner, line, st::historyFileThumbRadialFg); +} + +void ItemMessage::save(SaveState state) { + ItemBase::save(state); + if (!_size.isEmpty()) { + ensureRatio(RatioFor(visibleRect().width(), _size.width())); + } + ((state == SaveState::Keep) ? _kept : _saved) = _clip + ? std::make_optional(_clip->state()) + : std::nullopt; +} + +void ItemMessage::restore(SaveState state) { + if (!hasState(state)) { + return; + } + ItemBase::restore(state); + const auto &saved = (state == SaveState::Keep) ? _kept : _saved; + if (_clip && saved) { + _clip->restore(*saved); + } +} + +void ItemMessage::paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *w) { + ensureRatio(neededRatio(p)); + if (w) { + _playersReleased = false; + if (_clip) { + _clip->resume(); + } + } + if (!_image.isNull()) { + const auto &image = (_cutout && w) + ? composeFrame() + : (_cutout && !_composite.isNull()) + ? _composite + : _image; + p->setRenderHint(QPainter::SmoothPixmapTransform); + p->drawImage(visibleRect(), image); + } + if (_radial && w) { + paintLoading(p); + } + ItemBase::paint(p, option, w); +} + +int ItemMessage::type() const { + return Type; +} + +bool ItemMessage::animated() const { + return _cutout && _clip && _clip->animated(); +} + +bool ItemMessage::hasContent() const { + return _clip && _clip->hasContent(); +} + +QByteArray ItemMessage::content() const { + return _clip ? _clip->content() : QByteArray(); +} + +crl::time ItemMessage::loopDuration() const { + return _clip ? _clip->loopDuration() : 0; +} + +VideoTrim ItemMessage::trim() const { + return _clip ? _clip->trim() : VideoTrim(); +} + +void ItemMessage::releasePlayers() { + _playersReleased = true; + if (_clip) { + _clip->stop(); + } +} + +void ItemMessage::setStatus(Status status) { + if (status != Status::Normal) { + releasePlayers(); + } + ItemBase::setStatus(status); + if (status == Status::Normal) { + checkVideo(); + } +} + +VideoClip *ItemMessage::videoClip() { + return _clip.get(); +} + +Media::Encode::AnimatedEntity ItemMessage::animatedEntity( + const QTransform &sceneToCanvas) const { + auto result = ItemAnimated::animatedEntity(sceneToCanvas); + if (_cutout && !_image.isNull()) { + result.cutout = cutout(); + } + return result; +} + +Media::Encode::AnimatedEntity::Kind ItemMessage::entityKind() const { + return Media::Encode::AnimatedEntity::Kind::Webm; +} + +QRectF ItemMessage::entityRect() const { + return visibleRect(); +} + +const std::shared_ptr &ItemMessage::source() const { + return _source; +} + +void ItemMessage::setSource(std::shared_ptr source) { + _videoLifetime.destroy(); + _clip = nullptr; + _radial = nullptr; + _cutout = false; + _source = std::move(source); + _renderer = std::make_unique(_source); + _ratio = 0; + attachRenderer(); + watchVideo(); + notifyVideoClipChanged(); + update(); +} + +std::optional ItemMessage::dark() const { + return _dark; +} + +void ItemMessage::setDark(std::optional dark) { + if (_dark == dark) { + return; + } + _dark = dark; + _ratio = 0; + _renderer->setDark(dark); + refresh(); +} + +void ItemMessage::setEditCallback(EditCallback callback) { + _edit = std::move(callback); +} + +bool ItemMessage::editable() const { + return _edit && _source->link().has_value(); +} + +QRectF ItemMessage::visibleRect() const { + return fittedRect(_size); +} + +bool ItemMessage::flippable() const { + return false; +} + +void ItemMessage::fillContextMenu(not_null menu) { + const auto dark = _dark.value_or(Window::Theme::IsNightMode()); + menu->addAction( + (dark + ? tr::lng_settings_theme_day(tr::now) + : tr::lng_settings_theme_night(tr::now)), + [=] { setDark(!dark); }, + &st::mediaMenuIconNightMode); + if (editable()) { + menu->addAction( + tr::lng_menu_formatting_link_edit(tr::now), + [=] { _edit(this); }, + &st::mediaMenuIconEdit); + } + if (_clip && _clip->hasAudio()) { + AddVolumeAction(menu, _clip->volume(), [=](float64 volume) { + _clip->setVolume(volume); + }); + } +} + +void ItemMessage::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { + if (editable()) { + _edit(this); + } else { + ItemBase::mouseDoubleClickEvent(event); + } +} + +std::shared_ptr ItemMessage::duplicate(ItemBase::Data data) const { + auto result = std::make_shared( + _source, + std::make_unique(_source), + std::move(data), + _dark, + _videoOptions); + result->_edit = _edit; + if (_clip && result->_clip) { + result->_clip->restore(_clip->state()); + } + return result; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_message.h b/Telegram/SourceFiles/editor/scene/scene_item_message.h new file mode 100644 index 0000000000..c5e3c6bb6b --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_message.h @@ -0,0 +1,121 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/weak_ptr.h" +#include "editor/scene/scene_item_animated.h" +#include "editor/video/video_clip.h" + +namespace Ui { +class RadialAnimation; +} // namespace Ui + +namespace Editor { + +class MessageRenderer; +class MessageSource; + +struct MessageVideoOptions { + bool play = false; + bool sound = false; +}; + +class ItemMessage final : public ItemAnimated, public base::has_weak_ptr { +public: + enum { Type = ItemBase::Type + 5 }; + + using EditCallback = Fn)>; + + ItemMessage( + std::shared_ptr source, + std::unique_ptr renderer, + ItemBase::Data data, + std::optional dark = std::nullopt, + MessageVideoOptions video = {}); + ~ItemMessage(); + + void paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *widget) override; + int type() const override; + [[nodiscard]] bool animated() const override; + [[nodiscard]] bool hasContent() const override; + [[nodiscard]] QByteArray content() const override; + [[nodiscard]] crl::time loopDuration() const override; + [[nodiscard]] VideoTrim trim() const override; + void releasePlayers() override; + void setStatus(Status status) override; + void save(SaveState state) override; + void restore(SaveState state) override; + [[nodiscard]] VideoClip *videoClip() override; + [[nodiscard]] Media::Encode::AnimatedEntity animatedEntity( + const QTransform &sceneToCanvas) const override; + + [[nodiscard]] const std::shared_ptr &source() const; + void setSource(std::shared_ptr source); + [[nodiscard]] std::optional dark() const; + void setDark(std::optional dark); + void setEditCallback(EditCallback callback); + +protected: + [[nodiscard]] Media::Encode::AnimatedEntity::Kind entityKind() + const override; + [[nodiscard]] QRectF entityRect() const override; + [[nodiscard]] QRectF visibleRect() const override; + [[nodiscard]] bool flippable() const override; + void fillContextMenu(not_null menu) override; + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + std::shared_ptr duplicate(ItemBase::Data data) const override; + +private: + void attachRenderer(); + void scheduleRefresh(); + void refresh(); + void setImage(QImage image, int ratio); + void updateSize(); + void ensureRatio(int ratio); + [[nodiscard]] bool editable() const; + [[nodiscard]] int neededRatio(not_null p) const; + + void watchVideo(); + void checkVideo(); + void createClip(std::shared_ptr source); + void checkCutout(); + void updateRadial(crl::time now); + void notifyVideoClipChanged(); + [[nodiscard]] QRect holeRect() const; + [[nodiscard]] QRect framesRect() const; + [[nodiscard]] Media::Encode::AnimatedEntity::Cutout cutout() const; + [[nodiscard]] const QImage &composeFrame(); + void paintLoading(QPainter *p) const; + + std::shared_ptr _source; + std::unique_ptr _renderer; + EditCallback _edit; + const MessageVideoOptions _videoOptions; + std::unique_ptr _clip; + std::unique_ptr _radial; + std::optional _saved; + std::optional _kept; + QImage _image; + mutable QImage _mask; + QImage _composite; + QSize _size; + QRect _mediaRect; + std::optional _dark; + int _ratio = 0; + bool _refreshScheduled = false; + bool _cutout = false; + bool _playersReleased = false; + + rpl::lifetime _videoLifetime; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_shape.cpp b/Telegram/SourceFiles/editor/scene/scene_item_shape.cpp index 84069c5279..63c3697ef4 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_shape.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_shape.cpp @@ -156,6 +156,14 @@ QRectF ItemShape::shapeRect() const { return result; } +QRectF ItemShape::visibleRect() const { + const auto bounds = (_shape == ShapeType::Arrow) + ? (arrowShaftPath().boundingRect() + | arrowHeadPath().boundingRect()) + : shapePath().boundingRect(); + return bounds + Margins(_strokeWidth / 2.); +} + float64 ItemShape::direction() const { return flipped() ? -1. : 1.; } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_shape.h b/Telegram/SourceFiles/editor/scene/scene_item_shape.h index 957706e001..9d37231b09 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_shape.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_shape.h @@ -49,6 +49,7 @@ protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void hoverMoveEvent(QGraphicsSceneHoverEvent *event) override; + [[nodiscard]] QRectF visibleRect() const override; private: struct ArrowPoints { QPointF start; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index b26505ff65..aa1c710c44 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -684,16 +684,7 @@ void ItemText::paint( const QStyleOptionGraphicsItem *option, QWidget *w) { if (!_pixmap.isNull()) { - const auto rect = contentRect(); - const auto pixmapSize = QSizeF( - _pixmap.size() / style::DevicePixelRatio() - ).scaled(rect.size(), Qt::KeepAspectRatio); - const auto resultRect = QRectF( - rect.topLeft(), - pixmapSize - ).translated( - (rect.width() - pixmapSize.width()) / 2., - (rect.height() - pixmapSize.height()) / 2.); + const auto resultRect = visibleRect(); p->save(); p->setRenderHint(QPainter::SmoothPixmapTransform); if (flipped()) { @@ -708,6 +699,10 @@ void ItemText::paint( ItemBase::paint(p, option, w); } +QRectF ItemText::visibleRect() const { + return fittedRect(_pixmap.size() / style::DevicePixelRatio()); +} + int ItemText::type() const { return Type; } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h index d18802a320..60fbd819f6 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -100,6 +100,7 @@ public: void restore(SaveState state) override; protected: + [[nodiscard]] QRectF visibleRect() const override; void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_video.cpp b/Telegram/SourceFiles/editor/scene/scene_item_video.cpp index 2e210cd9f9..484639ba8e 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_video.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_video.cpp @@ -7,11 +7,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "editor/scene/scene_item_video.h" -#include "ui/image/image_prepare.h" +#include "editor/editor_audio_menu.h" #include "ui/rect.h" -#include - namespace Editor { namespace { @@ -30,75 +28,49 @@ constexpr auto kPlaybackFrameSide = 512; } // namespace -ItemVideo::ItemVideo(std::shared_ptr source, ItemBase::Data data) +ItemVideo::ItemVideo( + std::shared_ptr source, + ItemBase::Data data) : ItemAnimated(std::move(data)) -, _source(std::move(source)) -, _frameSize(FrameSizeFor(_source->thumbnail)) -, _image(_source->thumbnail) { +, _clip(std::make_unique(std::move(source), [=] { update(); })) +, _frameSize(FrameSizeFor(_clip->source()->thumbnail)) +, _image(_clip->source()->thumbnail) { if (flipped()) { performFlip(); } setAspectRatio(_image.isNull() ? 1. : (_image.height() / float64(_image.width()))); - createPlayer(); } -void ItemVideo::createPlayer() { - if (!hasContent()) { - return; - } - const auto callback = [=](::Media::Clip::Notification value) { - clipCallback(value); - }; - _reader = _source->path.isEmpty() - ? ::Media::Clip::MakeReader(_source->content, callback) - : ::Media::Clip::MakeReader(_source->path, callback); +ItemVideo::~ItemVideo() = default; + +VideoClip *ItemVideo::videoClip() { + return _clip.get(); } -void ItemVideo::clipCallback(::Media::Clip::Notification notification) { - using namespace ::Media::Clip; - if (notification == Notification::Reinit) { - if (_reader && _reader->state() == State::Error) { - _reader.setBad(); - } else if (_reader && _reader->ready() && !_reader->started()) { - _reader->start({ .frame = _frameSize }); - } - } - update(); +VideoTrim ItemVideo::trim() const { + return _clip->trim(); } bool ItemVideo::animated() const { - return _reader.valid() || _releasedAnimation; + return _clip->animated(); } bool ItemVideo::hasContent() const { - return !_source->path.isEmpty() || !_source->content.isEmpty(); + return _clip->hasContent(); } QByteArray ItemVideo::content() const { - if (_source->path.isEmpty()) { - return _source->content; - } - auto file = QFile(_source->path); - if (file.size() > Images::kReadBytesLimit - || !file.open(QIODevice::ReadOnly)) { - return QByteArray(); - } - return file.readAll(); + return _clip->content(); } crl::time ItemVideo::loopDuration() const { - return _source->duration; + return _clip->loopDuration(); } void ItemVideo::releasePlayers() { - if (!animated()) { - return; - } - _releasedAnimation = true; - _pendingRecreate = true; - _reader.reset(); + _clip->stop(); } void ItemVideo::setStatus(Status status) { @@ -108,6 +80,19 @@ void ItemVideo::setStatus(Status status) { ItemBase::setStatus(status); } +void ItemVideo::save(SaveState state) { + ItemBase::save(state); + ((state == SaveState::Keep) ? _kept : _saved) = _clip->state(); +} + +void ItemVideo::restore(SaveState state) { + if (!hasState(state)) { + return; + } + ItemBase::restore(state); + _clip->restore((state == SaveState::Keep) ? _kept : _saved); +} + int ItemVideo::type() const { return Type; } @@ -116,36 +101,41 @@ Media::Encode::AnimatedEntity::Kind ItemVideo::entityKind() const { return Media::Encode::AnimatedEntity::Kind::Webm; } -QImage ItemVideo::currentFrame() { - if (_reader && _reader->started()) { - auto result = _reader->current({ .frame = _frameSize }, crl::now()); - if (!result.isNull()) { - return result; - } - } - return _image; -} - void ItemVideo::paint( QPainter *p, const QStyleOptionGraphicsItem *option, QWidget *w) { - if (_pendingRecreate && w) { - _pendingRecreate = false; - createPlayer(); + if (w) { + _clip->resume(); + } + const auto frame = _clip->frame(_frameSize); + if (frame.isNull()) { + paintFrame(p, _image, false, false); + } else { + paintFrame(p, frame, true, flipped()); } - const auto live = _reader && _reader->started(); - paintFrame(p, currentFrame(), live, live && flipped()); ItemBase::paint(p, option, w); } +void ItemVideo::fillContextMenu(not_null menu) { + if (_clip->hasAudio()) { + AddVolumeAction(menu, _clip->volume(), [=](float64 volume) { + _clip->setVolume(volume); + }); + } +} + void ItemVideo::performFlip() { _image = _image.transformed(QTransform().scale(-1, 1)); update(); } std::shared_ptr ItemVideo::duplicate(ItemBase::Data data) const { - return std::make_shared(_source, std::move(data)); + auto result = std::make_shared( + _clip->source(), + std::move(data)); + result->_clip->restore(_clip->state()); + return result; } } // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_video.h b/Telegram/SourceFiles/editor/scene/scene_item_video.h index bf38d56e12..ab4090a742 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_video.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_video.h @@ -8,7 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "editor/scene/scene_item_animated.h" -#include "media/clip/media_clip_reader.h" +#include "editor/video/video_clip.h" namespace Editor { @@ -16,14 +16,9 @@ class ItemVideo final : public ItemAnimated { public: enum { Type = ItemBase::Type + 4 }; - struct Source { - QString path; - QByteArray content; - QImage thumbnail; - crl::time duration = 0; - }; + ItemVideo(std::shared_ptr source, ItemBase::Data data); + ~ItemVideo(); - ItemVideo(std::shared_ptr source, ItemBase::Data data); void paint( QPainter *p, const QStyleOptionGraphicsItem *option, @@ -32,27 +27,27 @@ public: [[nodiscard]] bool hasContent() const override; [[nodiscard]] QByteArray content() const override; [[nodiscard]] crl::time loopDuration() const override; + [[nodiscard]] VideoTrim trim() const override; void releasePlayers() override; void setStatus(Status status) override; + void save(SaveState state) override; + void restore(SaveState state) override; int type() const override; + [[nodiscard]] VideoClip *videoClip() override; protected: [[nodiscard]] Media::Encode::AnimatedEntity::Kind entityKind() const override; + void fillContextMenu(not_null menu) override; void performFlip() override; std::shared_ptr duplicate(ItemBase::Data data) const override; private: - void createPlayer(); - void clipCallback(::Media::Clip::Notification notification); - [[nodiscard]] QImage currentFrame(); - - const std::shared_ptr _source; + const std::unique_ptr _clip; const QSize _frameSize; - ::Media::Clip::ReaderPointer _reader; QImage _image; - bool _releasedAnimation = false; - bool _pendingRecreate = false; + VideoClip::State _saved; + VideoClip::State _kept; }; diff --git a/Telegram/SourceFiles/editor/scene/scene_text_editing.cpp b/Telegram/SourceFiles/editor/scene/scene_text_editing.cpp index 2ee3a877f6..4750c07f1a 100644 --- a/Telegram/SourceFiles/editor/scene/scene_text_editing.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_text_editing.cpp @@ -426,7 +426,7 @@ void TextEditController::createAtCenter(int rotation, bool flipped) { _defaultFontSize); const auto emojiDoc = proxy->document(); - const auto sceneCenter = sceneRect.center(); + const auto sceneCenter = _scene->canvasRect().center(); const auto adjustWidth = [=] { applyAutoShrink(); const auto maxTextWidth = sessionWrapWidth(); diff --git a/Telegram/SourceFiles/editor/video/video_clip.cpp b/Telegram/SourceFiles/editor/video/video_clip.cpp new file mode 100644 index 0000000000..1f5603ca98 --- /dev/null +++ b/Telegram/SourceFiles/editor/video/video_clip.cpp @@ -0,0 +1,147 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/video/video_clip.h" + +#include "editor/video/video_segment_player.h" +#include "editor/video/video_timeline.h" +#include "ui/image/image_prepare.h" + +namespace Editor { + +VideoClip::VideoClip( + std::shared_ptr source, + Fn repaint) +: _source(std::move(source)) +, _player(std::make_unique( + _source->path, + _source->content)) { + _player->repaints( + ) | rpl::on_next(std::move(repaint), _lifetime); + _player->start(); +} + +VideoClip::~VideoClip() = default; + +const std::shared_ptr &VideoClip::source() const { + return _source; +} + +crl::time VideoClip::duration() const { + return _source->duration; +} + +not_null VideoClip::player() const { + return _player.get(); +} + +auto VideoClip::timelineFrames() +-> const std::shared_ptr & { + if (!_timelineFrames) { + _timelineFrames = std::make_shared(); + } + return _timelineFrames; +} + +VideoTrim VideoClip::trim() const { + return _trim; +} + +void VideoClip::setTrim(VideoTrim trim) { + const auto full = duration(); + if (full > 0) { + trim.from = std::clamp(trim.from, crl::time(0), full); + trim.till = (trim.till > 0) + ? std::clamp(trim.till, trim.from, full) + : 0; + } + if (_trim == trim) { + return; + } + _trim = trim; + _player->setSegment(_trim.from, _trim.till); +} + +crl::time VideoClip::loopDuration() const { + const auto full = duration(); + if (full <= 0) { + return 0; + } + const auto till = (_trim.till > _trim.from) ? _trim.till : full; + return till - _trim.from; +} + +bool VideoClip::hasAudio() const { + return _source->hasAudio; +} + +float64 VideoClip::volume() const { + return _volume; +} + +void VideoClip::setVolume(float64 volume) { + _volume = std::clamp(volume, 0., 1.); + _player->setVolume(_volume); +} + +bool VideoClip::sounding() const { + return hasAudio() && (_volume > 0.); +} + +void VideoClip::setSoundEnabled(bool enabled) { + _player->setSound(enabled && hasAudio()); +} + +VideoClip::State VideoClip::state() const { + return { .trim = _trim, .volume = _volume }; +} + +void VideoClip::restore(const State &state) { + setTrim(state.trim); + setVolume(state.volume); +} + +bool VideoClip::animated() const { + return _player->valid() || _released; +} + +bool VideoClip::hasContent() const { + return !_source->path.isEmpty() || !_source->content.isEmpty(); +} + +QByteArray VideoClip::content() const { + if (_source->path.isEmpty()) { + return _source->content; + } + auto file = QFile(_source->path); + if (file.size() > Images::kReadBytesLimit + || !file.open(QIODevice::ReadOnly)) { + return QByteArray(); + } + return file.readAll(); +} + +QImage VideoClip::frame(QSize size) { + return _player->frame(size); +} + +void VideoClip::stop() { + if (!animated()) { + return; + } + _released = true; + _stopped = true; + _player->stop(); +} + +void VideoClip::resume() { + if (base::take(_stopped)) { + _player->start(); + } +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/video/video_clip.h b/Telegram/SourceFiles/editor/video/video_clip.h new file mode 100644 index 0000000000..8989d4cb80 --- /dev/null +++ b/Telegram/SourceFiles/editor/video/video_clip.h @@ -0,0 +1,71 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "editor/photo_editor_inner_common.h" + +namespace Editor { + +class SegmentPlayer; +class VideoTimelineFramesCache; + +struct VideoClipSource { + QString path; + QByteArray content; + QImage thumbnail; + crl::time duration = 0; + bool hasAudio = false; +}; + +class VideoClip final { +public: + struct State { + VideoTrim trim; + float64 volume = 1.; + }; + + VideoClip(std::shared_ptr source, Fn repaint); + ~VideoClip(); + + [[nodiscard]] const std::shared_ptr &source() const; + [[nodiscard]] crl::time duration() const; + [[nodiscard]] not_null player() const; + [[nodiscard]] auto timelineFrames() + -> const std::shared_ptr &; + [[nodiscard]] VideoTrim trim() const; + void setTrim(VideoTrim trim); + [[nodiscard]] crl::time loopDuration() const; + [[nodiscard]] bool hasAudio() const; + [[nodiscard]] float64 volume() const; + void setVolume(float64 volume); + [[nodiscard]] bool sounding() const; + void setSoundEnabled(bool enabled); + [[nodiscard]] State state() const; + void restore(const State &state); + + [[nodiscard]] bool animated() const; + [[nodiscard]] bool hasContent() const; + [[nodiscard]] QByteArray content() const; + [[nodiscard]] QImage frame(QSize size); + void stop(); + void resume(); + +private: + const std::shared_ptr _source; + const std::unique_ptr _player; + std::shared_ptr _timelineFrames; + VideoTrim _trim; + float64 _volume = 1.; + bool _released = false; + bool _stopped = false; + + rpl::lifetime _lifetime; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/video/video_editor.cpp b/Telegram/SourceFiles/editor/video/video_editor.cpp index 878bd31081..2f1d28321b 100644 --- a/Telegram/SourceFiles/editor/video/video_editor.cpp +++ b/Telegram/SourceFiles/editor/video/video_editor.cpp @@ -10,12 +10,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/timer.h" #include "editor/editor_crop.h" #include "editor/video/video_quality_slider.h" +#include "editor/video/video_segment_player.h" #include "editor/video/video_timeline.h" +#include "editor/video/video_timeline_seeker.h" #include "lang/lang_keys.h" -#include "media/streaming/media_streaming_document.h" -#include "media/streaming/media_streaming_instance.h" -#include "media/streaming/media_streaming_loader_local.h" -#include "media/streaming/media_streaming_player.h" #include "media/view/media_view_pip.h" #include "ui/effects/ripple_animation.h" #include "ui/layers/layer_widget.h" @@ -34,8 +32,6 @@ namespace { using Media::View::FlipSizeByRotation; -constexpr auto kSeekThrottle = crl::time(120); - constexpr auto kBubbleMinVisible = crl::time(600); constexpr auto kBubbleDuration = crl::time(500); @@ -171,16 +167,6 @@ void BarTextButton::paintEvent(QPaintEvent *e) { return result; } -[[nodiscard]] Media::Streaming::FrameRequest FrameRequestFor( - QSize size, - bool keepAlpha) { - auto result = Media::Streaming::FrameRequest(); - result.resize = size * style::DevicePixelRatio(); - result.outer = result.resize; - result.keepAlpha = keepAlpha; - return result; -} - } // namespace VideoEditor::VideoEditor( @@ -192,7 +178,11 @@ VideoEditor::VideoEditor( , _dimensions(descriptor.dimensions) , _duration(std::max(descriptor.duration, crl::time(1))) , _data(descriptor.data) -, _initial(descriptor.initial) { +, _initial(descriptor.initial) +, _player(std::make_unique( + _path, + _content, + SegmentPlayerOptions{ .keepAlpha = _data.webmSticker })) { _geometry = _initial.geometry; _gif = _initial.gif; _geometry.cropType = _data.editor.cropType; @@ -263,40 +253,21 @@ void VideoEditor::setupTimeline() { _from = _timeline->from(); _till = _timeline->till(); _cover = _timeline->cover(); - _position = _from; - - struct State { - base::Timer timer; - crl::time pending = -1; - }; - const auto state = lifetime().make_state(); - state->timer.setCallback([=] { - if (state->pending >= 0) { - const auto position = state->pending; - state->pending = -1; - restart(position); - } - }); - const auto seek = [=](crl::time position) { - state->pending = position; - if (!state->timer.isActive()) { - state->timer.callOnce(kSeekThrottle); - } - }; + _seeker = std::make_unique( + _timeline.get(), + _player.get()); _timeline->trimChanges( - ) | rpl::on_next([=](crl::time edge) { + ) | rpl::on_next([=] { _from = _timeline->from(); _till = _timeline->till(); _cover = _timeline->cover(); refreshSizeEstimate(); - seek(edge); }, _timeline->lifetime()); _timeline->coverChanges( ) | rpl::on_next([=](crl::time cover) { _cover = cover; - seek(cover); updateBubble(); refreshCoverPreview(); }, _timeline->lifetime()); @@ -306,17 +277,6 @@ void VideoEditor::setupTimeline() { _dragging = dragging; if (dragging) { _draggingHead = _timeline->draggingHead(); - if (_instance) { - _instance->pause(); - } - } else { - state->timer.cancel(); - const auto latest = (state->pending >= 0) - ? state->pending - : _position; - state->pending = -1; - const auto resume = std::clamp(latest, _from, _till); - restart((resume >= _till) ? _from : resume); } if (!dragging && _draggingHead) { _draggingHead = false; @@ -558,40 +518,21 @@ void VideoEditor::setupControls() { _cancel.fire({}); }); _confirm->setClickedCallback([=] { + _timeline->commitPendingEdit(); _done.fire(collect()); }); } void VideoEditor::setupStreaming() { - using namespace Media::Streaming; - - auto loader = _path.isEmpty() - ? MakeBytesLoader(_content) - : MakeFileLoader(_path); - if (!loader) { - return; - } - _instance = std::make_unique( - std::make_shared(std::move(loader)), - nullptr); - if (!_instance->valid()) { - _instance = nullptr; - return; - } - _instance->lockPlayer(); - _instance->player().updates( - ) | rpl::on_next_error([=](Update &&update) { - handleUpdate(std::move(update)); - }, [=](Error &&) { - _instance = nullptr; + _player->repaints( + ) | rpl::on_next([=] { update(); - }, _instance->lifetime()); + if (_player->held()) { + updateBubble(); + } + }, lifetime()); - restart(_from); -} - -bool VideoEditor::held() const { - return _dragging || _userPaused; + _player->start(); } void VideoEditor::setupTapToPause() { @@ -630,15 +571,11 @@ void VideoEditor::setupTapToPause() { } void VideoEditor::togglePause() { - if (!_instance) { + if (!_player->valid()) { return; } _userPaused = !_userPaused; - if (_userPaused) { - _instance->pause(); - } else { - _instance->resume(); - } + _player->setPaused(_userPaused); update(); } @@ -795,64 +732,6 @@ void VideoEditor::updateBubble() { _bubble->update(); } -void VideoEditor::restart(crl::time position) { - if (!_instance) { - return; - } - using namespace Media::Streaming; - if (!_frameRect.isEmpty() - && _instance->player().ready() - && !_instance->player().videoSize().isEmpty()) { - _lastFrame = _instance->frame(FrameRequestFor( - _frameRect.size(), - _data.webmSticker)).copy(); - } - _position = std::clamp(position, _from, _till); - auto options = PlaybackOptions(); - options.mode = Mode::Video; - options.position = _position; - options.loop = false; - _instance->play(options); - if (held()) { - _instance->pause(); - } - if (!_dragging && _timeline) { - _timeline->setPlaybackPosition(_position); - } - update(); -} - -void VideoEditor::handleUpdate(Media::Streaming::Update &&update) { - using namespace Media::Streaming; - v::match(update.data, [&](Information &) { - this->update(); - }, [&](PreloadedVideo) { - }, [&](UpdateVideo &data) { - _position = data.position; - if (held()) { - _instance->pause(); - this->update(); - updateBubble(); - return; - } - if (_position >= _till) { - restart(_from); - return; - } - if (_timeline) { - _timeline->setPlaybackPosition(_position); - } - this->update(); - }, [&](PreloadedAudio) { - }, [&](UpdateAudio) { - }, [&](WaitingForData) { - }, [&](SpeedEstimate) { - }, [&](MutedByOther) { - }, [&](Finished) { - restart(_from); - }); -} - void VideoEditor::applyGeometry() { const auto size = this->size(); if (size.isEmpty()) { @@ -919,8 +798,10 @@ void VideoEditor::applyGeometry() { _frameMatrix.rotate(_geometry.angle); const auto geometry = _frameMatrix.mapRect(_frameRect); + const auto m = _crop->cropMargins(); _crop->applyTransform( - geometry + _crop->cropMargins(), + geometry + m, + QPoint(m.left(), m.top()), _geometry.angle, _geometry.flipped, frameSizeF); @@ -962,21 +843,7 @@ void VideoEditor::paint(QPainter &p) { if (_frameRect.isEmpty()) { return; } - auto frame = QImage(); - if (_instance - && _instance->player().ready() - && !_instance->player().videoSize().isEmpty()) { - frame = _instance->frame(FrameRequestFor( - _frameRect.size(), - _data.webmSticker)); - if (!held()) { - // Marking a frame shown lets the player walk past a pause. - _instance->markFrameShown(); - } - } - if (frame.isNull()) { - frame = _lastFrame; - } + const auto frame = _player->frame(_frameRect.size()); if (frame.isNull()) { return; } @@ -1012,6 +879,7 @@ void VideoEditor::keyPressEvent(QKeyEvent *e) { if (e->key() == Qt::Key_Escape) { _cancel.fire({}); } else if (e->key() == Qt::Key_Enter || e->key() == Qt::Key_Return) { + _timeline->commitPendingEdit(); _done.fire(collect()); } } diff --git a/Telegram/SourceFiles/editor/video/video_editor.h b/Telegram/SourceFiles/editor/video/video_editor.h index 170f056a76..9b1586e4fd 100644 --- a/Telegram/SourceFiles/editor/video/video_editor.h +++ b/Telegram/SourceFiles/editor/video/video_editor.h @@ -14,11 +14,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/video/video_editor_common.h" #include "ui/rp_widget.h" -namespace Media::Streaming { -class Instance; -struct Update; -} // namespace Media::Streaming - namespace Ui { class IconButton; class LayerWidget; @@ -29,6 +24,8 @@ class FlatLabel; namespace Editor { class Crop; +class SegmentPlayer; +class TimelineSeeker; class VideoTimeline; class VideoQualitySlider; @@ -68,8 +65,6 @@ private: void setupSizeEstimate(); void refreshSizeEstimate(); void refreshQualityLevels(); - void handleUpdate(Media::Streaming::Update &&update); - void restart(crl::time position); void setupTapToPause(); void updateBubble(); void startCapture(); @@ -78,7 +73,6 @@ private: void refreshCoverPreview(); void invalidateCoverPreview(); void togglePause(); - [[nodiscard]] bool held() const; void paintPlayBadge(QPainter &p); void applyGeometry(); void paint(QPainter &p); @@ -98,12 +92,12 @@ private: crl::time _from = 0; crl::time _till = 0; crl::time _cover = 0; - crl::time _position = 0; bool _gif = false; - std::unique_ptr _instance; + std::unique_ptr _player; base::unique_qptr _crop; base::unique_qptr _timeline; + std::unique_ptr _seeker; base::unique_qptr _quality; base::unique_qptr _controls; base::unique_qptr _bar; @@ -122,7 +116,6 @@ private: QRect _frameRect; QTransform _frameMatrix; QRect _innerRect; - QImage _lastFrame; bool _dragging = false; bool _userPaused = false; crl::time _bubbleCover = -1; diff --git a/Telegram/SourceFiles/editor/video/video_segment_player.cpp b/Telegram/SourceFiles/editor/video/video_segment_player.cpp new file mode 100644 index 0000000000..a40afda19b --- /dev/null +++ b/Telegram/SourceFiles/editor/video/video_segment_player.cpp @@ -0,0 +1,259 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/video/video_segment_player.h" + +#include "media/player/media_player_instance.h" +#include "media/streaming/media_streaming_common.h" +#include "media/streaming/media_streaming_document.h" +#include "media/streaming/media_streaming_instance.h" +#include "media/streaming/media_streaming_loader_local.h" +#include "media/streaming/media_streaming_player.h" + +namespace Editor { +namespace { + +[[nodiscard]] Media::Streaming::FrameRequest FrameRequestFor( + QSize size, + bool keepAlpha) { + auto result = Media::Streaming::FrameRequest(); + result.resize = size * style::DevicePixelRatio(); + result.outer = result.resize; + result.keepAlpha = keepAlpha; + return result; +} + +} // namespace + +SegmentPlayer::SegmentPlayer( + QString path, + QByteArray content, + SegmentPlayerOptions options) +: _path(std::move(path)) +, _content(std::move(content)) +, _options(options) +, _volume(options.volume) { +} + +SegmentPlayer::~SegmentPlayer() = default; + +void SegmentPlayer::start() { + using namespace Media::Streaming; + if (_instance) { + return; + } + auto loader = _path.isEmpty() + ? MakeBytesLoader(_content) + : MakeFileLoader(_path); + if (!loader) { + return; + } + _instance = std::make_unique( + std::make_shared(std::move(loader)), + nullptr); + if (!_instance->valid()) { + _instance = nullptr; + return; + } + _instance->lockPlayer(); + _instance->player().updates( + ) | rpl::on_next_error([=](Update &&update) { + handleUpdate(std::move(update)); + }, [=](Error &&) { + crl::on_main(base::make_weak(this), [=] { handleError(); }); + }, _instance->lifetime()); + + restart(_from); +} + +void SegmentPlayer::stop() { + _instance = nullptr; +} + +bool SegmentPlayer::valid() const { + return (_instance != nullptr); +} + +bool SegmentPlayer::ready() const { + return _instance + && _instance->player().ready() + && (_options.audio || !_instance->player().videoSize().isEmpty()); +} + +crl::time SegmentPlayer::segmentTill() const { + return (_till > _from) + ? _till + : std::numeric_limits::max(); +} + +void SegmentPlayer::setSegment(crl::time from, crl::time till) { + _from = std::max(from, crl::time(0)); + _till = (till > _from) ? till : 0; + const auto outside = (_position < _from) + || (_position >= segmentTill()); + if (outside && !held()) { + restart(_from); + } +} + +void SegmentPlayer::keepLastFrame() { + if (_options.audio || !ready() || _frameSize.isEmpty()) { + return; + } + const auto frame = _instance->frame( + FrameRequestFor(_frameSize, _options.keepAlpha)); + if (!frame.isNull()) { + _lastFrame = frame.copy(); + } +} + +void SegmentPlayer::restart(crl::time position) { + if (!_instance) { + return; + } + using namespace Media::Streaming; + keepLastFrame(); + _position = std::clamp(position, _from, segmentTill()); + auto options = PlaybackOptions(); + options.mode = _options.audio + ? Mode::Audio + : (_sound && !_soundFailed) + ? Mode::Both + : Mode::Video; + options.position = _position; + options.volume = _volume; + options.loop = false; + if (options.mode != Mode::Video) { + pauseOtherPlayback(); + } + _instance->play(options); + if (held()) { + _instance->pause(); + } + _positionUpdates.fire_copy(_position); + _repaints.fire({}); +} + +void SegmentPlayer::pauseOtherPlayback() { + if (std::exchange(_pausedOthers, true)) { + return; + } + const auto player = Media::Player::instance(); + player->pause(AudioMsgId::Type::Voice); + player->pause(AudioMsgId::Type::Song); +} + +void SegmentPlayer::applyHeld() { + if (!_instance) { + return; + } else if (held()) { + _instance->pause(); + } else { + _instance->resume(); + } +} + +void SegmentPlayer::setPaused(bool paused) { + if (_paused == paused) { + return; + } + _paused = paused; + applyHeld(); +} + +void SegmentPlayer::setSeeking(bool seeking) { + if (_seeking == seeking) { + return; + } + _seeking = seeking; + applyHeld(); +} + +void SegmentPlayer::setVolume(float64 volume) { + if (_volume == volume) { + return; + } + _volume = volume; + if (_instance) { + _instance->setVolume(volume); + } +} + +void SegmentPlayer::setSound(bool sound) { + if (_options.audio || (_sound == sound)) { + return; + } + _sound = sound; + if (!sound) { + _pausedOthers = false; + } + restart(_position); +} + +void SegmentPlayer::handleUpdate(Media::Streaming::Update &&update) { + using namespace Media::Streaming; + v::match(update.data, [&](Information &) { + _repaints.fire({}); + }, [&](PreloadedVideo) { + }, [&](UpdateVideo &data) { + if (!_options.audio) { + handlePosition(data.position); + } + }, [&](PreloadedAudio) { + }, [&](UpdateAudio &data) { + if (_options.audio) { + handlePosition(data.position); + } + }, [&](WaitingForData) { + }, [&](SpeedEstimate) { + }, [&](MutedByOther) { + }, [&](Finished) { + restart(_from); + }); +} + +void SegmentPlayer::handleError() { + _instance = nullptr; + if (_sound && !_soundFailed && !_options.audio) { + _soundFailed = true; + start(); + } + _repaints.fire({}); +} + +void SegmentPlayer::handlePosition(crl::time position) { + _position = position; + if (held()) { + _instance->pause(); + _repaints.fire({}); + return; + } else if (_position >= segmentTill()) { + restart(_from); + return; + } + _positionUpdates.fire_copy(_position); + _repaints.fire({}); +} + +QImage SegmentPlayer::frame(QSize size) { + _frameSize = size; + if (_options.audio) { + return QImage(); + } else if (ready()) { + auto result = _instance->frame( + FrameRequestFor(size, _options.keepAlpha)); + if (!held()) { + _instance->markFrameShown(); + } + if (!result.isNull()) { + return result; + } + } + return _lastFrame; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/video/video_segment_player.h b/Telegram/SourceFiles/editor/video/video_segment_player.h new file mode 100644 index 0000000000..9ecc6f6e19 --- /dev/null +++ b/Telegram/SourceFiles/editor/video/video_segment_player.h @@ -0,0 +1,98 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/weak_ptr.h" + +namespace Media::Streaming { +class Instance; +struct Update; +} // namespace Media::Streaming + +namespace Editor { + +struct SegmentPlayerOptions { + bool keepAlpha = false; + bool audio = false; + float64 volume = 1.; +}; + +class SegmentPlayer final : public base::has_weak_ptr { +public: + SegmentPlayer( + QString path, + QByteArray content, + SegmentPlayerOptions options = {}); + ~SegmentPlayer(); + + void start(); + void stop(); + [[nodiscard]] bool valid() const; + [[nodiscard]] bool ready() const; + + [[nodiscard]] crl::time from() const { + return _from; + } + [[nodiscard]] crl::time till() const { + return _till; + } + [[nodiscard]] crl::time position() const { + return _position; + } + [[nodiscard]] bool held() const { + return _paused || _seeking; + } + + void setSegment(crl::time from, crl::time till); + void restart(crl::time position); + void setPaused(bool paused); + void setSeeking(bool seeking); + void setVolume(float64 volume); + void setSound(bool sound); + + [[nodiscard]] QImage frame(QSize size); + + [[nodiscard]] rpl::producer positionUpdates() const { + return _positionUpdates.events(); + } + [[nodiscard]] rpl::producer<> repaints() const { + return _repaints.events(); + } + +private: + void handleUpdate(Media::Streaming::Update &&update); + void handleError(); + void handlePosition(crl::time position); + void pauseOtherPlayback(); + void applyHeld(); + void keepLastFrame(); + [[nodiscard]] crl::time segmentTill() const; + + const QString _path; + const QByteArray _content; + const SegmentPlayerOptions _options; + + std::unique_ptr _instance; + QImage _lastFrame; + QSize _frameSize; + crl::time _from = 0; + crl::time _till = 0; + crl::time _position = 0; + float64 _volume = 1.; + bool _sound = false; + bool _soundFailed = false; + bool _pausedOthers = false; + bool _paused = false; + bool _seeking = false; + + rpl::event_stream _positionUpdates; + rpl::event_stream<> _repaints; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/video/video_timeline.cpp b/Telegram/SourceFiles/editor/video/video_timeline.cpp index 443ccb951f..7470821867 100644 --- a/Telegram/SourceFiles/editor/video/video_timeline.cpp +++ b/Telegram/SourceFiles/editor/video/video_timeline.cpp @@ -9,49 +9,77 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "media/media_video_frames.h" #include "ui/painter.h" -#include "ui/text/format_values.h" +#include "ui/rect.h" #include "styles/style_editor.h" -#include -#include - namespace Editor { namespace { constexpr auto kMaxFrames = 24; +constexpr auto kMaxCachedFrameSets = 8; constexpr auto kDotDuration = crl::time(500); +constexpr auto kReloadDelay = crl::time(150); -// Frames are scaled on paint, so small width drift needs no re-extract. constexpr auto kFrameWidthTolerance = 0.25; +[[nodiscard]] TrimTimelineDescriptor TrimDescriptor( + const VideoTimelineDescriptor &descriptor) { + return { + .duration = descriptor.duration, + .maxDuration = descriptor.maxDuration, + .minDuration = descriptor.minDuration, + .from = descriptor.from, + .till = descriptor.till, + .cover = descriptor.cover, + .trimOnly = descriptor.trimOnly, + }; +} + +void PaintFramePart( + QPainter &p, + const QImage &frame, + const QRect &target, + int sourceLeft) { + const auto ratio = frame.devicePixelRatio(); + p.drawImage(target, frame, QRect( + int(base::SafeRound(sourceLeft * ratio)), + 0, + int(base::SafeRound(target.width() * ratio)), + frame.height())); +} + } // namespace +const VideoTimelineFrames *VideoTimelineFramesCache::find( + Fn matches) const { + for (const auto &set : ranges::views::reverse(_sets)) { + if (matches(set)) { + return &set; + } + } + return nullptr; +} + +void VideoTimelineFramesCache::add(VideoTimelineFrames set) { + _sets.push_back(std::move(set)); + if (_sets.size() > kMaxCachedFrameSets) { + _sets.erase(_sets.begin()); + } +} + +int VideoTimelineFramesCache::size() const { + return int(_sets.size()); +} + VideoTimeline::VideoTimeline( not_null parent, VideoTimelineDescriptor descriptor) -: RpWidget(parent) -, _descriptor(std::move(descriptor)) -, _duration(std::max(_descriptor.duration, crl::time(1))) -, _maxDuration((_descriptor.maxDuration > 0) - ? std::min(_descriptor.maxDuration, _duration) - : _duration) -, _minDuration(std::min(_descriptor.minDuration, _maxDuration)) -, _from(0) -, _till(_maxDuration) -, _cover(0) { - setMouseTracking(true); - - // Restore the selection when the editor is opened for a second time. - _from = std::clamp( - _descriptor.from, - crl::time(0), - std::max(_duration - _minDuration, crl::time(0))); - const auto limit = std::min(_from + _maxDuration, _duration); - _till = (_descriptor.till > _from) - ? std::min(_descriptor.till, limit) - : limit; - _cover = std::clamp(_descriptor.cover, _from, _till); - +: TrimTimeline(parent, TrimDescriptor(descriptor)) +, _path(descriptor.path) +, _content(descriptor.content) +, _dimensions(descriptor.dimensions) +, _cache(descriptor.cache) +, _reloadTimer([=] { reloadFrames(); }) { sizeValue( ) | rpl::filter([=](QSize size) { return !size.isEmpty(); @@ -61,69 +89,14 @@ VideoTimeline::VideoTimeline( } VideoTimeline::~VideoTimeline() { - if (_framesCancel) { - _framesCancel->store(true); + if (_loading) { + _loading->cancel->store(true); } } -int VideoTimeline::resizeGetHeight(int newWidth) { - return st::videoTimelineHeight - + st::videoTimelinePlayheadOverflow - + st::videoTimelinePlayheadOutline; -} - -QRect VideoTimeline::stripRect() const { - const auto top = st::videoTimelineLabelHeight - + st::videoTimelineLabelSkip; - const auto handle = st::videoTimelineHandleWidth; - return QRect( - handle, - top, - std::max(width() - handle * 2, 1), - st::videoTimelineStripHeight); -} - -QRect VideoTimeline::labelRect() const { - // Full width, so the size lines up with the quality labels below. - return QRect(0, 0, width(), st::videoTimelineLabelHeight); -} - -void VideoTimeline::moveWindowTo(crl::time center) { - const auto span = _till - _from; - const auto half = span / 2; - const auto from = std::clamp( - center - half, - crl::time(0), - std::max(_duration - span, crl::time(0))); - if (_from == from) { - return; - } - _from = from; - _till = from + span; - setCover(std::clamp(_cover, _from, _till), true); - _trimChanges.fire_copy(_from); -} - -crl::time VideoTimeline::timeAt(int x) const { - const auto strip = stripRect(); - if (strip.width() <= 0) { - return 0; - } - const auto shift = std::clamp(x - strip.x(), 0, strip.width()); - return crl::time( - base::SafeRound(shift * float64(_duration) / strip.width())); -} - -int VideoTimeline::xAt(crl::time time) const { - const auto strip = stripRect(); - const auto clamped = std::clamp(time, crl::time(0), _duration); - return strip.x() + int(base::SafeRound( - clamped * float64(strip.width()) / _duration)); -} - QPoint VideoTimeline::coverDot() const { return QPoint( - xAt(_cover), + xAt(cover()), stripRect().y() - st::videoTimelinePlayheadOverflow - st::videoTimelinePlayheadOutline @@ -131,69 +104,82 @@ QPoint VideoTimeline::coverDot() const { - st::videoTimelineDotActiveSize / 2); } -bool VideoTimeline::draggingHead() const { - return (_grab == Grab::Head); +void VideoTimeline::headGrabChanged(bool grabbed) { + _dotActive.start( + [=] { update(); }, + grabbed ? 0. : 1., + grabbed ? 1. : 0., + kDotDuration, + anim::easeOutQuint); } -void VideoTimeline::setPlaybackPosition(crl::time position) { - const auto clamped = std::clamp(position, _from, _till); - if (_playback == clamped) { - return; - } - _playback = clamped; - update(); -} - -void VideoTimeline::setSizeLabel(const QString &text) { - if (_sizeLabel == text) { - return; - } - _sizeLabel = text; - update(); +void VideoTimeline::visibleRangeChanged() { + _reloadTimer.callOnce(kReloadDelay); } void VideoTimeline::reloadFrames() { const auto strip = stripRect(); const auto height = strip.height(); - const auto dimensions = _descriptor.dimensions; - if (strip.isEmpty() || dimensions.isEmpty() || height <= 0) { + if (strip.isEmpty() || _dimensions.isEmpty() || height <= 0) { return; } const auto aspectWidth = std::max( int(base::SafeRound( - height * dimensions.width() / float64(dimensions.height()))), + height * _dimensions.width() / float64(_dimensions.height()))), 1); const auto count = std::clamp( (strip.width() + aspectWidth - 1) / aspectWidth, 1, kMaxFrames); const auto frameWidth = (strip.width() + count - 1) / count; - const auto kept = (int(_frames.size()) == count) - && (_framesBox.height() == height) - && (std::abs(frameWidth - _framesBox.width()) - <= _framesBox.width() * kFrameWidthTolerance); - _frameWidth = frameWidth; - if (kept) { + const auto from = visibleFrom(); + const auto span = visibleTill() - from; + if (span <= 0) { return; } - if (_framesCancel) { - _framesCancel->store(true); + const auto matches = [&](const VideoTimelineFrames &set) { + return (int(set.frames.size()) == count) + && (set.from == from) + && (set.span == span) + && (set.box.height() == height) + && (std::abs(frameWidth - set.box.width()) + <= set.box.width() * kFrameWidthTolerance); + }; + if (_loading && matches(_loading->set)) { + return; + } else if (_loading) { + _loading->cancel->store(true); + _loading = nullptr; } - _framesBox = QSize(frameWidth, height); - _frames = std::vector(count); + if (matches(_frames)) { + return; + } + const auto cached = _cache ? _cache->find(matches) : nullptr; + if (cached) { + _frames = *cached; + update(); + return; + } + _loading = std::make_unique(Loading{ + .set = { + .frames = std::vector(count), + .from = from, + .span = span, + .box = QSize(frameWidth, height), + }, + .cancel = std::make_shared>(false), + }); auto positions = std::vector(); positions.reserve(count); for (auto i = 0; i != count; ++i) { - positions.push_back(crl::time( - base::SafeRound((i + 0.5) * _duration / count))); + positions.push_back(from + crl::time( + base::SafeRound((i + 0.5) * span / count))); } - const auto cancel = std::make_shared>(false); - _framesCancel = cancel; - - const auto path = _descriptor.path; - const auto content = _descriptor.content; - const auto box = _framesBox * style::DevicePixelRatio(); + const auto cancel = _loading->cancel; + const auto path = _path; + const auto content = _content; + const auto box = _loading->set.box * style::DevicePixelRatio(); crl::async([=, weak = base::make_weak(this)] { Media::Video::ExtractFrames(path, content, { .positions = positions, @@ -205,275 +191,90 @@ void VideoTimeline::reloadFrames() { } frame.setDevicePixelRatio(style::DevicePixelRatio()); crl::on_main(weak, [=, frame = std::move(frame)]() mutable { - if (cancel->load() || index >= int(_frames.size())) { + if (cancel->load() + || !_loading + || index >= int(_loading->set.frames.size())) { return; } - _frames[index] = std::move(frame); + _loading->set.frames[index] = std::move(frame); update(); }); return true; }); + crl::on_main(weak, [=] { + if (cancel->load() || !_loading) { + return; + } + _frames = std::move(_loading->set); + _loading = nullptr; + if (_cache) { + _cache->add(_frames); + } + update(); + }); }); } -VideoTimeline::Grab VideoTimeline::grabAt(QPoint position) const { - const auto slop = st::videoTimelineHandleHitSlop; - const auto handle = st::videoTimelineHandleWidth; - const auto x = position.x(); - const auto left = xAt(_from); - const auto right = xAt(_till); - - const auto span = std::max(right - left, 1); - const auto inside = std::min(int(slop), span / 3); - if (x >= left - handle - slop && x <= left + inside) { - return Grab::Left; - } else if (x <= right + handle + slop && x >= right - inside) { - return Grab::Right; - } else if (x > left && x < right) { - return Grab::Head; - } - return Grab::Window; -} - -crl::time VideoTimeline::minSelection() const { - const auto strip = stripRect(); - // Keeps the head reachable when a long clip squeezes the window. - const auto pixels = st::videoTimelinePlayheadWidth - + st::videoTimelineHandleHitSlop; - const auto byPixels = (strip.width() > pixels) - ? crl::time(base::SafeRound( - pixels * float64(_duration) / strip.width())) - : _duration; - return std::clamp( - std::max(_minDuration, byPixels), - crl::time(0), - _maxDuration); -} - -void VideoTimeline::updateCursor(Grab grab) { - setCursor((grab == Grab::None) ? style::cur_default : style::cur_sizehor); -} - -void VideoTimeline::mousePressEvent(QMouseEvent *e) { - if (e->button() != Qt::LeftButton) { - return; - } - const auto position = e->pos(); - _grab = grabAt(position); - if (_grab == Grab::Left) { - _grabShift = position.x() - xAt(_from); - } else if (_grab == Grab::Right) { - _grabShift = position.x() - xAt(_till); - } else { - _grabShift = 0; - } - updateCursor(_grab); - if (_grab == Grab::Head) { - _dotActive.start( - [=] { update(); }, - 0., - 1., - kDotDuration, - anim::easeOutQuint); - } - _draggingChanges.fire(true); - applyGrab(position); -} - -void VideoTimeline::mouseMoveEvent(QMouseEvent *e) { - if (_grab == Grab::None) { - updateCursor(grabAt(e->pos())); - return; - } - applyGrab(e->pos()); -} - -void VideoTimeline::mouseReleaseEvent(QMouseEvent *e) { - if (_grab == Grab::None) { - return; - } - const auto wasHead = (_grab == Grab::Head); - _grab = Grab::None; - _grabShift = 0; - if (wasHead) { - _dotActive.start( - [=] { update(); }, - 1., - 0., - kDotDuration, - anim::easeOutQuint); - } - updateCursor(grabAt(e->pos())); - _draggingChanges.fire(false); -} - -void VideoTimeline::leaveEventHook(QEvent *e) { - if (_grab == Grab::None) { - setCursor(style::cur_default); - } -} - -void VideoTimeline::applyGrab(QPoint position) { - const auto at = timeAt(position.x() - _grabShift); - const auto minimum = minSelection(); - switch (_grab) { - case Grab::Left: { - const auto highest = std::max(_till - minimum, crl::time(0)); - _from = std::clamp(at, crl::time(0), highest); - if (_till - _from > _maxDuration) { - _till = _from + _maxDuration; - } - setCover(std::clamp(_cover, _from, _till), true); - _trimChanges.fire_copy(_from); - } break; - case Grab::Right: { - const auto lowest = std::min(_from + minimum, _duration); - _till = std::clamp(at, lowest, _duration); - if (_till - _from > _maxDuration) { - _from = _till - _maxDuration; - } - setCover(std::clamp(_cover, _from, _till), true); - _trimChanges.fire_copy(_till); - } break; - case Grab::Head: { - setCover(std::clamp(at, _from, _till), true); - } break; - case Grab::Window: { - moveWindowTo(at); - } break; - case Grab::None: return; - } - update(); -} - -void VideoTimeline::setCover(crl::time cover, bool notify) { - if (_cover == cover) { - return; - } - _cover = cover; - _playback = cover; - if (notify) { - _coverChanges.fire_copy(_cover); - } - update(); -} - -void VideoTimeline::paintEvent(QPaintEvent *e) { - auto p = QPainter(this); - auto hq = PainterHighQualityEnabler(p); - const auto strip = stripRect(); - if (strip.isEmpty()) { - return; - } - auto path = QPainterPath(); - const auto radius = st::videoTimelineRadius; - path.addRoundedRect(QRectF(strip), radius, radius); - p.setClipPath(path); - paintFrames(p, strip); - p.setClipping(false); - - paintSelection(p, strip); - paintHead(p, strip); - paintDuration(p, strip); - paintCoverDot(p); -} - -void VideoTimeline::paintFrames(QPainter &p, const QRect &strip) { +void VideoTimeline::paintStrip(QPainter &p, const QRect &strip) { p.fillRect(strip, st::videoTimelinePlaceholderBg); - if (_frameWidth <= 0) { + paintFrames(p, strip, _frames); + if (_loading) { + paintFrames(p, strip, _loading->set); + } +} + +void VideoTimeline::paintFrames( + QPainter &p, + const QRect &strip, + const VideoTimelineFrames &set) { + const auto count = int(set.frames.size()); + if (!count || set.span <= 0) { return; } - const auto count = int(_frames.size()); + const auto stripRight = rect::right(strip); for (auto i = 0; i != count; ++i) { - const auto &frame = _frames[i]; + const auto &frame = set.frames[i]; if (frame.isNull()) { continue; } - const auto x = strip.x() + i * strip.width() / count; - p.drawImage(QRect(x, strip.y(), _frameWidth, strip.height()), frame); + const auto left = xAt(set.from + i * set.span / count); + const auto right = xAt(set.from + (i + 1) * set.span / count); + if (right <= strip.x() || left >= stripRight) { + continue; + } + const auto natural = set.box.width(); + const auto slot = std::max(right - left, 1); + if (slot <= natural) { + PaintFramePart( + p, + frame, + QRect(left, strip.y(), slot, strip.height()), + (natural - slot) / 2); + continue; + } + for (auto x = left; x < right; x += natural) { + PaintFramePart( + p, + frame, + QRect(x, strip.y(), std::min(natural, right - x), strip.height()), + 0); + } } } -void VideoTimeline::paintSelection(QPainter &p, const QRect &strip) { - const auto left = xAt(_from); - const auto right = xAt(_till); - const auto radius = st::videoTimelineRadius; - - if (left > strip.x()) { - p.fillRect( - QRect(strip.x(), strip.y(), left - strip.x(), strip.height()), - st::videoTimelineDimBg); +void VideoTimeline::paintOverlay(QPainter &p) { + if (trimOnly()) { + return; } - const auto stripRight = strip.x() + strip.width(); - if (right < stripRight) { - p.fillRect( - QRect(right, strip.y(), stripRight - right, strip.height()), - st::videoTimelineDimBg); + const auto strip = stripRect(); + const auto centre = coverDot(); + if (centre.x() < strip.x() || centre.x() > rect::right(strip)) { + return; } - - const auto handle = st::videoTimelineHandleWidth; - const auto border = st::videoTimelineHandleGripWidth; - const auto outer = QRectF( - left - handle, - strip.y(), - (right - left) + handle * 2, - strip.height()); - auto frame = QPainterPath(); - frame.addRoundedRect(outer, radius, radius); - auto inner = QPainterPath(); - inner.addRect(QRectF( - left, - strip.y() + border, - std::max(right - left, 0), - std::max(strip.height() - border * 2, 0))); - - p.setPen(Qt::NoPen); - p.setBrush(st::videoTimelineFg); - p.drawPath(frame.subtracted(inner)); - - const auto gripWidth = st::videoTimelineHandleGripWidth; - const auto gripHeight = std::min( - int(st::videoTimelineHandleGripHeight), - strip.height() / 2); - const auto gripY = strip.y() + (strip.height() - gripHeight) / 2; - p.setBrush(st::videoTimelineDimBg); - for (const auto x : { left - handle + (handle - gripWidth) / 2, - right + (handle - gripWidth) / 2 }) { - p.drawRoundedRect( - QRectF(x, gripY, gripWidth, gripHeight), - gripWidth / 2., - gripWidth / 2.); - } -} - -void VideoTimeline::paintHead(QPainter &p, const QRect &strip) { - const auto width = st::videoTimelinePlayheadWidth; - const auto outline = st::videoTimelinePlayheadOutline; - const auto overflow = st::videoTimelinePlayheadOverflow; - const auto x = std::clamp( - xAt((_playback >= 0) ? _playback : _cover) - width / 2., - 1. * xAt(_from), - 1. * std::max(xAt(_till) - width, xAt(_from))); - const auto head = QRectF( - x, - strip.y() - overflow, - width, - strip.height() + overflow * 2); - const auto full = head.marginsAdded( - { 1. * outline, 1. * outline, 1. * outline, 1. * outline }); - p.setPen(Qt::NoPen); - p.setBrush(st::videoTimelineDimBg); - p.drawRoundedRect(full, width / 2. + outline, width / 2. + outline); - p.setBrush(st::videoTimelineFg); - p.drawRoundedRect(head, width / 2., width / 2.); -} - -void VideoTimeline::paintCoverDot(QPainter &p) { - const auto active = _dotActive.value((_grab == Grab::Head) ? 1. : 0.); + const auto active = _dotActive.value(draggingHead() ? 1. : 0.); const auto size = st::videoTimelineDotSize + (st::videoTimelineDotActiveSize - st::videoTimelineDotSize) * active; - const auto centre = coverDot(); p.setPen(Qt::NoPen); p.setBrush(st::videoTimelineDotFg); p.drawEllipse(QRectF( @@ -483,48 +284,4 @@ void VideoTimeline::paintCoverDot(QPainter &p) { size)); } -void VideoTimeline::paintDuration(QPainter &p, const QRect &strip) { - const auto stamp = [](crl::time value) { - return Ui::FormatDurationText(int(value / 1000)) - + '.' - + QString::number((value % 1000) / 100); - }; - const auto text = (_till - _from >= _duration) - ? stamp(_till - _from) - : (stamp(_from) + QString::fromUtf8(" – ") + stamp(_till)); - const auto label = labelRect(); - const auto &font = st::videoTimelineDurationStyle.font; - const auto width = font->width(text); - p.setFont(font); - - // They must never overlap, so the size goes whole or not at all. - const auto sizeWidth = _sizeLabel.isEmpty() - ? 0 - : font->width(_sizeLabel); - const auto skip = st::videoTimelineSizeSkip; - const auto sizeShown = sizeWidth - && (width + skip + sizeWidth <= label.width()); - if (sizeShown) { - p.setPen(st::videoTimelineSizeFg); - p.drawText(label, Qt::AlignVCenter | Qt::AlignRight, _sizeLabel); - } - const auto available = sizeShown - ? (label.width() - sizeWidth - skip) - : label.width(); - const auto shown = (width <= available) - ? text - : font->elided(text, available); - const auto shownWidth = std::min(width, available); - const auto center = (xAt(_from) + xAt(_till)) / 2; - const auto x = std::clamp( - center - shownWidth / 2, - label.x(), - label.x() + std::max(available - shownWidth, 0)); - p.setPen(st::videoTimelineDurationFg); - p.drawText( - QRect(x, label.y(), shownWidth, label.height()), - Qt::AlignVCenter | Qt::AlignLeft, - shown); -} - } // namespace Editor diff --git a/Telegram/SourceFiles/editor/video/video_timeline.h b/Telegram/SourceFiles/editor/video/video_timeline.h index adc315e25d..98a572c72d 100644 --- a/Telegram/SourceFiles/editor/video/video_timeline.h +++ b/Telegram/SourceFiles/editor/video/video_timeline.h @@ -7,16 +7,36 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once -#include "base/weak_ptr.h" +#include "base/timer.h" +#include "editor/editor_trim_timeline.h" #include "ui/effects/animations.h" -#include "ui/rp_widget.h" namespace Editor { +struct VideoTimelineFrames { + std::vector frames; + crl::time from = 0; + crl::time span = 0; + QSize box; +}; + +class VideoTimelineFramesCache final { +public: + [[nodiscard]] const VideoTimelineFrames *find( + Fn matches) const; + void add(VideoTimelineFrames set); + [[nodiscard]] int size() const; + +private: + std::vector _sets; + +}; + struct VideoTimelineDescriptor { QString path; QByteArray content; QSize dimensions; + std::shared_ptr cache; crl::time duration = 0; crl::time maxDuration = 0; @@ -26,107 +46,47 @@ struct VideoTimelineDescriptor { crl::time from = 0; crl::time till = 0; crl::time cover = 0; + + bool trimOnly = false; }; -class VideoTimeline final - : public Ui::RpWidget - , public base::has_weak_ptr { +class VideoTimeline final : public TrimTimeline { public: VideoTimeline( not_null parent, VideoTimelineDescriptor descriptor); ~VideoTimeline(); - [[nodiscard]] crl::time from() const { - return _from; - } - [[nodiscard]] crl::time till() const { - return _till; - } - [[nodiscard]] crl::time cover() const { - return _cover; - } - - [[nodiscard]] rpl::producer trimChanges() const { - return _trimChanges.events(); - } - [[nodiscard]] rpl::producer coverChanges() const { - return _coverChanges.events(); - } - [[nodiscard]] rpl::producer draggingChanges() const { - return _draggingChanges.events(); - } - - void setPlaybackPosition(crl::time position); - - void setSizeLabel(const QString &text); - [[nodiscard]] QPoint coverDot() const; - [[nodiscard]] bool draggingHead() const; - - [[nodiscard]] int resizeGetHeight(int newWidth) override; - private: - enum class Grab { - None, - Left, - Right, - Head, - - Window, + struct Loading { + VideoTimelineFrames set; + std::shared_ptr> cancel; }; - void paintEvent(QPaintEvent *e) override; - void mousePressEvent(QMouseEvent *e) override; - void mouseMoveEvent(QMouseEvent *e) override; - void mouseReleaseEvent(QMouseEvent *e) override; - void leaveEventHook(QEvent *e) override; + void paintStrip(QPainter &p, const QRect &strip) override; + void paintOverlay(QPainter &p) override; + void headGrabChanged(bool grabbed) override; + void visibleRangeChanged() override; - [[nodiscard]] QRect stripRect() const; - [[nodiscard]] QRect labelRect() const; - [[nodiscard]] crl::time minSelection() const; - void moveWindowTo(crl::time center); - [[nodiscard]] crl::time timeAt(int x) const; - [[nodiscard]] int xAt(crl::time time) const; - [[nodiscard]] Grab grabAt(QPoint position) const; - - void applyGrab(QPoint position); - void setCover(crl::time cover, bool notify); - void updateCursor(Grab grab); void reloadFrames(); - void paintFrames(QPainter &p, const QRect &strip); - void paintSelection(QPainter &p, const QRect &strip); - void paintHead(QPainter &p, const QRect &strip); - void paintCoverDot(QPainter &p); - void paintDuration(QPainter &p, const QRect &strip); + void paintFrames( + QPainter &p, + const QRect &strip, + const VideoTimelineFrames &set); - const VideoTimelineDescriptor _descriptor; - const crl::time _duration = 0; - const crl::time _maxDuration = 0; - const crl::time _minDuration = 0; + const QString _path; + const QByteArray _content; + const QSize _dimensions; + const std::shared_ptr _cache; - crl::time _from = 0; - crl::time _till = 0; - crl::time _cover = 0; - // Negative means nothing played yet; zero is a real position. - crl::time _playback = -1; + VideoTimelineFrames _frames; + std::unique_ptr _loading; + base::Timer _reloadTimer; - QString _sizeLabel; - - std::vector _frames; - QSize _framesBox; - int _frameWidth = 0; - std::shared_ptr> _framesCancel; - - Grab _grab = Grab::None; - int _grabShift = 0; Ui::Animations::Simple _dotActive; - rpl::event_stream _trimChanges; - rpl::event_stream _coverChanges; - rpl::event_stream _draggingChanges; - }; } // namespace Editor diff --git a/Telegram/SourceFiles/editor/video/video_timeline_seeker.cpp b/Telegram/SourceFiles/editor/video/video_timeline_seeker.cpp new file mode 100644 index 0000000000..8bcf5c9fcc --- /dev/null +++ b/Telegram/SourceFiles/editor/video/video_timeline_seeker.cpp @@ -0,0 +1,86 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/video/video_timeline_seeker.h" + +#include "editor/editor_trim_timeline.h" +#include "editor/video/video_segment_player.h" + +namespace Editor { +namespace { + +constexpr auto kSeekThrottle = crl::time(120); + +} // namespace + +TimelineSeeker::TimelineSeeker( + not_null timeline, + not_null player) +: _timeline(timeline) +, _player(player) +, _timer([=] { + const auto position = std::exchange(_pending, crl::time(-1)); + if (position >= 0) { + _player->restart(position); + } +}) { + _player->setSegment(_timeline->from(), _timeline->till()); + + _timeline->trimChanges( + ) | rpl::on_next([=](crl::time edge) { + _player->setSegment(_timeline->from(), _timeline->till()); + seek(edge); + }, _lifetime); + + _timeline->coverChanges( + ) | rpl::on_next([=](crl::time position) { + seek(position); + }, _lifetime); + + _timeline->draggingChanges( + ) | rpl::on_next([=](bool dragging) { + _dragging = dragging; + if (dragging) { + _player->setSeeking(true); + } else { + finishDragging(); + } + }, _lifetime); + + _player->positionUpdates( + ) | rpl::on_next([=](crl::time position) { + if (!_dragging) { + _timeline->setPlaybackPosition(position); + } + }, _lifetime); +} + +TimelineSeeker::~TimelineSeeker() { + if (_dragging) { + _player->setSeeking(false); + } +} + +void TimelineSeeker::seek(crl::time position) { + _pending = position; + if (!_timer.isActive()) { + _timer.callOnce(kSeekThrottle); + } +} + +void TimelineSeeker::finishDragging() { + _timer.cancel(); + const auto latest = (_pending >= 0) ? _pending : _player->position(); + _pending = -1; + const auto from = _timeline->from(); + const auto till = _timeline->till(); + const auto resume = std::clamp(latest, from, till); + _player->setSeeking(false); + _player->restart((resume >= till) ? from : resume); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/video/video_timeline_seeker.h b/Telegram/SourceFiles/editor/video/video_timeline_seeker.h new file mode 100644 index 0000000000..1123b261c9 --- /dev/null +++ b/Telegram/SourceFiles/editor/video/video_timeline_seeker.h @@ -0,0 +1,39 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/timer.h" + +namespace Editor { + +class SegmentPlayer; +class TrimTimeline; + +class TimelineSeeker final { +public: + TimelineSeeker( + not_null timeline, + not_null player); + ~TimelineSeeker(); + +private: + void seek(crl::time position); + void finishDragging(); + + const not_null _timeline; + const not_null _player; + + base::Timer _timer; + crl::time _pending = -1; + bool _dragging = false; + + rpl::lifetime _lifetime; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/video_item_timeline.cpp b/Telegram/SourceFiles/editor/video_item_timeline.cpp new file mode 100644 index 0000000000..5ed8bc9423 --- /dev/null +++ b/Telegram/SourceFiles/editor/video_item_timeline.cpp @@ -0,0 +1,111 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "editor/video_item_timeline.h" + +#include "editor/editor_audio_menu.h" +#include "editor/video/video_clip.h" +#include "editor/video/video_timeline.h" +#include "editor/video/video_timeline_seeker.h" +#include "ui/widgets/popup_menu.h" +#include "styles/style_editor.h" + +#include + +namespace Editor { + +VideoItemTimeline::VideoItemTimeline(not_null parent) +: RpWidget(parent) { +} + +VideoItemTimeline::~VideoItemTimeline() = default; + +void VideoItemTimeline::setClip(std::shared_ptr clip) { + if (_clip == clip) { + return; + } + _clipLifetime.destroy(); + _menu = nullptr; + _seeker = nullptr; + _timeline = nullptr; + _clip = std::move(clip); + if (!_clip) { + return; + } + const auto &source = _clip->source(); + const auto trim = _clip->trim(); + _timeline = base::make_unique_q( + this, + VideoTimelineDescriptor{ + .path = source->path, + .content = source->content, + .dimensions = source->thumbnail.size(), + .cache = _clip->timelineFrames(), + .duration = _clip->duration(), + .from = trim.from, + .till = trim.till, + .trimOnly = true, + }); + _timeline->show(); + _seeker = std::make_unique( + _timeline.get(), + _clip->player()); + _clip->setTrim({ _timeline->from(), _timeline->till() }); + + _timeline->trimChanges( + ) | rpl::on_next([=] { + _clip->setTrim({ _timeline->from(), _timeline->till() }); + _lengthChanges.fire(_clip->loopDuration()); + }, _clipLifetime); + + if (width() > 0) { + resizeToWidth(width()); + } +} + +rpl::producer VideoItemTimeline::lengthChanges() const { + return _lengthChanges.events(); +} + +void VideoItemTimeline::refreshTrim() { + if (_timeline) { + const auto trim = _clip->trim(); + _timeline->setTrim(trim.from, trim.till); + } +} + +void VideoItemTimeline::commitPendingEdit() { + if (_timeline) { + _timeline->commitPendingEdit(); + } +} + +void VideoItemTimeline::contextMenuEvent(QContextMenuEvent *e) { + if (!_clip || !_clip->hasAudio()) { + return; + } + _menu = base::make_unique_q( + this, + st::photoEditorMediaMenu); + const auto clip = _clip; + AddVolumeAction(_menu.get(), clip->volume(), [=](float64 volume) { + clip->setVolume(volume); + }); + _menu->popup(e->globalPos()); + e->accept(); +} + +int VideoItemTimeline::resizeGetHeight(int newWidth) { + const auto height = st::photoEditorButtonBarHeight; + if (_timeline) { + _timeline->resizeToWidth(std::max(newWidth, 1)); + _timeline->moveToLeft(0, (height - _timeline->height()) / 2); + } + return height; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/video_item_timeline.h b/Telegram/SourceFiles/editor/video_item_timeline.h new file mode 100644 index 0000000000..66a4f7d9b9 --- /dev/null +++ b/Telegram/SourceFiles/editor/video_item_timeline.h @@ -0,0 +1,50 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/unique_qptr.h" +#include "ui/rp_widget.h" + +namespace Ui { +class PopupMenu; +} // namespace Ui + +namespace Editor { + +class TimelineSeeker; +class VideoClip; +class VideoTimeline; + +class VideoItemTimeline final : public Ui::RpWidget { +public: + explicit VideoItemTimeline(not_null parent); + ~VideoItemTimeline(); + + void setClip(std::shared_ptr clip); + void refreshTrim(); + void commitPendingEdit(); + + [[nodiscard]] rpl::producer lengthChanges() const; + + int resizeGetHeight(int newWidth) override; + +protected: + void contextMenuEvent(QContextMenuEvent *e) override; + +private: + std::shared_ptr _clip; + base::unique_qptr _timeline; + std::unique_ptr _seeker; + base::unique_qptr _menu; + rpl::event_stream _lengthChanges; + + rpl::lifetime _clipLifetime; + +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/history/history_item.h b/Telegram/SourceFiles/history/history_item.h index 9937900d74..2462f6d629 100644 --- a/Telegram/SourceFiles/history/history_item.h +++ b/Telegram/SourceFiles/history/history_item.h @@ -354,6 +354,12 @@ public: [[nodiscard]] bool isEphemeral() const { return _flags & MessageFlag::Ephemeral; } + [[nodiscard]] bool isFakeHistoryItem() const { + return _flags & MessageFlag::FakeHistoryItem; + } + [[nodiscard]] bool isLegacyMessage() const { + return _flags & MessageFlag::Legacy; + } [[nodiscard]] bool canBeSelected() const; [[nodiscard]] bool isFakeAboutView() const { return _flags & MessageFlag::FakeAboutView; @@ -697,9 +703,6 @@ private: void detectTextLinks(const TextWithEntities &textWithEntities); void setTextValue(TextWithEntities text, bool force = false); [[nodiscard]] bool isTooOldForEdit(TimeId now) const; - [[nodiscard]] bool isLegacyMessage() const { - return _flags & MessageFlag::Legacy; - } [[nodiscard]] bool checkDiscussionLink(ChannelId id) const; void updateSentContent( diff --git a/Telegram/SourceFiles/history/view/history_view_bottom_info.cpp b/Telegram/SourceFiles/history/view/history_view_bottom_info.cpp index 21ac2d0973..9971a38282 100644 --- a/Telegram/SourceFiles/history/view/history_view_bottom_info.cpp +++ b/Telegram/SourceFiles/history/view/history_view_bottom_info.cpp @@ -502,7 +502,8 @@ void BottomInfo::layoutDateText() { const auto prefix = !author.isEmpty() ? u", "_q : QString(); const auto date = editedPrimary ? FormatEditedDate(_data.date, _data.editedDate) - : edited + ((_data.flags & Data::Flag::ForwardedDate) + : edited + ((_data.flags + & (Data::Flag::ForwardedDate | Data::Flag::FullDate)) ? Ui::FormatDateTimeSavedFrom(_data.date) : QLocale().toString(_data.date.time(), QLocale::ShortFormat)); const auto afterAuthor = prefix + date; @@ -678,6 +679,8 @@ BottomInfo::Data BottomInfoDataFromMessage(not_null message) { } if (message->context() == Context::ShortcutMessages) { result.flags |= Flag::Shortcut; + } else if (message->context() == Context::MediaEditor) { + result.flags |= Flag::FullDate; } if (!item->isPost() || !item->hasRealFromId() diff --git a/Telegram/SourceFiles/history/view/history_view_bottom_info.h b/Telegram/SourceFiles/history/view/history_view_bottom_info.h index 6c82f97195..f38195faa2 100644 --- a/Telegram/SourceFiles/history/view/history_view_bottom_info.h +++ b/Telegram/SourceFiles/history/view/history_view_bottom_info.h @@ -47,6 +47,7 @@ public: EditedPrimary = 0x800, Ephemeral = 0x1000, Updated = 0x2000, + FullDate = 0x4000, //Unread, // We don't want to pass and update it in Date for now. }; friend inline constexpr bool is_flag_type(Flag) { return true; }; diff --git a/Telegram/SourceFiles/history/view/history_view_element.cpp b/Telegram/SourceFiles/history/view/history_view_element.cpp index 783728172f..2abb5c1318 100644 --- a/Telegram/SourceFiles/history/view/history_view_element.cpp +++ b/Telegram/SourceFiles/history/view/history_view_element.cpp @@ -1503,7 +1503,20 @@ bool Element::isTopicRootReply() const { } bool Element::hidesBottomInfo() const { - return data()->isWelcomeTemplate(); + return data()->isWelcomeTemplate() + || (data()->isFakeHistoryItem() + && context() == Context::MediaEditor); +} + +ReplyKeyboard *Element::inlineReplyKeyboard() const { + return (_context == Context::MediaEditor) + ? nullptr + : _data->inlineReplyKeyboard(); +} + +bool Element::hasCommentsButton() const { + return (_context != Context::MediaEditor) + && (_data->repliesAreComments() || _data->externalReply()); } int Element::skipBlockWidth() const { @@ -2858,7 +2871,8 @@ void Element::setupReactions(Element *replacing) { void Element::refreshReactions() { using namespace Reactions; auto reactionsData = InlineListDataFromMessage(this); - if (reactionsData.reactions.empty()) { + if (reactionsData.reactions.empty() + || context() == Context::MediaEditor) { setReactions(nullptr); return; } diff --git a/Telegram/SourceFiles/history/view/history_view_element.h b/Telegram/SourceFiles/history/view/history_view_element.h index b34991c46b..a560bfee6e 100644 --- a/Telegram/SourceFiles/history/view/history_view_element.h +++ b/Telegram/SourceFiles/history/view/history_view_element.h @@ -21,6 +21,7 @@ class UserData; struct HistoryMessageReply; struct PreparedServiceText; struct HistoryMessageReplyMarkup; +class ReplyKeyboard; namespace Data { class Thread; @@ -79,6 +80,7 @@ enum class Context : char { ScheduledTopic, ChatPreview, WelcomeMessages, + MediaEditor, }; enum class OnlyEmojiAndSpaces : char { @@ -458,6 +460,8 @@ public: [[nodiscard]] not_null data() const; [[nodiscard]] not_null history() const; [[nodiscard]] Media *media() const; + [[nodiscard]] bool hasCommentsButton() const; + [[nodiscard]] ReplyKeyboard *inlineReplyKeyboard() const; [[nodiscard]] Context context() const; void refreshDataId(); diff --git a/Telegram/SourceFiles/history/view/history_view_message.cpp b/Telegram/SourceFiles/history/view/history_view_message.cpp index c99e3fb361..004db15788 100644 --- a/Telegram/SourceFiles/history/view/history_view_message.cpp +++ b/Telegram/SourceFiles/history/view/history_view_message.cpp @@ -779,7 +779,7 @@ bool Message::prepareRichPageTextRect(QRect &trect) const { if (_reactions && !reactionsInBubble) { g.setHeight(g.height() - st::mediaInBubbleSkip - _reactions->height()); } - if (const auto keyboard = item->inlineReplyKeyboard()) { + if (const auto keyboard = inlineReplyKeyboard()) { g.setHeight( g.height() - st::msgBotKbButton.margin @@ -1164,7 +1164,6 @@ void Message::applyGroupAdminChanges( } void Message::animateReaction(Ui::ReactionFlyAnimationArgs &&args) { - const auto item = data(); const auto media = this->media(); auto g = countGeometry(); @@ -1188,7 +1187,7 @@ void Message::animateReaction(Ui::ReactionFlyAnimationArgs &&args) { return; } - const auto keyboard = item->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); auto keyboardHeight = 0; if (keyboard) { keyboardHeight = keyboard->naturalHeight(); @@ -1226,7 +1225,6 @@ QRect Message::effectIconGeometry() const { if (hidesBottomInfo()) { return {}; } - const auto item = data(); const auto media = this->media(); auto g = countGeometry(); @@ -1236,7 +1234,7 @@ QRect Message::effectIconGeometry() const { const auto bubble = drawBubble(); const auto reactionsInBubble = _reactions && embedReactionsInBubble(); const auto mediaDisplayed = media && media->isDisplayed(); - const auto keyboard = item->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); auto keyboardHeight = 0; if (keyboard) { keyboardHeight = keyboard->naturalHeight(); @@ -1614,10 +1612,11 @@ QSize Message::performCountOptimalSize() { } // if we have a text bubble we can resize it to fit the keyboard // but if we have only media we don't do that - if (markup && markup->inlineKeyboard && hasVisibleText()) { - accumulate_max(maxWidth, markup->inlineKeyboard->naturalWidth()); + const auto keyboard = inlineReplyKeyboard(); + if (keyboard && hasVisibleText()) { + accumulate_max(maxWidth, keyboard->naturalWidth()); if (bubble) { - const auto kbw = markup->inlineKeyboard->naturalWidth(); + const auto kbw = keyboard->naturalWidth(); if (kbw > int(_nonTextMaxWidth)) { _nonTextMaxWidth = std::min(kbw, kMaxWidth); } @@ -1735,7 +1734,8 @@ void Message::draw(Painter &p, const PaintContext &context) const { p.translate(selectionTranslation, 0); } - if (item->hasUnrequestedFactcheck()) { + if (item->hasUnrequestedFactcheck() + && (Message::context() != Context::MediaEditor)) { item->history()->session().factchecks().requestFor(item); } @@ -1784,7 +1784,7 @@ void Message::draw(Painter &p, const PaintContext &context) const { const auto reactionsHeight = st::mediaInBubbleSkip + _reactions->height(); gForIntervals.setHeight(gForIntervals.height() - reactionsHeight); } - const auto keyboard = item->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); if (keyboard) { const auto keyboardHeight = st::msgBotKbButton.margin + keyboard->naturalHeight(); gForIntervals.setHeight(gForIntervals.height() - keyboardHeight); @@ -1797,7 +1797,7 @@ void Message::draw(Painter &p, const PaintContext &context) const { const auto customHighlight = mediaDisplayed && media->customHighlight(); if (!mediaSelectionIntervals.empty() || customHighlight) { auto localMediaBottom = gForIntervals.top() + gForIntervals.height(); - if (data()->repliesAreComments() || data()->externalReply()) { + if (hasCommentsButton()) { localMediaBottom -= st::historyCommentsButtonHeight; } if (_viewButton) { @@ -2186,6 +2186,7 @@ void Message::draw(Painter &p, const PaintContext &context) const { media->paintBubbleFireworks(p, g, context.now); } } else if (media && media->isDisplayed()) { + _lastMediaPosition = g.topLeft(); p.translate(g.topLeft()); media->draw(p, context.translated( -g.topLeft() @@ -2372,7 +2373,7 @@ void Message::paintCommentsButton( Painter &p, QRect &g, const PaintContext &context) const { - if (!data()->repliesAreComments() && !data()->externalReply()) { + if (!hasCommentsButton()) { return; } if (!_comments) { @@ -2915,7 +2916,8 @@ void Message::paintForwardedInfo( } p.setTextPalette(stm->textPalette); - if (!forwarded->psaType.isEmpty()) { + if (!forwarded->psaType.isEmpty() + && (Message::context() != Context::MediaEditor)) { const auto entry = Get(); Assert(entry != nullptr); const auto shown = entry->buttonVisibleAnimation.value( @@ -3064,7 +3066,9 @@ void Message::paintText( }; const auto appearing = Get(); - const auto appearingClip = appearing && appearing->use; + const auto appearingClip = appearing + && appearing->use + && (Message::context() != Context::MediaEditor); auto linePostprocess = std::optional(); if (appearingClip) { const auto shown = appearing->shownLine; @@ -3240,7 +3244,6 @@ PointState Message::pointState(QPoint point) const { } const auto media = this->media(); - const auto item = data(); const auto reactionsInBubble = _reactions && embedReactionsInBubble(); if (drawBubble()) { if (!g.contains(point)) { @@ -3255,7 +3258,7 @@ PointState Message::pointState(QPoint point) const { auto mediaOnBottom = (mediaDisplayed && media->isBubbleBottom()) || check || (entry/* && entry->isBubbleBottom()*/); auto mediaOnTop = (mediaDisplayed && media->isBubbleTop()) || (entry && entry->isBubbleTop()); - if (item->repliesAreComments() || item->externalReply()) { + if (hasCommentsButton()) { g.setHeight(g.height() - st::historyCommentsButtonHeight); } @@ -3847,15 +3850,15 @@ bool Message::hasFromPhoto() const { case Context::History: case Context::ChatPreview: case Context::TTLViewer: + case Context::MediaEditor: case Context::Pinned: case Context::Replies: case Context::SavedSublist: case Context::ScheduledTopic: { const auto item = data(); - if (item->isPostHidingAuthor()) { - return false; - } else if (item->isPost()) { - return true; + if (item->isPost()) { + return (context() == Context::MediaEditor) + || !item->isPostHidingAuthor(); } else if (item->isEmpty() || item->isFakeAboutView() || isCommentsRootView()) { @@ -3934,7 +3937,7 @@ TextState Message::textState( } } - const auto keyboard = item->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); auto keyboardHeight = 0; if (keyboard) { keyboardHeight = keyboard->naturalHeight(); @@ -4847,7 +4850,7 @@ void Message::updatePressed(QPoint point) { g.setHeight(g.height() - reactionsHeight); } - const auto keyboard = item->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); if (keyboard) { auto keyboardHeight = st::msgBotKbButton.margin + keyboard->naturalHeight(); g.setHeight(g.height() - keyboardHeight); @@ -5295,7 +5298,7 @@ Reactions::ButtonParameters Message::reactionButtonParameters( result.pointer = position; const auto onTheLeft = hasRightLayout(); - const auto keyboard = data()->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); const auto keyboardHeight = keyboard ? (st::msgBotKbButton.margin + keyboard->naturalHeight()) : 0; @@ -5654,6 +5657,10 @@ int Message::viewButtonHeight() const { } void Message::updateViewButtonExistence() { + if (context() == Context::MediaEditor) { + _viewButton = nullptr; + return; + } const auto item = data(); const auto make = [=](auto &&from) { return std::make_unique( @@ -5756,6 +5763,7 @@ bool Message::hasFromName() const { case Context::History: case Context::ChatPreview: case Context::TTLViewer: + case Context::MediaEditor: case Context::Pinned: case Context::Replies: case Context::SavedSublist: @@ -5929,7 +5937,7 @@ int Message::minWidthForMedia() const { accumulate_max(result, added + st::semiboldFont->width( tr::lng_replies_view_original(tr::now))); } - if (const auto keyboard = data()->inlineReplyKeyboard()) { + if (const auto keyboard = inlineReplyKeyboard()) { accumulate_max(result, keyboard->naturalWidth()); } return result; @@ -5973,7 +5981,9 @@ bool Message::displayRightActionComments() const { } std::optional Message::rightActionSize() const { - if (displayRightActionComments()) { + if (context() == Context::MediaEditor) { + return std::nullopt; + } else if (displayRightActionComments()) { const auto views = data()->Get(); Assert(views != nullptr); return (views->repliesSmall.textWidth > 0) @@ -6526,7 +6536,7 @@ Ui::BubbleRounding Message::countMessageRounding() const { const auto smallBottom = isBubbleAttachedToNext(); const auto media = smallBottom ? nullptr : this->media(); const auto item = data(); - const auto keyboard = item->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); const auto skipTail = smallBottom || (media && media->skipBubbleTail()) || (keyboard != nullptr) @@ -6552,7 +6562,7 @@ Ui::BubbleRounding Message::countMessageRounding() const { Ui::BubbleRounding Message::countBubbleRounding( Ui::BubbleRounding messageRounding) const { - if ([[maybe_unused]] const auto _ = data()->inlineReplyKeyboard()) { + if ([[maybe_unused]] const auto _ = inlineReplyKeyboard()) { messageRounding.bottomLeft = messageRounding.bottomRight = Ui::BubbleCornerRounding::Small; @@ -6798,7 +6808,7 @@ int Message::resizeContentGetHeight(int newWidth) { newHeight += (bottomInfoHeight - st::msgDateFont->height); } - if (item->repliesAreComments() || item->externalReply()) { + if (hasCommentsButton()) { newHeight += st::historyCommentsButtonHeight; } else if (_comments) { _comments = nullptr; @@ -6821,7 +6831,7 @@ int Message::resizeContentGetHeight(int newWidth) { } } - if (const auto keyboard = item->inlineReplyKeyboard()) { + if (const auto keyboard = inlineReplyKeyboard()) { const auto keyboardHeight = st::msgBotKbButton.margin + keyboard->naturalHeight(); newHeight += keyboardHeight; keyboard->resize(contentWidth, keyboardHeight - st::msgBotKbButton.margin); @@ -7226,6 +7236,7 @@ const HistoryMessageEdited *Message::displayedEditBadge() const { void Message::ensureSummarizeButton() const { if (data()->canBeSummarized() + && (context() != Context::MediaEditor) /*&& item->originalText().text.size() >= kSummarizeThreshold*/) { if (!_summarize) { _summarize diff --git a/Telegram/SourceFiles/history/view/history_view_reply.cpp b/Telegram/SourceFiles/history/view/history_view_reply.cpp index 3fbba2c81e..69601e7cf8 100644 --- a/Telegram/SourceFiles/history/view/history_view_reply.cpp +++ b/Telegram/SourceFiles/history/view/history_view_reply.cpp @@ -466,6 +466,9 @@ void Reply::update( text, _multiline ? Ui::ItemTextDefaultOptions() : Ui::DialogTextOptions(), helper.context()); + if (view->context() == Context::MediaEditor) { + _text.setSpoilerRevealed(true, anim::type::instant); + } updateName(view, data); @@ -979,7 +982,9 @@ void Reply::paint( .outer = to.size(), }); p.drawPixmap(to.x(), to.y(), preview); - if (_spoiler) { + const auto mediaEditor = (view->context() + == Context::MediaEditor); + if (_spoiler && !mediaEditor) { view->clearCustomEmojiRepaint(); FillPreviewSpoiler( p, diff --git a/Telegram/SourceFiles/history/view/history_view_service_message.cpp b/Telegram/SourceFiles/history/view/history_view_service_message.cpp index f9004762ad..fc436431c1 100644 --- a/Telegram/SourceFiles/history/view/history_view_service_message.cpp +++ b/Telegram/SourceFiles/history/view/history_view_service_message.cpp @@ -522,8 +522,7 @@ QSize Service::performCountCurrentSize(int newWidth) { } } - const auto item = data(); - if (const auto keyboard = item->inlineReplyKeyboard()) { + if (const auto keyboard = inlineReplyKeyboard()) { const auto keyboardWidth = mediaDisplayed ? media->width() : contentWidth; const auto keyboardHeight = st::msgBotKbButton.margin + keyboard->naturalHeight(); newHeight += keyboardHeight; @@ -641,8 +640,7 @@ void Service::draw(Painter &p, const PaintContext &context) const { p.translate(-reactionsPosition); } - const auto item = data(); - const auto keyboard = item->inlineReplyKeyboard(); + const auto keyboard = inlineReplyKeyboard(); if (keyboard) { // We need to count geometry without keyboard for bubble selection // intervals counting below. @@ -757,7 +755,7 @@ TextState Service::textState(QPoint point, StateRequest request) const { } } - auto keyboard = item->inlineReplyKeyboard(); + auto keyboard = inlineReplyKeyboard(); auto keyboardHeight = 0; if (keyboard) { keyboardHeight = keyboard->naturalHeight(); diff --git a/Telegram/SourceFiles/history/view/history_view_text_helper.cpp b/Telegram/SourceFiles/history/view/history_view_text_helper.cpp index 5465ddf487..64e565d782 100644 --- a/Telegram/SourceFiles/history/view/history_view_text_helper.cpp +++ b/Telegram/SourceFiles/history/view/history_view_text_helper.cpp @@ -21,6 +21,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace HistoryView { void InitElementTextPart(not_null view, Ui::Text::String &text) { + if (view->context() == Context::MediaEditor) { + text.setSpoilerRevealed(true, anim::type::instant); + } if (text.hasSpoilers()) { text.setSpoilerLinkFilter([weak = base::make_weak(view)]( const ClickContext &context) { diff --git a/Telegram/SourceFiles/history/view/media/history_view_birthday_suggestion.cpp b/Telegram/SourceFiles/history/view/media/history_view_birthday_suggestion.cpp index 76c8d826a9..a107694314 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_birthday_suggestion.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_birthday_suggestion.cpp @@ -87,7 +87,7 @@ namespace HistoryView { (isSelf ? st::birthdaySuggestTableLastPadding : st::birthdaySuggestTablePadding))); - if (!isSelf) { + if (!isSelf && (parent->context() != Context::MediaEditor)) { auto link = std::make_shared([=]( ClickContext context) { Core::App().openInternalUrl( diff --git a/Telegram/SourceFiles/history/view/media/history_view_community_added.cpp b/Telegram/SourceFiles/history/view/media/history_view_community_added.cpp index afb638be55..ed5df928aa 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_community_added.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_community_added.cpp @@ -230,15 +230,17 @@ auto GenerateCommunityAddedMedia( st::msgServiceGiftBoxTitlePadding.bottom()), st::premiumPreviewAbout.style)); - push(MakeGenericButtonPart( - tr::lng_community_view(tr::now), - QMargins( - 0, - st::msgServiceGiftBoxButtonMargins.top(), - 0, - st::msgServiceGiftBoxButtonMargins.bottom()), - [=] { parent->repaint(); }, - open)); + if (parent->context() != Context::MediaEditor) { + push(MakeGenericButtonPart( + tr::lng_community_view(tr::now), + QMargins( + 0, + st::msgServiceGiftBoxButtonMargins.top(), + 0, + st::msgServiceGiftBoxButtonMargins.bottom()), + [=] { parent->repaint(); }, + open)); + } }; } diff --git a/Telegram/SourceFiles/history/view/media/history_view_contact.cpp b/Telegram/SourceFiles/history/view/media/history_view_contact.cpp index 5ce698f80d..16f2c29a62 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_contact.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_contact.cpp @@ -255,8 +255,11 @@ QSize Contact::countOptimalSize() { } const auto vcardBoxFactory = _vcardBoxFactory; + const auto mediaEditor = (_parent->context() == Context::MediaEditor); _buttons.clear(); - if (_contact) { + if (mediaEditor) { + _mainButton.link = nullptr; + } else if (_contact) { const auto message = tr::lng_contact_send_message(tr::now).toUpper(); _buttons.push_back({ message, @@ -280,7 +283,7 @@ QSize Contact::countOptimalSize() { AddContactClickHandler(_parent->data()), }); } - if (vcardBoxFactory) { + if (vcardBoxFactory && !mediaEditor) { _mainButton.link = std::make_shared([=]( const ClickContext &context) { const auto my = context.other.value(); diff --git a/Telegram/SourceFiles/history/view/media/history_view_document.cpp b/Telegram/SourceFiles/history/view/media/history_view_document.cpp index 4cba1e85b8..3755550731 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_document.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_document.cpp @@ -727,6 +727,7 @@ void Document::draw( ensureDataMediaCreated(); const auto cornerDownload = downloadInCorner(); + const auto mediaEditor = (_parent->context() == Context::MediaEditor); if (!_dataMedia->canBePlayed()) { _dataMedia->automaticLoad(_realParent->fullId(), _realParent); @@ -737,7 +738,7 @@ void Document::draw( int captionw = width - st::msgPadding.left() - st::msgPadding.right(); - if (displayLoading) { + if (displayLoading && !mediaEditor) { ensureAnimation(); if (!_animation->radial.animating()) { _animation->radial.start(dataProgress()); @@ -769,7 +770,10 @@ void Document::draw( FillThumbnailOverlay(p, rthumb, rounding, context); } - if (radial || (!loaded && !_data->loading()) || _data->waitingForAlbum()) { + if (!mediaEditor + && (radial + || (!loaded && !_data->loading()) + || _data->waitingForAlbum())) { const auto backOpacity = (loaded && !_data->uploading()) ? radialOpacity : 1.; p.setPen(Qt::NoPen); p.setBrush(sti->msgDateImgBg); @@ -801,7 +805,7 @@ void Document::draw( } } - if (_data->status != FileUploadFailed) { + if (_data->status != FileUploadFailed && !mediaEditor) { const auto &lnk = (_data->loading() || _data->uploading()) ? thumbed->linkcancell : dataLoaded() @@ -820,7 +824,9 @@ void Document::draw( && _openl; const auto ttlRect = hasTtlBadge ? TTLRectFromInner(inner) : QRect(); - paintPlaybackBlobs(p, context, inner); + if (!mediaEditor) { + paintPlaybackBlobs(p, context, inner); + } const auto coverDrawn = _data->isSongWithCover() && DrawThumbnailAsSongCover( @@ -853,11 +859,12 @@ void Document::draw( } const auto &icon = [&]() -> const style::icon& { - if (_data->waitingForAlbum()) { + if (!mediaEditor && _data->waitingForAlbum()) { return _data->isSongWithCover() ? sti->historyFileThumbWaiting : stm->historyFileWaiting; - } else if (!cornerDownload + } else if (!mediaEditor + && !cornerDownload && (_data->loading() || _data->uploading())) { return _data->isSongWithCover() ? sti->historyFileThumbCancel @@ -866,7 +873,7 @@ void Document::draw( return _data->isSongWithCover() ? sti->historyFileThumbPause : stm->historyFilePause; - } else if (loaded || _dataMedia->canBePlayed()) { + } else if (mediaEditor || loaded || _dataMedia->canBePlayed()) { return _dataMedia->canBePlayed() ? (_data->isSongWithCover() ? sti->historyFileThumbPlay @@ -913,7 +920,7 @@ void Document::draw( icon.paintInCenter(q, inner); } - if (radial && !cornerDownload) { + if (radial && !cornerDownload && !mediaEditor) { QRect rinner(inner.marginsRemoved(QMargins(st::msgFileRadialLine, st::msgFileRadialLine, st::msgFileRadialLine, st::msgFileRadialLine))); _animation->radial.draw(q, rinner, st::msgFileRadialLine, stm->historyFileRadialFg); } @@ -973,7 +980,9 @@ void Document::draw( } } - drawCornerDownload(p, context, mode); + if (!mediaEditor) { + drawCornerDownload(p, context, mode); + } } auto namewidth = width - nameleft - nameright; auto statuswidth = namewidth; @@ -1016,7 +1025,7 @@ void Document::draw( base::SafeRound(_voiceHoverProgress * voice->lastDurationMs) / 1000, voice->lastDurationMs / 1000); } - if (voice->transcribe) { + if (voice->transcribe && !mediaEditor) { const auto size = voice->transcribe->size(); namewidth -= st::historyTranscribeSkip + size.width(); const auto x = nameleft + namewidth + st::historyTranscribeSkip; @@ -1060,7 +1069,7 @@ void Document::draw( p.setPen(stm->mediaFg); p.drawTextLeft(nameleft, statustop, width, statusText); - if (_realParent->isUnreadMedia()) { + if (_realParent->isUnreadMedia() && !mediaEditor) { auto w = st::normalFont->width(statusText); if (w + st::mediaUnreadSkip + st::mediaUnreadSize <= statuswidth) { p.setPen(Qt::NoPen); @@ -1646,8 +1655,13 @@ bool Document::updateStatusText() const { auto showPause = false; auto statusSize = int64(); auto realDuration = TimeId(); + const auto mediaEditor = (_parent->context() == Context::MediaEditor); if (_data->status == FileDownloadFailed || _data->status == FileUploadFailed) { statusSize = Ui::FileStatusSizeFailed; + } else if (mediaEditor) { + statusSize = dataLoaded() + ? Ui::FileStatusSizeLoaded + : Ui::FileStatusSizeReady; } else if (_data->uploading()) { statusSize = _data->uploadingData->offset; } else if (_data->loading()) { @@ -1658,7 +1672,8 @@ bool Document::updateStatusText() const { statusSize = Ui::FileStatusSizeReady; } - if (_data->isVoiceMessage() || _transcribedRound) { + if (mediaEditor) { + } else if (_data->isVoiceMessage() || _transcribedRound) { const auto state = ::Media::Player::instance()->getState(AudioMsgId::Type::Voice); if (state.id == AudioMsgId(_data, _realParent->fullId(), state.id.externalPlayId()) && !::Media::Player::IsStoppedOrStopping(state.state)) { diff --git a/Telegram/SourceFiles/history/view/media/history_view_gif.cpp b/Telegram/SourceFiles/history/view/media/history_view_gif.cpp index 5934096148..2bf6f420d3 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_gif.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_gif.cpp @@ -579,7 +579,9 @@ bool Gif::underCursor(bool fullFeatured) const { } bool Gif::autoplayEnabled() const { - if (_realParent->isSponsored()) { + if (_parent->context() == Context::MediaEditor) { + return false; + } else if (_realParent->isSponsored()) { return true; } return Data::AutoDownload::ShouldAutoPlay( @@ -629,6 +631,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { const auto sti = context.imageStyle(); const auto cornerDownload = downloadInCorner(); const auto autoplay = autoplayEligible(true); + const auto mediaEditor = (_parent->context() == Context::MediaEditor); const auto activeRoundPlaying = activeRoundStreamed(); auto paintx = 0, painty = 0, paintw = width(), painth = height(); @@ -671,7 +674,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { const auto inTTLViewer = _parent->delegate()->elementContext() == Context::TTLViewer; - const auto revealed = revealedProgress(); + const auto revealed = mediaEditor ? 1. : revealedProgress(); const auto fullHiddenBySpoiler = (revealed == 0.); if (revealed < 1.) { validateSpoilerImageCache(rthumb.size(), rounding); @@ -706,6 +709,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { : nullptr; if (displayLoading + && !mediaEditor && (!streamedForWaiting || item->isSending() || _data->uploading() @@ -787,10 +791,10 @@ void Gif::draw(Painter &p, const PaintContext &context) const { validateThumbCache({ usew, painth }, isRound, rounding); p.drawImage(rthumb, _thumbCache); } - if (isRound) { + if (isRound && !mediaEditor) { paintRoundPlaybackProgress(p, context, rthumb, inTTLViewer); } - if (!isRound) { + if (!isRound && !mediaEditor) { paintTimestampMark(p, rthumb, rounding); } @@ -819,6 +823,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { const auto ttlCovered = _ttlCover && (revealed < 1.); const auto paintInCenter = !_sensitiveSpoiler + && !mediaEditor && (radial || (!streamingMode && ((!loaded && !_data->loading()) || !autoplay)) @@ -897,7 +902,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { } } p.setOpacity(1.); - } else if (_sensitiveSpoiler) { + } else if (_sensitiveSpoiler && !mediaEditor) { drawSpoilerTag(p, rthumb, context, [&] { return spoilerTagBackground(); }); @@ -921,7 +926,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { if (!unwrapped && !skipDrawingSurrounding) { const auto sponsoredSkip = !_data->isVideoFile() && _realParent->isSponsored(); - if ((!isRound || !inWebPage) && !sponsoredSkip) { + if ((!isRound || !inWebPage) && !sponsoredSkip && !mediaEditor) { if (ttlCovered) { PaintTtlLabel(p, QPoint(), width(), _realParent, context); } else { @@ -929,7 +934,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { } } } else if (!skipDrawingSurrounding) { - if (isRound) { + if (isRound && !mediaEditor) { const auto mediaUnread = item->hasUnreadMediaFlag(); const auto statusText = _seeking ? Ui::FormatDurationText(1 + int64(base::SafeRound( @@ -1112,7 +1117,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { paintTranscribe(p, usex, fullBottom, false, context); } } - if (_drawTtl) { + if (_drawTtl && !mediaEditor) { _drawTtl(p, rthumb, context); } } @@ -1768,6 +1773,7 @@ void Gif::drawGrouped( not_null cache) const { ensureDataMediaCreated(); const auto item = _parent->data(); + const auto mediaEditor = (_parent->context() == Context::MediaEditor); const auto loaded = dataLoaded(); const auto displayLoading = item->isSending() || item->hasFailed() @@ -1777,7 +1783,7 @@ void Gif::drawGrouped( _smallGroupPart = !fullFeaturedGrouped(sides); const auto cornerDownload = !_smallGroupPart && downloadInCorner(); - const auto revealed = revealedProgress(); + const auto revealed = mediaEditor ? 1. : revealedProgress(); const auto fullHiddenBySpoiler = (revealed == 0.); if (revealed < 1.) { validateSpoilerImageCache(geometry.size(), rounding); @@ -1808,6 +1814,7 @@ void Gif::drawGrouped( : nullptr; if (displayLoading + && !mediaEditor && (!streamedForWaiting || item->isSending() || _data->uploading() @@ -1884,6 +1891,7 @@ void Gif::drawGrouped( } const auto paintInCenter = !_sensitiveSpoiler + && !mediaEditor && (radial || (!streamingMode && ((!loaded && !_data->loading()) || !autoplay))); @@ -1968,7 +1976,7 @@ void Gif::drawGrouped( } p.setOpacity(1.); } - if (!_smallGroupPart) { + if (!_smallGroupPart && !mediaEditor) { drawCornerStatus(p, context, geometry.topLeft()); } } @@ -2093,8 +2101,7 @@ bool Gif::needsBubble() const { return false; } const auto item = _parent->data(); - return item->repliesAreComments() - || item->externalReply() + return _parent->hasCommentsButton() || item->viaBot() || !item->emptyText() || _parent->displayReply() @@ -2711,6 +2718,7 @@ void Gif::ensureTranscribeButton() const { && (!media || !media->ttlSeconds()) && !_parent->data()->isScheduled() && !_parent->data()->isAdminLogEntry() + && (_parent->context() != Context::MediaEditor) && (_data->session().premium() || _data->session().api().transcribes().trialsSupport())) { if (!_transcribe) { diff --git a/Telegram/SourceFiles/history/view/media/history_view_location.cpp b/Telegram/SourceFiles/history/view/media/history_view_location.cpp index d8cd48fced..06f0bb12f9 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_location.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_location.cpp @@ -426,7 +426,10 @@ void Location::draw(Painter &p, const PaintContext &context) const { ensureMediaCreated(); validateImageCache(rthumb.size(), rounding); - const auto paintPrevious = _live && !_live->previous.isNull(); + const auto mediaEditor = (_parent->context() == Context::MediaEditor); + const auto paintPrevious = !mediaEditor + && _live + && !_live->previous.isNull(); auto opacity = _imageCache.isNull() ? 0. : 1.; if (paintPrevious) { opacity = _live->crossfade.value(opacity); @@ -536,7 +539,12 @@ void Location::draw(Painter &p, const PaintContext &context) const { painty += thumbh; if (_live) { painth -= thumbh; - paintLiveRemaining(p, context, { paintx, painty, paintw, painth }); + if (!mediaEditor) { + paintLiveRemaining( + p, + context, + { paintx, painty, paintw, painth }); + } } paintText(); if (!_live && !hasText && _parent->media() == this) { @@ -787,8 +795,7 @@ bool Location::needsBubble() const { return true; } const auto item = _parent->data(); - return item->repliesAreComments() - || item->externalReply() + return _parent->hasCommentsButton() || item->viaBot() || _parent->displayReply() || _parent->displayForwardedFrom() diff --git a/Telegram/SourceFiles/history/view/media/history_view_media.cpp b/Telegram/SourceFiles/history/view/media/history_view_media.cpp index 03e0d99743..e854dea7ff 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_media.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_media.cpp @@ -653,9 +653,7 @@ HistoryItem *Media::itemForText() const { } bool Media::isRoundedInBubbleBottom() const { - return isBubbleBottom() - && !_parent->data()->repliesAreComments() - && !_parent->data()->externalReply(); + return isBubbleBottom() && !_parent->hasCommentsButton(); } Images::CornersMaskRef MediaRoundingMask( diff --git a/Telegram/SourceFiles/history/view/media/history_view_media_generic.cpp b/Telegram/SourceFiles/history/view/media/history_view_media_generic.cpp index 0715082e5e..4e8ef79522 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_media_generic.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_media_generic.cpp @@ -152,7 +152,7 @@ void MediaGeneric::draw(Painter &p, const PaintContext &context) const { p.setPen(Qt::NoPen); p.setBrush(context.st->msgServiceBg()); const auto rect = QRect(0, 0, width(), height()); - if (parent()->data()->inlineReplyKeyboard()) { + if (parent()->inlineReplyKeyboard()) { const auto half = rect.height() / 2; p.setClipRect(rect - QMargins(0, 0, 0, half)); p.drawRoundedRect(rect, radius, radius); diff --git a/Telegram/SourceFiles/history/view/media/history_view_media_grouped.cpp b/Telegram/SourceFiles/history/view/media/history_view_media_grouped.cpp index c7878ff937..6d48c99a34 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_media_grouped.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_media_grouped.cpp @@ -492,7 +492,7 @@ void GroupedMedia::draw(Painter &p, const PaintContext &context) const { history()->owner().registerHeavyViewPart(_parent); } - if (tagged) { + if (tagged && (_parent->context() != Context::MediaEditor)) { tagged->drawSpoilerTag(p, fullRect, context, [&] { return generateSpoilerTagBackground(fullRect); }); @@ -946,8 +946,7 @@ bool GroupedMedia::computeNeedBubble() const { return true; } if (const auto item = _parent->data()) { - if (item->repliesAreComments() - || item->externalReply() + if (_parent->hasCommentsButton() || item->viaBot() || _parent->displayReply() || _parent->displayForwardedFrom() diff --git a/Telegram/SourceFiles/history/view/media/history_view_photo.cpp b/Telegram/SourceFiles/history/view/media/history_view_photo.cpp index 79e85568f5..63d06d9947 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_photo.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_photo.cpp @@ -319,6 +319,7 @@ QSize Photo::countCurrentSize(int newWidth) { const auto showEnlarge = (_parent->media() != this) && _parent->data()->media() && !_parent->data()->isSponsored() + && (_parent->context() != Context::MediaEditor) && _parent->data()->media()->webpage() && _parent->data()->media()->webpage()->suggestEnlargePhoto() && (newWidth >= enlargeOuter) @@ -351,13 +352,14 @@ void Photo::draw(Painter &p, const PaintContext &context) const { const auto preview = _data->extendedMediaPreview(); const auto loaded = preview || _dataMedia->loaded(); const auto displayLoading = !preview && _data->displayLoading(); + const auto mediaEditor = (_parent->context() == Context::MediaEditor); const auto hostedInstantView = IsHostedInstantViewMedia(_parent); auto inWebPage = (_parent->media() != this); auto paintx = 0, painty = 0, paintw = width(), painth = height(); auto bubble = _parent->hasBubble(); - if (displayLoading) { + if (displayLoading && !mediaEditor) { ensureAnimation(); if (!_animation->radial.animating()) { _animation->radial.start(_dataMedia->progress()); @@ -378,7 +380,7 @@ void Photo::draw(Painter &p, const PaintContext &context) const { Assert(rounding.has_value()); fillImageShadow(p, rthumb, *rounding, context); } - const auto revealed = _spoiler + const auto revealed = (_spoiler && !mediaEditor) ? _spoiler->revealAnimation.value(_spoiler->revealed ? 1. : 0.) : 1.; if (revealed < 1.) { @@ -404,6 +406,7 @@ void Photo::draw(Painter &p, const PaintContext &context) const { && _spoiler && !_spoiler->revealed; const auto paintInCenter = !_sensitiveSpoiler + && !mediaEditor && (radial || (!loaded && !_data->loading()) || ttlCovered); if (paintInCenter || showEnlarge) { p.setPen(Qt::NoPen); @@ -455,12 +458,12 @@ void Photo::draw(Painter &p, const PaintContext &context) const { sti->historyFileThumbRadialFg, context.paused); } - } else if (_sensitiveSpoiler || preview) { + } else if ((_sensitiveSpoiler || preview) && !mediaEditor) { drawSpoilerTag(p, rthumb, context, [&] { return spoilerTagBackground(); }); } - if (ttlCovered) { + if (ttlCovered && !mediaEditor) { PaintTtlLabel( p, QPoint(paintx, painty), @@ -828,8 +831,9 @@ void Photo::drawGrouped( const auto preview = _data->extendedMediaPreview(); const auto loaded = preview || _dataMedia->loaded(); const auto displayLoading = !preview && _data->displayLoading(); + const auto mediaEditor = (_parent->context() == Context::MediaEditor); - if (displayLoading) { + if (displayLoading && !mediaEditor) { ensureAnimation(); if (!_animation->radial.animating()) { _animation->radial.start(_dataMedia->progress()); @@ -837,7 +841,7 @@ void Photo::drawGrouped( } const auto radial = isRadialAnimation(); - const auto revealed = _spoiler + const auto revealed = (_spoiler && !mediaEditor) ? _spoiler->revealAnimation.value(_spoiler->revealed ? 1. : 0.) : 1.; if (revealed < 1.) { @@ -870,6 +874,7 @@ void Photo::drawGrouped( && _spoiler && !_spoiler->revealed; const auto paintInCenter = !_sensitiveSpoiler + && !mediaEditor && (radial || (!loaded && !_data->loading()) || _data->waitingForAlbum() @@ -940,7 +945,7 @@ void Photo::drawGrouped( context.paused); } } - if (ttlCovered) { + if (ttlCovered && !mediaEditor) { PaintTtlLabel( p, geometry.topLeft(), @@ -1199,8 +1204,7 @@ bool Photo::needsBubble() const { } const auto item = _parent->data(); return !item->isService() - && (item->repliesAreComments() - || item->externalReply() + && (_parent->hasCommentsButton() || item->viaBot() || !item->emptyText() || _parent->displayReply() diff --git a/Telegram/SourceFiles/history/view/media/history_view_poll.cpp b/Telegram/SourceFiles/history/view/media/history_view_poll.cpp index 0f4eab1071..8e2f8e2d3b 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_poll.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_poll.cpp @@ -854,11 +854,13 @@ auto Poll::Footer::computeLayout(int innerWidth) const -> Layout { ? 0 : st::historyPollTotalVotesSkip; - const auto timerText = closeTimerText(); + const auto mediaEditor + = (_owner->_parent->context() == Context::MediaEditor); + const auto timerText = mediaEditor ? QString() : closeTimerText(); const auto hasTimer = !timerText.isEmpty(); const auto lineHeight = st::msgDateFont->height; - if (result.compact) { + if (result.compact || mediaEditor) { result.kind = Layout::Kind::PassiveLabel; if (hasTimer) { if (timerFooterMultiline(innerWidth)) { @@ -2240,7 +2242,7 @@ QSize Poll::countOptimalSize() { } bool Poll::showVotes() const { - if (_adminShowResults) { + if (_adminShowResults || (_parent->context() == Context::MediaEditor)) { return true; } if (_flags & PollData::Flag::HideResultsUntilClose) { @@ -2360,7 +2362,8 @@ bool Poll::inlineFooter() const { } bool Poll::canAddOption() const { - return (_flags & PollData::Flag::OpenAnswers) + return (_parent->context() != Context::MediaEditor) + && (_flags & PollData::Flag::OpenAnswers) && !(_flags & PollData::Flag::Closed) && !_parent->data()->Has() && (int(_poll->answers.size()) @@ -3549,7 +3552,7 @@ void Poll::Header::paintShowSolution( const PaintContext &context) const { const auto shown = _solutionButtonAnimation.value( _solutionButtonVisible ? 1. : 0.); - if (!shown) { + if (!shown || (_owner->_parent->context() == Context::MediaEditor)) { return; } if (!_showSolutionLink) { diff --git a/Telegram/SourceFiles/history/view/media/history_view_service_box.cpp b/Telegram/SourceFiles/history/view/media/history_view_service_box.cpp index e2e657205a..03f15eec96 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_service_box.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_service_box.cpp @@ -95,13 +95,17 @@ ServiceBox::ServiceBox( + st::giftBoxReleasedByMargin.bottom() + st::msgServiceGiftBoxTitlePadding.bottom())) + _subtitle.countHeight(_maxWidth) - + (!_content->button() + + ((!_content->button() + || (parent->context() == Context::MediaEditor)) ? 0 : (_content->buttonSkip() + st::msgServiceGiftBoxButtonHeight)) + st::msgServiceGiftBoxButtonMargins.bottom())) , _innerSize(_size - QSize(0, st::msgServiceGiftBoxTopSkip)) { InitElementTextPart(_parent, _subtitle); - if (auto text = _content->button()) { + auto text = (_parent->context() == Context::MediaEditor) + ? rpl::producer() + : _content->button(); + if (text) { _button.repaint = [=] { repaint(); }; std::move(text) | rpl::on_next([=](QString value) { _button.text.setText(st::semiboldTextStyle, value); @@ -186,7 +190,7 @@ void ServiceBox::draw(Painter &p, const PaintContext &context) const { p.setBrush(context.st->msgServiceBg()); const auto radius = st::msgServiceGiftBoxRadius; - if (_parent->data()->inlineReplyKeyboard()) { + if (_parent->inlineReplyKeyboard()) { const auto r = Rect(_innerSize); const auto half = r.height() / 2; p.setClipRect(r - QMargins(0, 0, 0, half)); diff --git a/Telegram/SourceFiles/history/view/media/history_view_story_mention.cpp b/Telegram/SourceFiles/history/view/media/history_view_story_mention.cpp index 534a74d588..8bbeabf840 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_story_mention.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_story_mention.cpp @@ -126,20 +126,22 @@ void StoryMention::draw( const auto thumbnail = QRectF(geometry.marginsRemoved( QMargins(padding, padding, padding, padding))); - const auto added = 0.5 * (_unread + const auto unread = _unread + && (_parent->context() != Context::MediaEditor); + const auto added = 0.5 * (unread ? st::storyMentionUnreadSkipTwice : st::storyMentionReadSkipTwice); const auto outline = thumbnail.marginsAdded( QMarginsF(added, added, added, added)); - if (_unread && _paletteVersion != style::PaletteVersion()) { + if (unread && _paletteVersion != style::PaletteVersion()) { _paletteVersion = style::PaletteVersion(); _unreadBrush = QBrush(Ui::UnreadStoryOutlineGradient(outline)); } auto readColor = context.st->msgServiceFg()->c; readColor.setAlphaF(std::min(1. * readColor.alphaF(), kReadOutlineAlpha)); p.setPen(QPen( - _unread ? _unreadBrush : QBrush(readColor), - 0.5 * (_unread + unread ? _unreadBrush : QBrush(readColor), + 0.5 * (unread ? st::storyMentionUnreadStrokeTwice : st::storyMentionReadStrokeTwice))); p.setBrush(Qt::NoBrush); diff --git a/Telegram/SourceFiles/history/view/media/history_view_theme_document.cpp b/Telegram/SourceFiles/history/view/media/history_view_theme_document.cpp index 12df63ef02..65f8f230bd 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_theme_document.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_theme_document.cpp @@ -268,7 +268,8 @@ void ThemeDocument::draw(Painter &p, const PaintContext &context) const { p.setPen(st->msgDateImgFg()); p.drawTextLeft(statusX, statusY, width(), _statusText, statusW - 2 * st::msgDateImgPadding.x()); } - if (radial || (!loaded && !_data->loading())) { + if ((radial || (!loaded && !_data->loading())) + && (_parent->context() != Context::MediaEditor)) { const auto radialOpacity = (radial && loaded && !_data->uploading()) ? _animation->radial.opacity() : 1.; diff --git a/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp b/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp index 79d6434b4e..3490f92e9d 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp @@ -171,7 +171,8 @@ QSize TodoList::countOptimalSize() { } bool TodoList::canComplete() const { - return (_parent->data()->out() + return (_parent->context() != Context::MediaEditor) + && (_parent->data()->out() || _parent->history()->peer->isSelf() || _todolist->othersCanComplete()) && _parent->data()->isRegular() diff --git a/Telegram/SourceFiles/history/view/media/history_view_unique_gift.cpp b/Telegram/SourceFiles/history/view/media/history_view_unique_gift.cpp index 81d48610fd..bb95362263 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_unique_gift.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_unique_gift.cpp @@ -569,7 +569,8 @@ auto GenerateUniqueGiftMedia( std::move(image))); } - if (descriptor.skipViewAction) { + if (descriptor.skipViewAction + || (parent->context() == Context::MediaEditor)) { push(std::make_unique( QSize(0, st::chatUniqueButtonPadding.bottom()), nullptr)); @@ -825,7 +826,8 @@ auto AuctionBg( p.setClipping(false); }*/ - if (state->particles) { + if (state->particles + && (view->context() != Context::MediaEditor)) { p.setClipRect(full); state->particles->paint(p, full, context.now, context.paused); p.setClipping(false); @@ -834,7 +836,8 @@ auto AuctionBg( const auto now = base::unixtime::now(); const auto startsIn = std::max(startDate - now, 0); const auto left = std::max(endDate - now, 0); - if (startsIn > 0 || left > 0) { + if ((startsIn > 0 || left > 0) + && (view->context() != Context::MediaEditor)) { if (!state->timer) { state->timer = std::make_unique([=] { view->repaint(); diff --git a/Telegram/SourceFiles/history/view/media/history_view_web_page.cpp b/Telegram/SourceFiles/history/view/media/history_view_web_page.cpp index 882164a3f7..f665d67542 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_web_page.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_web_page.cpp @@ -415,8 +415,9 @@ QSize WebPage::countOptimalSize() { || (sponsored && !sponsored->hasMedia && _data->photo)); // Detect _openButtonWidth before counting paddings. + const auto mediaEditor = (_parent->context() == Context::MediaEditor); _openButton = Ui::Text::String(); - if (HasButton(_data)) { + if (!mediaEditor && HasButton(_data)) { const auto context = Core::TextContext({ .session = &_data->session(), .customEmojiLoopLimit = 1, @@ -426,7 +427,9 @@ QSize WebPage::countOptimalSize() { PageToPhrase(_data), kMarkupTextOptions, context); - } else if (sponsored && !sponsored->buttonText.isEmpty()) { + } else if (!mediaEditor + && sponsored + && !sponsored->buttonText.isEmpty()) { auto phrase = TextWithEntities{ tr::upper(sponsored->buttonText) }; if (!sponsored->isLinkInternal) { phrase.append(st::historyExternalLinkIcon); @@ -739,7 +742,9 @@ QSize WebPage::countOptimalSize() { accumulate_max(maxWidth, maxMediaWidth); minHeight += _attach->minHeight() - rect::m::sum::v(bubble); } - if (_data->type == WebPageType::Video && _data->duration) { + if (_data->type == WebPageType::Video + && _data->duration + && !mediaEditor) { _duration = Ui::FormatDurationText(_data->duration); _durationWidth = st::msgDateFont->width(_duration); } @@ -1053,7 +1058,9 @@ void WebPage::draw(Painter &p, const PaintContext &context) const { *backgroundEmojiCache, backgroundEmojiData->firstGiftFrame); } - } else if (factcheck && factcheck->expandable) { + } else if (factcheck + && factcheck->expandable + && (_parent->context() != Context::MediaEditor)) { const auto &icon = factcheck->expanded ? _st.collapse : _st.expand; const auto &position = factcheck->expanded ? _st.collapsePosition @@ -1336,7 +1343,8 @@ void WebPage::draw(Painter &p, const PaintContext &context) const { && _collage.empty() && _data->photo && !_data->document) { - if (_attach->isReadyForOpen()) { + if (_attach->isReadyForOpen() + && (_parent->context() != Context::MediaEditor)) { if (_data->siteName == u"YouTube"_q) { st->youtubeIcon().paint( p, @@ -1431,7 +1439,9 @@ WebPage::FactcheckData *WebPage::factcheckData() const { } WebPage::HintData *WebPage::hintData() const { - if (const auto sponsored = sponsoredData()) { + if (_parent->context() == Context::MediaEditor) { + return nullptr; + } else if (const auto sponsored = sponsoredData()) { return sponsored->hint.link ? &sponsored->hint : nullptr; } else if (const auto factcheck = factcheckData()) { return factcheck->hint.link ? &factcheck->hint : nullptr; diff --git a/Telegram/SourceFiles/media/audio/media_audio.cpp b/Telegram/SourceFiles/media/audio/media_audio.cpp index c0de2bb26c..05a2a13d95 100644 --- a/Telegram/SourceFiles/media/audio/media_audio.cpp +++ b/Telegram/SourceFiles/media/audio/media_audio.cpp @@ -321,7 +321,7 @@ void Mixer::Track::reattach(AudioMsgId::Type type) { if (!IsStopped(state.state) && (state.state != State::PausedAtEnd) && !state.waitingForData) { - alSourcef(stream.source, AL_GAIN, ComputeVolume(type)); + alSourcef(stream.source, AL_GAIN, ComputeVolume(type) * volume); alSourcePlay(stream.source); if (IsPaused(state.state)) { // We must always start the source if we want the AL_SAMPLE_OFFSET to be applied. @@ -424,6 +424,7 @@ int Mixer::Track::getNotQueuedBufferIndex() { void Mixer::Track::setExternalData( std::unique_ptr data) { nextSpeed = speed = data ? data->speed : 1.; + volume = data ? data->volume : 1.; externalData = std::move(data); } @@ -759,6 +760,26 @@ void Mixer::setSpeedFromExternal(const AudioMsgId &audioId, float64 speed) { } } +// Thread: Main. Locks: AudioMutex. +void Mixer::setVolumeFromExternal( + const AudioMsgId &audioId, + float64 volume) { + QMutexLocker lock(&AudioMutex); + const auto type = audioId.type(); + const auto track = trackForType(type); + if (!track || track->state.id != audioId || track->volume == volume) { + return; + } + track->volume = volume; + if (track->isStreamCreated() && track->state.state == State::Playing) { + alSourcef( + track->stream.source, + AL_GAIN, + ComputeVolume(type) * volume); + checkCurrentALError(type); + } +} + Streaming::TimePoint Mixer::getExternalSyncTimePoint( const AudioMsgId &audio) const { Expects(audio.externalPlayId() != 0); @@ -897,7 +918,10 @@ void Mixer::resume(const AudioMsgId &audio, bool fast) { return; } - alSourcef(track->stream.source, AL_GAIN, ComputeVolume(type)); + alSourcef( + track->stream.source, + AL_GAIN, + ComputeVolume(type) * track->volume); if (!checkCurrentALError(type)) return; if (state == AL_STOPPED) { @@ -1147,7 +1171,12 @@ void Fader::onTimer() { auto track = mixer()->trackForType(type, index); if (IsStopped(track->state.state) || track->state.state == State::Paused || !track->isStreamCreated()) return; - auto emitSignals = updateOnePlayback(track, hasPlaying, hasFading, volumeMultiplier, suppressGainChanged); + auto emitSignals = updateOnePlayback( + track, + hasPlaying, + hasFading, + volumeMultiplier * track->volume, + suppressGainChanged); if (emitSignals & EmitError) error(track->state.id); if (emitSignals & EmitStopped) audioStopped(track->state.id); if (emitSignals & EmitPositionUpdated) playPositionUpdated(track->state.id); diff --git a/Telegram/SourceFiles/media/audio/media_audio.h b/Telegram/SourceFiles/media/audio/media_audio.h index dd1f43f8cd..919cceace5 100644 --- a/Telegram/SourceFiles/media/audio/media_audio.h +++ b/Telegram/SourceFiles/media/audio/media_audio.h @@ -157,6 +157,7 @@ public: // Thread: Main. Locks: AudioMutex. void setSpeedFromExternal(const AudioMsgId &audioId, float64 speed); + void setVolumeFromExternal(const AudioMsgId &audioId, float64 volume); Streaming::TimePoint getExternalSyncTimePoint( const AudioMsgId &audio) const; @@ -247,6 +248,7 @@ private: bool loading = false; bool loaded = false; bool waitingForBuffer = false; + float64 volume = 1.; // Speed dependent values. float64 speed = 1.; diff --git a/Telegram/SourceFiles/media/audio/media_audio_loaders.cpp b/Telegram/SourceFiles/media/audio/media_audio_loaders.cpp index 5cff8167e5..a3a1104b13 100644 --- a/Telegram/SourceFiles/media/audio/media_audio_loaders.cpp +++ b/Telegram/SourceFiles/media/audio/media_audio_loaders.cpp @@ -340,7 +340,10 @@ void Loaders::loadData(AudioMsgId audio, crl::time positionMs) { return; } - alSourcef(track->stream.source, AL_GAIN, ComputeVolume(type)); + alSourcef( + track->stream.source, + AL_GAIN, + ComputeVolume(type) * track->volume); if (!internal::audioCheckError()) { setStoppedState(track, State::StoppedAtError); emitError(type); diff --git a/Telegram/SourceFiles/media/audio/media_child_ffmpeg_loader.h b/Telegram/SourceFiles/media/audio/media_child_ffmpeg_loader.h index a8fa393200..65354d967e 100644 --- a/Telegram/SourceFiles/media/audio/media_child_ffmpeg_loader.h +++ b/Telegram/SourceFiles/media/audio/media_child_ffmpeg_loader.h @@ -17,6 +17,7 @@ struct ExternalSoundData { FFmpeg::FramePointer frame; crl::time duration = 0; float64 speed = 1.; // 0.5 <= speed <= 2. + float64 volume = 1.; // 0. <= volume <= 1. }; struct ExternalSoundPart { diff --git a/Telegram/SourceFiles/media/media_audio_waveform.cpp b/Telegram/SourceFiles/media/media_audio_waveform.cpp new file mode 100644 index 0000000000..5d08b59f53 --- /dev/null +++ b/Telegram/SourceFiles/media/media_audio_waveform.cpp @@ -0,0 +1,258 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "media/media_audio_waveform.h" + +#include "ffmpeg/ffmpeg_bytes_io_wrap.h" +#include "ffmpeg/ffmpeg_utility.h" + +namespace Media::Audio { +namespace { + +using namespace FFmpeg; + +constexpr auto kMinimalPeak = 2500; +constexpr auto kMaxPeak = int64(std::numeric_limits::max()); +constexpr auto kPeakAverageRatio = 1.8; +constexpr auto kFineSpansPerSecond = 50; +constexpr auto kMaxFineSpans = 100'000; + +using SamplesCallback = Fn; + +class Decoder final { +public: + [[nodiscard]] bool open(const QString &path, const QByteArray &bytes); + [[nodiscard]] bool read( + const SamplesCallback &callback, + const std::atomic *cancel); + +private: + [[nodiscard]] bool convert( + AVFrame *frame, + const SamplesCallback &callback); + + ReadBytesWrap _bytesWrap; + ReadFileWrap _fileWrap; + FormatPointer _input; + CodecPointer _decoder; + SwresamplePointer _swr; + FramePointer _decoded; + Packet _packet; + std::vector _buffer; + int _streamIndex = -1; + +}; + +bool Decoder::open(const QString &path, const QByteArray &bytes) { + if (!bytes.isEmpty()) { + _bytesWrap = ReadBytesWrap{ + .size = int64(bytes.size()), + .data = reinterpret_cast(bytes.constData()), + }; + _input = MakeFormatPointer( + &_bytesWrap, + &ReadBytesWrap::Read, + nullptr, + &ReadBytesWrap::Seek); + } else { + _fileWrap.file.setFileName(path); + if (!_fileWrap.file.open(QIODevice::ReadOnly)) { + return false; + } + _input = MakeFormatPointer( + &_fileWrap, + &ReadFileWrap::Read, + nullptr, + &ReadFileWrap::Seek); + } + if (!_input) { + return false; + } + if (AvErrorWrap(avformat_find_stream_info(_input.get(), nullptr))) { + return false; + } + _streamIndex = av_find_best_stream( + _input.get(), + AVMEDIA_TYPE_AUDIO, + -1, + -1, + nullptr, + 0); + if (_streamIndex < 0) { + return false; + } + _decoder = MakeCodecPointer({ .stream = _input->streams[_streamIndex] }); + if (!_decoder) { + return false; + } + _decoded = MakeFramePointer(); + return _decoded != nullptr; +} + +bool Decoder::convert(AVFrame *frame, const SamplesCallback &callback) { + auto mono = AVChannelLayout AV_CHANNEL_LAYOUT_MONO; + _swr = MakeSwresamplePointer( + &frame->ch_layout, + AVSampleFormat(frame->format), + frame->sample_rate, + &mono, + AV_SAMPLE_FMT_S16, + frame->sample_rate, + &_swr); + if (!_swr) { + return false; + } + const auto upper = int(swr_get_out_samples( + _swr.get(), + frame->nb_samples)); + if (upper <= 0) { + return true; + } + _buffer.resize(upper); + auto out = reinterpret_cast(_buffer.data()); + const auto samples = swr_convert( + _swr.get(), + &out, + upper, + (const uint8_t**)frame->extended_data, + frame->nb_samples); + if (samples < 0) { + return false; + } else if (samples > 0) { + callback(_buffer.data(), samples, frame->sample_rate); + } + return true; +} + +bool Decoder::read( + const SamplesCallback &callback, + const std::atomic *cancel) { + auto eof = false; + while (true) { + if (cancel && cancel->load()) { + return false; + } + const auto got = AvErrorWrap(avcodec_receive_frame( + _decoder.get(), + _decoded.get())); + if (!got) { + if (!convert(_decoded.get(), callback)) { + return false; + } + continue; + } else if (got.code() == AVERROR_EOF) { + return true; + } else if (got.code() != AVERROR(EAGAIN)) { + return false; + } + if (eof) { + const auto sent = AvErrorWrap(avcodec_send_packet( + _decoder.get(), + nullptr)); + if (sent && sent.code() != AVERROR_EOF) { + return false; + } + continue; + } + av_packet_unref(&_packet.fields()); + const auto read = AvErrorWrap(av_read_frame( + _input.get(), + &_packet.fields())); + if (read.code() == AVERROR_EOF) { + eof = true; + continue; + } else if (read) { + return false; + } else if (_packet.fields().stream_index != _streamIndex) { + continue; + } + const auto sent = AvErrorWrap(avcodec_send_packet( + _decoder.get(), + &_packet.fields())); + if (sent && sent.code() != AVERROR_INVALIDDATA) { + return false; + } + } +} + +} // namespace + +Waveform LoadWaveform( + const QString &path, + const QByteArray &bytes, + int count, + std::shared_ptr> cancel) { + auto result = Waveform(); + if (count <= 0) { + return result; + } + auto decoder = Decoder(); + if (!decoder.open(path, bytes)) { + return result; + } + auto fine = std::vector(); + auto current = uint16(0); + auto inSpan = int64(0); + auto spanSize = int64(0); + const auto read = decoder.read([&]( + const int16 *samples, + int available, + int rate) { + if (!spanSize) { + spanSize = std::max(int64(rate / kFineSpansPerSecond), int64(1)); + } + for (const auto value : gsl::make_span(samples, available)) { + accumulate_max(current, uint16(std::abs(int(value)))); + if (++inSpan < spanSize) { + continue; + } else if (fine.size() < kMaxFineSpans) { + fine.push_back(current); + current = 0; + inSpan = 0; + continue; + } + for (auto i = 0; i != kMaxFineSpans / 2; ++i) { + fine[i] = std::max(fine[2 * i], fine[2 * i + 1]); + } + fine.resize(kMaxFineSpans / 2); + spanSize *= 2; + } + }, cancel.get()); + if (cancel && cancel->load()) { + return result; + } else if (inSpan && fine.size() < kMaxFineSpans) { + fine.push_back(current); + } + if (fine.empty() || (!read && fine.size() < kFineSpansPerSecond)) { + return result; + } + result.bars.resize(count); + const auto total = int64(fine.size()); + for (auto i = 0; i != count; ++i) { + const auto from = int64(i) * total / count; + const auto till = std::max(int64(i + 1) * total / count, from + 1); + auto peak = uint16(0); + for (auto j = from; j < till && j < total; ++j) { + accumulate_max(peak, fine[j]); + } + result.bars[i] = peak; + } + auto sum = int64(0); + for (const auto bar : result.bars) { + sum += bar; + } + const auto peak = std::max( + int64(kMinimalPeak), + int64(sum * kPeakAverageRatio / count)); + result.peak = uint16(std::min(peak, kMaxPeak)); + for (auto &bar : result.bars) { + bar = std::min(bar, result.peak); + } + return result; +} + +} // namespace Media::Audio diff --git a/Telegram/SourceFiles/media/media_audio_waveform.h b/Telegram/SourceFiles/media/media_audio_waveform.h new file mode 100644 index 0000000000..7e132e3c3d --- /dev/null +++ b/Telegram/SourceFiles/media/media_audio_waveform.h @@ -0,0 +1,27 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +namespace Media::Audio { + +struct Waveform { + std::vector bars; + uint16 peak = 0; + + [[nodiscard]] bool empty() const { + return bars.empty(); + } +}; + +[[nodiscard]] Waveform LoadWaveform( + const QString &path, + const QByteArray &bytes, + int count, + std::shared_ptr> cancel = nullptr); + +} // namespace Media::Audio diff --git a/Telegram/SourceFiles/media/media_video_canvas.cpp b/Telegram/SourceFiles/media/media_video_canvas.cpp new file mode 100644 index 0000000000..3adebe10fe --- /dev/null +++ b/Telegram/SourceFiles/media/media_video_canvas.cpp @@ -0,0 +1,118 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "media/media_video_canvas.h" + +namespace Media::Encode { +namespace { + +constexpr auto kSampleSide = 240; +constexpr auto kTallRatio = 1.29; +constexpr auto kSampleTop = 0.1; +constexpr auto kSampleBottom = 0.9; + +[[nodiscard]] QColor AdaptColor(const QColor &color) { + const auto hue = float64(color.hsvHueF()); + auto saturation = float64(color.hsvSaturationF()); + auto value = float64(color.valueF()); + value = std::clamp(value - 0.05, 0.15, 0.85); + if (saturation > 0.1 && saturation <= 0.95) { + if (saturation <= 0.5) { + saturation = std::min(saturation + 0.2, 1.); + } else if (saturation > 0.8) { + saturation = std::max(saturation - 0.4, 0.); + } + } + return QColor::fromHsvF(hue, saturation, value); +} + +} // namespace + +CanvasBackground DominantCanvasBackground(const QImage &image) { + if (image.isNull()) { + return {}; + } + const auto sample = image.scaled( + kSampleSide, + kSampleSide, + Qt::KeepAspectRatio, + Qt::SmoothTransformation + ).convertToFormat(QImage::Format_ARGB32); + if (sample.isNull()) { + return {}; + } + const auto x = sample.width() / 2; + const auto top = sample.pixelColor(x, int(sample.height() * kSampleTop)); + const auto bottom = sample.pixelColor( + x, + std::min(int(sample.height() * kSampleBottom), sample.height() - 1)); + return { AdaptColor(top), AdaptColor(bottom) }; +} + +QRect CanvasPlacement(QSize image, QSize canvas) { + if (image.isEmpty() || canvas.isEmpty()) { + return QRect(QPoint(), canvas); + } + auto scale = canvas.width() / float64(image.width()); + if (image.height() / float64(image.width()) > kTallRatio) { + scale = std::max(scale, canvas.height() / float64(image.height())); + } + const auto size = QSize( + std::max(int(base::SafeRound(image.width() * scale)), 1), + std::max(int(base::SafeRound(image.height() * scale)), 1)); + return QRect( + QPoint( + (canvas.width() - size.width()) / 2, + (canvas.height() - size.height()) / 2), + size); +} + +void PaintCanvasBackground( + QPainter &p, + const QRect &rect, + const CanvasBackground &background) { + if (!background.valid()) { + return; + } + auto gradient = QLinearGradient(rect.topLeft(), rect.bottomLeft()); + gradient.setStops({ + { 0., background.top }, + { 1., background.bottom }, + }); + p.fillRect(rect, gradient); +} + +QRect RoundedCanvasRect(const QRectF &rect) { + return QRect( + QPoint( + int(base::SafeRound(rect.x())), + int(base::SafeRound(rect.y()))), + QSize( + std::max(int(base::SafeRound(rect.width())), 1), + std::max(int(base::SafeRound(rect.height())), 1))); +} + +QImage FitOnCanvas( + const QImage &image, + QSize canvas, + const CanvasBackground &background, + QRect placement) { + if (image.isNull() || canvas.isEmpty()) { + return image; + } + if (placement.isEmpty()) { + placement = CanvasPlacement(image.size(), canvas); + } + auto result = QImage(canvas, QImage::Format_ARGB32_Premultiplied); + auto p = QPainter(&result); + PaintCanvasBackground(p, result.rect(), background); + p.setRenderHint(QPainter::SmoothPixmapTransform); + p.drawImage(placement, image); + return result; +} + +} // namespace Media::Encode diff --git a/Telegram/SourceFiles/media/media_video_canvas.h b/Telegram/SourceFiles/media/media_video_canvas.h new file mode 100644 index 0000000000..403bbacdbb --- /dev/null +++ b/Telegram/SourceFiles/media/media_video_canvas.h @@ -0,0 +1,38 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +namespace Media::Encode { + +struct CanvasBackground { + QColor top; + QColor bottom; + + [[nodiscard]] bool valid() const { + return top.isValid() && bottom.isValid(); + } +}; + +[[nodiscard]] CanvasBackground DominantCanvasBackground(const QImage &image); + +[[nodiscard]] QRect CanvasPlacement(QSize image, QSize canvas); + +void PaintCanvasBackground( + QPainter &p, + const QRect &rect, + const CanvasBackground &background); + +[[nodiscard]] QRect RoundedCanvasRect(const QRectF &rect); + +[[nodiscard]] QImage FitOnCanvas( + const QImage &image, + QSize canvas, + const CanvasBackground &background, + QRect placement = QRect()); + +} // namespace Media::Encode diff --git a/Telegram/SourceFiles/media/media_video_encode.cpp b/Telegram/SourceFiles/media/media_video_encode.cpp index 9f546d222a..cbe23f2837 100644 --- a/Telegram/SourceFiles/media/media_video_encode.cpp +++ b/Telegram/SourceFiles/media/media_video_encode.cpp @@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ffmpeg/ffmpeg_frame_generator.h" #include "ffmpeg/ffmpeg_utility.h" #include "lottie/lottie_frame_generator.h" +#include "ui/rect.h" #include #include @@ -45,6 +46,7 @@ constexpr auto kAudioBitratePerChannel = 64'000; constexpr auto kStaleTempTimeout = 24 * 60 * 60; constexpr auto kMp3InMp4MinFrequency = 16'000; +constexpr auto kSkippedFrameSide = 2; // MAX_FRAME_RATE in openh264, a faster source overshoots by the ratio. constexpr auto kRateControlFps = 60.; @@ -66,19 +68,8 @@ constexpr auto kSilentAudioFillMargin = crl::time(1000); return QDir::tempPath() + u"/tdtranscode"_q; } -[[nodiscard]] QString TempFileTemplate(const QString &extension) { - const auto directory = TempDirectory(); - QDir().mkpath(directory); - QFile::setPermissions( - directory, - QFileDevice::ReadUser - | QFileDevice::WriteUser - | QFileDevice::ExeUser); - return directory + u"/XXXXXX."_q + extension; -} - [[nodiscard]] QString TempFileTemplate() { - return TempFileTemplate(u"mp4"_q); + return Media::Encode::TempFileTemplate(u"mp4"_q); } [[nodiscard]] int EvenDown(int value) { @@ -459,11 +450,459 @@ struct AudioFifoDeleter { }; using AudioFifoPointer = std::unique_ptr; +struct PacketDeleter { + void operator()(AVPacket *value) { + av_packet_free(&value); + } +}; +using PacketPointer = std::unique_ptr; + +class MusicMixer final { +public: + [[nodiscard]] bool init( + const MusicTrack &track, + const AVChannelLayout &layout, + AVSampleFormat format, + int rate); + [[nodiscard]] bool mixInto(AVFrame *frame, int64 pts); + +private: + [[nodiscard]] bool decodeMore(); + [[nodiscard]] bool decodeStep(); + [[nodiscard]] bool pushDecoded(AVFrame *frame); + [[nodiscard]] bool pushSilence(); + [[nodiscard]] bool skipFifo(int64 samples); + [[nodiscard]] bool seekToStart(); + + ReadBytesWrap _bytesWrap; + ReadFileWrap _fileWrap; + FormatPointer _input; + CodecPointer _decoder; + SwresamplePointer _swr; + AudioFifoPointer _fifo; + FramePointer _decoded; + PacketPointer _packet; + AVChannelLayout _layout = {}; + AVSampleFormat _format = AV_SAMPLE_FMT_FLTP; + int _rate = 0; + int _streamIndex = -1; + crl::time _from = 0; + int64 _start = 0; + int64 _limit = 0; + int64 _consumed = 0; + int64 _skipDecoded = 0; + int64 _segment = 0; + int64 _segmentPushed = 0; + int64 _padding = 0; + float64 _volume = 1.; + bool _loop = false; + bool _eof = false; + bool _flushed = false; + bool _skipSeeded = false; + +}; + +bool MusicMixer::init( + const MusicTrack &track, + const AVChannelLayout &layout, + AVSampleFormat format, + int rate) { + if (track.empty() || rate <= 0 || format != AV_SAMPLE_FMT_FLTP) { + return false; + } + av_channel_layout_copy(&_layout, &layout); + _format = format; + _rate = rate; + _volume = std::clamp(track.volume, 0., 2.); + if (!track.bytes.isEmpty()) { + _bytesWrap = ReadBytesWrap{ + .size = int64(track.bytes.size()), + .data = reinterpret_cast(track.bytes.constData()), + }; + _input = MakeFormatPointer( + &_bytesWrap, + &ReadBytesWrap::Read, + nullptr, + &ReadBytesWrap::Seek); + } else { + _fileWrap.file.setFileName(track.path); + if (!_fileWrap.file.open(QIODevice::ReadOnly)) { + return false; + } + _input = MakeFormatPointer( + &_fileWrap, + &ReadFileWrap::Read, + nullptr, + &ReadFileWrap::Seek); + } + if (!_input) { + return false; + } + const auto probed = AvErrorWrap(avformat_find_stream_info( + _input.get(), + nullptr)); + if (probed) { + LogError(u"avformat_find_stream_info"_q, probed, u"music"_q); + return false; + } + _streamIndex = av_find_best_stream( + _input.get(), + AVMEDIA_TYPE_AUDIO, + -1, + -1, + nullptr, + 0); + if (_streamIndex < 0) { + LogError(u"av_find_best_stream"_q, u"music"_q); + return false; + } + const auto stream = _input->streams[_streamIndex]; + _decoder = MakeCodecPointer({ .stream = stream }); + if (!_decoder) { + return false; + } + _from = std::max(track.from, crl::time(0)); + _loop = track.loop; + if (_from > 0 && !seekToStart()) { + return false; + } + _start = av_rescale(std::max(track.position, crl::time(0)), _rate, 1000); + _segment = (track.till > _from) + ? av_rescale(track.till - _from, _rate, 1000) + : 0; + _limit = _loop ? 0 : _segment; + _skipDecoded = av_rescale(_from, _rate, 1000); + _fifo = AudioFifoPointer(av_audio_fifo_alloc( + _format, + _layout.nb_channels, + _rate)); + _decoded = MakeFramePointer(); + _packet = PacketPointer(av_packet_alloc()); + return _fifo && _decoded && _packet; +} + +bool MusicMixer::pushDecoded(AVFrame *frame) { + if (frame) { + _swr = MakeSwresamplePointer( + &frame->ch_layout, + AVSampleFormat(frame->format), + frame->sample_rate, + &_layout, + _format, + _rate, + &_swr); + if (!_swr) { + return false; + } + if (!_skipSeeded) { + // av_seek_frame lands on a keyframe before the trimmed start. + _skipSeeded = true; + const auto stream = _input->streams[_streamIndex]; + const auto best = frame->best_effort_timestamp; + const auto raw = (best != AV_NOPTS_VALUE) ? best : frame->pts; + if (raw != AV_NOPTS_VALUE) { + const auto at = av_rescale_q( + raw, + stream->time_base, + AVRational{ 1, _rate }); + _skipDecoded = std::max(_skipDecoded - at, int64(0)); + } else { + _skipDecoded = 0; + } + } + } else if (!_swr) { + return true; + } + const auto in = frame ? frame->nb_samples : 0; + const auto upper = int(swr_get_out_samples(_swr.get(), in)); + if (upper <= 0) { + return true; + } + auto converted = MakeFramePointer(); + if (!converted) { + return false; + } + converted->nb_samples = upper; + converted->format = _format; + converted->sample_rate = _rate; + av_channel_layout_copy(&converted->ch_layout, &_layout); + const auto error = AvErrorWrap(av_frame_get_buffer(converted.get(), 0)); + if (error) { + LogError(u"av_frame_get_buffer"_q, error, u"music"_q); + return false; + } + const auto samples = swr_convert( + _swr.get(), + converted->extended_data, + upper, + frame ? const_cast(frame->extended_data) : nullptr, + in); + if (samples < 0) { + LogError(u"swr_convert"_q, AvErrorWrap(samples), u"music"_q); + return false; + } else if (!samples) { + return true; + } + const auto skip = std::min(_skipDecoded, int64(samples)); + _skipDecoded -= skip; + auto count = int64(samples) - skip; + if (_loop && _segment > 0) { + count = std::min(count, _segment - _segmentPushed); + } + if (count > 0) { + const auto bytes = av_get_bytes_per_sample(_format) * skip; + auto planes = std::vector(_layout.nb_channels); + for (auto i = 0; i != _layout.nb_channels; ++i) { + planes[i] = converted->extended_data[i] + bytes; + } + const auto written = av_audio_fifo_write( + _fifo.get(), + planes.data(), + int(count)); + if (written < count) { + LogError(u"av_audio_fifo_write"_q, u"music"_q); + return false; + } + _segmentPushed += count; + } + if (_loop && _segment > 0 && _segmentPushed >= _segment) { + return seekToStart(); + } + return true; +} + +bool MusicMixer::pushSilence() { + const auto count = std::min(_padding, int64(_rate)); + auto silence = MakeFramePointer(); + if (!silence) { + return false; + } + silence->nb_samples = int(count); + silence->format = _format; + silence->sample_rate = _rate; + av_channel_layout_copy(&silence->ch_layout, &_layout); + const auto error = AvErrorWrap(av_frame_get_buffer(silence.get(), 0)); + if (error) { + LogError(u"av_frame_get_buffer"_q, error, u"music"_q); + return false; + } + av_samples_set_silence( + silence->extended_data, + 0, + int(count), + _layout.nb_channels, + _format); + const auto written = av_audio_fifo_write( + _fifo.get(), + reinterpret_cast(silence->extended_data), + int(count)); + if (written < count) { + LogError(u"av_audio_fifo_write"_q, u"music"_q); + return false; + } + _segmentPushed += count; + _padding -= count; + return (_padding > 0) || seekToStart(); +} + +bool MusicMixer::seekToStart() { + const auto stream = _input->streams[_streamIndex]; + const auto target = av_rescale_q( + _from, + AVRational{ 1, 1000 }, + stream->time_base); + const auto error = AvErrorWrap(av_seek_frame( + _input.get(), + _streamIndex, + target, + AVSEEK_FLAG_BACKWARD)); + if (error) { + LogError(u"av_seek_frame"_q, error, u"music"_q); + return false; + } + avcodec_flush_buffers(_decoder.get()); + _swr = nullptr; + _skipDecoded = av_rescale(_from, _rate, 1000); + _skipSeeded = false; + _segmentPushed = 0; + _padding = 0; + _eof = false; + return true; +} + +bool MusicMixer::skipFifo(int64 samples) { + if (samples <= 0) { + return true; + } + auto scratch = MakeFramePointer(); + if (!scratch) { + return false; + } + scratch->nb_samples = int(samples); + scratch->format = _format; + scratch->sample_rate = _rate; + av_channel_layout_copy(&scratch->ch_layout, &_layout); + const auto error = AvErrorWrap(av_frame_get_buffer(scratch.get(), 0)); + if (error) { + LogError(u"av_frame_get_buffer"_q, error, u"music"_q); + return false; + } + const auto read = av_audio_fifo_read( + _fifo.get(), + reinterpret_cast(scratch->extended_data), + int(samples)); + return (read == samples); +} + +bool MusicMixer::decodeMore() { + if (!_flushed && !decodeStep()) { + _flushed = true; + } + return true; +} + +bool MusicMixer::decodeStep() { + if (_padding > 0) { + return pushSilence(); + } + while (true) { + const auto got = AvErrorWrap(avcodec_receive_frame( + _decoder.get(), + _decoded.get())); + if (!got) { + if (!pushDecoded(_decoded.get())) { + return false; + } + return true; + } else if (got.code() == AVERROR_EOF) { + if (_loop && _segmentPushed > 0) { + _padding = std::max(_segment - _segmentPushed, int64(0)); + if (_padding > 0) { + return pushSilence(); + } else if (seekToStart()) { + continue; + } + } + _flushed = true; + return pushDecoded(nullptr); + } else if (got.code() != AVERROR(EAGAIN)) { + LogError(u"avcodec_receive_frame"_q, got, u"music"_q); + return false; + } + if (_eof) { + const auto sent = AvErrorWrap(avcodec_send_packet( + _decoder.get(), + nullptr)); + if (sent && sent.code() != AVERROR_EOF) { + LogError(u"avcodec_send_packet"_q, sent, u"music"_q); + return false; + } + continue; + } + av_packet_unref(_packet.get()); + const auto read = AvErrorWrap(av_read_frame( + _input.get(), + _packet.get())); + if (read.code() == AVERROR_EOF) { + _eof = true; + continue; + } else if (read) { + LogError(u"av_read_frame"_q, read, u"music"_q); + return false; + } else if (_packet->stream_index != _streamIndex) { + continue; + } + const auto sent = AvErrorWrap(avcodec_send_packet( + _decoder.get(), + _packet.get())); + if (sent && sent.code() != AVERROR_INVALIDDATA) { + LogError(u"avcodec_send_packet"_q, sent, u"music"_q); + return false; + } + } +} + +bool MusicMixer::mixInto(AVFrame *frame, int64 pts) { + const auto count = int64(frame->nb_samples); + const auto end = _limit + ? (_start + _limit) + : std::numeric_limits::max(); + const auto from = std::max(pts, _start); + const auto till = std::min(pts + count, end); + if (from >= till) { + return true; + } + const auto wanted = from - _start; + while (_consumed < wanted) { + const auto available = int64(av_audio_fifo_size(_fifo.get())); + if (available > 0) { + const auto drop = std::min(available, wanted - _consumed); + if (!skipFifo(drop)) { + return false; + } + _consumed += drop; + } else if (_flushed) { + return true; + } else if (!decodeMore()) { + return false; + } + } + const auto needed = till - from; + while (int64(av_audio_fifo_size(_fifo.get())) < needed && !_flushed) { + if (!decodeMore()) { + return false; + } + } + const auto take = std::min( + needed, + int64(av_audio_fifo_size(_fifo.get()))); + if (take <= 0) { + return true; + } + auto mix = MakeFramePointer(); + if (!mix) { + return false; + } + mix->nb_samples = int(take); + mix->format = _format; + mix->sample_rate = _rate; + av_channel_layout_copy(&mix->ch_layout, &_layout); + const auto error = AvErrorWrap(av_frame_get_buffer(mix.get(), 0)); + if (error) { + LogError(u"av_frame_get_buffer"_q, error, u"music"_q); + return false; + } + const auto read = av_audio_fifo_read( + _fifo.get(), + reinterpret_cast(mix->extended_data), + int(take)); + if (read < take) { + LogError(u"av_audio_fifo_read"_q, u"music"_q); + return false; + } + _consumed += take; + const auto offset = int(from - pts); + const auto channels = std::min( + _layout.nb_channels, + frame->ch_layout.nb_channels); + for (auto channel = 0; channel != channels; ++channel) { + const auto src = reinterpret_cast( + mix->extended_data[channel]); + const auto dst = reinterpret_cast( + frame->extended_data[channel]) + offset; + for (auto i = 0; i != int(take); ++i) { + dst[i] = std::clamp(dst[i] + src[i] * float(_volume), -1.f, 1.f); + } + } + return true; +} + class AudioTranscoder final { public: [[nodiscard]] bool init( not_null output, not_null inStream); + void addMusic(const MusicTrack &track); [[nodiscard]] bool process( not_null output, AVPacket *packet); @@ -482,6 +921,7 @@ private: AudioFifoPointer _fifo; FramePointer _decodedFrame; FramePointer _encodeFrame; + std::vector> _music; AVStream *_stream = nullptr; AVRational _inTimeBase = AVRational{ 0, 1 }; int64 _pts = 0; @@ -489,6 +929,17 @@ private: }; +void AudioTranscoder::addMusic(const MusicTrack &track) { + auto music = std::make_unique(); + if (music->init( + track, + _encoder->ch_layout, + _encoder->sample_fmt, + _encoder->sample_rate)) { + _music.push_back(std::move(music)); + } +} + bool AudioTranscoder::init( not_null output, not_null inStream) { @@ -683,6 +1134,11 @@ bool AudioTranscoder::encodeFromFifo( _encoder->ch_layout.nb_channels, _encoder->sample_fmt); } + for (const auto &music : _music) { + if (!music->mixInto(_encodeFrame.get(), _pts)) { + return false; + } + } _encodeFrame->pts = _pts; _pts += take; if (!EncodeAndWrite( @@ -715,7 +1171,8 @@ class SilentAudioWriter final { public: [[nodiscard]] bool init( not_null output, - crl::time limit); + crl::time limit, + const std::vector &music = {}); [[nodiscard]] bool writeUntil( not_null output, crl::time position); @@ -724,6 +1181,7 @@ public: private: CodecPointer _encoder; FramePointer _frame; + std::vector> _music; AVStream *_stream = nullptr; crl::time _limit = 0; int64 _pts = 0; @@ -732,7 +1190,8 @@ private: bool SilentAudioWriter::init( not_null output, - crl::time limit) { + crl::time limit, + const std::vector &music) { _limit = limit; const auto codec = avcodec_find_encoder(AV_CODEC_ID_AAC); if (!codec) { @@ -749,12 +1208,13 @@ bool SilentAudioWriter::init( LogError(u"avcodec_alloc_context3"_q, u"silent"_q); return false; } - av_channel_layout_default(&_encoder->ch_layout, 1); + const auto channels = music.empty() ? 1 : 2; + av_channel_layout_default(&_encoder->ch_layout, channels); _encoder->codec_type = AVMEDIA_TYPE_AUDIO; _encoder->sample_fmt = AV_SAMPLE_FMT_FLTP; _encoder->sample_rate = kAudioFrequency; _encoder->time_base = AVRational{ 1, kAudioFrequency }; - _encoder->bit_rate = kAudioBitratePerChannel; + _encoder->bit_rate = kAudioBitratePerChannel * channels; if (output->oformat->flags & AVFMT_GLOBALHEADER) { _encoder->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; } @@ -784,6 +1244,16 @@ bool SilentAudioWriter::init( LogError(u"av_frame_get_buffer"_q, error, u"silent"_q); return false; } + for (const auto &track : music) { + auto mixer = std::make_unique(); + if (mixer->init( + track, + _encoder->ch_layout, + _encoder->sample_fmt, + _encoder->sample_rate)) { + _music.push_back(std::move(mixer)); + } + } return true; } @@ -807,6 +1277,11 @@ bool SilentAudioWriter::writeUntil( size, _encoder->ch_layout.nb_channels, _encoder->sample_fmt); + for (const auto &music : _music) { + if (!music->mixInto(_frame.get(), _pts)) { + return false; + } + } _frame->pts = _pts; if (!EncodeAndWrite(_encoder.get(), _stream, output, _frame.get())) { return false; @@ -820,10 +1295,19 @@ bool SilentAudioWriter::finish(not_null output) { return EncodeAndWrite(_encoder.get(), _stream, output, nullptr); } +[[nodiscard]] QSize EntityFrameSize(const AnimatedEntity &entity) { + return entity.cutout + ? entity.cutout->frames.size() + : entity.geometry.size().toSize(); +} + class EntityPlayer final { public: explicit EntityPlayer(const AnimatedEntity &entity) - : _size(entity.geometry.size().toSize()) { + : _size(EntityFrameSize(entity)) + , _from(std::max(entity.from, crl::time(0))) + , _trimmed(entity.till > _from) + , _till(_trimmed ? entity.till : std::numeric_limits::max()) { if (_size.isEmpty() || entity.bytes.isEmpty()) { return; } @@ -837,34 +1321,62 @@ public: } [[nodiscard]] QImage frameAt(crl::time position) { + const auto skippedSize = Size(kSkippedFrameSide); while (_generator && (_covered <= position)) { - auto frame = _generator->renderNext(std::move(_storage), _size); + const auto start = _sourcePosition; + const auto skip = (start < _from); + auto frame = _generator->renderNext( + skip ? QImage() : std::move(_storage), + skip ? skippedSize : _size); if (frame.image.isNull()) { - if (std::exchange(_restarted, true)) { - _generator = nullptr; - break; - } - _generator->jumpToStart(); + restart(); continue; } - _restarted = false; - _storage = std::move(_current); - _current = std::move(frame.image); - _covered += std::max(frame.duration, crl::time(1)); - if (frame.last) { - _generator->jumpToStart(); + const auto end = start + std::max(frame.duration, crl::time(1)); + _sourcePosition = end; + if ((end > _from) && (start < _till)) { + if (skip) { + frame.image = _generator->renderCurrent( + std::move(_storage), + _size).image; + } + _shown = true; + _storage = std::move(_current); + _current = std::move(frame.image); + _covered += std::min(end, _till) - std::max(start, _from); + } else if (!skip) { + _storage = std::move(frame.image); + } + if (frame.last && _shown && _trimmed && (end < _till)) { + _covered += _till - end; + } + if (frame.last || (end >= _till)) { + restart(); } } return _current; } private: + void restart() { + if (!std::exchange(_shown, false)) { + _generator = nullptr; + return; + } + _generator->jumpToStart(); + _sourcePosition = 0; + } + std::unique_ptr _generator; QImage _current; QImage _storage; QSize _size; + crl::time _from = 0; + bool _trimmed = false; + crl::time _till = 0; crl::time _covered = 0; - bool _restarted = false; + crl::time _sourcePosition = 0; + bool _shown = false; }; @@ -914,6 +1426,17 @@ private: const auto outVideoStream = video.stream; auto encoder = std::move(video.codec); + auto music = std::optional(); + if (!still.music.empty()) { + music.emplace(); + if (!music->init( + output.get(), + still.duration + kSilentAudioFillMargin, + still.music)) { + return {}; + } + } + auto error = AvErrorWrap(avformat_write_header(output.get(), nullptr)); if (error) { LogError(u"avformat_write_header"_q, error); @@ -928,6 +1451,7 @@ private: ? std::make_unique(*entity) : nullptr); } + auto composites = std::vector(overlay.size()); auto encodeFrame = MakeFramePointer(); if (!encodeFrame) { @@ -964,7 +1488,8 @@ private: const auto &entity = std::get( overlay[index]); const auto frame = player->frameAt(position); - if (frame.isNull()) { + const auto &cutout = entity.cutout; + if (frame.isNull() && !cutout) { continue; } p.save(); @@ -977,7 +1502,15 @@ private: p.scale(-1., 1.); } p.translate(-center); - p.drawImage(entity.geometry, frame); + if (cutout && frame.isNull()) { + p.drawImage(entity.geometry, cutout->picture); + } else if (cutout) { + p.drawImage( + entity.geometry, + ComposeCutout(*cutout, frame, composites[index])); + } else { + p.drawImage(entity.geometry, frame); + } p.restore(); } else { p.drawImage(0, 0, std::get(overlay[index])); @@ -1013,6 +1546,9 @@ private: encodeFrame.get())) { return {}; } + if (music && !music->writeUntil(output.get(), position)) { + return {}; + } if (progress && !progress((i + 1) / float64(framesCount))) { return {}; } @@ -1024,6 +1560,14 @@ private: nullptr)) { return {}; } + if (music) { + const auto until = crl::time( + base::SafeRound(framesCount * 1000. / fps)); + if (!music->writeUntil(output.get(), until) + || !music->finish(output.get())) { + return {}; + } + } error = AvErrorWrap(av_write_trailer(output.get())); if (error) { LogError(u"av_write_trailer"_q, error); @@ -1559,7 +2103,8 @@ struct TranscodeAttempt { } } - auto temp = QTemporaryFile(TempFileTemplate(webm ? u"webm"_q : u"mp4"_q)); + auto temp = QTemporaryFile( + Media::Encode::TempFileTemplate(webm ? u"webm"_q : u"mp4"_q)); if (!temp.open()) { return {}; } @@ -1646,14 +2191,18 @@ struct TranscodeAttempt { if (inAudioStream) { const auto codecId = inAudioStream->codecpar->codec_id; const auto frequency = inAudioStream->codecpar->sample_rate; - const auto copyCompatible = (codecId == AV_CODEC_ID_AAC) - || ((codecId == AV_CODEC_ID_MP3) - && (frequency >= kMp3InMp4MinFrequency)); + const auto copyCompatible = source.music.empty() + && ((codecId == AV_CODEC_ID_AAC) + || ((codecId == AV_CODEC_ID_MP3) + && (frequency >= kMp3InMp4MinFrequency))); if (!copyCompatible) { audioTranscoder.emplace(); if (!audioTranscoder->init(output.get(), inAudioStream)) { return {}; } + for (const auto &track : source.music) { + audioTranscoder->addMusic(track); + } } else { outAudioStream = avformat_new_stream(output.get(), nullptr); if (!outAudioStream) { @@ -1676,9 +2225,11 @@ struct TranscodeAttempt { ? std::min(span + kSilentAudioFillMargin, kMaxSilentAudioFill) : kMaxSilentAudioFill; auto silentAudio = std::optional(); - if (!inAudioStream && source.silentAudio && !webm) { + if (!inAudioStream + && (source.silentAudio || !source.music.empty()) + && !webm) { silentAudio.emplace(); - if (!silentAudio->init(output.get(), silentLimit)) { + if (!silentAudio->init(output.get(), silentLimit, source.music)) { return {}; } } @@ -2121,6 +2672,40 @@ crl::time TranscodedDuration( return std::max(till - from, crl::time(0)); } +QString TempFileTemplate(const QString &extension) { + const auto directory = TempDirectory(); + QDir().mkpath(directory); + QFile::setPermissions( + directory, + QFileDevice::ReadUser + | QFileDevice::WriteUser + | QFileDevice::ExeUser); + return directory + u"/XXXXXX."_q + extension; +} + +// Frame goes under the cut hole; picture alpha trims it to bubble shape. +const QImage &ComposeCutout( + const AnimatedEntity::Cutout &cutout, + const QImage &frame, + QImage &composite) { + if (composite.isNull()) { + composite = cutout.picture.copy(); + composite.setDevicePixelRatio(1.); + } + auto p = QPainter(&composite); + p.setClipRect(cutout.hole); + p.setRenderHint(QPainter::SmoothPixmapTransform); + p.setCompositionMode(QPainter::CompositionMode_Source); + p.drawImage(cutout.hole, cutout.picture, cutout.hole); + p.setCompositionMode(QPainter::CompositionMode_DestinationOut); + p.drawImage(cutout.hole, cutout.mask); + p.setCompositionMode(QPainter::CompositionMode_DestinationOver); + p.drawImage(cutout.frames, frame); + p.setCompositionMode(QPainter::CompositionMode_DestinationIn); + p.drawImage(cutout.hole, cutout.picture, cutout.hole); + return composite; +} + Result Run(Job &&job, Fn progress) { return v::match(job.source, [&](VideoSource &data) { auto result = Result(); diff --git a/Telegram/SourceFiles/media/media_video_encode.h b/Telegram/SourceFiles/media/media_video_encode.h index 654479d8a3..b3628f2795 100644 --- a/Telegram/SourceFiles/media/media_video_encode.h +++ b/Telegram/SourceFiles/media/media_video_encode.h @@ -14,19 +14,51 @@ struct AnimatedEntity { Lottie, Webm, }; + + // Frames show through the hole cut by mask in the picture at geometry. + struct Cutout { + QImage picture; + QImage mask; + QRect hole; + QRect frames; + }; + Kind kind = Kind::Lottie; QByteArray bytes; QRectF geometry; float64 rotation = 0.; bool flipped = false; + std::optional cutout; + + crl::time from = 0; + crl::time till = 0; }; using Layer = std::variant; +// The part [from, till) of the file plays from the result position. +struct MusicTrack { + QString path; + QByteArray bytes; + crl::time position = 0; + crl::time from = 0; + // Zero means the end of the music. + crl::time till = 0; + float64 volume = 1.; + // Repeats the part till the result ends, a shorter music + // is padded with silence to keep the part length. + bool loop = false; + + [[nodiscard]] bool empty() const { + return path.isEmpty() && bytes.isEmpty(); + } +}; + struct StillSource { QImage base; crl::time duration = 0; float64 fps = 30.; + std::vector music; }; struct VideoSource { @@ -60,6 +92,8 @@ struct VideoSource { float64 fpsLimit = 0.; crl::time coverPosition = -1; + + std::vector music; }; struct Job { @@ -145,4 +179,11 @@ struct TranscodeResult { void ClearStaleTempFiles(); +[[nodiscard]] QString TempFileTemplate(const QString &extension); + +[[nodiscard]] const QImage &ComposeCutout( + const AnimatedEntity::Cutout &cutout, + const QImage &frame, + QImage &composite); + } // namespace Media::Encode diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp index 7d89c447fc..36d6b68cd4 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp @@ -135,6 +135,7 @@ void AudioTrack::mixerInit() { data->codec = std::move(_stream.codec); data->duration = _stream.duration; data->speed = _options.speed; + data->volume = _options.volume; Media::Player::mixer()->play( _audioId, @@ -188,6 +189,11 @@ void AudioTrack::setSpeed(float64 speed) { Media::Player::mixer()->setSpeedFromExternal(_audioId, speed); } +void AudioTrack::setVolume(float64 volume) { + _options.volume = volume; + Media::Player::mixer()->setVolumeFromExternal(_audioId, volume); +} + rpl::producer<> AudioTrack::waitingForData() const { return _waitingForData.events(); } diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h index ec6a4ff7ba..1863f3d9f8 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h @@ -33,6 +33,7 @@ public: // Called from the main thread. void setSpeed(float64 speed); + void setVolume(float64 volume); [[nodiscard]] rpl::producer<> waitingForData() const; // Called from the main thread. diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_common.h b/Telegram/SourceFiles/media/streaming/media_streaming_common.h index 567e09fb7a..1495fa982a 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_common.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_common.h @@ -44,6 +44,7 @@ struct PlaybackOptions { crl::time position = 0; crl::time durationOverride = 0; float64 speed = 1.; // Valid values between 0.5 and 2. + float64 volume = 1.; // Valid values between 0. and 1. AudioMsgId audioId; bool syncVideoByAudio = true; bool waitForMarkAsShown = false; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_instance.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_instance.cpp index 4d4149ce35..2fda7c4657 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_instance.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_instance.cpp @@ -176,6 +176,12 @@ void Instance::setSpeed(float64 speed) { _shared->player().setSpeed(speed); } +void Instance::setVolume(float64 volume) { + Expects(_shared != nullptr); + + _shared->player().setVolume(volume); +} + bool Instance::waitingShown() const { Expects(_shared != nullptr); diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_instance.h b/Telegram/SourceFiles/media/streaming/media_streaming_instance.h index 3147794019..190d0ff519 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_instance.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_instance.h @@ -69,6 +69,7 @@ public: [[nodiscard]] float64 speed() const; void setSpeed(float64 speed); + void setVolume(float64 volume); [[nodiscard]] bool waitingShown() const; [[nodiscard]] float64 waitingOpacity() const; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp index 950c0e2f1d..2317a1da25 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp @@ -532,6 +532,7 @@ void Player::fail(Error error) { void Player::play(const PlaybackOptions &options) { Expects(options.speed >= kSpeedMin && options.speed <= kSpeedMax); + Expects(options.volume >= 0. && options.volume <= 1.); // Looping video with audio is not supported for now. Expects(!options.loop || (options.mode != Mode::Both)); @@ -861,6 +862,17 @@ void Player::setSpeed(float64 speed) { } } +void Player::setVolume(float64 volume) { + Expects(volume >= 0. && volume <= 1.); + + if (_options.volume != volume) { + _options.volume = volume; + if (active() && _audio) { + _audio->setVolume(volume); + } + } +} + void Player::setWaitForMarkAsShown(bool wait) { if (_options.waitForMarkAsShown != wait) { _options.waitForMarkAsShown = wait; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_player.h b/Telegram/SourceFiles/media/streaming/media_streaming_player.h index 0b8257dae4..adfd5b5fac 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_player.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_player.h @@ -49,6 +49,7 @@ public: [[nodiscard]] float64 speed() const; void setSpeed(float64 speed); + void setVolume(float64 volume); void setWaitForMarkAsShown(bool wait); [[nodiscard]] bool playing() const; @@ -175,8 +176,8 @@ private: // Immutable while File is active. base::has_weak_ptr _sessionGuard; - // Immutable while File is active except '.speed'. - // '.speed' is changed from the main thread. + // Immutable while File is active except '.speed' and '.volume'. + // '.speed' and '.volume' are changed from the main thread. PlaybackOptions _options; // Belongs to the File thread while File is active. diff --git a/Telegram/SourceFiles/media/view/media_view.style b/Telegram/SourceFiles/media/view/media_view.style index b282428311..8e69f8d125 100644 --- a/Telegram/SourceFiles/media/view/media_view.style +++ b/Telegram/SourceFiles/media/view/media_view.style @@ -665,6 +665,11 @@ storiesEmojiPan: EmojiPan(defaultEmojiPan) { "photo_editor/camera-24x24", storiesComposeWhiteText }}; + audioButtonIcon: icon {{ + "photo_editor/widget_music", + storiesComposeWhiteText + }}; + linkButtonIcon: icon {{ "limits/links", storiesComposeWhiteText }}; menu: storiesPopupMenuWithIcons; menuAttention: storiesMenuWithIconsAttention; expandedSeparator: mediaviewWideMenuSeparator; diff --git a/Telegram/SourceFiles/storage/file_upload.cpp b/Telegram/SourceFiles/storage/file_upload.cpp index 7fea7ec085..a19470a24b 100644 --- a/Telegram/SourceFiles/storage/file_upload.cpp +++ b/Telegram/SourceFiles/storage/file_upload.cpp @@ -466,6 +466,11 @@ void Uploader::runTranscode(FullMsgId itemId) { return !std::get_if(&layer); }), end(fallback.overlay)); + using StillSource = Media::Encode::StillSource; + if (const auto still = std::get_if( + &fallback.source)) { + still->music.clear(); + } result = Media::Encode::Run(std::move(fallback), nullptr); } bytes = std::move(result.bytes); diff --git a/Telegram/SourceFiles/storage/localimageloader.cpp b/Telegram/SourceFiles/storage/localimageloader.cpp index e4bec38fe7..c75607d9e0 100644 --- a/Telegram/SourceFiles/storage/localimageloader.cpp +++ b/Telegram/SourceFiles/storage/localimageloader.cpp @@ -757,15 +757,17 @@ void FileLoadTask::process(ProcessArgs &&args) { Qt::IgnoreAspectRatio, Qt::SmoothTransformation); } + const auto gif = _animationAsGif && still->music.empty(); auto information = std::make_unique< Ui::PreparedFileInformation>(); information->filemime = "video/mp4"; information->media = Ui::PreparedFileInformation::Video{ - .isGifv = _animationAsGif, + .isGifv = gif, .supportsStreaming = true, + .hasAudio = !still->music.empty(), .duration = still->duration, .thumbnail = std::move(preview), - .modifications = { .gif = _animationAsGif }, + .modifications = { .gif = gif }, }; _information = std::move(information); _content = QByteArray(); diff --git a/Telegram/SourceFiles/storage/storage_media_prepare.cpp b/Telegram/SourceFiles/storage/storage_media_prepare.cpp index 2e7eb3c987..11faf2a4e1 100644 --- a/Telegram/SourceFiles/storage/storage_media_prepare.cpp +++ b/Telegram/SourceFiles/storage/storage_media_prepare.cpp @@ -11,6 +11,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/photo_editor_common.h" #include "editor/scene/scene.h" #include "editor/scene/scene_item_sticker.h" +#include "ffmpeg/ffmpeg_bytes_io_wrap.h" +#include "ffmpeg/ffmpeg_utility.h" +#include "media/audio/media_audio.h" #include "platform/platform_file_utilities.h" #include "lang/lang_keys.h" #include "storage/localimageloader.h" @@ -58,6 +61,76 @@ QSize PrepareShownDimensions(const QImage &preview, int sideLimit) { : result; } +[[nodiscard]] Editor::AudioTrack AudioTrackFromSong( + const QString &path, + const QByteArray &content, + PreparedFileInformation::Song &&song) { + if (song.duration <= 0) { + return {}; + } + if (!Ui::ValidateThumbDimensions( + song.cover.width(), + song.cover.height())) { + song.cover = QImage(); + } + return { + .path = path, + .content = content, + .title = std::move(song.title), + .performer = std::move(song.performer), + .cover = std::move(song.cover), + .duration = song.duration, + }; +} + +[[nodiscard]] bool IsAudioFile(const QString &path, const QString &mime) { + return mime.startsWith(u"audio/"_q) + || (!path.isEmpty() + && (Core::DetectNameType(path) == Core::NameType::Audio)); +} + +[[nodiscard]] bool AudioStreamDecodable( + const QString &path, + const QByteArray &content) { + using namespace FFmpeg; + auto bytesWrap = ReadBytesWrap{ + .size = int64(content.size()), + .data = reinterpret_cast(content.constData()), + }; + auto fileWrap = ReadFileWrap(); + auto input = FormatPointer(); + if (!content.isEmpty()) { + input = MakeFormatPointer( + &bytesWrap, + &ReadBytesWrap::Read, + nullptr, + &ReadBytesWrap::Seek); + } else { + fileWrap.file.setFileName(path); + if (!fileWrap.file.open(QIODevice::ReadOnly)) { + return false; + } + input = MakeFormatPointer( + &fileWrap, + &ReadFileWrap::Read, + nullptr, + &ReadFileWrap::Seek); + } + if (!input + || AvErrorWrap(avformat_find_stream_info(input.get(), nullptr))) { + return false; + } + auto decoder = (const AVCodec*)nullptr; + const auto index = av_find_best_stream( + input.get(), + AVMEDIA_TYPE_AUDIO, + -1, + -1, + &decoder, + 0); + return (index >= 0) && (decoder != nullptr); +} + void PrepareDetailsInParallel(PreparedList &result, int previewWidth) { Expects(result.files.size() <= Ui::MaxAlbumItems()); @@ -79,7 +152,8 @@ void PrepareDetailsInParallel(PreparedList &result, int previewWidth) { bool ValidatePhotoEditorMediaDragData( not_null data, - bool withVideo) { + bool composeAnimated, + bool composeSound) { const auto urls = Core::ReadMimeUrls(data); if (urls.size() > 1) { return false; @@ -93,7 +167,8 @@ bool ValidatePhotoEditorMediaDragData( using namespace Core; const auto file = Platform::File::UrlToLocal(url); const auto mime = MimeTypeForFile(QFileInfo(file)).name(); - return (withVideo && FileLoadTask::IsVideoFile(file, mime)) + return (composeAnimated && FileLoadTask::IsVideoFile(file, mime)) + || (composeSound && IsAudioFile(file, mime)) || (FileIsImage(file, mime) && QImageReader(file).canRead()); } } @@ -107,12 +182,16 @@ PhotoEditorMedia ReadPhotoEditorMedia( if (path.isEmpty() && content.size() > Images::kReadBytesLimit) { return {}; } + const auto mime = path.isEmpty() + ? Core::MimeTypeForData(content).name() + : Core::MimeTypeForFile(QFileInfo(path)).name(); + if (IsAudioFile(path, mime)) { + return { .audio = ReadPhotoEditorAudio(path, content) }; + } const auto information = FileLoadTask::ReadMediaInformation( path, content, - path.isEmpty() - ? Core::MimeTypeForData(content).name() - : Core::MimeTypeForFile(QFileInfo(path)).name()); + mime); if (const auto image = std::get_if(&information->media)) { return { .image = std::move(image->data) }; } @@ -127,11 +206,45 @@ PhotoEditorMedia ReadPhotoEditorMedia( .videoPath = path, .videoContent = content, .videoDuration = video->duration, + .videoHasAudio = (video->hasAudio + && AudioStreamDecodable(path, content)), + }; + } + using Song = PreparedFileInformation::Song; + if (const auto song = std::get_if(&information->media)) { + return { + .audio = AudioTrackFromSong(path, content, std::move(*song)), }; } return {}; } +void ReadPhotoEditorMediaAsync( + const QString &path, + const QByteArray &content, + Fn done) { + crl::async([=] { + auto media = ReadPhotoEditorMedia(path, content); + crl::on_main([=, media = std::move(media)]() mutable { + done(std::move(media)); + }); + }); +} + +Editor::AudioTrack ReadPhotoEditorAudio( + const QString &path, + const QByteArray &content) { + if (path.isEmpty() && content.isEmpty()) { + return {}; + } + using Song = PreparedFileInformation::Song; + auto information = Media::Player::PrepareForSending(path, content); + const auto song = std::get_if(&information.media); + return song + ? AudioTrackFromSong(path, content, std::move(*song)) + : Editor::AudioTrack(); +} + bool ValidateEditMediaDragData( not_null data, Ui::AlbumType albumType) { @@ -509,18 +622,21 @@ bool ApplyModifications(PreparedList &list, bool composeAnimated) { file.path = QString(); file.content = QByteArray(); const auto &scene = image->modifications.paint; - if (composeAnimated && scene && scene->hasAnimatedItems()) { + if (composeAnimated && scene && scene->hasAnimatedResult()) { auto job = Editor::ComposeAnimatedJob( image->data, image->modifications); - const auto animated = ranges::any_of( - job.overlay, - [](const Media::Encode::Layer &layer) { - const auto entity - = std::get_if( - &layer); - return entity && !entity->bytes.isEmpty(); - }); + const auto still = std::get_if( + &job.source); + const auto animated = (still && !still->music.empty()) + || ranges::any_of( + job.overlay, + [](const Media::Encode::Layer &layer) { + const auto entity + = std::get_if( + &layer); + return entity && !entity->bytes.isEmpty(); + }); if (animated) { file.animationJob = std::make_shared( std::move(job)); diff --git a/Telegram/SourceFiles/storage/storage_media_prepare.h b/Telegram/SourceFiles/storage/storage_media_prepare.h index efb800c7e1..d051ef46de 100644 --- a/Telegram/SourceFiles/storage/storage_media_prepare.h +++ b/Telegram/SourceFiles/storage/storage_media_prepare.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "core/file_utilities.h" +#include "editor/photo_editor_common.h" namespace tr { template @@ -21,10 +22,6 @@ struct PreparedList; enum class AlbumType; } // namespace Ui -namespace Editor { -struct PhotoModifications; -} // namespace Editor - namespace Storage { enum class MimeDataState { @@ -52,24 +49,34 @@ enum class MimeDataState { [[nodiscard]] MimeDataState ComputeMimeDataState(const QMimeData *data); [[nodiscard]] bool ValidatePhotoEditorMediaDragData( not_null data, - bool withVideo); + bool composeAnimated, + bool composeSound); struct PhotoEditorMedia { QImage image; QString videoPath; QByteArray videoContent; crl::time videoDuration = 0; + bool videoHasAudio = false; + Editor::AudioTrack audio; [[nodiscard]] bool video() const { return !videoPath.isEmpty() || !videoContent.isEmpty(); } [[nodiscard]] explicit operator bool() const { - return !image.isNull(); + return !image.isNull() || !audio.empty(); } }; [[nodiscard]] PhotoEditorMedia ReadPhotoEditorMedia( const QString &path, const QByteArray &content); +void ReadPhotoEditorMediaAsync( + const QString &path, + const QByteArray &content, + Fn done); +[[nodiscard]] Editor::AudioTrack ReadPhotoEditorAudio( + const QString &path, + const QByteArray &content); [[nodiscard]] bool ValidateEditMediaDragData( not_null data, Ui::AlbumType albumType); diff --git a/Telegram/SourceFiles/storage/streamed_file_downloader.cpp b/Telegram/SourceFiles/storage/streamed_file_downloader.cpp index e03d91e969..46e714c05b 100644 --- a/Telegram/SourceFiles/storage/streamed_file_downloader.cpp +++ b/Telegram/SourceFiles/storage/streamed_file_downloader.cpp @@ -27,7 +27,7 @@ StreamedFileDownloader::StreamedFileDownloader( MTP::DcId dcId, Data::FileOrigin origin, Cache::Key cacheKey, - MediaKey fileLocationKey, + std::optional fileLocationKey, std::shared_ptr reader, // For FileLoader diff --git a/Telegram/SourceFiles/storage/streamed_file_downloader.h b/Telegram/SourceFiles/storage/streamed_file_downloader.h index 5b3b7fdee4..25cd818a42 100644 --- a/Telegram/SourceFiles/storage/streamed_file_downloader.h +++ b/Telegram/SourceFiles/storage/streamed_file_downloader.h @@ -29,7 +29,7 @@ public: MTP::DcId dcId, Data::FileOrigin origin, Cache::Key cacheKey, - MediaKey fileLocationKey, + std::optional fileLocationKey, std::shared_ptr reader, // For FileLoader @@ -60,7 +60,7 @@ private: uint64 _objectId = 0; Data::FileOrigin _origin; Cache::Key _cacheKey; - MediaKey _fileLocationKey; + std::optional _fileLocationKey; std::shared_ptr _reader; std::vector _partIsSaved; // vector :D diff --git a/Telegram/SourceFiles/test/test_widgets.cpp b/Telegram/SourceFiles/test/test_widgets.cpp index 2368916703..3613f5dc2d 100644 --- a/Telegram/SourceFiles/test/test_widgets.cpp +++ b/Telegram/SourceFiles/test/test_widgets.cpp @@ -281,7 +281,8 @@ void Drag( not_null widget, QPoint from, QPoint to, - int steps) { + int steps, + Qt::KeyboardModifiers modifiers) { const auto alive = base::make_weak(widget); const auto makeEvent = [&](QEvent::Type type, QPoint local, auto button) { return QMouseEvent( @@ -292,7 +293,7 @@ void Drag( (type == QEvent::MouseButtonRelease) ? Qt::NoButton : Qt::LeftButton, - Qt::NoModifier); + modifiers); }; auto press = makeEvent(QEvent::MouseButtonPress, from, Qt::LeftButton); if (!DeliverAndSettle(alive, press)) { diff --git a/Telegram/SourceFiles/test/test_widgets.h b/Telegram/SourceFiles/test/test_widgets.h index 0128fa87a7..144a7474cb 100644 --- a/Telegram/SourceFiles/test/test_widgets.h +++ b/Telegram/SourceFiles/test/test_widgets.h @@ -156,7 +156,8 @@ void Drag( not_null widget, QPoint from, QPoint to, - int steps = 8); + int steps = 8, + Qt::KeyboardModifiers modifiers = Qt::NoModifier); // Synthesizes a wheel event at the widget center by default. |angleDelta| // uses Qt's native eighths-of-a-degree convention (120 is one wheel step). diff --git a/Telegram/SourceFiles/ui/chat/attach/attach_album_thumbnail.cpp b/Telegram/SourceFiles/ui/chat/attach/attach_album_thumbnail.cpp index ace810de6b..e36a3f249d 100644 --- a/Telegram/SourceFiles/ui/chat/attach/attach_album_thumbnail.cpp +++ b/Telegram/SourceFiles/ui/chat/attach/attach_album_thumbnail.cpp @@ -43,10 +43,12 @@ AlbumThumbnail::AlbumThumbnail( , _fullPreview(file.videoCover ? file.videoCover->preview : file.preview) , _shrinkSize(int(std::ceil(st::roundRadiusLarge / 1.4))) , _isPhoto(file.type == PreparedFile::Type::Photo) -, _isVideo(file.type == PreparedFile::Type::Video) +, _isVideo((file.type == PreparedFile::Type::Video) + || file.hasAudioEditScene()) , _canEditVideo(file.canEditVideo()) , _canShowHighQualityBadge(file.canUseHighQualityPhoto()) -, _canShowAnimatedBadge(file.hasAnimatedEditScene()) +, _canShowAnimatedBadge(file.hasAnimatedEditScene() + && !file.hasAudioEditScene()) , _videoQuality(file.videoQuality()) , _ttlSeconds(file.ttlSeconds) , _isCompressedSticker(Core::IsMimeSticker(file.information->filemime)) diff --git a/Telegram/SourceFiles/ui/chat/attach/attach_prepare.cpp b/Telegram/SourceFiles/ui/chat/attach/attach_prepare.cpp index 4ecb720147..f16d907a13 100644 --- a/Telegram/SourceFiles/ui/chat/attach/attach_prepare.cpp +++ b/Telegram/SourceFiles/ui/chat/attach/attach_prepare.cpp @@ -152,6 +152,14 @@ void PaintMediaBadge( : AlbumType::None; } +[[nodiscard]] bool SendsAnimationAsGif(const PreparedFile &file) { + const auto job = file.animationJob.get(); + const auto still = job + ? std::get_if(&job->source) + : nullptr; + return job && (!still || still->music.empty()); +} + [[nodiscard]] std::vector GroupRanges( const std::vector &files, SendFilesWay way, @@ -166,7 +174,7 @@ void PaintMediaBadge( auto from = 0; auto groupType = AlbumType::None; for (auto i = 0; i != int(files.size()); ++i) { - const auto fileGroupType = (files[i].animationJob + const auto fileGroupType = (SendsAnimationAsGif(files[i]) || files[i].sendsVideoAsGif()) ? AlbumType::None : GroupTypeForFile( @@ -281,7 +289,16 @@ bool PreparedFile::hasAnimatedEditScene() const { : nullptr; return image && image->modifications.paint - && image->modifications.paint->hasAnimatedItems(); + && image->modifications.paint->hasAnimatedResult(); +} + +bool PreparedFile::hasAudioEditScene() const { + const auto image = information + ? std::get_if(&information->media) + : nullptr; + return image + && image->modifications.paint + && image->modifications.paint->hasSoundResult(); } AlbumType PreparedFile::albumType(bool sendImagesAsPhotos) const { diff --git a/Telegram/SourceFiles/ui/chat/attach/attach_prepare.h b/Telegram/SourceFiles/ui/chat/attach/attach_prepare.h index d7004aa3fd..dae82beb91 100644 --- a/Telegram/SourceFiles/ui/chat/attach/attach_prepare.h +++ b/Telegram/SourceFiles/ui/chat/attach/attach_prepare.h @@ -95,6 +95,7 @@ struct PreparedFile { [[nodiscard]] bool isGifv() const; [[nodiscard]] bool canUseHighQualityPhoto() const; [[nodiscard]] bool hasAnimatedEditScene() const; + [[nodiscard]] bool hasAudioEditScene() const; [[nodiscard]] bool sendsVideoAsGif() const; [[nodiscard]] bool canEditVideo() const; diff --git a/Telegram/SourceFiles/ui/chat/attach/attach_single_media_preview.cpp b/Telegram/SourceFiles/ui/chat/attach/attach_single_media_preview.cpp index 639b976976..01b5659bca 100644 --- a/Telegram/SourceFiles/ui/chat/attach/attach_single_media_preview.cpp +++ b/Telegram/SourceFiles/ui/chat/attach/attach_single_media_preview.cpp @@ -27,7 +27,8 @@ SingleMediaPreview *SingleMediaPreview::Create( if (const auto image = std::get_if( &file.information->media)) { preview = Editor::ImageModified(image->data, image->modifications); - animated = animationPreview = image->animated; + animated = image->animated || file.hasAudioEditScene(); + animationPreview = image->animated; hasModifications = !image->modifications.empty(); } else if (const auto video = std::get_if( &file.information->media)) { @@ -61,9 +62,11 @@ SingleMediaPreview *SingleMediaPreview::Create( file.spoiler, animationPreview ? file.path : QString(), type); - result->setModifyAllowed(file.canEditVideo()); + result->setModifyAllowed( + file.canEditVideo() || file.hasAudioEditScene()); result->setCanShowHighQualityBadge(file.canUseHighQualityPhoto()); - result->setCanShowAnimatedBadge(file.hasAnimatedEditScene()); + result->setCanShowAnimatedBadge(file.hasAnimatedEditScene() + && !file.hasAudioEditScene()); result->setVideoQuality(file.videoQuality()); return result; } diff --git a/Telegram/SourceFiles/ui/effects/loading_element.cpp b/Telegram/SourceFiles/ui/effects/loading_element.cpp index edbf54638e..3c9a9ed0c9 100644 --- a/Telegram/SourceFiles/ui/effects/loading_element.cpp +++ b/Telegram/SourceFiles/ui/effects/loading_element.cpp @@ -19,16 +19,28 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_widgets.h" namespace Ui { + +LoadingLine::LoadingLine(int thickness, int skip, const QColor &color) +: _thickness(thickness) +, _skip(skip) +, _color(color) { +} + +int LoadingLine::height() const { + return _thickness + _skip; +} + +void LoadingLine::paint(QPainter &p, int width) { + auto hq = PainterHighQualityEnabler(p); + + p.setPen(Qt::NoPen); + p.setBrush(_color); + const auto radius = _thickness / 2.; + p.drawRoundedRect(QRect(0, 0, width, _thickness), radius, radius); +} + namespace { -class LoadingElement { -public: - LoadingElement() = default; - - [[nodiscard]] virtual int height() const = 0; - virtual void paint(QPainter &p, int width) = 0; -}; - class LoadingText final : public LoadingElement { public: LoadingText(const style::TextStyle &st); diff --git a/Telegram/SourceFiles/ui/effects/loading_element.h b/Telegram/SourceFiles/ui/effects/loading_element.h index 3faa3a187a..a9b239bd40 100644 --- a/Telegram/SourceFiles/ui/effects/loading_element.h +++ b/Telegram/SourceFiles/ui/effects/loading_element.h @@ -20,6 +20,28 @@ namespace Ui { class RpWidget; +class LoadingElement { +public: + virtual ~LoadingElement() = default; + + [[nodiscard]] virtual int height() const = 0; + virtual void paint(QPainter &p, int width) = 0; +}; + +class LoadingLine final : public LoadingElement { +public: + LoadingLine(int thickness, int skip, const QColor &color); + + [[nodiscard]] int height() const override; + void paint(QPainter &p, int width) override; + +private: + const int _thickness; + const int _skip; + const QColor _color; + +}; + object_ptr CreateLoadingTextWidget( not_null parent, const style::TextStyle &st, diff --git a/Telegram/SourceFiles/ui/menu_icons.style b/Telegram/SourceFiles/ui/menu_icons.style index dc61210537..a0cca304fb 100644 --- a/Telegram/SourceFiles/ui/menu_icons.style +++ b/Telegram/SourceFiles/ui/menu_icons.style @@ -259,6 +259,8 @@ mediaMenuIconInfo: icon {{ "menu/info", mediaviewMenuFg }}; mediaMenuIconBlock: icon {{ "menu/block", mediaviewMenuFg }}; mediaMenuIconFlip: icon {{ "menu/flip", mediaviewMenuFg }}; +mediaMenuIconEdit: icon {{ "menu/edit", mediaviewMenuFg }}; +mediaMenuIconNightMode: icon {{ "menu/night_mode", mediaviewMenuFg }}; mediaMenuIconFont: icon {{ "menu/fonts", mediaviewMenuFg }}; mediaMenuIconTextStylePlain: icon {{ "menu/text_style_plain-22x22", mediaviewMenuFg, point(0px, 2px) }}; mediaMenuIconTextStyleFramed: icon {{ "menu/text_style_framed-22x22", mediaviewMenuFg, point(0px, 2px) }}; diff --git a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp index fa23ca2317..919fe233bf 100644 --- a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp +++ b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp @@ -12,6 +12,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "storage/serialize_common.h" #include "core/application.h" #include "core/core_settings.h" +#include "data/data_wall_paper.h" +#include "ui/chat/chat_theme.h" #include "ui/style/style_palette_colorizer.h" #include "window/themes/window_theme.h" @@ -371,6 +373,38 @@ Fn PrepareCurrentPaletteCallback() { }; } +Ui::ChatThemeBackground PrepareDefaultBackground(bool dark) { + if (!dark) { + const auto paper = Data::DefaultWallPaper(); + return Ui::PrepareBackgroundImage({ + .path = u":/gui/art/background.tgv"_q, + .gzipSvg = true, + .colors = paper.backgroundColors(), + .isPattern = true, + .patternOpacity = paper.patternOpacity(), + .generateGradient = true, + .gradientRotation = paper.gradientRotation(), + }); + } + auto instance = Instance(); + const auto loaded = LoadFromFile( + kNightBaseFile.utf16(), + &instance, + nullptr, + nullptr, + style::colorizer()); + Assert(loaded); + auto prepared = Ui::PreprocessBackgroundImage( + std::move(instance.background)); + prepared.setDevicePixelRatio(style::DevicePixelRatio()); + return { + .prepared = prepared, + .preparedForTiled = Ui::PrepareImageForTiled(prepared), + .colorForFill = Ui::CalculateImageMonoColor(prepared), + .tile = instance.tiled, + }; +} + QByteArray AccentColors::serialize() const { auto result = QByteArray(); if (_data.empty()) { diff --git a/Telegram/SourceFiles/window/themes/window_themes_embedded.h b/Telegram/SourceFiles/window/themes/window_themes_embedded.h index fd0a9ba6f6..fa794e88e6 100644 --- a/Telegram/SourceFiles/window/themes/window_themes_embedded.h +++ b/Telegram/SourceFiles/window/themes/window_themes_embedded.h @@ -11,6 +11,10 @@ namespace style { struct colorizer; } // namespace style +namespace Ui { +struct ChatThemeBackground; +} // namespace Ui + namespace Window { namespace Theme { @@ -65,5 +69,8 @@ void Colorize( std::optional accent); [[nodiscard]] Fn PrepareCurrentPaletteCallback(); +// Chat background paired with the PreparePaletteCallback palette. +[[nodiscard]] Ui::ChatThemeBackground PrepareDefaultBackground(bool dark); + } // namespace Theme } // namespace Window diff --git a/Telegram/cmake/td_ui.cmake b/Telegram/cmake/td_ui.cmake index 852019c31a..65fdff6064 100644 --- a/Telegram/cmake/td_ui.cmake +++ b/Telegram/cmake/td_ui.cmake @@ -174,10 +174,18 @@ PRIVATE editor/controllers/undo_controller.cpp editor/controllers/undo_controller.h + editor/editor_audio_disc_button.cpp + editor/editor_audio_disc_button.h + editor/editor_audio_menu.cpp + editor/editor_audio_menu.h editor/editor_crop.cpp editor/editor_crop.h + editor/editor_link_pill.cpp + editor/editor_link_pill.h editor/editor_layer_widget.cpp editor/editor_layer_widget.h + editor/editor_trim_timeline.cpp + editor/editor_trim_timeline.h editor/photo_editor_common.cpp editor/photo_editor_common.h editor/photo_editor_inner_common.h @@ -193,12 +201,12 @@ PRIVATE editor/scene/scene_item_image.h editor/scene/scene_item_line.cpp editor/scene/scene_item_line.h + editor/scene/scene_item_link.cpp + editor/scene/scene_item_link.h editor/scene/scene_item_shape.cpp editor/scene/scene_item_shape.h editor/scene/scene_item_text.cpp editor/scene/scene_item_text.h - editor/scene/scene_item_video.cpp - editor/scene/scene_item_video.h editor/scene/scene_emoji_document.cpp editor/scene/scene_emoji_document.h editor/scene/scene_text_editing.cpp @@ -271,7 +279,11 @@ PRIVATE media/player/media_player_dropdown.cpp media/player/media_player_dropdown.h + media/media_audio_waveform.cpp + media/media_audio_waveform.h media/media_common.h + media/media_video_canvas.cpp + media/media_video_canvas.h menu/gift_resale_filter.cpp menu/gift_resale_filter.h