diff --git a/data/gresource.xml b/data/gresource.xml index 68859ad8e..214c4aad6 100644 --- a/data/gresource.xml +++ b/data/gresource.xml @@ -69,6 +69,7 @@ gtk/dropdown/expiration.ui ui/views/base.ui + ui/views/media_viewer.ui ui/views/profile_header.ui ui/views/sidebar/view.ui ui/views/sidebar/account.ui diff --git a/data/style.css b/data/style.css index 35326c2fe..7c0a35c64 100644 --- a/data/style.css +++ b/data/style.css @@ -231,11 +231,14 @@ flowboxchild { background: alpha(@success_bg_color, 0.1); } -.media-viewer-headerbar { - background: rgba(0, 0, 0, .7); +.media-viewer { color: white; } +.media-viewer .top-bar { + background-image: none; +} + .ttl-status-heading { font-size: small; } diff --git a/data/ui/dialogs/main.ui b/data/ui/dialogs/main.ui index 8b3820f31..7f44b3017 100644 --- a/data/ui/dialogs/main.ui +++ b/data/ui/dialogs/main.ui @@ -10,38 +10,24 @@ - - 1 - 1 - 0 - crossfade - 1 - - - main - - - - - - - - - - - - + + + + 0 - - - media_viewer - - + + + + + + + + - + diff --git a/data/ui/views/media_viewer.ui b/data/ui/views/media_viewer.ui new file mode 100644 index 000000000..c856e915a --- /dev/null +++ b/data/ui/views/media_viewer.ui @@ -0,0 +1,183 @@ + + + + + + Open in Browser + mediaviewer.open-in-browser + + + Copy URL + mediaviewer.copy-url + + + Save Media + mediaviewer.save-as + + + Copy Media + mediaviewer.copy-media + action-disabled + + + + \ No newline at end of file diff --git a/src/API/Status/PreviewCard.vala b/src/API/Status/PreviewCard.vala index 8f94669d0..bc0768ec0 100644 --- a/src/API/Status/PreviewCard.vala +++ b/src/API/Status/PreviewCard.vala @@ -215,7 +215,7 @@ public class Tuba.API.PreviewCard : Entity, Widgetizable { Host.open_uri (card_url); } else { if (bookwyrm_obj == null) { - app.main_window.show_media_viewer_remote_video (res_url, null, card_url); + app.main_window.show_media_viewer (res_url, Tuba.Attachment.MediaType.VIDEO, null, 0, null, false, null, card_url, true); } else { app.main_window.show_book (bookwyrm_obj, card_url); } diff --git a/src/Dialogs/MainWindow.vala b/src/Dialogs/MainWindow.vala index a57f7db07..113b2a913 100644 --- a/src/Dialogs/MainWindow.vala +++ b/src/Dialogs/MainWindow.vala @@ -3,7 +3,7 @@ public class Tuba.Dialogs.MainWindow: Adw.ApplicationWindow, Saveable { [GtkChild] unowned Adw.NavigationView navigation_view; [GtkChild] public unowned Adw.OverlaySplitView split_view; [GtkChild] unowned Views.Sidebar sidebar; - [GtkChild] unowned Gtk.Stack main_stack; + // [GtkChild] unowned Gtk.Stack main_stack; [GtkChild] unowned Views.MediaViewer media_viewer; [GtkChild] unowned Adw.Breakpoint breakpoint; @@ -17,6 +17,18 @@ public class Tuba.Dialogs.MainWindow: Adw.ApplicationWindow, Saveable { var gtk_settings = Gtk.Settings.get_default (); breakpoint.add_setter (this, "is-mobile", true); notify["is-mobile"].connect (update_selected_home_item); + media_viewer.bind_property ("visible", split_view, "can-focus", GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.INVERT_BOOLEAN); + media_viewer.notify["visible"].connect (on_media_viewer_toggle); + } + + private weak Gtk.Widget? media_viewer_source_widget; + private void on_media_viewer_toggle () { + if (is_media_viewer_visible || media_viewer_source_widget == null) return; + + Gtk.Widget focusable_widget = media_viewer_source_widget; + while (focusable_widget != null && !focusable_widget.focusable) focusable_widget = focusable_widget.get_parent (); + if (focusable_widget != null) focusable_widget.grab_focus (); + media_viewer_source_widget = null; } public bool is_home { @@ -44,53 +56,35 @@ public class Tuba.Dialogs.MainWindow: Adw.ApplicationWindow, Saveable { #endif } - public bool is_media_viewer_visible () { - return main_stack.visible_child_name == "media_viewer"; + public bool is_media_viewer_visible { + get { return media_viewer.visible; } } public void scroll_media_viewer (int pos) { - if (!is_media_viewer_visible ()) return; + if (!is_media_viewer_visible) return; media_viewer.scroll_to (pos); } - public void show_media_viewer (string url, string? alt_text, bool video, Gdk.Paintable? preview, int? pos) { - if (!is_media_viewer_visible ()) { - main_stack.visible_child_name = "media_viewer"; - media_viewer.clear.connect (hide_media_viewer); - } - - if (video) { - media_viewer.add_video (url, preview, pos); - } else { - media_viewer.add_image (url, alt_text, preview, pos); - } - } - - public void show_media_viewer_single (string? url, Gdk.Paintable? paintable) { - if (paintable == null) return; - - if (!is_media_viewer_visible ()) { - main_stack.visible_child_name = "media_viewer"; - media_viewer.clear.connect (hide_media_viewer); - } - - media_viewer.set_single_paintable (url, paintable); - } - - public void show_media_viewer_remote_video (string url, Gdk.Paintable? preview, string? user_friendly_url = null) { - if (!is_media_viewer_visible ()) { - main_stack.visible_child_name = "media_viewer"; - media_viewer.clear.connect (hide_media_viewer); + public void show_media_viewer ( + string url, + Tuba.Attachment.MediaType media_type, + Gdk.Paintable? preview, + int? pos = null, + Gtk.Widget? source_widget = null, + bool as_is = false, + string? alt_text = null, + string? user_friendly_url = null, + bool stream = false + ) { + if (as_is && preview == null) return; + + media_viewer.add_media (url, media_type, preview, pos, as_is, alt_text, user_friendly_url, stream); + + if (!is_media_viewer_visible) { + media_viewer.reveal (source_widget); + media_viewer_source_widget = source_widget; } - - media_viewer.set_remote_video (url, preview, user_friendly_url); - } - - public void hide_media_viewer () { - if (!is_media_viewer_visible ()) return; - - main_stack.visible_child_name = "main"; } public void show_book (API.BookWyrm book, string? fallback = null) { @@ -151,7 +145,7 @@ public class Tuba.Dialogs.MainWindow: Adw.ApplicationWindow, Saveable { } public bool back () { - if (is_media_viewer_visible ()) { + if (is_media_viewer_visible) { media_viewer.clear (); return true; }; diff --git a/src/Views/MediaViewer.vala b/src/Views/MediaViewer.vala index ce0976a47..60c22acac 100644 --- a/src/Views/MediaViewer.vala +++ b/src/Views/MediaViewer.vala @@ -1,8 +1,71 @@ -// Mostly inspired by Loupe https://gitlab.gnome.org/Incubator/loupe +// Mostly inspired by Loupe https://gitlab.gnome.org/GNOME/loupe and Fractal https://gitlab.gnome.org/GNOME/fractal + +public class Tuba.Attachment { + public enum MediaType { + IMAGE, + VIDEO, + GIFV, + AUDIO, + UNKNOWN; + + public bool can_copy () { + switch (this) { + case IMAGE: + return true; + default: + return false; + } + } + + public bool is_video () { + switch (this) { + case VIDEO: + case GIFV: + case AUDIO: + return true; + default: + return false; + } + } + + public string to_string () { + switch (this) { + case IMAGE: + return "IMAGE"; + case VIDEO: + return "VIDEO"; + case GIFV: + return "GIFV"; + case AUDIO: + return "AUDIO"; + default: + return "UNKNOWN"; + } + } + + public static MediaType from_string (string media_type) { + string media_type_up = media_type.up (); + switch (media_type_up) { + case "IMAGE": + return IMAGE; + case "VIDEO": + return VIDEO; + case "GIFV": + return GIFV; + case "AUDIO": + return AUDIO; + default: + return UNKNOWN; + } + } + } +} -public class Tuba.Views.MediaViewer : Gtk.Box { +[GtkTemplate (ui = "/dev/geopjr/Tuba/ui/views/media_viewer.ui")] +public class Tuba.Views.MediaViewer : Gtk.Widget, Gtk.Buildable, Adw.Swipeable { const double MAX_ZOOM = 20; static double last_used_volume = 1.0; + const uint CANCEL_SWIPE_ANIMATION_DURATION = 400; public class Item : Adw.Bin { private Gtk.Stack stack; @@ -138,8 +201,8 @@ public class Tuba.Views.MediaViewer : Gtk.Box { Gdk.Paintable? paintable, bool t_is_video = false ) { - child_widget = child; - is_video = t_is_video; + this.child_widget = child; + this.is_video = t_is_video; stack.add_named (setup_scrolledwindow (child), "child"); this.url = t_url; @@ -147,15 +210,6 @@ public class Tuba.Views.MediaViewer : Gtk.Box { if (paintable != null) overlay.child = new Gtk.Picture.for_paintable (paintable); } - public Item.static (Gtk.Widget child, string t_url) { - child_widget = child; - - stack.add_named (setup_scrolledwindow (child), "child"); - this.url = t_url; - - done (); - } - ~Item () { debug ("Destroying MediaViewer.Item"); @@ -172,6 +226,8 @@ public class Tuba.Views.MediaViewer : Gtk.Box { } public void done () { + if (is_done) return; + spinner.spinning = false; stack.visible_child_name = "child"; if (is_video) { @@ -231,38 +287,39 @@ public class Tuba.Views.MediaViewer : Gtk.Box { }; private Gee.ArrayList items = new Gee.ArrayList (); - protected Gtk.Button fullscreen_btn; - protected Adw.HeaderBar headerbar; - private Adw.Carousel carousel; - private Adw.CarouselIndicatorDots carousel_dots; protected SimpleAction copy_media_simple_action; - protected Gtk.PopoverMenu context_menu { get; set; } - construct { - carousel = new Adw.Carousel () { - vexpand = true, - hexpand = true, - css_classes = {"osd"} - }; + [GtkChild] unowned Gtk.PopoverMenu context_menu; + [GtkChild] unowned Gtk.Button fullscreen_btn; + [GtkChild] unowned Adw.HeaderBar headerbar; + + [GtkChild] unowned Gtk.Revealer page_buttons_revealer; + [GtkChild] unowned Gtk.Button prev_btn; + [GtkChild] unowned Gtk.Button next_btn; + + [GtkChild] unowned Gtk.Revealer zoom_buttons_revealer; + [GtkChild] unowned Gtk.Button zoom_out_btn; + [GtkChild] unowned Gtk.Button zoom_in_btn; + + [GtkChild] unowned Tuba.Widgets.ScaleRevealer scale_revealer; + [GtkChild] unowned Adw.Carousel carousel; + [GtkChild] unowned Adw.CarouselIndicatorDots carousel_dots; + private double swipe_children_opacity { + set { + headerbar.opacity = + carousel_dots.opacity = + page_buttons_revealer.opacity = + zoom_buttons_revealer.opacity = value; + } + } + + construct { // Move between media using the arrow keys var keypresscontroller = new Gtk.EventControllerKey (); keypresscontroller.key_pressed.connect (on_keypress); add_controller (keypresscontroller); - var overlay = new Gtk.Overlay () { - vexpand = true, - hexpand = true - }; - - Gtk.Widget zoom_btns; - Gtk.Widget page_btns; - generate_media_buttons (out page_btns, out zoom_btns); - - overlay.add_overlay (page_btns); - overlay.add_overlay (zoom_btns); - overlay.child = carousel; - var drag = new Gtk.GestureDrag (); drag.drag_begin.connect (on_drag_begin); drag.drag_update.connect (on_drag_update); @@ -279,9 +336,6 @@ public class Tuba.Views.MediaViewer : Gtk.Box { motion.motion.connect (on_motion); add_controller (motion); - orientation = Gtk.Orientation.VERTICAL; - spacing = 0; - var actions = new GLib.SimpleActionGroup (); actions.add_action_entries (ACTION_ENTRIES, this); @@ -291,62 +345,136 @@ public class Tuba.Views.MediaViewer : Gtk.Box { this.insert_action_group ("mediaviewer", actions); - headerbar = new Adw.HeaderBar () { - title_widget = new Gtk.Label (_("Media Viewer")) { - css_classes = {"title"} - }, - css_classes = {"flat", "media-viewer-headerbar"} - }; - var back_btn = new Gtk.Button.from_icon_name ("tuba-left-large-symbolic") { - tooltip_text = _("Go Back") - }; - back_btn.clicked.connect (on_back_clicked); - headerbar.pack_start (back_btn); + this.notify["visible"].connect (on_visible_toggle); + carousel.notify["n-pages"].connect (on_carousel_n_pages_changed); + carousel.page_changed.connect (on_carousel_page_changed); + scale_revealer.transition_done.connect (on_scale_revealer_transition_end); + context_menu.set_parent (this); - var end_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0); - fullscreen_btn = new Gtk.Button.from_icon_name ("view-fullscreen-symbolic") { - tooltip_text = _("Toggle Fullscreen") - }; - fullscreen_btn.clicked.connect (toggle_fullscreen); + setup_mouse_previous_click (); + setup_double_click (); + setup_mouse_secondary_click (); + setup_swipe_close (); + } + ~MediaViewer () { + debug ("Destroying MediaViewer"); + context_menu.unparent (); + } - var menu_model = create_actions_menu (); - var actions_btn = new Gtk.MenuButton () { - icon_name = "view-more-symbolic", - menu_model = menu_model - }; + private void on_visible_toggle () { + if (this.visible) this.grab_focus (); + } - context_menu = new Gtk.PopoverMenu.from_model (menu_model) { - has_arrow = false, - halign = Gtk.Align.START + private double swipe_progress { get; set; } + public Adw.SwipeTracker swipe_tracker; + private void setup_swipe_close () { + swipe_tracker = new Adw.SwipeTracker (this) { + orientation = Gtk.Orientation.VERTICAL, + enabled = true, + allow_mouse_drag = true }; - context_menu.set_parent (this); + swipe_tracker.update_swipe.connect (on_update_swipe); + swipe_tracker.end_swipe.connect (on_end_swipe); + } - end_box.append (fullscreen_btn); - end_box.append (actions_btn); - headerbar.pack_end (end_box); + private void on_update_swipe (double progress) { + this.swipe_children_opacity = 0.0; + this.swipe_progress = progress; + this.queue_allocate (); + this.queue_draw (); + } - carousel_dots = new Adw.CarouselIndicatorDots () { - carousel = carousel, - css_classes = {"osd"}, - visible = false + private void on_end_swipe (double velocity, double to) { + if (to == 0.0) { + var target = new Adw.CallbackAnimationTarget (swipe_animation_target_cb); + var animation = new Adw.TimedAnimation (this, swipe_progress, 0.0, CANCEL_SWIPE_ANIMATION_DURATION, target) { + easing = Adw.Easing.EASE_OUT_QUART + }; + animation.done.connect (on_swipe_animation_end); + animation.play (); + } else { + clear (); + this.swipe_children_opacity = 1.0; + } + } + + private void swipe_animation_target_cb (double value) { + this.swipe_progress = value; + this.queue_allocate (); + this.queue_draw (); + } + + private void on_swipe_animation_end () { + this.swipe_children_opacity = 1.0; + } + + public override void size_allocate (int width, int height, int baseline) { + int swipe_y_offset = (int) (-height * swipe_progress); + Gtk.Allocation allocation = Gtk.Allocation () { + x = 0, + y = swipe_y_offset, + width = width, + height = height }; + scale_revealer.allocate_size (allocation, baseline); + } + + public override void snapshot (Gtk.Snapshot snapshot) { + double progress = double.min ( + 1.0 - swipe_progress.abs (), + scale_revealer.animation.value + ); + + if (progress > 0.0) { + Gdk.RGBA background_color = Gdk.RGBA () { + red = 0.0f, + green = 0.0f, + blue = 0.0f, + alpha = (float) progress + }; + Graphene.Rect bounds = Graphene.Rect () { + origin = Graphene.Point () { x = 0.0f, y = 0.0f }, + size = Graphene.Size () { width = (float) this.get_width (), height = (float) this.get_height () } + }; - carousel.bind_property ("n_pages", carousel_dots, "visible", BindingFlags.SYNC_CREATE, (b, src, ref target) => { - target.set_boolean (src.get_uint () > 1); - return true; - }); + snapshot.append_color (background_color, bounds); + } - append (headerbar); - append (overlay); - append (carousel_dots); + this.snapshot_child (scale_revealer, snapshot); + } - setup_mouse_previous_click (); - setup_double_click (); - setup_mouse_secondary_click (); + public double get_cancel_progress () { + return 0.0; } - ~MediaViewer () { - debug ("Destroying MediaViewer"); - context_menu.unparent (); + + public double get_distance () { + return (double) this.get_height (); + } + + public double get_progress () { + return swipe_progress; + } + + public double[] get_snap_points () { + return {-1.0, 0.0, 1.0}; + } + + public Gdk.Rectangle get_swipe_area (Adw.NavigationDirection navigation_direction, bool is_drag) { + return { + 0, + 0, + this.get_width (), + this.get_height () + }; + } + + private void on_scale_revealer_transition_end () { + if (!scale_revealer.reveal_child) { + this.visible = false; + swipe_progress = 0.0; + scale_revealer.source_widget = null; + reset_media_viewer (); + } } int? old_height; @@ -424,27 +552,17 @@ public class Tuba.Views.MediaViewer : Gtk.Box { return true; } - protected void on_back_clicked () { - clear (); + [GtkCallback] + public void clear () { + if (!revealed) reset_media_viewer (); + scale_revealer.reveal_child = false; } - protected void toggle_fullscreen () { + [GtkCallback] + private void toggle_fullscreen () { this.fullscreen = !this._fullscreen; } - protected GLib.Menu create_actions_menu () { - var menu_model = new GLib.Menu (); - menu_model.append (_("Open in Browser"), "mediaviewer.open-in-browser"); - menu_model.append (_("Copy URL"), "mediaviewer.copy-url"); - menu_model.append (_("Save Media"), "mediaviewer.save-as"); - - var copy_media_menu_item = new MenuItem (_("Copy Media"), "mediaviewer.copy-media"); - copy_media_menu_item.set_attribute_value ("hidden-when", "action-disabled"); - menu_model.append_item (copy_media_menu_item); - - return menu_model; - } - private void copy_url () { Item? page = safe_get ((int) carousel.position); if (page == null) return; @@ -542,7 +660,7 @@ public class Tuba.Views.MediaViewer : Gtk.Box { } private void handle_mouse_previous_click (int n_press, double x, double y) { - on_back_clicked (); + clear (); } private void on_double_click (int n_press, double x, double y) { @@ -554,8 +672,9 @@ public class Tuba.Views.MediaViewer : Gtk.Box { page.on_double_click (); } - public virtual signal void clear () { + private void reset_media_viewer () { this.fullscreen = false; + todo_items.clear (); items.foreach ((item) => { carousel.remove (item); @@ -564,45 +683,83 @@ public class Tuba.Views.MediaViewer : Gtk.Box { }); items.clear (); + revealed = false; } private async string download_video (string url) throws Error { return yield Host.download (url); } - public void add_video (string url, Gdk.Paintable? preview, int? pos) { - var video = new Gtk.Video (); - var item = new Item (video, url, preview, true); - if (pos == null) { - carousel.append (item); - items.add (item); + private bool revealed = false; + public void reveal (Gtk.Widget? widget) { + if (revealed) return; + + this.visible = true; + scale_revealer.source_widget = widget; + scale_revealer.reveal_child = true; + + revealed = true; + do_todo_items (); + } + + public void add_media ( + string url, + Tuba.Attachment.MediaType media_type, + Gdk.Paintable? preview, + int? pos = null, + bool as_is = false, + string? alt_text = null, + string? user_friendly_url = null, + bool stream = false + ) { + Item item; + string final_friendly_url = user_friendly_url == null ? url : user_friendly_url; + Gdk.Paintable? final_preview = as_is ? null : preview; + + if (media_type.is_video ()) { + var video = new Gtk.Video (); + item = new Item (video, final_friendly_url, final_preview, true); + + if (stream) { + File file = File.new_for_uri (url); + video.set_file (file); + } else if (!as_is) { + download_video.begin (url, (obj, res) => { + try { + var path = download_video.end (res); + video.set_filename (path); + add_todo_item (item); + } + catch (Error e) { + var dlg = app.inform (_("Error"), e.message); + dlg.present (); + } + }); + } } else { - carousel.insert (item, pos); - items.insert (pos, item); - } + var picture = new Gtk.Picture (); - download_video.begin (url, (obj, res) => { - try { - var path = download_video.end (res); - video.set_filename (path); - item.done (); - } - catch (Error e) { - var dlg = app.inform (_("Error"), e.message); - dlg.present (); + if (!settings.media_viewer_expand_pictures) { + picture.valign = picture.halign = Gtk.Align.CENTER; } - }); - } - public void add_image (string url, string? alt_text, Gdk.Paintable? preview, int? pos) { - var picture = new Gtk.Picture (); + item = new Item (picture, final_friendly_url, final_preview); + item.zoom_changed.connect (on_zoom_change); - if (!settings.media_viewer_expand_pictures) { - picture.valign = picture.halign = Gtk.Align.CENTER; + if (alt_text != null) picture.alternative_text = alt_text; + + if (!as_is) { + image_cache.request_paintable (url, (is_loaded, data) => { + if (is_loaded) { + picture.paintable = data; + add_todo_item (item); + } + }); + } else { + picture.paintable = preview; + } } - var item = new Item (picture, url, preview); - item.zoom_changed.connect (on_zoom_change); if (pos == null) { carousel.append (item); items.add (item); @@ -611,42 +768,27 @@ public class Tuba.Views.MediaViewer : Gtk.Box { items.insert (pos, item); } - if (alt_text != null) picture.alternative_text = alt_text; - - image_cache.request_paintable (url, (is_loaded, data) => { - if (is_loaded) { - picture.paintable = data; - item.done (); - } - }); + if (as_is || stream) add_todo_item (item); } - public void set_remote_video (string url, Gdk.Paintable? preview, string? user_friendly_url = null) { - var video = new Gtk.Video (); - var item = new Item (video, user_friendly_url, preview, true); - - File file = File.new_for_uri (url); - video.set_file (file); - item.done (); - - carousel.append (item); - items.add (item); - - carousel.page_changed (0); - } - - public void set_single_paintable (string url, Gdk.Paintable paintable) { - var picture = new Gtk.Picture (); - picture.paintable = paintable; - - if (!settings.media_viewer_expand_pictures) { - picture.valign = picture.halign = Gtk.Align.CENTER; + private Gee.ArrayList todo_items = new Gee.ArrayList (); + private void add_todo_item (Item todo_item) { + if (revealed) { + todo_item.done (); + } else { + todo_items.add (todo_item.url); } + } + private void do_todo_items () { + if (todo_items.size == 0 || items.size == 0) return; - var item = new Item.static (picture, url); - item.zoom_changed.connect (on_zoom_change); - carousel.append (item); - items.add (item); + items.foreach (item => { + if (todo_items.contains (item.url)) { + item.done (); + todo_items.remove (item.url); + } + return true; + }); } public void scroll_to (int pos, bool should_timeout = true) { @@ -660,7 +802,7 @@ public class Tuba.Views.MediaViewer : Gtk.Box { // https://gitlab.gnome.org/GNOME/libadwaita/-/issues/597 // https://gitlab.gnome.org/GNOME/libadwaita/-/merge_requests/827 uint timeout = 0; - timeout = Timeout.add (1000, () => { + timeout = Timeout.add (250, () => { if (pos < items.size) carousel.scroll_to (carousel.get_nth_page (pos), true); GLib.Source.remove (timeout); @@ -669,106 +811,60 @@ public class Tuba.Views.MediaViewer : Gtk.Box { }, Priority.LOW); } - private Gtk.Button zoom_out_btn; - private Gtk.Button zoom_in_btn; - private Gtk.Revealer page_buttons_revealer; - private Gtk.Revealer zoom_buttons_revealer; - private void generate_media_buttons (out Gtk.Revealer page_btns, out Gtk.Revealer zoom_btns) { - var t_page_btns = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 12); - var t_zoom_btns = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 12); - - var prev_btn = new Gtk.Button.from_icon_name ("go-previous-symbolic") { - css_classes = {"circular", "osd", "media-viewer-fab"}, - tooltip_text = _("Previous Attachment") - }; - - var next_btn = new Gtk.Button.from_icon_name ("go-next-symbolic") { - css_classes = {"circular", "osd", "media-viewer-fab"}, - tooltip_text = _("Next Attachment") - }; - - prev_btn.clicked.connect (() => scroll_to (((int) carousel.position) - 1, false)); - next_btn.clicked.connect (() => scroll_to (((int) carousel.position) + 1, false)); - - carousel.notify["n-pages"].connect (() => { - var has_more_than_1_item = carousel.n_pages > 1; - - prev_btn.visible = has_more_than_1_item; - next_btn.visible = has_more_than_1_item; - }); - - t_page_btns.append (prev_btn); - t_page_btns.append (next_btn); + [GtkCallback] + private void on_previous_clicked () { + scroll_to (((int) carousel.position) - 1, false); + } - zoom_out_btn = new Gtk.Button.from_icon_name ("zoom-out-symbolic") { - css_classes = {"circular", "osd", "media-viewer-fab"}, - tooltip_text = _("Zoom Out") - }; + [GtkCallback] + private void on_next_clicked () { + scroll_to (((int) carousel.position) + 1, false); + } - zoom_in_btn = new Gtk.Button.from_icon_name ("zoom-in-symbolic") { - css_classes = {"circular", "osd", "media-viewer-fab"}, - tooltip_text = _("Zoom In") - }; - - zoom_out_btn.clicked.connect (() => { - Item? page = safe_get ((int) carousel.position); + [GtkCallback] + private void on_zoom_out_clicked () { + Item? page = safe_get ((int) carousel.position); if (page == null) return; page.zoom_out (); - }); - zoom_in_btn.clicked.connect (() => { - Item? page = safe_get ((int) carousel.position); + } + + [GtkCallback] + private void on_zoom_in_clicked () { + Item? page = safe_get ((int) carousel.position); if (page == null) return; page.zoom_in (); - }); - - carousel.page_changed.connect ((pos) => { - prev_btn.sensitive = pos > 0; - next_btn.sensitive = pos < items.size - 1; - - Item? page = safe_get ((int) pos); - // Media buttons overlap the video - // controller, so position them higher - if (page != null && page.is_video) { - page_buttons_revealer.margin_bottom = zoom_buttons_revealer.margin_bottom = 68; - zoom_buttons_revealer.visible = false; - play_video ((int) pos); - copy_media_simple_action.set_enabled (false); - } else { - page_buttons_revealer.margin_bottom = zoom_buttons_revealer.margin_bottom = 18; - zoom_buttons_revealer.visible = true; - pause_all_videos (); - copy_media_simple_action.set_enabled (true); - } - - on_zoom_change (); - }); + } + + private void on_carousel_page_changed (uint pos) { + prev_btn.sensitive = pos > 0; + next_btn.sensitive = pos < items.size - 1; + + Item? page = safe_get ((int) pos); + // Media buttons overlap the video + // controller, so position them higher + if (page != null && page.is_video) { + page_buttons_revealer.margin_bottom = zoom_buttons_revealer.margin_bottom = 68; + zoom_buttons_revealer.visible = false; + play_video ((int) pos); + copy_media_simple_action.set_enabled (false); + } else { + page_buttons_revealer.margin_bottom = zoom_buttons_revealer.margin_bottom = 18; + zoom_buttons_revealer.visible = true; + pause_all_videos (); + copy_media_simple_action.set_enabled (true); + } - t_zoom_btns.append (zoom_out_btn); - t_zoom_btns.append (zoom_in_btn); - - zoom_buttons_revealer = new Gtk.Revealer () { - child = t_zoom_btns, - transition_type = Gtk.RevealerTransitionType.CROSSFADE, - valign = Gtk.Align.END, - halign = Gtk.Align.END, - margin_end = 18, - margin_bottom = 18, - visible = false - }; + on_zoom_change (); + } - page_buttons_revealer = new Gtk.Revealer () { - child = t_page_btns, - transition_type = Gtk.RevealerTransitionType.CROSSFADE, - valign = Gtk.Align.END, - halign = Gtk.Align.START, - margin_start = 18, - margin_bottom = 18 - }; + private void on_carousel_n_pages_changed () { + bool has_more_than_1_item = carousel.n_pages > 1; - page_btns = page_buttons_revealer; - zoom_btns = zoom_buttons_revealer; + carousel_dots.visible = has_more_than_1_item; + prev_btn.visible = has_more_than_1_item; + next_btn.visible = has_more_than_1_item; } public void on_zoom_change () { @@ -778,6 +874,7 @@ public class Tuba.Views.MediaViewer : Gtk.Box { bool can_zoom_out = page == null ? false : page.can_zoom_out; zoom_out_btn.sensitive = can_zoom_out; carousel.interactive = !can_zoom_out; + swipe_tracker.enabled = !can_zoom_out; } // ArrayList will segfault if we #get diff --git a/src/Widgets/Attachment/Image.vala b/src/Widgets/Attachment/Image.vala index 7027ad621..375eb3381 100644 --- a/src/Widgets/Attachment/Image.vala +++ b/src/Widgets/Attachment/Image.vala @@ -1,7 +1,4 @@ public class Tuba.Widgets.Attachment.Image : Widgets.Attachment.Item { - const string[] ALLOWED_TYPES = {"IMAGE", "VIDEO", "GIFV", "AUDIO"}; - const string[] VIDEO_TYPES = {"GIFV", "VIDEO", "AUDIO"}; - protected Gtk.Picture pic; protected Gtk.Overlay media_overlay; @@ -45,7 +42,6 @@ public class Tuba.Widgets.Attachment.Image : Widgets.Attachment.Item { button.child = media_overlay; } - const string[] CAN_COPY_KINDS = { "IMAGE" }; protected Gtk.Image? media_icon = null; protected override void on_rebind () { base.on_rebind (); @@ -53,13 +49,13 @@ public class Tuba.Widgets.Attachment.Image : Widgets.Attachment.Item { image_cache.request_paintable (entity.preview_url, on_cache_response); - if (media_kind in VIDEO_TYPES) { + if (media_kind.is_video ()) { media_icon = new Gtk.Image () { valign = Gtk.Align.CENTER, halign = Gtk.Align.CENTER }; - if (media_kind != "AUDIO") { + if (media_kind != Tuba.Attachment.MediaType.AUDIO) { media_icon.css_classes = { "osd", "circular", "attachment-overlay-icon" }; media_icon.icon_name = "media-playback-start-symbolic"; } else { @@ -72,7 +68,7 @@ public class Tuba.Widgets.Attachment.Image : Widgets.Attachment.Item { media_icon.icon_size = Gtk.IconSize.LARGE; } - copy_media_simple_action.set_enabled (media_kind in CAN_COPY_KINDS); + copy_media_simple_action.set_enabled (media_kind.can_copy ()); } protected override void copy_media () { @@ -110,7 +106,7 @@ public class Tuba.Widgets.Attachment.Image : Widgets.Attachment.Item { return; } - if (media_kind in ALLOWED_TYPES) { + if (media_kind != Tuba.Attachment.MediaType.UNKNOWN) { load_image_in_media_viewer (null); on_any_attachment_click (entity.url); } else { // Fallback @@ -119,7 +115,7 @@ public class Tuba.Widgets.Attachment.Image : Widgets.Attachment.Item { } public void load_image_in_media_viewer (int? pos) { - app.main_window.show_media_viewer (entity.url, pic.alternative_text, media_kind in VIDEO_TYPES, pic.paintable, pos); + app.main_window.show_media_viewer (entity.url, media_kind, pic.paintable, pos, this, false, pic.alternative_text); } public signal void on_any_attachment_click (string url) {} diff --git a/src/Widgets/Attachment/Item.vala b/src/Widgets/Attachment/Item.vala index d5d7f40e8..0f31b8ba7 100644 --- a/src/Widgets/Attachment/Item.vala +++ b/src/Widgets/Attachment/Item.vala @@ -1,5 +1,4 @@ public class Tuba.Widgets.Attachment.Item : Adw.Bin { - public API.Attachment entity { get; set; default = null; } protected Gtk.GestureClick gesture_click_controller { get; set; } protected Gtk.GestureLongPress gesture_lp_controller { get; set; } @@ -16,7 +15,7 @@ public class Tuba.Widgets.Attachment.Item : Adw.Bin { protected Gtk.Button alt_btn; protected Gtk.Box badge_box; protected ulong alt_btn_clicked_id; - protected string media_kind; + protected Tuba.Attachment.MediaType media_kind; private void copy_url () { Host.copy (entity.url); @@ -168,6 +167,7 @@ public class Tuba.Widgets.Attachment.Item : Adw.Bin { default_width = 400, default_height = 300 }; + window.add_binding_action (Gdk.Key.Escape, 0, "window.close", null); toolbar_view.add_top_bar (headerbar); toolbar_view.set_content (scrolledwindow); @@ -199,7 +199,7 @@ public class Tuba.Widgets.Attachment.Item : Adw.Bin { protected virtual void on_rebind () { alt_btn.visible = entity != null && entity.description != null && entity.description != ""; - media_kind = entity.kind.up (); + media_kind = Tuba.Attachment.MediaType.from_string (entity.kind); } protected virtual void on_click () { @@ -222,7 +222,7 @@ public class Tuba.Widgets.Attachment.Item : Adw.Bin { gesture_click_controller.set_state (Gtk.EventSequenceState.CLAIMED); gesture_lp_controller.set_state (Gtk.EventSequenceState.CLAIMED); - if (app.main_window.is_media_viewer_visible ()) return; + if (app.main_window.is_media_viewer_visible) return; Gdk.Rectangle rectangle = { (int) x, (int) y, diff --git a/src/Widgets/ProfileCover.vala b/src/Widgets/ProfileCover.vala index bf8ac9268..64e1a4616 100644 --- a/src/Widgets/ProfileCover.vala +++ b/src/Widgets/ProfileCover.vala @@ -64,11 +64,11 @@ protected class Tuba.Widgets.Cover : Gtk.Box { private string avi_url { get; set; default=""; } private string header_url { get; set; default=""; } void open_header_in_media_viewer () { - app.main_window.show_media_viewer_single (header_url, background.paintable); + app.main_window.show_media_viewer (header_url, Tuba.Attachment.MediaType.IMAGE, background.paintable, null, background, true); } void open_pfp_in_media_viewer () { - app.main_window.show_media_viewer_single (avi_url, avatar.custom_image); + app.main_window.show_media_viewer (avi_url, Tuba.Attachment.MediaType.IMAGE, avatar.custom_image, null, avatar, true); } public Cover (Views.Profile.ProfileAccount profile) { diff --git a/src/Widgets/ScaleRevealer.vala b/src/Widgets/ScaleRevealer.vala new file mode 100644 index 000000000..03118cb13 --- /dev/null +++ b/src/Widgets/ScaleRevealer.vala @@ -0,0 +1,145 @@ +// ScaleRevealer is ported from Fractal +// https://gitlab.gnome.org/GNOME/fractal/-/blob/e1976cd4e182cc8513d52c1a985a4fce9a056ad2/src/components/scale_revealer.rs + +public class Tuba.Widgets.ScaleRevealer : Adw.Bin { + const uint ANIMATION_DURATION = 250; + + public signal void transition_done (); + public Adw.TimedAnimation animation { get; construct set; } + public Gtk.Widget? source_widget { get; set; } + public Gdk.Texture? source_widget_texture { get; set; } + + private bool _reveal_child = false; + public bool reveal_child { + get { + return _reveal_child; + } + + set { + if (_reveal_child == value) return; + animation.value_from = animation.value; + + if (value) { + animation.value_to = 1.0; + this.visible = true; + + if (source_widget == null) { + source_widget_texture = null; + } else { + source_widget_texture = render_widget_to_texture (this.source_widget); + source_widget.opacity = 0.0; + } + } else { + animation.value_to = 0.0; + } + + _reveal_child = value; + animation.play (); + this.notify_property ("reveal-child"); + } + } + + private Gdk.Texture? render_widget_to_texture (Gtk.Widget widget) { + var widget_paintable = new Gtk.WidgetPaintable (widget); + var t_snapshot = new Gtk.Snapshot (); + + widget_paintable.snapshot ( + t_snapshot, + widget_paintable.get_intrinsic_width (), + widget_paintable.get_intrinsic_height () + ); + + var node = t_snapshot.to_node (); + var native = widget.get_native (); + if (native == null || node == null) return null; + + return native.get_renderer ().render_texture (node, null); + } + + construct { + var target = new Adw.CallbackAnimationTarget (animation_target_cb); + animation = new Adw.TimedAnimation (this, 0.0, 1.0, ANIMATION_DURATION, target) { + easing = Adw.Easing.EASE_OUT_QUART + }; + animation.done.connect (on_animation_end); + + this.visible = false; + } + + private void on_animation_end () { + if (!reveal_child) { + if (source_widget != null) + source_widget.opacity = 1.0; + this.visible = false; + } + + transition_done (); + } + + private void animation_target_cb (double value) { + this.queue_draw (); + } + + const Graphene.Rect FALLBACK_BOUNDS = { + { 0.0f, 0.0f }, + { 100.0f, 100.0f } + }; + + public override void snapshot (Gtk.Snapshot snapshot) { + if (this.child == null) return; + + var progress = this.animation.value; + if (progress == 1.0) { + this.snapshot_child (this.child, snapshot); + return; + } + var rev_progress = (1.0 - progress).abs (); + + // Vala will complain about possibly unassigned local variable + // if source_bounds doesn't have a default value + Graphene.Rect source_bounds = FALLBACK_BOUNDS; + + // let's avoid reassigning source_bounds by splitting it into + // two if statements + if (this.source_widget != null) { + if (!this.source_widget.compute_bounds (this, out source_bounds)) source_bounds = FALLBACK_BOUNDS; + } + + float x_scale = source_bounds.get_width () / this.get_width (); + float y_scale = source_bounds.get_height () / this.get_height (); + + x_scale = 1.0f + (x_scale - 1.0f) * (float) rev_progress; + y_scale = 1.0f + (y_scale - 1.0f) * (float) rev_progress; + + float x = source_bounds.get_x () * (float) rev_progress; + float y = source_bounds.get_y () * (float) rev_progress; + + snapshot.translate (Graphene.Point () { x = x, y = y }); + snapshot.scale (x_scale, y_scale); + + if (source_widget == null) return; + if (source_widget_texture == null) { + warning ("The source widget texture is None, using child snapshot as fallback"); + this.snapshot_child (this.child, snapshot); + } else { + if (progress > 0.0) { + snapshot.push_cross_fade (progress); + source_widget_texture.snapshot ( + snapshot, + this.get_width (), + this.get_height () + ); + snapshot.pop (); + + this.snapshot_child (this.child, snapshot); + snapshot.pop (); + } else if (progress <= 0.0) { + source_widget_texture.snapshot ( + snapshot, + this.get_width (), + this.get_height () + ); + } + } + } +} diff --git a/src/Widgets/meson.build b/src/Widgets/meson.build index 03d998b0e..41d8bace0 100644 --- a/src/Widgets/meson.build +++ b/src/Widgets/meson.build @@ -14,6 +14,7 @@ sources += files( 'ProfileCover.vala', 'RelationshipButton.vala', 'RichLabel.vala', + 'ScaleRevealer.vala', 'Status.vala', 'StatusActionButton.vala', 'VoteBox.vala',