#include "../include/QuickMedia.hpp" #include "../plugins/Manganelo.hpp" #include "../plugins/Mangadex.hpp" #include "../plugins/LocalManga.hpp" #include "../plugins/LocalAnime.hpp" #include "../plugins/MangaGeneric.hpp" #include "../plugins/MangaCombined.hpp" #include "../plugins/MediaGeneric.hpp" #include "../plugins/Youtube.hpp" #include "../plugins/Peertube.hpp" #include "../plugins/DramaCool.hpp" #include "../plugins/Fourchan.hpp" #include "../plugins/NyaaSi.hpp" #include "../plugins/Matrix.hpp" #include "../plugins/Soundcloud.hpp" #include "../plugins/Lbry.hpp" #include "../plugins/FileManager.hpp" #include "../plugins/Pipe.hpp" #include "../plugins/Saucenao.hpp" #include "../plugins/Info.hpp" #include "../plugins/HotExamples.hpp" #include "../plugins/AniList.hpp" #include "../include/Scale.hpp" #include "../include/Program.hpp" #include "../include/VideoPlayer.hpp" #include "../include/StringUtils.hpp" #include "../include/GoogleCaptcha.hpp" #include "../include/Notification.hpp" #include "../include/ImageViewer.hpp" #include "../include/ImageUtils.hpp" #include "../external/cppcodec/base64_url.hpp" #include "../include/Entry.hpp" #include "../include/NetUtils.hpp" #include "../include/ResourceLoader.hpp" #include "../include/Config.hpp" #include "../include/Tabs.hpp" #include "../include/Theme.hpp" #include "../include/Utils.hpp" #include "../include/Downloader.hpp" #include "../include/Storage.hpp" #include "../include/AsyncImageLoader.hpp" #include "../plugins/utils/UniqueProcess.hpp" #include #include "../include/gui/Button.hpp" #include "../external/hash-library/sha256.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include } #include #include #include #include #include #include static int FPS_IDLE; static const double IDLE_TIMEOUT_SEC = 2.0; static const mgl::vec2i AVATAR_THUMBNAIL_SIZE(std::floor(32), std::floor(32)); struct Logo { const char *dark_theme_path; const char *light_theme_path; Logo(const char *path) : dark_theme_path(path), light_theme_path(path) { } Logo(const char *dark_theme_path, const char *light_theme_path) : dark_theme_path(dark_theme_path), light_theme_path(light_theme_path) { } }; static const std::pair valid_plugins[] = { std::make_pair("launcher", nullptr), std::make_pair("manganelo", "manganelo_logo.png"), std::make_pair("manganelos", "manganelos_logo.png"), std::make_pair("mangatown", { "mangatown_logo.png", "mangatown_logo_light.png" }), std::make_pair("mangakatana", "mangakatana_logo.png"), std::make_pair("mangadex", { "mangadex_logo.png", "mangadex_logo_light.png" }), std::make_pair("onimanga", nullptr), std::make_pair("local-manga", nullptr), std::make_pair("local-anime", nullptr), std::make_pair("manga", nullptr), std::make_pair("youtube", { "yt_logo_rgb_dark_small.png", "yt_logo_rgb_light_small.png" }), std::make_pair("peertube", "peertube_logo.png"), std::make_pair("dramacool", "dramacool_logo.png"), std::make_pair("soundcloud", "soundcloud_logo.png"), std::make_pair("lbry", "lbry_logo.png"), std::make_pair("pornhub", "pornhub_logo.png"), std::make_pair("spankbang", "spankbang_logo.png"), std::make_pair("xvideos", "xvideos_logo.png"), std::make_pair("xhamster", "xhamster_logo.png"), std::make_pair("4chan", "4chan_logo.png"), std::make_pair("nyaa.si", { "nyaa_si_logo.png", "nyaa_si_logo_light.png" }), std::make_pair("matrix", { "matrix_logo.png", "matrix_logo_light.png" }), std::make_pair("anilist", "anilist_logo.png"), std::make_pair("hotexamples", nullptr), std::make_pair("file-manager", nullptr), std::make_pair("stdin", nullptr), std::make_pair("saucenao", nullptr), std::make_pair("download", nullptr) }; static bool is_color_scheme_dark() { mgl::Color col = QuickMedia::get_theme().shade_color; return (col.r + col.g + col.b) / 3 < 128; } static const char* get_plugin_logo_name(const char *plugin_name) { for(const auto &valid_plugin : valid_plugins) { if(strcmp(plugin_name, valid_plugin.first) == 0) return is_color_scheme_dark() ? valid_plugin.second.dark_theme_path : valid_plugin.second.light_theme_path; } return nullptr; } static std::string get_no_avatar_image_path() { return is_color_scheme_dark() ? "images/no_avatar.png" : "images/no_avatar_light.png"; } // Prevent writing to broken pipe from exiting the program static void sigpipe_handler(int) { } static int x_error_handler(Display*, XErrorEvent*) { return 0; } static int x_io_error_handler(Display*) { return 0; } static const XRRModeInfo* get_mode_info(const XRRScreenResources *sr, RRMode id) { for(int i = 0; i < sr->nmode; ++i) { if(sr->modes[i].id == id) return &sr->modes[i]; } return nullptr; } static void for_each_active_monitor_output(Display *display, std::function callback_func) { XRRScreenResources *screen_res = XRRGetScreenResources(display, DefaultRootWindow(display)); if(!screen_res) return; for(int i = 0; i < screen_res->noutput; ++i) { XRROutputInfo *out_info = XRRGetOutputInfo(display, screen_res, screen_res->outputs[i]); if(out_info && out_info->crtc && out_info->connection == RR_Connected) { XRRCrtcInfo *crt_info = XRRGetCrtcInfo(display, screen_res, out_info->crtc); if(crt_info && crt_info->mode) { const XRRModeInfo *mode_info = get_mode_info(screen_res, crt_info->mode); if(mode_info) callback_func(crt_info, mode_info); } if(crt_info) XRRFreeCrtcInfo(crt_info); } if(out_info) XRRFreeOutputInfo(out_info); } XRRFreeScreenResources(screen_res); } static int get_largest_monitor_height(Display *display) { int max_height = 0; for_each_active_monitor_output(display, [&max_height](const XRRCrtcInfo *crtc_info, const XRRModeInfo*) { // Need to get the min of width or height because we want to get the smallest size for monitors in portrait mode, for mobile devices such as pinephone int width_or_height = std::min((int)crtc_info->width, (int)crtc_info->height); max_height = std::max(max_height, width_or_height); }); if(max_height == 0) max_height = XHeightOfScreen(DefaultScreenOfDisplay(display)); return std::max(max_height, 240); } static void get_screen_resolution(Display *display, int *width, int *height) { *width = XWidthOfScreen(DefaultScreenOfDisplay(display)); *height = XHeightOfScreen(DefaultScreenOfDisplay(display)); } static mgl::Color interpolate_colors(mgl::Color source, mgl::Color target, double progress) { int diff_r = (int)target.r - (int)source.r; int diff_g = (int)target.g - (int)source.g; int diff_b = (int)target.b - (int)source.b; int diff_a = (int)target.a - (int)source.a; return mgl::Color( source.r + diff_r * progress, source.g + diff_g * progress, source.b + diff_b * progress, source.a + diff_a * progress); } static std::string base64_url_encode(const std::string &data) { return cppcodec::base64_url::encode(data); } static std::string base64_url_decode(const std::string &data) { return cppcodec::base64_url::decode(data); } static void set_window_icon(Display *display, Window window, const std::string &icon_filepath) { mgl::Image image; if(!image.load_from_file(icon_filepath.c_str()) || image.get_num_channels() != 4) { fprintf(stderr, "Warning: failed to load window icon: %s\n", icon_filepath.c_str()); return; } const size_t icon_buffer_size = 2 + image.get_size().x * image.get_size().y; unsigned long *icon_buffer = new unsigned long[icon_buffer_size]; icon_buffer[0] = image.get_size().x; icon_buffer[1] = image.get_size().y; const unsigned char *image_data = image.data(); for(size_t i = 0; i < (size_t)image.get_size().x * (size_t)image.get_size().y; ++i) { const size_t image_data_index = i * 4; icon_buffer[2 + i] = (long)image_data[image_data_index + 0] << 16 | (long)image_data[image_data_index + 1] << 8 | (long)image_data[image_data_index + 2] << 0 | (long)image_data[image_data_index + 3] << 24; } Atom net_wm_icon = XInternAtom(display, "_NET_WM_ICON", False); XChangeProperty(display, window, net_wm_icon, XA_CARDINAL, 32, PropModeReplace, (const unsigned char*)icon_buffer, icon_buffer_size); XFlush(display); delete []icon_buffer; } namespace QuickMedia { enum class HistoryType { YOUTUBE, MANGA }; class HistoryPage : public LazyFetchPage { public: HistoryPage(Program *program, Page *search_page, HistoryType history_type, bool local_thumbnail = false) : LazyFetchPage(program), search_page(search_page), history_type(history_type), local_thumbnail(local_thumbnail) {} const char* get_title() const override { return "History"; } PluginResult submit(const SubmitArgs &args, std::vector &result_tabs) override { return search_page->submit(args, result_tabs); } PluginResult lazy_fetch(BodyItems &result_items) override { switch(history_type) { case HistoryType::YOUTUBE: program->youtube_get_watch_history(result_items); break; case HistoryType::MANGA: program->manga_get_watch_history(program->get_plugin_name(), result_items, local_thumbnail); break; } return PluginResult::OK; } bool reload_on_page_change() override { return true; } const char* get_bookmark_name() const override { return search_page->get_bookmark_name(); } private: Page *search_page; HistoryType history_type; bool local_thumbnail; }; using OptionsPageHandler = std::function; class OptionsPage : public Page { public: OptionsPage(Program *program, std::string title) : Page(program), title(std::move(title)) {} const char* get_title() const override { return title.c_str(); } PluginResult submit(const SubmitArgs &args, std::vector&) override { const int handlers_index = atoi(args.url.c_str()); handlers[handlers_index](); program->set_go_to_previous_page(); return PluginResult::OK; } bool submit_is_async() const override { return false; } void add_option(Body *body, std::string title, std::string description, OptionsPageHandler handler) { assert(handler); auto body_item = BodyItem::create(std::move(title)); if(!description.empty()) { body_item->set_description(std::move(description)); body_item->set_description_color(get_theme().faded_text_color); } body_item->url = std::to_string(handlers.size()); handlers.push_back(std::move(handler)); body->append_item(std::move(body_item)); } private: std::string title; std::vector handlers; }; Program::Program() : disp(nullptr), window_size(1050, 1080), current_page(PageType::EXIT), image_index(0) { } Program::~Program() { window.close(); images_to_upscale_queue.close(); if(image_upscale_thead.joinable()) image_upscale_thead.join(); if(matrix) delete matrix; if(disp) XCloseDisplay(disp); } static void usage() { fprintf(stderr, "usage: quickmedia [plugin|url] [--no-video] [--upscale-images] [--upscale-images-always] [--dir ] [--instance ] [-e ] [--video-max-height ]\n"); fprintf(stderr, "OPTIONS:\n"); fprintf(stderr, " plugin|url The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, onimanga, local-manga, local-anime, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, dramacool, file-manager, stdin, pornhub, spankbang, xvideos or xhamster. This can also be a youtube url, youtube channel url or a 4chan thread url\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default\n"); fprintf(stderr, " --upscale-images-always Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default\n"); fprintf(stderr, " --dir Set the start directory when using file-manager. Default is the the users home directory\n"); fprintf(stderr, " --instance The instance to use for peertube\n"); fprintf(stderr, " -e Embed QuickMedia into another window\n"); fprintf(stderr, " --video-max-height Media plugins will try to select a video source that is this size or smaller\n"); fprintf(stderr, "EXAMPLES:\n"); fprintf(stderr, " quickmedia\n"); fprintf(stderr, " quickmedia --upscale-images-always manganelo\n"); fprintf(stderr, " quickmedia https://www.youtube.com/watch?v=jHg91NVHh3s\n"); fprintf(stderr, " echo -e \"hello\\nworld\" | quickmedia stdin\n"); fprintf(stderr, " tabbed -c -k quickmedia launcher -e\n"); } static bool is_manga_plugin(const char *plugin_name) { return strcmp(plugin_name, "manga") == 0 || strcmp(plugin_name, "manganelo") == 0 || strcmp(plugin_name, "manganelos") == 0 || strcmp(plugin_name, "mangatown") == 0 || strcmp(plugin_name, "mangakatana") == 0 || strcmp(plugin_name, "mangadex") == 0 || strcmp(plugin_name, "onimanga") == 0 || strcmp(plugin_name, "local-manga") == 0; } static std::shared_ptr create_launcher_body_item(const char *title, const char *plugin_name, const std::string &dark_theme_thumbnail_url, const std::string &light_theme_thumbnail_url) { auto body_item = BodyItem::create(title); body_item->url = plugin_name; std::string thumbnail_url = is_color_scheme_dark() ? dark_theme_thumbnail_url : light_theme_thumbnail_url; if(!thumbnail_url.empty()) { body_item->thumbnail_url = std::move(thumbnail_url); body_item->thumbnail_is_local = true; } body_item->thumbnail_size.x = 32; body_item->thumbnail_size.y = 32; return body_item; } static std::shared_ptr create_launcher_body_item(const char *title, const char *plugin_name, const std::string &thumbnail_url) { return create_launcher_body_item(title, plugin_name, thumbnail_url, thumbnail_url); } static bool convert_to_absolute_path(std::filesystem::path &path) { char resolved[PATH_MAX]; if(!realpath(path.c_str(), resolved)) return false; path = resolved; return true; } int Program::run(int argc, char **argv) { mgl_init(); if(argc < 1) { usage(); return -1; } Window parent_window = None; video_max_height = 0; std::vector tabs; const char *url = nullptr; std::string program_path = Path(argv[0]).parent().data; std::string instance; std::string download_filename; bool no_dialog = false; for(int i = 1; i < argc; ++i) { if(!plugin_name && argv[i][0] != '-') { std::string youtube_url_converted = invidious_url_to_youtube_url(argv[i]); std::string youtube_channel_id; std::string youtube_video_id_dummy; std::string fourchan_id_dummy; if(youtube_url_extract_channel_id(youtube_url_converted, youtube_channel_id, launch_url)) { launch_url_type = LaunchUrlType::YOUTUBE_CHANNEL; plugin_name = "youtube"; continue; } else if(youtube_url_extract_id(youtube_url_converted, youtube_video_id_dummy)) { launch_url_type = LaunchUrlType::YOUTUBE_VIDEO; launch_url = std::move(youtube_url_converted); plugin_name = "youtube"; continue; } else if(fourchan_extract_url(argv[i], fourchan_id_dummy, fourchan_id_dummy, fourchan_id_dummy)) { launch_url_type = LaunchUrlType::FOURCHAN_THREAD; launch_url = argv[i]; plugin_name = "4chan"; continue; } for(const auto &valid_plugin : valid_plugins) { if(strcmp(argv[i], valid_plugin.first) == 0) { plugin_name = argv[i]; break; } } if(!plugin_name) { fprintf(stderr, "\"%s\" is not a valid plugin/youtube url\n", argv[i]); usage(); return -1; } } if(strcmp(argv[i], "--no-video") == 0) { force_no_video = true; } else if(strcmp(argv[i], "--upscale-images") == 0) { upscale_image_action = UpscaleImageAction::LOW_RESOLUTION; } else if(strcmp(argv[i], "--upscale-images-force") == 0 || strcmp(argv[i], "--upscale-images-always") == 0) { upscale_image_action = UpscaleImageAction::FORCE; } else if(strcmp(argv[i], "--instance") == 0) { if(i < argc - 1) { instance = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing instance after --instance argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "--dir") == 0) { if(i < argc - 1) { file_manager_start_dir = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing directory after --dir argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "--low-cpu-mode") == 0) { low_cpu_mode = true; } else if(strcmp(argv[i], "-u") == 0) { if(i < argc - 1) { url = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing url after -u argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "-e") == 0) { if(i < argc - 1) { parent_window = strtoll(argv[i + 1], nullptr, 0); if(parent_window == None && errno == EINVAL) { fprintf(stderr, "Invalid -e argument. Argument has to be a number\n"); usage(); return -1; } ++i; } else { fprintf(stderr, "Missing window id after -e argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "--video-max-height") == 0) { if(i < argc - 1) { errno = 0; video_max_height = strtoll(argv[i + 1], nullptr, 0); if(errno == EINVAL) { fprintf(stderr, "Invalid --video-max-height argument. Argument has to be a number\n"); usage(); return -1; } ++i; } else { fprintf(stderr, "Missing number after --video-max-height argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "--download-filename") == 0) { if(i < argc - 1) { download_filename = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing filename after --download-filename argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "--no-dialog") == 0) { no_dialog = true; } else if(strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { usage(); return 0; } else if(argv[i][0] == '-') { fprintf(stderr, "Invalid option %s\n", argv[i]); usage(); return -1; } } if(!plugin_name) plugin_name = "launcher"; if(low_cpu_mode) FPS_IDLE = 3; else FPS_IDLE = 20; if(upscale_image_action != UpscaleImageAction::NO) { if(!is_manga_plugin(plugin_name)) { fprintf(stderr, "Option --upscale-images/-upscale-images-force is only valid for manga plugins\n"); return -2; } if(!is_program_executable_by_name("waifu2x-ncnn-vulkan")) { fprintf(stderr, "waifu2x-ncnn-vulkan needs to be installed when using the --upscale-images/--upscale-images-always option\n"); return -2; } image_upscale_thead = std::thread([this]{ std::optional copy_op_opt; while(true) { copy_op_opt = images_to_upscale_queue.pop_wait(); if(!copy_op_opt) break; CopyOp ©_op = copy_op_opt.value(); Path tmp_file = copy_op.source; tmp_file.append(".tmp.png"); fprintf(stderr, "Upscaling %s\n", copy_op.source.data.c_str()); const char *args[] = { "waifu2x-ncnn-vulkan", "-n", "3", "-i", copy_op.source.data.c_str(), "-o", tmp_file.data.c_str(), nullptr }; if(exec_program(args, nullptr, nullptr) != 0) { fprintf(stderr, "Warning: failed to upscale %s with waifu2x-ncnn-vulkan\n", copy_op.source.data.c_str()); // No conversion, but we need the file to have the destination name to see that the operation completed (and read it) if(rename_atomic(copy_op.source.data.c_str(), copy_op.destination.data.c_str()) != 0) perror(tmp_file.data.c_str()); continue; } if(rename_atomic(tmp_file.data.c_str(), copy_op.destination.data.c_str()) != 0) perror(tmp_file.data.c_str()); copy_op.destination.append(".upscaled"); file_overwrite(copy_op.destination.data.c_str(), "1"); } }); } std::filesystem::path home_path = get_home_dir().data; if(file_manager_start_dir.empty()) { file_manager_start_dir = home_path; } else { if(!convert_to_absolute_path(file_manager_start_dir)) fprintf(stderr, "Warning: failed to get absolute path for path: %s\n", file_manager_start_dir.c_str()); } int start_tab_index = 0; FileManagerMimeType fm_mine_type = FILE_MANAGER_MIME_TYPE_ALL; FileSelectionHandler file_selection_handler = nullptr; FileSelectionHandler saucenao_file_selection_handler = [this](FileManagerPage*, const std::filesystem::path&) { std::vector tabs; tabs.push_back(Tab{create_body(), std::make_unique(this, selected_files[0], true), nullptr}); return tabs; }; no_video = force_no_video; init(parent_window, program_path, no_dialog); if(strcmp(plugin_name, "download") == 0) { if(!url) { fprintf(stderr, "-u argument has to be set when using the download plugin\n"); usage(); return -1; } download_page(url, download_filename, no_dialog); return exit_code; } if(strcmp(plugin_name, "saucenao") == 0) { plugin_name = "file-manager"; fm_mine_type = FILE_MANAGER_MIME_TYPE_IMAGE; file_selection_handler = std::move(saucenao_file_selection_handler); } load_plugin_by_name(tabs, start_tab_index, fm_mine_type, std::move(file_selection_handler), std::move(instance)); while(!tabs.empty() || matrix) { if(matrix) { if(matrix->load_cached_session() == PluginResult::OK) { current_page = PageType::CHAT; } else { fprintf(stderr, "Failed to load session cache, redirecting to login page\n"); current_page = PageType::CHAT_LOGIN; chat_login_page(); } after_matrix_login_page(); goto done; } page_loop(tabs, start_tab_index); tabs.clear(); if(strcmp(plugin_name, "launcher") == 0) { plugin_name = pipe_selected_text.c_str(); if(strcmp(plugin_name, "saucenao") == 0) { plugin_name = "file-manager"; fm_mine_type = FILE_MANAGER_MIME_TYPE_IMAGE; file_selection_handler = std::move(saucenao_file_selection_handler); } load_plugin_by_name(tabs, start_tab_index, fm_mine_type, std::move(file_selection_handler), ""); } } done: if(plugin_name && strcmp(plugin_name, "matrix") == 0 && !matrix_instance_already_running) remove_quickmedia_instance_lock(get_cache_dir().join("matrix").data.c_str(), "matrix"); return exit_code; } // TODO: Move to mgl static mgl::vec2i get_global_mouse_position(Display *display) { Window dummy_w; int dummy_i; unsigned int dummy_u; mgl::vec2i mouse_pos; XQueryPointer(display, DefaultRootWindow(display), &dummy_w, &dummy_w, &mouse_pos.x, &mouse_pos.y, &dummy_i, &dummy_i, &dummy_u); return mouse_pos; } static mgl::vec2i get_focused_monitor_center(Display *disp, mgl::vec2i &monitor_size) { int screen = DefaultScreen(disp); monitor_size.x = DisplayWidth(disp, screen); monitor_size.y = DisplayHeight(disp, screen); int screen_center_x = monitor_size.x / 2; int screen_center_y = monitor_size.y / 2; mgl::vec2i focused_monitor_center(screen_center_x, screen_center_y); mgl::vec2i mouse_pos = get_global_mouse_position(disp); for_each_active_monitor_output(disp, [&focused_monitor_center, mouse_pos, &monitor_size](const XRRCrtcInfo *crtc_info, const XRRModeInfo*){ if(mgl::Rect(mgl::vec2i(crtc_info->x, crtc_info->y), mgl::vec2i(crtc_info->width, crtc_info->height)).contains(mouse_pos)) { monitor_size.x = crtc_info->width; monitor_size.y = crtc_info->height; focused_monitor_center = mgl::vec2i(crtc_info->x + crtc_info->width/2, crtc_info->y + crtc_info->height/2); } }); return focused_monitor_center; } void Program::init(mgl::WindowHandle parent_window, std::string &program_path, bool no_dialog) { disp = XOpenDisplay(NULL); if (!disp) { show_notification("QuickMedia", "Failed to open display to X11 server", Urgency::CRITICAL); abort(); } XSetErrorHandler(x_error_handler); XSetIOErrorHandler(x_io_error_handler); // Initialize config and theme early to prevent possible race condition on initialize get_config(); get_theme(); mgl::vec2i monitor_size; mgl::vec2i focused_monitor_center = get_focused_monitor_center(disp, monitor_size); if(strcmp(plugin_name, "download") == 0) { window_size.x = std::min(900, monitor_size.x - 100); window_size.y = std::min(900, monitor_size.y - 100); } else { window_size.x = std::min(window_size.x, monitor_size.x - 100); window_size.y = std::min(window_size.y, monitor_size.y - 100); } mgl::Window::CreateParams window_create_params; window_create_params.position = { focused_monitor_center.x - window_size.x / 2, focused_monitor_center.y - window_size.y / 2 }; window_create_params.size = window_size; if(strcmp(plugin_name, "download") == 0) { window_create_params.min_size = window_size; window_create_params.max_size = window_size; } window_create_params.hidden = no_dialog; window_create_params.parent_window = parent_window; window_create_params.background_color = get_theme().background_color; if(!window.create("QuickMedia", std::move(window_create_params))) { show_notification("QuickMedia", "Failed to create opengl window", Urgency::CRITICAL); abort(); } window.set_low_latency(get_config().low_latency_mode); if(!program_path.empty() && program_path.back() != '/') program_path += '/'; resources_root = "/usr/share/quickmedia/"; if(get_file_type(program_path + "../../../images/manganelo_logo.png") == FileType::REGULAR) { resources_root = program_path + "../../../"; } set_resource_loader_root_path(resources_root.c_str()); init_body_themes(); set_window_icon(disp, window.get_system_handle(), resources_root + "icons/qm_logo.png"); if(!is_touch_enabled() && get_config().enable_shaders) { if(get_theme().circle_mask_enabled && !circle_mask_shader.load_from_file((resources_root + "shaders/circle_mask.glsl").c_str(), mgl::Shader::Type::Fragment)) { show_notification("QuickMedia", "Failed to load " + resources_root + "/shaders/circle_mask.glsl", Urgency::CRITICAL); abort(); } if(get_theme().rounded_rectangles) { if(get_theme().drop_shadow) { if(!rounded_rectangle_shader.load_from_file((resources_root + "shaders/rounded_rectangle.glsl").c_str(), mgl::Shader::Type::Fragment)) { show_notification("QuickMedia", "Failed to load " + resources_root + "/shaders/rounded_rectangle.glsl", Urgency::CRITICAL); abort(); } } else { if(!rounded_rectangle_shader.load_from_file((resources_root + "shaders/rounded_rectangle_no_shadow.glsl").c_str(), mgl::Shader::Type::Fragment)) { show_notification("QuickMedia", "Failed to load " + resources_root + "/shaders/rounded_rectangle_no_shadow.glsl", Urgency::CRITICAL); abort(); } } if(!rounded_rectangle_mask_shader.load_from_file((resources_root + "shaders/rounded_rectangle_mask.glsl").c_str(), mgl::Shader::Type::Fragment)) { show_notification("QuickMedia", "Failed to load " + resources_root + "/shaders/rounded_rectangle_mask.glsl", Urgency::CRITICAL); abort(); } } } const char *loading_icon_path = is_color_scheme_dark() ? "images/loading_icon.png" : "images/loading_icon_light.png"; if(!loading_icon.load_from_file((resources_root + loading_icon_path).c_str())) { show_notification("QuickMedia", "Failed to load " + resources_root + loading_icon_path, Urgency::CRITICAL); abort(); } load_sprite.set_texture(&loading_icon); mgl::vec2i loading_icon_size = loading_icon.get_size(); load_sprite.set_origin(mgl::vec2f(loading_icon_size.x * 0.5f, loading_icon_size.y * 0.5f)); TextureLoader::get_texture("images/search_icon.png"); TextureLoader::get_texture("images/arrow.png"); struct sigaction action; action.sa_handler = sigpipe_handler; sigemptyset(&action.sa_mask); action.sa_flags = 0; sigaction(SIGPIPE, &action, NULL); XSetErrorHandler(x_error_handler); XSetIOErrorHandler(x_io_error_handler); idle = false; if(create_directory_recursive(get_cache_dir().join("media")) != 0) { show_notification("QuickMedia", "Failed to create media directory", Urgency::CRITICAL); abort(); } if(create_directory_recursive(get_cache_dir().join("thumbnails")) != 0) { show_notification("QuickMedia", "Failed to create thumbnails directory", Urgency::CRITICAL); abort(); } if(create_directory_recursive(get_storage_dir()) != 0) { show_notification("QuickMedia", "Failed to create storage directory", Urgency::CRITICAL); abort(); } //if(create_directory_recursive(get_storage_dir().join("file-manager")) != 0) { // show_notification("QuickMedia", "Failed to create file-manager directory", Urgency::CRITICAL); // abort(); //} const char *qm_phone_factor = getenv("QM_PHONE_FACTOR"); if(qm_phone_factor && atoi(qm_phone_factor) == 1) show_room_side_panel = false; else show_room_side_panel = true; main_thread_id = std::this_thread::get_id(); auto window_size_u = window.get_size(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; } // Returns size_t(-1) if not found static size_t find_end_of_json_array(const char *str, size_t start, size_t size) { if(size <= start || str[start] != '[') return size_t(-1); bool inside_string = false; bool escape = false; int array_depth = 0; for(size_t i = start; i < size; ++i) { char c = str[i]; if(c == '"' && !escape) { inside_string = !inside_string; } else if(c == '\\') { escape = !escape; } else if(c == '[' && !inside_string && !escape) { ++array_depth; } else if(c == ']' && !inside_string && !escape) { --array_depth; if(array_depth == 0) return i + 1; } else { escape = false; } } return size_t(-1); } static void add_manganelos_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("http://manganelos.com/search?q=%s&page=%p", 1) .text_handler({{"//div[class='media-left cover-manga']//a", "title", "href", "/manga/"}}) .thumbnail_handler({{"//div[class='media-left cover-manga']//img[class='media-object']", "src", "/mangaimage/"}}) .list_chapters_handler("//section[id='examples']//div[class='chapter-list']//a", "text", "href", nullptr) .list_page_images_handler("//p[id='arraydata']", "text", nullptr, [](std::vector &urls) { if(urls.size() != 1) return; std::string urls_combined = urls.front(); urls.clear(); string_split(urls_combined, ',', [&urls](const char *str, size_t size) { std::string url(str, size); url = strip(url); if(!url.empty()) urls.push_back(std::move(url)); return true; }); }) .manga_id_handler("/manga/", "?"); } static void add_mangatown_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("https://www.mangatown.com/search?name=%s&page=%p", 1) .text_handler({{"//p[class='title']/a", "title", "href", "/manga/"}}) .thumbnail_handler({{"//a[class='manga_cover']/img", "src", nullptr}}) .authors_handler({ {"//div[class='detail_content']//a", "text", "href", "/author/"}, {"//div[class='detail_content']//a", "text", "href", "/artist/"} }) .list_chapters_handler("//ul[class='chapter_list']//a", "text", "href", "/manga/") .list_chapters_uploaded_time_handler("//ul[class='chapter_list']//span[class='time']", "text", nullptr) .list_page_images_pagination_handler( "//div[class='page_select']//option", "text", "//img[id='image']", "src", nullptr, "//a[class='next_page']", "href", nullptr) .manga_id_handler("/manga/", "/"); } static std::vector extract_javascript_sections(const std::string &html_source) { std::vector sections; size_t start = 0; while(true) { start = html_source.find("", start); if(start == std::string::npos) break; start += 1; size_t end = html_source.find("", start); if(end == std::string::npos) break; sections.push_back(html_source.substr(start, end - start)); start = end + 9; } return sections; } static std::vector get_javascript_string_arrays_unique(const std::string &js_source) { std::vector arrays; size_t start = 0; while(true) { start = js_source.find("=['", start); if(start == std::string::npos) break; start += 3; size_t end = js_source.find("]", start); if(end == std::string::npos) break; size_t sources_start = start - 2; // just before [ size_t json_end = find_end_of_json_array(js_source.c_str(), sources_start, js_source.size()); if(json_end == size_t(-1)) break; sources_start += 1; json_end -= 1; std::string urls_str = js_source.substr(sources_start, json_end - sources_start); string_replace_all(urls_str, "'", ""); string_split(urls_str, ',', [&arrays](const char *str, size_t size) { std::string url(str, size); url = strip(url); if(!url.empty() && (arrays.empty() || arrays.back() != url)) arrays.push_back(std::move(url)); return true; }); start = end + 1; } return arrays; } static void add_mangakatana_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("https://mangakatana.com/page/%p?search=%s&search_by=book_name", 1) .text_handler({ {"//div[id='book_list']//h3[class='title']//a", "text", "href", "/manga/"}, {"//div[id='single_book']//h1[class='heading']", "text", nullptr, nullptr} }) .thumbnail_handler({ {"//div[id='book_list']//div[class='media']//img", "src", nullptr}, {"//div[id='single_book']//div[class='cover']//img", "src", nullptr} }) .description_handler({ {"//div[id='book_list']//div[class='*summary*']", "text"}, {"//div[id='single_book']//div[class='*summary*']", "text"} }) .authors_handler({{"//div[id='single_book']//a[class='author']", "text", "href", "/author/"}}) .list_chapters_handler("//div[class='chapters']//div[class='chapter']//a[0]", "text", "href", "/manga/") .list_chapters_uploaded_time_handler("//div[class='chapters']//div[class='update_time']", "text", nullptr) .list_page_images_custom_handler([](const std::string &html_source) { std::vector urls; for(const std::string &js_section : extract_javascript_sections(html_source)) { std::vector js_string_array = get_javascript_string_arrays_unique(js_section); urls.insert(urls.end(), std::move_iterator(js_string_array.begin()), std::move_iterator(js_string_array.end())); } return urls; }) .manga_id_handler("/manga/", nullptr); } static void add_onimanga_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("https://onimanga.com/search?search=%s", 1) .text_handler({{"//li[class='manga-name']/a", "text", "href", nullptr}}) .list_chapters_handler("//div[class='manga-chapters']//div[class='chapter']//a", "text", "href", nullptr) .list_page_images_handler("//img[class='page']", "src", "/scans/") .manga_id_handler("/", nullptr); } static void add_pornhub_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://www.pornhub.com/video/search?search=%s&page=%p", 1) .text_handler({{"//div[class='nf-videos']//div[class='phimage']//a", "title", "href", "/view_video.php"}}) .thumbnail_handler({{"//div[class='nf-videos']//div[class='phimage']//img", "data-src", "/videos/"}}) .related_media_text_handler({{"//div[class='phimage']//a", "title", "href", "/view_video.php"}}) .related_media_thumbnail_handler({{"//div[class='phimage']//img", "data-src", nullptr}}); } static void add_spankbang_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://spankbang.com/s/%s/%p/", 1) .text_handler({{"//div[class='main_results']//div[class='video-item']//a[class='n']", "text", "href", "/video/"}}) .thumbnail_handler({{"//div[class='main_results']//div[class='video-item']//img", "data-src", nullptr}}) .related_media_text_handler({{"//div[class='right']//div[class='video-item']//a[class='n']", "text", "href", "/video/"}}) .related_media_thumbnail_handler({{"//div[class='right']//div[class='video-item']//img", "data-src", nullptr}}); } static void add_xvideos_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://www.xvideos.com/?k=%s&p=%p", 0) .text_handler({{"//div[id='main']//div[class='thumb-under']//a", "title", "href", "/video"}}) .thumbnail_handler({{"//div[id='main']//div[class='thumb']//img", "data-src", "/videos"}}) .video_url_custom_handler([](const std::string &html_source) -> std::string { size_t start_index = html_source.find("html5player.setVideoHLS"); if(start_index == std::string::npos) return ""; start_index += 23; start_index = html_source.find("'", start_index); if(start_index == std::string::npos) return ""; start_index += 1; size_t end_index = html_source.find("'", start_index); if(end_index == std::string::npos) return ""; return html_source.substr(start_index, end_index - start_index); }) .related_media_custom_handler([](const std::string &html_source) { std::vector related_items; size_t related_start = html_source.find("video_related=["); if(related_start == std::string::npos) return related_items; related_start += 14; // just before [ size_t json_end = find_end_of_json_array(html_source.c_str(), related_start, html_source.size()); if(json_end == size_t(-1)) return related_items; Json::Value json_root; Json::CharReaderBuilder json_builder; std::unique_ptr json_reader(json_builder.newCharReader()); std::string json_errors; if(!json_reader->parse(html_source.c_str() + related_start, html_source.c_str() + json_end, &json_root, &json_errors)) { fprintf(stderr, "Failed to parse xvideos related json, error: %s\n", json_errors.c_str()); return related_items; } if(!json_root.isArray()) return related_items; for(const Json::Value &json_item : json_root) { if(!json_item.isObject()) continue; const Json::Value &title_json = json_item["tf"]; const Json::Value &url_json = json_item["u"]; const Json::Value &thumbnail_url_json = json_item["i"]; if(!title_json.isString() || !url_json.isString()) continue; MediaRelatedItem related_item; related_item.title = title_json.asString(); related_item.url = url_json.asString(); if(thumbnail_url_json.isString()) related_item.thumbnail_url = thumbnail_url_json.asString(); related_items.push_back(std::move(related_item)); } return related_items; }); } static void add_xhamster_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://xhamster.com/search/%s?page=%p", 1) .text_handler({{"//div[data-role='video-list']//div[class='video-thumb-info']//a", "text", "href", "/videos/"}}) .thumbnail_handler({{"//div[data-role='video-list']//img", "src", "xhcdn"}}) .related_media_text_handler({{"//div[data-role='video-relations']//div[class='video-thumb-info']//a", "text", "href", "/videos/"}}) .related_media_thumbnail_handler({{"//div[data-role='video-relations']//img", "src", "xhcdn"}}); } void Program::check_youtube_dl_installed(const std::string &plugin_name) { if(yt_dl_name) return; if(is_program_executable_by_name("yt-dlp")) { yt_dl_name = "yt-dlp"; } else if(is_program_executable_by_name("youtube-dl")) { yt_dl_name = "youtube-dl"; } else { show_notification("QuickMedia", "yt-dlp or youtube-dl needs to be installed to play " + plugin_name + " videos", Urgency::CRITICAL); exit(10); } } void Program::load_plugin_by_name(std::vector &tabs, int &start_tab_index, FileManagerMimeType fm_mime_type, FileSelectionHandler file_selection_handler, std::string instance) { if(!plugin_name || plugin_name[0] == '\0') return; window.set_title(("QuickMedia - " + std::string(plugin_name)).c_str()); no_video = force_no_video; if(strcmp(plugin_name, "youtube-audio") == 0) { plugin_name = "youtube"; no_video = true; } std::string plugin_logo_path; const char *plugin_logo_name = get_plugin_logo_name(plugin_name); if(plugin_logo_name) plugin_logo_path = resources_root + "images/" + plugin_logo_name; plugin_logo.clear(); if(!plugin_logo_path.empty()) { if(!plugin_logo.load_from_file(plugin_logo_path.c_str())) fprintf(stderr, "Failed to load plugin logo, path: %s\n", plugin_logo_path.c_str()); // TODO: Fix //plugin_logo.generateMipmap(); } if(strcmp(plugin_name, "launcher") == 0) { auto pipe_body = create_body(true); pipe_body->set_items({ create_launcher_body_item("4chan", "4chan", resources_root + "icons/4chan_launcher.png"), create_launcher_body_item("AniList", "anilist", resources_root + "images/anilist_logo.png"), create_launcher_body_item("DramaCool", "dramacool", resources_root + "images/dramacool_logo.png"), create_launcher_body_item("Hot Examples", "hotexamples", ""), create_launcher_body_item("Lbry", "lbry", resources_root + "icons/lbry_launcher.png"), create_launcher_body_item("Local anime", "local-anime", ""), create_launcher_body_item("Local manga", "local-manga", ""), create_launcher_body_item("Manga (all)", "manga", ""), create_launcher_body_item("Mangadex", "mangadex", resources_root + "icons/mangadex_launcher.png"), create_launcher_body_item("Mangakatana", "mangakatana", resources_root + "icons/mangakatana_launcher.png"), create_launcher_body_item("Manganelo", "manganelo", resources_root + "icons/manganelo_launcher.png"), create_launcher_body_item("Manganelos", "manganelos", resources_root + "icons/manganelos_launcher.png"), create_launcher_body_item("Mangatown", "mangatown", resources_root + "icons/mangatown_launcher.png", resources_root + "icons/mangatown_launcher_light.png"), create_launcher_body_item("Onimanga", "onimanga", ""), create_launcher_body_item("Matrix", "matrix", resources_root + "icons/matrix_launcher.png"), create_launcher_body_item("Nyaa.si", "nyaa.si", resources_root + "icons/nyaa_si_launcher.png"), create_launcher_body_item("PeerTube", "peertube", resources_root + "images/peertube_logo.png"), create_launcher_body_item("SauceNAO", "saucenao", ""), create_launcher_body_item("Soundcloud", "soundcloud", resources_root + "icons/soundcloud_launcher.png"), create_launcher_body_item("YouTube", "youtube", resources_root + "icons/yt_launcher.png"), create_launcher_body_item("YouTube (audio only)", "youtube-audio", resources_root + "icons/yt_launcher.png"), }); tabs.push_back(Tab{std::move(pipe_body), std::make_unique(this, "Select plugin to launch"), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "manganelo") == 0) { auto search_page = std::make_unique(this); tabs.push_back(Tab{create_body(), std::make_unique(this, search_page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; } else if(strcmp(plugin_name, "manganelos") == 0) { auto search_page = std::make_unique(this, plugin_name, "http://manganelos.com/"); add_manganelos_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::make_unique(this, search_page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; } else if(strcmp(plugin_name, "mangatown") == 0) { auto search_page = std::make_unique(this, plugin_name, "https://www.mangatown.com/"); add_mangatown_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::make_unique(this, search_page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; } else if(strcmp(plugin_name, "mangakatana") == 0) { auto search_page = std::make_unique(this, plugin_name, "https://mangakatana.com/", false); add_mangakatana_handlers(search_page.get()); auto bookmarks_body = create_body(); bookmarks_body->draw_thumbnails = false; auto history_body = create_body(); history_body->draw_thumbnails = false; tabs.push_back(Tab{std::move(bookmarks_body), std::make_unique(this, search_page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA); tabs.push_back(Tab{std::move(history_body), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; } else if(strcmp(plugin_name, "mangadex") == 0) { auto search_page = std::make_unique(this); tabs.push_back(Tab{create_body(), std::make_unique(this, search_page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; } else if(strcmp(plugin_name, "onimanga") == 0) { auto search_page = std::make_unique(this, plugin_name, "https://onimanga.com/"); add_onimanga_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::make_unique(this, search_page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; } else if(strcmp(plugin_name, "local-manga") == 0) { auto search_page = std::make_unique(this, true); tabs.push_back(Tab{create_body(false, true), std::make_unique(this, search_page.get(), true), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA, true); tabs.push_back(Tab{create_body(false, true), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; } else if(strcmp(plugin_name, "local-anime") == 0) { if(get_config().local_anime.directory.empty()) { show_notification("QuickMedia", "local_anime.directory config is not set", Urgency::CRITICAL); exit(1); } if(get_file_type(get_config().local_anime.directory) != FileType::DIRECTORY) { show_notification("QuickMedia", "local_anime.directory config is not set to a valid directory", Urgency::CRITICAL); exit(1); } auto search_page = std::make_unique(this); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 0; } else if(strcmp(plugin_name, "manga") == 0) { auto mangadex = std::make_unique(this); auto manganelo = std::make_unique(this); auto manganelos = std::make_unique(this, "manganelos", "http://manganelos.com/"); add_manganelos_handlers(manganelos.get()); auto mangatown = std::make_unique(this, "mangatown", "https://www.mangatown.com/"); add_mangatown_handlers(mangatown.get()); auto mangakatana = std::make_unique(this, "mangakatana", "https://mangakatana.com/", false); add_mangakatana_handlers(mangakatana.get()); auto onimanga = std::make_unique(this, "onimanga", "https://onimanga.com/"); add_onimanga_handlers(onimanga.get()); auto local_manga = std::make_unique(this, false); // TODO: Use async task pool std::vector pages; pages.push_back({std::move(manganelo), "Manganelo", "manganelo"}); pages.push_back({std::move(manganelos), "Manganelos", "manganelos"}); pages.push_back({std::move(mangatown), "Mangatown", "mangatown"}); pages.push_back({std::move(mangakatana), "Mangakatana", "mangakatana"}); pages.push_back({std::move(onimanga), "Onimanga", "onimanga"}); pages.push_back({std::move(mangadex), "Mangadex", "mangadex"}); if(!get_config().local_manga.directory.empty()) pages.push_back({std::move(local_manga), "Local manga", "local-manga", true}); tabs.push_back(Tab{create_body(), std::make_unique(this, std::move(pages)), create_search_bar("Search...", 400)}); } else if(strcmp(plugin_name, "nyaa.si") == 0) { auto categories_nyaa_si_body = create_body(); BodyItems body_items; get_nyaa_si_categories(body_items); categories_nyaa_si_body->set_items(std::move(body_items)); tabs.push_back(Tab{std::move(categories_nyaa_si_body), std::make_unique(this, false), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); auto categories_sukebei_body = create_body(); get_sukebei_categories(body_items); categories_sukebei_body->set_items(body_items); tabs.push_back(Tab{std::move(categories_sukebei_body), std::make_unique(this, true), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "4chan") == 0) { if(launch_url_type == LaunchUrlType::FOURCHAN_THREAD) { std::string board_id, thread_id, post_id; fourchan_extract_url(launch_url, board_id, thread_id, post_id); auto body = create_body(); auto thread_page = std::make_unique(this, std::move(board_id), std::move(thread_id), std::move(post_id), ""); page_stack.push(current_page); current_page = PageType::IMAGE_BOARD_THREAD; image_board_thread_page(thread_page.get(), body.get()); exit(exit_code); } else { auto boards_page = std::make_unique(this, resources_root); FourchanBoardsPage *boards_page_ptr = boards_page.get(); auto boards_body = create_body(); BodyItems body_items; boards_page->get_boards(body_items); boards_body->set_items(std::move(body_items)); tabs.push_back(Tab{std::move(boards_body), std::move(boards_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); auto login_page = std::make_unique(this, "4chan pass login", boards_page_ptr, &tabs, 1); FourchanLoginPage *login_page_ptr = login_page.get(); tabs.push_back(Tab{ create_body(), std::move(login_page), nullptr, {} }); login_page_ptr->login_inputs = &tabs.back().login_inputs; } } else if(strcmp(plugin_name, "hotexamples") == 0) { auto body = create_body(); BodyItems body_items; hot_examples_front_page_fill(body_items); body->set_items(std::move(body_items)); tabs.push_back(Tab{std::move(body), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "anilist") == 0) { tabs.push_back(Tab{create_body(), std::make_unique(this, AniListMediaType::ANIME), create_search_bar("Search...", 300)}); tabs.push_back(Tab{create_body(), std::make_unique(this, AniListMediaType::MANGA), create_search_bar("Search...", 300)}); } else if(strcmp(plugin_name, "file-manager") == 0) { auto file_manager_page = std::make_unique(this, fm_mime_type, file_selection_handler); if(!file_manager_page->set_current_directory(file_manager_start_dir)) fprintf(stderr, "Warning: Invalid directory provided with --dir\n"); auto file_manager_body = create_body(false, get_config().file_manager.grid_view); BodyItems body_items; file_manager_page->get_files_in_directory(body_items); file_manager_body->set_items(std::move(body_items)); tabs.push_back(Tab{std::move(file_manager_body), std::move(file_manager_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "stdin") == 0) { auto pipe_body = create_body(); BodyItems body_items; PipePage::load_body_items_from_stdin(body_items); pipe_body->set_items(std::move(body_items)); tabs.push_back(Tab{std::move(pipe_body), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "youtube") == 0) { if(launch_url_type == LaunchUrlType::YOUTUBE_CHANNEL) { YoutubeChannelPage::create_each_type(this, std::move(launch_url), "", "Channel", tabs); } else if(launch_url_type == LaunchUrlType::YOUTUBE_VIDEO) { current_page = PageType::VIDEO_CONTENT; auto youtube_video_page = std::make_unique(this, std::move(launch_url), false); video_content_page(nullptr, youtube_video_page.get(), "", false, nullptr, 0); } else { start_tab_index = 1; tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 100)}); auto history_body = create_body(false, true); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::YOUTUBE); tabs.push_back(Tab{std::move(history_body), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } } else if(strcmp(plugin_name, "peertube") == 0) { if(instance.empty()) { tabs.push_back(Tab{create_body(false, false), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else { tabs.push_back(Tab{create_body(false, true), std::make_unique(this, instance), create_search_bar("Search...", 500)}); } } else if(strcmp(plugin_name, "dramacool") == 0) { tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 350)}); } else if(strcmp(plugin_name, "pornhub") == 0) { check_youtube_dl_installed(plugin_name); auto search_page = std::make_unique(this, "https://www.pornhub.com/", mgl::vec2i(320/1.5f, 180/1.5f), true); add_pornhub_handlers(search_page.get()); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", 500)}); use_youtube_dl = true; } else if(strcmp(plugin_name, "spankbang") == 0) { check_youtube_dl_installed(plugin_name); auto search_page = std::make_unique(this, "https://spankbang.com/", mgl::vec2i(500/2.5f, 281/2.5f), true); add_spankbang_handlers(search_page.get()); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", 500)}); use_youtube_dl = true; } else if(strcmp(plugin_name, "xvideos") == 0) { std::vector extra_commands = { { "--header", "Cookie: last_views=%5B%2236247565-" + std::to_string(time(nullptr)) + "%22%5D" } }; auto search_page = std::make_unique(this, "https://www.xvideos.com/", mgl::vec2i(352/1.5f, 198/1.5f), true, std::move(extra_commands)); add_xvideos_handlers(search_page.get()); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "xhamster") == 0) { check_youtube_dl_installed(plugin_name); auto search_page = std::make_unique(this, "https://xhamster.com/", mgl::vec2i(240, 135), true); add_xhamster_handlers(search_page.get()); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", 500)}); use_youtube_dl = true; } else if(strcmp(plugin_name, "soundcloud") == 0) { tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 500)}); no_video = true; } else if(strcmp(plugin_name, "lbry") == 0) { tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "matrix") == 0) { assert(!matrix); if(create_directory_recursive(get_cache_dir().join("matrix").join("events")) != 0) { show_notification("QuickMedia", "Failed to create events cache directory", Urgency::CRITICAL); abort(); } if(is_quickmedia_instance_already_running(get_cache_dir().join("matrix").data.c_str(), "matrix")) { matrix_instance_already_running = true; } else { matrix_instance_already_running = false; if(!set_quickmedia_instance_unique(get_cache_dir().join("matrix").data.c_str(), "matrix")) { show_notification("QuickMedia", "Failed to set quickmedia process as unique", Urgency::CRITICAL); exit(exit_code); } } matrix = new Matrix(matrix_instance_already_running); } else { assert(false); } } void Program::common_event_handler(mgl::Event &event) { if(event.type == mgl::Event::KeyPressed) { if(event.key.code == mgl::Keyboard::Q && event.key.control) window.close(); } } void Program::handle_x11_events() { if(window.is_open()) { window_closed = false; } else { window_closed = true; current_page = PageType::EXIT; } } void Program::base_event_handler(mgl::Event &event, PageType previous_page, Body *body, SearchBar *search_bar, bool handle_keypress, bool handle_searchbar) { if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; } else if(handle_keypress && event.type == mgl::Event::KeyPressed) { if(event.key.code == mgl::Keyboard::Escape) { current_page = previous_page; } } else if(handle_searchbar) { assert(search_bar); search_bar->on_event(window, event); } } void Program::event_idle_handler(const mgl::Event &event) { if(event.type == mgl::Event::KeyPressed || event.type == mgl::Event::TextEntered) idle_active_handler(); } void Program::idle_active_handler() { if(idle) window.set_framerate_limit(0); idle = false; idle_timer.restart(); } void Program::update_idle_state() { if(idle) return; if(idle_timer.get_elapsed_time_seconds() > IDLE_TIMEOUT_SEC) { window.set_framerate_limit(FPS_IDLE); idle = true; } } static void fill_youtube_history_items_from_json(const Json::Value &history_json, BodyItems &history_items) { assert(history_json.isArray()); std::vector history_json_items; for(const Json::Value &item : history_json) { if(!item.isObject()) continue; const Json::Value ×tamp = item["timestamp"]; if(!timestamp.isNumeric()) continue; history_json_items.push_back(&item); } std::sort(history_json_items.begin(), history_json_items.end(), [](const Json::Value *val1, const Json::Value *val2) { const Json::Value ×tamp1 = (*val1)["timestamp"]; const Json::Value ×tamp2 = (*val2)["timestamp"]; return timestamp1.asInt64() > timestamp2.asInt64(); }); time_t time_now = time(NULL); for(const Json::Value *item : history_json_items) { const Json::Value &video_id = (*item)["id"]; if(!video_id.isString()) continue; const Json::Value &title = (*item)["title"]; if(!title.isString()) continue; const Json::Value ×tamp = (*item)["timestamp"]; std::string title_str = title.asString(); std::string video_id_str = video_id.asString(); auto body_item = BodyItem::create(std::move(title_str)); body_item->url = "https://www.youtube.com/watch?v=" + video_id_str; body_item->thumbnail_url = "https://img.youtube.com/vi/" + video_id_str + "/mqdefault.jpg"; body_item->set_description("Watched " + seconds_to_relative_time_str(time_now - timestamp.asInt64())); body_item->set_description_color(get_theme().faded_text_color); body_item->thumbnail_size = mgl::vec2i(192, 108); history_items.push_back(std::move(body_item)); } } static Path get_history_filepath(const char *plugin_name) { Path history_dir = get_storage_dir().join("history"); if(create_directory_recursive(history_dir) != 0) { show_notification("QuickMedia", "Failed to create history directory " + history_dir.data, Urgency::CRITICAL); exit(1); } Path history_filepath = history_dir; return history_filepath.join(plugin_name).append(".json"); } // This is not cached because we could have multiple instances of QuickMedia running the same plugin! // TODO: Find a way to optimize this Json::Value Program::load_history_json() { Path history_filepath = get_history_filepath(plugin_name); Json::Value json_result; FileType file_type = get_file_type(history_filepath); if(file_type == FileType::REGULAR) { if(!read_file_as_json(history_filepath, json_result) || !json_result.isArray()) { show_notification("QuickMedia", "Failed to read " + history_filepath.data, Urgency::CRITICAL); abort(); } } else { json_result = Json::Value(Json::arrayValue); } return json_result; } static Path get_recommended_filepath(const char *plugin_name) { Path video_history_dir = get_storage_dir().join("recommended"); if(create_directory_recursive(video_history_dir) != 0) { std::string err_msg = "Failed to create recommended directory "; err_msg += video_history_dir.data; show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); exit(1); } Path video_history_filepath = video_history_dir; return video_history_filepath.join(plugin_name).append(".json"); } Json::Value Program::load_recommended_json(const char *plugin_name) { Path recommended_filepath = get_recommended_filepath(plugin_name); Json::Value json_result; if(!read_file_as_json(recommended_filepath, json_result) || !json_result.isObject()) json_result = Json::Value(Json::objectValue); return json_result; } void Program::fill_recommended_items_from_json(const char *plugin_name, const Json::Value &recommended_json, BodyItems &body_items) { assert(recommended_json.isObject()); const int64_t recommendations_autodelete_period = 60*60*24*20; // 20 days time_t time_now = time(NULL); int num_items_deleted = 0; std::vector> recommended_items(recommended_json.size()); /* TODO: Optimize member access */ for(auto &member_name : recommended_json.getMemberNames()) { const Json::Value &recommended_item = recommended_json[member_name]; if(recommended_item.isObject()) { const Json::Value &recommended_timestamp_json = recommended_item["recommended_timestamp"]; const Json::Value &watched_timestamp_json = recommended_item["watched_timestamp"]; if(watched_timestamp_json.isNumeric() && time_now - watched_timestamp_json.asInt64() >= recommendations_autodelete_period) { ++num_items_deleted; } else if(recommended_timestamp_json.isNumeric() && time_now - recommended_timestamp_json.asInt64() >= recommendations_autodelete_period) { ++num_items_deleted; } else if(recommended_timestamp_json.isNull() && watched_timestamp_json.isNull()) { ++num_items_deleted; } else { recommended_items.push_back(std::make_pair(member_name, std::move(recommended_item))); } } } if(num_items_deleted > 0) { // TODO: Is there a better way? Json::Value new_recommendations(Json::objectValue); for(auto &recommended : recommended_items) { new_recommendations[recommended.first] = recommended.second; } fprintf(stderr, "Number of old recommendations to delete: %d\n", num_items_deleted); save_json_to_file_atomic(get_recommended_filepath(plugin_name), new_recommendations); } /* TODO: Better algorithm for recommendations */ std::sort(recommended_items.begin(), recommended_items.end(), [](std::pair &a, std::pair &b) { const Json::Value &a_timestamp_json = a.second["recommended_timestamp"]; const Json::Value &b_timestamp_json = b.second["recommended_timestamp"]; int64_t a_timestamp = 0; int64_t b_timestamp = 0; if(a_timestamp_json.isNumeric()) a_timestamp = a_timestamp_json.asInt64(); if(b_timestamp_json.isNumeric()) b_timestamp = b_timestamp_json.asInt64(); const Json::Value &a_recommended_count_json = a.second["recommended_count"]; const Json::Value &b_recommended_count_json = b.second["recommended_count"]; int64_t a_recommended_count = 0; int64_t b_recommended_count = 0; if(a_recommended_count_json.isNumeric()) a_recommended_count = a_recommended_count_json.asInt64(); if(b_recommended_count_json.isNumeric()) b_recommended_count = b_recommended_count_json.asInt64(); /* Put frequently recommended videos on top of recommendations. Each recommendation count is worth 5 minutes */ a_timestamp += (300 * a_recommended_count); b_timestamp += (300 * b_recommended_count); return a_timestamp > b_timestamp; }); for(auto it = recommended_items.begin(); it != recommended_items.end(); ++it) { const std::string &recommended_item_id = it->first; const Json::Value &recommended_item = it->second; int64_t watched_count = 0; const Json::Value &watched_count_json = recommended_item["watched_count"]; if(watched_count_json.isNumeric()) watched_count = watched_count_json.asInt64(); /* TODO: Improve recommendations with some kind of algorithm. Videos we have seen should be recommended in some cases */ if(watched_count != 0) continue; const Json::Value &recommended_title_json = recommended_item["title"]; if(!recommended_title_json.isString()) continue; auto body_item = BodyItem::create(recommended_title_json.asString()); body_item->url = "https://www.youtube.com/watch?v=" + recommended_item_id; body_item->thumbnail_url = "https://img.youtube.com/vi/" + recommended_item_id + "/mqdefault.jpg"; body_item->thumbnail_size = mgl::vec2i(192, 108); body_items.push_back(std::move(body_item)); // We dont want more than 150 recommendations if(body_items.size() == 150) break; } const auto seed = std::chrono::system_clock::now().time_since_epoch().count(); std::shuffle(body_items.begin(), body_items.end(), std::default_random_engine(seed)); } void Program::save_recommendations_from_related_videos(const char *plugin_name, const std::string &video_url, const std::string &video_title, const BodyItems &related_media_body_items) { std::string video_id; if(!youtube_url_extract_id(video_url, video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += video_url; err_msg += ", video wont be saved in recommendations"; show_notification("QuickMedia", err_msg.c_str(), Urgency::LOW); return; } Json::Value recommended_json = load_recommended_json(plugin_name); time_t time_now = time(NULL); Json::Value &existing_recommended_json = recommended_json[video_id]; if(existing_recommended_json.isObject()) { int64_t watched_count = 0; Json::Value &watched_count_json = existing_recommended_json["watched_count"]; if(watched_count_json.isNumeric()) watched_count = watched_count_json.asInt64(); existing_recommended_json["watched_count"] = watched_count + 1; existing_recommended_json["watched_timestamp"] = time_now; save_json_to_file_atomic(get_recommended_filepath(plugin_name), recommended_json); return; } else { Json::Value new_content_object(Json::objectValue); new_content_object["title"] = video_title; new_content_object["recommended_timestamp"] = time_now; new_content_object["recommended_count"] = 1; new_content_object["watched_count"] = 1; new_content_object["watched_timestamp"] = time_now; recommended_json[video_id] = std::move(new_content_object); } int saved_recommendation_count = 0; for(const auto &body_item : related_media_body_items) { std::string recommended_video_id; if(youtube_url_extract_id(body_item->url, recommended_video_id)) { Json::Value &existing_recommendation = recommended_json[recommended_video_id]; if(existing_recommendation.isObject()) { int64_t recommended_count = 0; Json::Value &count_json = existing_recommendation["recommended_count"]; if(count_json.isNumeric()) recommended_count = count_json.asInt64(); existing_recommendation["recommended_count"] = recommended_count + 1; existing_recommendation["recommended_timestamp"] = time_now; } else { Json::Value new_content_object(Json::objectValue); new_content_object["title"] = body_item->get_title(); new_content_object["recommended_timestamp"] = time_now; new_content_object["recommended_count"] = 1; recommended_json[recommended_video_id] = std::move(new_content_object); saved_recommendation_count++; /* TODO: Save more than the first 3 video that hasn't been watched yet? */ if(saved_recommendation_count == 3) break; } } else { fprintf(stderr, "Failed to extract id of youtube url %s, video wont be saved in recommendations\n", video_url.c_str()); } } save_json_to_file_atomic(get_recommended_filepath(plugin_name), recommended_json); } void Program::set_clipboard(const std::string &str) { window.set_clipboard(str); } void Program::manga_get_watch_history(const char *plugin_name, BodyItems &history_items, bool local_thumbnail) { // TOOD: Make generic, instead of checking for plugin Path content_storage_dir = get_storage_dir().join(plugin_name); if(create_directory_recursive(content_storage_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_storage_dir.data, Urgency::CRITICAL); abort(); } Json::Value history_json = load_history_json(); std::unordered_map manga_id_to_thumbnail_url_map; for(const Json::Value &history_item : history_json) { const Json::Value &id = history_item["id"]; const Json::Value &thumbnail_url = history_item["thumbnail_url"]; if(!id.isString() || !thumbnail_url.isString()) continue; manga_id_to_thumbnail_url_map[id.asString()] = thumbnail_url.asString(); } // TODO: Remove this once manga history file has been in use for a few months and is filled with history time_t now = time(NULL); for_files_in_dir_sort_last_modified(content_storage_dir, [&](const Path &filepath, FileType) { // This can happen when QuickMedia crashes/is killed while writing to storage. // In that case, the storage wont be corrupt but there will be .tmp files. // TODO: Remove these .tmp files if they exist during startup if(strcmp(filepath.ext(), ".tmp") == 0) return true; Json::Value body; if(!read_file_as_json(filepath, body) || !body.isObject()) { fprintf(stderr, "Failed to read json file: %s\n", filepath.data.c_str()); return true; } // TODO: Manga combined const char *filename = filepath.filename(); std::string manga_id = base64_url_decode(filename); const Json::Value &manga_name = body["name"]; if(!manga_name.isString()) return true; time_t last_modified_time = 0; file_get_last_modified_time_seconds(filepath.data.c_str(), &last_modified_time); // TODO: Add thumbnail auto body_item = BodyItem::create(manga_name.asString()); body_item->set_description("Last read " + seconds_to_relative_time_str(now - last_modified_time)); body_item->set_description_color(get_theme().faded_text_color); auto thumbnail_it = manga_id_to_thumbnail_url_map.find(manga_id); if(thumbnail_it != manga_id_to_thumbnail_url_map.end()) { body_item->thumbnail_url = thumbnail_it->second; body_item->thumbnail_size = {101, 141}; body_item->thumbnail_is_local = local_thumbnail; } if(strcmp(plugin_name, "manganelo") == 0) body_item->url = "https://manganelo.com/manga/" + manga_id; else if(strcmp(plugin_name, "manganelos") == 0) body_item->url = "http://manganelos.com/manga/" + manga_id; else if(strcmp(plugin_name, "mangadex") == 0) body_item->url = manga_id; else if(strcmp(plugin_name, "mangatown") == 0) body_item->url = "https://mangatown.com/manga/" + manga_id; else if(strcmp(plugin_name, "mangakatana") == 0) body_item->url = "https://mangakatana.com/manga/" + manga_id; else if(strcmp(plugin_name, "onimanga") == 0) body_item->url = "https://onimanga.com/" + manga_id; else if(strcmp(plugin_name, "local-manga") == 0) body_item->url = manga_id; else fprintf(stderr, "Error: Not implemented: filename to manga chapter list\n"); history_items.push_back(std::move(body_item)); return true; }); } void Program::youtube_get_watch_history(BodyItems &history_items) { fill_youtube_history_items_from_json(load_history_json(), history_items); } static void get_body_dimensions(const mgl::vec2i &window_size, SearchBar *search_bar, mgl::vec2f &body_pos, mgl::vec2f &body_size, bool has_tabs = false) { const float body_width = window_size.x; float tab_h = Tabs::get_shade_height(); if(!search_bar) tab_h += std::floor(10.0f * get_config().scale); if(!has_tabs) tab_h = 0.0f; float search_bottom = search_bar ? search_bar->getBottomWithoutShadow() : 0.0f; body_pos = mgl::vec2f(0.0f, search_bottom + tab_h); body_size = mgl::vec2f(body_width, window_size.y - search_bottom - tab_h); } std::unique_ptr Program::create_body(bool plain_text_list, bool prefer_card_view) { if(!rounded_rectangle_mask_shader.is_valid()) plain_text_list = true; auto body = std::make_unique(plain_text_list ? BODY_THEME_MINIMAL : BODY_THEME_MODERN_SPACIOUS, loading_icon, &rounded_rectangle_shader, &rounded_rectangle_mask_shader); body->card_view = prefer_card_view; body->thumbnail_mask_shader = &circle_mask_shader; return body; } std::unique_ptr Program::create_search_bar(const std::string &placeholder, int search_delay) { auto search_bar = std::make_unique(&plugin_logo, &rounded_rectangle_shader, placeholder, SearchBarType::Search); search_bar->text_autosearch_delay_ms = search_delay; return search_bar; } void Program::add_login_inputs(Tab *tab, std::vector login_inputs) { if(login_inputs.empty()) return; std::lock_guard lock(login_inputs_mutex); for(const LoginInput &login_input : login_inputs) { auto search_bar = std::make_unique(nullptr, &rounded_rectangle_shader, login_input.placeholder, login_input.type); search_bar->padding_top = 0.0f; search_bar->padding_bottom = 0.0f; search_bar->padding_x = 0.0f; search_bar->caret_visible = false; tab->login_inputs.inputs.push_back(std::move(search_bar)); } tab->login_inputs.inputs.front()->caret_visible = true; tab->login_inputs.needs_refresh = true; } bool Program::load_manga_content_storage(const char *service_name, const std::string &manga_title, const std::string &manga_url, const std::string &manga_id) { Path content_storage_dir = get_storage_dir().join(service_name); this->manga_id = manga_id; manga_id_base64 = base64_url_encode(manga_id); content_storage_file = content_storage_dir.join(manga_id_base64); content_storage_json.clear(); content_storage_file_modified = true; bool result = true; FileType file_type = get_file_type(content_storage_file); if(file_type == FileType::REGULAR) { result = read_file_as_json(content_storage_file, content_storage_json) && content_storage_json.isObject(); if(!result) { show_notification("QuickMedia", "Failed to read " + content_storage_file.data, Urgency::CRITICAL); abort(); } } else { result = true; } if(!content_storage_json.isObject()) content_storage_json = Json::Value(Json::objectValue); content_storage_json["name"] = manga_title; content_storage_json["url"] = manga_url; return result; } void Program::select_file(const std::string &filepath) { puts(filepath.c_str()); selected_files.clear(); selected_files.push_back(filepath); } bool Program::is_window_focused() { return window.has_focus(); } RoomData* Program::get_current_chat_room() { return current_chat_room; } void Program::set_go_to_previous_page() { go_to_previous_page = true; } void Program::set_pipe_selected_text(const std::string &text) { pipe_selected_text = text; } static bool is_url_video(const std::string &url) { return string_ends_with(url, ".webm") || string_ends_with(url, ".mp4") || string_ends_with(url, ".mkv") || string_ends_with(url, ".gif"); } bool Program::show_info_page(BodyItem *body_item, bool include_reverse_image_search) { if(!body_item) return false; std::string title = body_item->get_title(); std::string description = body_item->get_description(); std::string text = std::move(title); if(!description.empty()) { if(!text.empty()) text += '\n'; text += std::move(description); } auto body = create_body(); body->title_mark_urls = true; if(include_reverse_image_search && !body_item->url.empty() && !body_item->thumbnail_url.empty()) { std::string image_url = body_item->url; if(is_url_video(body_item->url)) image_url = body_item->thumbnail_url; body->append_item(InfoPage::add_reverse_image_search(image_url)); } std::vector urls = ranges_get_strings(text, extract_urls(text)); for(const std::string &url : urls) { body->append_item(InfoPage::add_url(url)); } if(body->get_num_items() == 0) return false; std::vector info_tabs; info_tabs.push_back(Tab{std::move(body), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); page_loop(info_tabs); return true; } // Returns -1 if not found int bookmark_find_item(Json::Value &root, const std::string &title, const std::string &author, const std::string &url) { if(!root.isArray()) return false; int index = -1; for(const Json::Value &item_json : root) { ++index; if(!item_json.isObject()) continue; const Json::Value &title_json = item_json["title"]; const Json::Value &author_json = item_json["author"]; const Json::Value &url_json = item_json["url"]; if((!title.empty() && title_json.isString() && strcmp(title.c_str(), title_json.asCString()) == 0) || (!author.empty() && author_json.isString() && strcmp(author.c_str(), author_json.asCString()) == 0) || (!url.empty() && url_json.isString() && strcmp(url.c_str(), url_json.asCString()) == 0)) { return index; } } return -1; } bool Program::toggle_bookmark(BodyItem *body_item, const char *bookmark_name) { assert(bookmark_name); Path bookmark_path = get_storage_dir().join("bookmarks"); if(create_directory_recursive(bookmark_path) != 0) { show_notification("QuickMedia", "Failed to update bookmark", Urgency::CRITICAL); return false; } bookmark_path.join(bookmark_name); Json::Value json_root; if(!read_file_as_json(bookmark_path, json_root) || !json_root.isArray()) json_root = Json::Value(Json::arrayValue); const int existing_index = bookmark_find_item(json_root, body_item->get_title(), body_item->get_author(), body_item->url); if(existing_index != -1) { Json::Value removed; json_root.removeIndex(existing_index, &removed); if(!save_json_to_file_atomic(bookmark_path, json_root)) { show_notification("QuickMedia", "Failed to update bookmark", Urgency::CRITICAL); return false; } std::string bookmark_title = body_item->get_title(); if(bookmark_title.empty()) bookmark_title = body_item->get_author(); show_notification("QuickMedia", "Removed " + bookmark_title + " from bookmarks"); removed = true; return true; } Json::Value new_item(Json::objectValue); if(!body_item->get_title().empty()) new_item["title"] = body_item->get_title(); if(!body_item->get_author().empty()) new_item["author"] = body_item->get_author(); if(!body_item->url.empty()) new_item["url"] = body_item->url; if(!body_item->thumbnail_url.empty()) new_item["thumbnail_url"] = body_item->thumbnail_url; new_item["timestamp"] = (int64_t)time(nullptr); json_root.append(std::move(new_item)); if(!save_json_to_file_atomic(bookmark_path, json_root)) { show_notification("QuickMedia", "Failed to update bookmark", Urgency::CRITICAL); return false; } std::string bookmark_title = body_item->get_title(); if(bookmark_title.empty()) bookmark_title = body_item->get_author(); show_notification("QuickMedia", "Added " + bookmark_title + " to bookmarks"); return true; } void Program::page_loop_render(mgl::Window &window, std::vector &tabs, int selected_tab, TabAssociatedData &tab_associated_data, const Json::Value *json_chapters, Tabs &ui_tabs) { if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->draw(window, window_size.to_vec2f(), true); float shade_extra_height = 0.0f; if(!tabs[selected_tab].search_bar) { shade_extra_height = std::floor(10.0f * get_config().scale); mgl::Rectangle shade_top(mgl::vec2f(window_size.x, shade_extra_height)); shade_top.set_color(get_theme().shade_color); window.draw(shade_top); } float tab_vertical_offset = tabs[selected_tab].search_bar ? tabs[selected_tab].search_bar->getBottomWithoutShadow() : 0.0f; ui_tabs.draw(window, mgl::vec2f(0.0f, tab_vertical_offset + shade_extra_height), window_size.x); tabs[selected_tab].body->draw(window, body_pos, body_size, *json_chapters); if(tab_associated_data.fetching_next_page_running) window.draw(gradient_points, 4, mgl::PrimitiveType::Quads); // TODO: mgl::PrimitiveType::Quads doesn't work with egl if(!tab_associated_data.search_result_text.get_string().empty() && !tabs[selected_tab].page->search_is_suggestion(tab_associated_data.search_text_empty)) { auto search_result_text_bounds = tab_associated_data.search_result_text.get_bounds(); tab_associated_data.search_result_text.set_position(mgl::vec2f( std::floor(body_pos.x + body_size.x * 0.5f - search_result_text_bounds.size.x * 0.5f), std::floor(body_pos.y + body_size.y * 0.5f - search_result_text_bounds.size.y * 0.5f))); window.draw(tab_associated_data.search_result_text); } if(!tabs[selected_tab].page->is_ready()) { mgl::Text loading_text("Loading...", *FontLoader::get_font(FontLoader::FontType::LATIN, get_config().body.loading_text_font_size * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); loading_text.set_color(get_theme().text_color); auto text_bounds = loading_text.get_bounds(); loading_text.set_position(mgl::vec2f( std::floor(body_pos.x + body_size.x * 0.5f - text_bounds.size.x * 0.5f), std::floor(body_pos.y + body_size.y * 0.5f - text_bounds.size.y * 0.5f))); window.draw(loading_text); } if(matrix) matrix->update(); if(matrix && !matrix->is_initial_sync_finished()) { // if(is_login_sync) { load_sprite.set_position(mgl::vec2f(body_pos.x + body_size.x * 0.5f, body_pos.y + body_size.y * 0.5f)); load_sprite.set_rotation(load_sprite_timer.get_elapsed_time_seconds() * 400.0); window.draw(load_sprite); // } std::string err_msg; if(matrix->did_initial_sync_fail(err_msg)) { show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); matrix->logout(); delete matrix; matrix = new Matrix(matrix_instance_already_running); current_page = PageType::CHAT_LOGIN; chat_login_page(); after_matrix_login_page(); window.close(); exit(exit_code); } } } static void set_search_bar_to_body_item_text(BodyItem *body_item, SearchBar *search_bar) { if(!body_item || !search_bar) return; if(!body_item->get_title().empty()) { search_bar->set_text(body_item->get_title()); return; } if(!body_item->get_author().empty()) { search_bar->set_text(body_item->get_author()); return; } } bool Program::page_loop(std::vector &tabs, int start_tab_index, PageLoopSubmitHandler after_submit_handler, bool go_to_previous_on_escape) { if(tabs.empty()) { show_notification("QuickMedia", "No tabs provided!", Urgency::CRITICAL); return false; } AsyncImageLoader::get_instance().update(); malloc_trim(0); idle_active_handler(); bool loop_running = true; bool redraw = true; for(Tab &tab : tabs) { assert(tab.body.get()); assert(tab.page.get()); if(tab.body->attach_side == AttachSide::BOTTOM) tab.body->select_last_item(); tab.page->on_navigate_to_page(tab.body.get()); } Tabs ui_tabs(&rounded_rectangle_shader); for(auto &tab : tabs) { ui_tabs.add_tab(tab.page->get_title(), tab.body.get()); } ui_tabs.set_selected(start_tab_index); ui_tabs.on_change_tab = [&tabs, &redraw](int prev_tab, int) { tabs[prev_tab].body->clear_cache(); redraw = true; }; const Json::Value *json_chapters = &Json::Value::nullSingleton(); std::vector tab_associated_data; for(size_t i = 0; i < tabs.size(); ++i) { TabAssociatedData data; data.search_result_text = mgl::Text("", *FontLoader::get_font(FontLoader::FontType::LATIN, get_config().body.loading_text_font_size * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); data.search_result_text.set_color(get_theme().text_color); data.card_view = tabs[i].body ? tabs[i].body->card_view : false; tab_associated_data.push_back(std::move(data)); } double gradient_inc = 0.0; const float gradient_height = 5.0f; auto window_size_u = window.get_size(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; std::function submit_handler = [this, &submit_handler, &after_submit_handler, &tabs, &tab_associated_data, &ui_tabs, &loop_running, &redraw](const std::string &search_text) { mgl::Event event; while(window.poll_event(event)) { common_event_handler(event); } const int selected_tab = ui_tabs.get_selected(); auto selected_item = tabs[selected_tab].body->get_selected_shared(); if(!selected_item && search_text.empty() && !tabs[selected_tab].page->allow_submit_no_selection()) return; if(tabs[selected_tab].page->allow_submit_no_selection() && (window.is_key_pressed(mgl::Keyboard::LControl) || window.is_key_pressed(mgl::Keyboard::RControl))) selected_item = nullptr; if(!selected_item && !tabs[selected_tab].page->allow_submit_no_selection()) return; hide_virtual_keyboard(); std::vector new_tabs; BodyItems new_body_items; const bool search_suggestion_submitted = tab_associated_data[selected_tab].search_suggestion_submitted; if((tabs[selected_tab].page->is_single_page() || (tabs[selected_tab].page->search_is_suggestion(tab_associated_data[selected_tab].search_text_empty) && !search_suggestion_submitted)) && tab_associated_data[selected_tab].fetch_future.valid()) { tabs[selected_tab].page->cancel_operation(); tab_associated_data[selected_tab].fetch_future.cancel(); tab_associated_data[selected_tab].fetch_status = FetchStatus::NONE; tab_associated_data[selected_tab].search_text_updated = false; } auto plugin_submit_handler = [&]() { SubmitArgs submit_args; submit_args.title = selected_item ? selected_item->get_title() : search_text; submit_args.url = selected_item ? selected_item->url : search_text; submit_args.thumbnail_url = selected_item ? selected_item->thumbnail_url : ""; submit_args.userdata = selected_item ? selected_item->userdata : nullptr; submit_args.extra = selected_item ? selected_item->extra : nullptr; if(tabs[selected_tab].page->search_is_suggestion(tab_associated_data[selected_tab].search_text_empty) && !search_suggestion_submitted) { tabs[selected_tab].body->card_view = tab_associated_data[selected_tab].card_view; PluginResult plugin_result = tabs[selected_tab].page->submit_suggestion(submit_args, new_body_items); return plugin_result == PluginResult::OK; } else { PluginResult plugin_result = tabs[selected_tab].page->submit(submit_args, new_tabs); return plugin_result == PluginResult::OK; } }; TaskResult submit_result; if(tabs[selected_tab].page->submit_is_async()) { submit_result = run_task_with_loading_screen(std::move(plugin_submit_handler)); } else { submit_result = plugin_submit_handler() ? TaskResult::TRUE : TaskResult::FALSE; } if(submit_result == TaskResult::CANCEL) { return; } else if(submit_result != TaskResult::TRUE) { // TODO: Show the exact cause of error (get error message from curl). show_notification("QuickMedia", std::string("Submit failed for page ") + tabs[selected_tab].page->get_title(), Urgency::CRITICAL); return; } idle_active_handler(); if(tabs[selected_tab].page->clear_search_after_submit() && tabs[selected_tab].search_bar) { if(!tabs[selected_tab].search_bar->get_text().empty()) { tabs[selected_tab].search_bar->clear(); tabs[selected_tab].search_bar->onTextUpdateCallback(""); } else if(tabs[selected_tab].body->attach_side == AttachSide::TOP) { int selected_item_index = tabs[selected_tab].body->get_selected_item(); tabs[selected_tab].body->select_first_item(); tabs[selected_tab].body->set_selected_item(selected_item_index, false); } } if(tabs[selected_tab].page->is_single_page() && !tabs[selected_tab].page->search_is_suggestion(tab_associated_data[selected_tab].search_text_empty)) { if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->clear(); if(new_tabs.size() == 1 && !new_tabs[0].page) { tabs[selected_tab].body = std::move(new_tabs[0].body); return; } else if(new_tabs.empty()) { loop_running = false; return; } } if(tabs[selected_tab].page->search_is_suggestion(tab_associated_data[selected_tab].search_text_empty) && !search_suggestion_submitted) { if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->set_text(selected_item ? selected_item->get_title() : search_text, false); tabs[selected_tab].body->set_items(std::move(new_body_items)); tab_associated_data[selected_tab].search_suggestion_submitted = true; return; } if(new_tabs.empty()) { return; } if(after_submit_handler) after_submit_handler(new_tabs); for(size_t i = 0; i < tabs.size(); ++i) { tabs[i].body->clear_cache(); } if(tabs[selected_tab].page->allow_submit_no_selection()) { page_loop(new_tabs, 0, after_submit_handler); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::MANGA_IMAGES) { page_stack.push(current_page); select_episode(selected_item.get(), false); Body *chapters_body = tabs[selected_tab].body.get(); tabs[selected_tab].search_bar->clear(); chapters_body->filter_search_fuzzy(""); // Needed (or not really) to go to the next chapter when reaching the last page of a chapter MangaImagesPage *manga_images_page = static_cast(new_tabs[0].page.get()); window.set_key_repeat_enabled(false); downloading_chapter_url.clear(); Path manga_progress_dir = get_storage_dir().join(manga_images_page->get_service_name()); if(create_directory_recursive(manga_progress_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + manga_progress_dir.data, Urgency::CRITICAL); current_page = pop_page_stack(); } else { while(window.is_open() && (current_page == PageType::IMAGES || current_page == PageType::IMAGES_CONTINUOUS)) { int page_navigation = 0; bool continue_left_off = false; if(current_page == PageType::IMAGES) { page_navigation = image_page(manga_images_page, chapters_body, continue_left_off); } else if(current_page == PageType::IMAGES_CONTINUOUS) { page_navigation = image_continuous_page(manga_images_page); } if(page_navigation == -1) { // previous chapter // TODO: Make this work if the list is sorted differently than from newest to oldest. if(chapters_body->select_next_item()) { select_episode(chapters_body->get_selected(), !continue_left_off); if(!continue_left_off) image_index = 99999; // Start at the page that shows we are at the end of the chapter manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } else if(!tab_associated_data[selected_tab].fetching_next_page_failed) { BodyItems new_body_items; const int fetch_page = tab_associated_data[selected_tab].fetched_page + 1; TaskResult load_next_page_result = run_task_with_loading_screen([&] { if(tabs[selected_tab].page->get_page("", fetch_page, new_body_items) != PluginResult::OK) { fprintf(stderr, "Failed to get next page (page %d)\n", fetch_page); return false; } return true; }); fprintf(stderr, "Finished fetching page %d, num new items: %zu\n", fetch_page, new_body_items.size()); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { tabs[selected_tab].body->append_items(new_body_items); tab_associated_data[selected_tab].fetched_page++; select_episode(chapters_body->get_selected(), !continue_left_off); if(!continue_left_off) image_index = 99999; // Start at the page that shows we are at the end of the chapter manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } else { tab_associated_data[selected_tab].fetching_next_page_failed = true; } if(load_next_page_result == TaskResult::CANCEL) { current_page = pop_page_stack(); break; } } } else if(page_navigation == 1) { // next chapter // TODO: Make this work if the list is sorted differently than from newest to oldest. if(chapters_body->select_previous_item()) { select_episode(chapters_body->get_selected(), !continue_left_off); manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } } } } content_storage_file_modified = true; image_download_cancel = true; image_download_future.cancel(); image_download_cancel = false; images_to_upscale_queue.clear(); num_manga_pages = 0; window.set_key_repeat_enabled(true); malloc_trim(0); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::IMAGE_BOARD_THREAD) { page_stack.push(current_page); current_page = PageType::IMAGE_BOARD_THREAD; image_board_thread_page(static_cast(new_tabs[0].page.get()), new_tabs[0].body.get()); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::VIDEO) { page_stack.push(current_page); current_page = PageType::VIDEO_CONTENT; int selected_index = tabs[selected_tab].body->get_selected_item(); video_content_page(tabs[selected_tab].page.get(), static_cast(new_tabs[0].page.get()), "", false, tabs[selected_tab].body.get(), selected_index, &tab_associated_data[selected_tab].fetched_page, tab_associated_data[selected_tab].update_search_text); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::CHAT) { MatrixChatPage *tmp_matrix_chat_page = static_cast(new_tabs[0].page.get()); std::string jump_to_event_id = tmp_matrix_chat_page->jump_to_event_id; MatrixRoomsPage *rooms_page = tmp_matrix_chat_page->rooms_page; Body *room_list_body = rooms_page->body; rooms_page->clear_search(); body_set_selected_item_by_url(room_list_body, tmp_matrix_chat_page->room_id); current_page = PageType::CHAT; current_chat_room = matrix->get_room_by_id(tmp_matrix_chat_page->room_id); rooms_page->body->show_drop_shadow = false; while(window.is_open() && current_chat_room) { auto matrix_chat_page = std::make_unique(this, current_chat_room->id, rooms_page, jump_to_event_id); bool move_room = chat_page(matrix_chat_page.get(), current_chat_room); matrix_chat_page->messages_tab_visible = false; if(!move_room) break; BodyItem *selected_item = room_list_body->get_selected(); if(!selected_item) break; current_chat_room = matrix->get_room_by_id(selected_item->url); jump_to_event_id.clear(); } rooms_page->body->show_drop_shadow = true; room_list_body->body_item_select_callback = [&submit_handler](BodyItem *body_item) { submit_handler(body_item->get_title()); }; current_chat_room = nullptr; } else { page_loop(new_tabs, 0, after_submit_handler); } for(size_t i = 0; i < tabs.size(); ++i) { const bool lazy_update = tabs[i].page->search_is_filter() || (tabs[i].search_bar && tabs[i].search_bar->get_text().empty()); if(tabs[i].page->is_lazy_fetch_page() && static_cast(tabs[i].page.get())->reload_on_page_change() && lazy_update) { tab_associated_data[i].lazy_fetch_finished = false; tab_associated_data[i].fetched_page = 0; const BodyItem *selected_item = tabs[i].body->get_selected(); tab_associated_data[i].body_item_url_before_refresh = selected_item ? selected_item->url : ""; tabs[i].body->clear_items(); } } for(Tab &tab : tabs) { tab.page->on_navigate_to_page(tab.body.get()); } redraw = true; idle_active_handler(); hide_virtual_keyboard(); }; std::function on_reached_end = [&ui_tabs, &tabs, &tab_associated_data, &gradient_inc] { const int selected_tab = ui_tabs.get_selected(); if(tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].fetching_next_page_running && !tab_associated_data[selected_tab].fetching_next_page_failed && (!tabs[selected_tab].search_bar || !tabs[selected_tab].page->search_is_filter() || tabs[selected_tab].search_bar->is_empty()) && tabs[selected_tab].body->get_num_visible_items() > 0 && (!tabs[selected_tab].page->search_is_suggestion(tab_associated_data[selected_tab].search_text_empty) || tab_associated_data[selected_tab].search_suggestion_submitted) && tabs[selected_tab].page->is_ready() && (!tabs[selected_tab].page->is_lazy_fetch_page() || tab_associated_data[selected_tab].lazy_fetch_finished)) { gradient_inc = 0.0; tab_associated_data[selected_tab].fetching_next_page_running = true; int next_page = tab_associated_data[selected_tab].fetched_page + 1; Page *page = tabs[selected_tab].page.get(); std::string update_search_text = tab_associated_data[selected_tab].update_search_text; tab_associated_data[selected_tab].next_page_future = AsyncTask([update_search_text{std::move(update_search_text)}, next_page, page]() { BodyItems result_items; if(page->get_page(update_search_text, next_page, result_items) != PluginResult::OK) fprintf(stderr, "Failed to get next page (page %d)\n", next_page); return result_items; }); } }; for(size_t i = 0; i < tabs.size(); ++i) { Tab &tab = tabs[i]; tab.body->body_item_select_callback = [&submit_handler](BodyItem *body_item) { submit_handler(body_item->get_title()); }; if(tab.body->attach_side == AttachSide::TOP) tab.body->on_bottom_reached = on_reached_end; else if(tab.body->attach_side == AttachSide::BOTTOM) tab.body->on_top_reached = on_reached_end; TabAssociatedData &associated_data = tab_associated_data[i]; if(tab.search_bar) { tab.search_bar->onTextUpdateCallback = [&associated_data, &tabs, i](const std::string &text) { if(!tabs[i].page->search_is_filter()) { associated_data.update_search_text = text; associated_data.search_text_updated = true; } else { tabs[i].body->filter_search_fuzzy(text); if(tabs[i].body->attach_side == AttachSide::TOP) tabs[i].body->select_first_item(); else if(tabs[i].body->attach_side == AttachSide::BOTTOM) tabs[i].body->select_last_item(); } associated_data.typing = false; }; tab.search_bar->onTextSubmitCallback = [&submit_handler, &associated_data](const std::string &search_text) { if(associated_data.typing) return; submit_handler(search_text); }; } } RoundedRectangle login_inputs_background(mgl::vec2f(1.0f, 1.0f), 10.0f * get_config().scale, get_theme().shade_color, &rounded_rectangle_shader); for(auto &tab : tabs) { for(auto &login_input : tab.login_inputs.inputs) { login_input->padding_top = 0.0f; login_input->padding_bottom = 0.0f; login_input->padding_x = 0.0f; login_input->caret_visible = false; } if(!tab.login_inputs.inputs.empty()) tab.login_inputs.inputs.front()->caret_visible = true; } const float login_input_padding_x = std::floor(20.0f * get_config().scale * get_config().spacing_scale); const float login_input_padding_y = std::floor(20.0f * get_config().scale * get_config().spacing_scale); const float login_input_spacing_y = std::floor(20.0f * get_config().scale * get_config().spacing_scale); mgl::Event event; mgl::Clock frame_timer; while (window.is_open() && loop_running) { int32_t frame_time_ms = frame_timer.restart() * 1000.0; while (window.poll_event(event)) { common_event_handler(event); const int selected_tab = ui_tabs.get_selected(); if(tabs[selected_tab].body->on_event(window, event)) idle_active_handler(); else event_idle_handler(event); if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; idle_active_handler(); } if(tabs[selected_tab].search_bar) { tabs[selected_tab].search_bar->on_event(window, event); } ui_tabs.on_event(event); if(event.type == mgl::Event::Resized || event.type == mgl::Event::GainedFocus) redraw = true; else if(event.type == mgl::Event::KeyPressed) { if(event.key.code == mgl::Keyboard::Escape && go_to_previous_on_escape) { return false; } else if(event.key.code == mgl::Keyboard::Enter) { if(!tabs[selected_tab].search_bar) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); submit_handler(selected_item ? selected_item->get_title() : ""); } } else if(event.key.code == mgl::Keyboard::T && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item && tabs[selected_tab].page->is_trackable()) { TrackablePage *trackable_page = dynamic_cast(tabs[selected_tab].page.get()); run_task_with_loading_screen([trackable_page, selected_item](){ return trackable_page->track(selected_item->get_title()) == TrackResult::OK; }); } } else if(event.key.code == mgl::Keyboard::B && event.key.control) { auto bookmark_item = tabs[selected_tab].page->get_bookmark_body_item(tabs[selected_tab].body->get_selected()); if(!bookmark_item) bookmark_item = tabs[selected_tab].body->get_selected_shared(); if(bookmark_item) { const char *bookmark_name = tabs[selected_tab].page->get_bookmark_name(); if(bookmark_name) { if(toggle_bookmark(bookmark_item.get(), bookmark_name)) { for(Tab &tab : tabs) { if(tab.page && tab.page->is_bookmark_page()) tab.page->needs_refresh = true; } } } } } else if(event.key.code == mgl::Keyboard::R && event.key.control) { tabs[selected_tab].page->toggle_read(tabs[selected_tab].body->get_selected()); } else if(event.key.code == mgl::Keyboard::C && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item) tabs[selected_tab].page->copy_to_clipboard(selected_item); } else if(event.key.code == mgl::Keyboard::I && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(show_info_page(selected_item, false)) redraw = true; } else if(event.key.code == mgl::Keyboard::Tab && !event.key.control) { set_search_bar_to_body_item_text(tabs[selected_tab].body->get_selected(), tabs[selected_tab].search_bar.get()); std::lock_guard lock(login_inputs_mutex); if(!tabs[selected_tab].login_inputs.inputs.empty()) { for(auto &login_input : tabs[selected_tab].login_inputs.inputs) { login_input->caret_visible = false; } tabs[selected_tab].login_inputs.focused_input = (tabs[selected_tab].login_inputs.focused_input + 1) % tabs[selected_tab].login_inputs.inputs.size(); tabs[selected_tab].login_inputs.inputs[tabs[selected_tab].login_inputs.focused_input]->caret_visible = true; idle_active_handler(); } } } std::lock_guard lock(login_inputs_mutex); if(!tabs[selected_tab].login_inputs.inputs.empty()) tabs[selected_tab].login_inputs.inputs[tabs[selected_tab].login_inputs.focused_input]->on_event(window, event); } update_idle_state(); handle_x11_events(); if(!loop_running || !window.is_open()) break; const int selected_tab = ui_tabs.get_selected(); if(redraw || tabs[selected_tab].login_inputs.needs_refresh) { redraw = false; tabs[selected_tab].login_inputs.needs_refresh = false; if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->onWindowResize(window_size.to_vec2f()); // TODO: Dont show tabs if there is only one tab get_body_dimensions(window_size, tabs[selected_tab].search_bar.get(), body_pos, body_size, true); if(tabs[selected_tab].body->attach_side == AttachSide::TOP) { gradient_points[0].position.x = 0.0f; gradient_points[0].position.y = window_size.y - gradient_height; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = window_size.y - gradient_height; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = window_size.y; gradient_points[3].position.x = 0.0f; gradient_points[3].position.y = window_size.y; } else if(tabs[selected_tab].body->attach_side == AttachSide::BOTTOM) { gradient_points[0].position.x = 0.0f; gradient_points[0].position.y = body_pos.y; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = body_pos.y; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = body_pos.y + gradient_height; gradient_points[3].position.x = 0.0f; gradient_points[3].position.y = body_pos.y + gradient_height; } std::lock_guard lock(login_inputs_mutex); const int num_inputs = tabs[selected_tab].login_inputs.inputs.size(); const int first_input_height = tabs[selected_tab].login_inputs.inputs.empty() ? 0 : tabs[selected_tab].login_inputs.inputs.front()->getBottomWithoutShadow(); login_inputs_background.set_size(mgl::vec2f( std::min((float)window_size.x, std::max(640.0f, window_size.x * 0.5f)), num_inputs * first_input_height + login_input_padding_y * 2.0f + login_input_spacing_y * std::max(0, num_inputs - 1))); login_inputs_background.set_position(window_size.to_vec2f() * 0.5f - login_inputs_background.get_size() * 0.5f); mgl::vec2f pos = login_inputs_background.get_position() + mgl::vec2f(login_input_padding_x, login_input_padding_y); for(auto &login_input : tabs[selected_tab].login_inputs.inputs) { login_input->set_position(pos); pos.y += login_input->getBottomWithoutShadow() + login_input_spacing_y; } } if(tab_associated_data[selected_tab].fetching_next_page_running) { double progress = 0.5 + std::sin(std::fmod(gradient_inc, 360.0) * 0.017453292519943295 - 1.5707963267948966*0.5) * 0.5; gradient_inc += (frame_time_ms * 0.5); mgl::Color bottom_color = interpolate_colors(get_theme().background_color, get_theme().loading_bar_color, progress); if(tabs[selected_tab].body->attach_side == AttachSide::TOP) { gradient_points[0].color = get_theme().background_color; gradient_points[1].color = get_theme().background_color; gradient_points[2].color = bottom_color; gradient_points[3].color = bottom_color; } else if(tabs[selected_tab].body->attach_side == AttachSide::BOTTOM) { gradient_points[0].color = bottom_color; gradient_points[1].color = bottom_color; gradient_points[2].color = get_theme().background_color; gradient_points[3].color = get_theme().background_color; } } if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->update(); if(tabs[selected_tab].page->needs_refresh && tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].fetching_next_page_running) { tabs[selected_tab].page->needs_refresh = false; if(tabs[selected_tab].page->is_lazy_fetch_page()) { tab_associated_data[selected_tab].lazy_fetch_finished = false; tab_associated_data[selected_tab].fetched_page = 0; } else if(!tabs[selected_tab].page->search_is_filter()) { tab_associated_data[selected_tab].search_text_updated = true; } const BodyItem *selected_item = tabs[selected_tab].body->get_selected(); tab_associated_data[selected_tab].body_item_url_before_refresh = selected_item ? selected_item->url : ""; tabs[selected_tab].body->clear_items(); } if(tabs[selected_tab].page->is_ready() && tabs[selected_tab].page->is_lazy_fetch_page() && tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].lazy_fetch_finished) { tab_associated_data[selected_tab].fetch_status = FetchStatus::LOADING; tab_associated_data[selected_tab].fetch_type = FetchType::LAZY; tab_associated_data[selected_tab].search_result_text.set_string("Loading..."); LazyFetchPage *lazy_fetch_page = static_cast(tabs[selected_tab].page.get()); tab_associated_data[selected_tab].fetch_future = AsyncTask([lazy_fetch_page]() { FetchResult fetch_result; fetch_result.result = lazy_fetch_page->lazy_fetch(fetch_result.body_items); return fetch_result; }); } for(size_t i = 0; i < tabs.size(); ++i) { TabAssociatedData &associated_data = tab_associated_data[i]; if(!tabs[i].page->is_ready()) continue; if(associated_data.fetching_next_page_running && associated_data.next_page_future.ready()) { const bool body_was_empty = tabs[i].body->get_num_items() == 0; BodyItems new_body_items = associated_data.next_page_future.get(); fprintf(stderr, "Finished fetching page %d, num new items: %zu\n", associated_data.fetched_page + 1, new_body_items.size()); int prev_selected_item = tabs[i].body->get_selected_item(); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { if(tabs[i].body->attach_side == AttachSide::TOP) tabs[i].body->append_items(std::move(new_body_items)); else if(tabs[i].body->attach_side == AttachSide::BOTTOM) tabs[i].body->prepend_items_reverse(std::move(new_body_items)); associated_data.fetched_page++; } else { associated_data.fetching_next_page_failed = true; } associated_data.fetching_next_page_running = false; if(tabs[i].body->attach_side == AttachSide::BOTTOM) { if(body_was_empty) { tabs[i].body->select_last_item(); } else { // TODO: Use select_next_item in a loop instead for |num_new_messages|? tabs[i].body->set_selected_item(prev_selected_item + num_new_messages, true); } } idle_active_handler(); } if(associated_data.search_text_updated && associated_data.fetch_status == FetchStatus::LOADING && associated_data.fetch_type == FetchType::SEARCH && associated_data.fetch_future.valid()) { tabs[i].page->cancel_operation(); associated_data.fetch_future.cancel(); associated_data.fetch_status = FetchStatus::NONE; } if(associated_data.search_text_updated && associated_data.fetch_status == FetchStatus::NONE && !associated_data.fetching_next_page_running) { associated_data.search_text_empty = associated_data.update_search_text.empty(); std::string update_search_text = associated_data.update_search_text; if(tabs[i].body && (!tabs[i].page->search_is_suggestion(associated_data.search_text_empty) || associated_data.search_suggestion_submitted)) tabs[i].body->clear_items(); associated_data.search_text_updated = false; associated_data.fetch_status = FetchStatus::LOADING; associated_data.fetch_type = FetchType::SEARCH; associated_data.search_result_text.set_string("Searching..."); associated_data.search_suggestion_submitted = false; Page *page = tabs[i].page.get(); associated_data.fetch_future = AsyncTask([update_search_text{std::move(update_search_text)}, page]() { FetchResult fetch_result; fetch_result.result = search_result_to_plugin_result(page->search(update_search_text, fetch_result.body_items)); return fetch_result; }); } if(associated_data.fetch_status == FetchStatus::LOADING && associated_data.fetch_type == FetchType::SEARCH && associated_data.fetch_future.ready()) { if(tabs[i].page->search_is_suggestion(associated_data.search_text_empty) && tabs[i].body) { if(associated_data.update_search_text.empty()) tabs[i].body->card_view = tab_associated_data[selected_tab].card_view; else tabs[i].body->card_view = false; } else { tabs[i].body->card_view = tab_associated_data[selected_tab].card_view; } if(!associated_data.search_text_updated) { FetchResult fetch_result = associated_data.fetch_future.get(); tabs[i].body->set_items(std::move(fetch_result.body_items)); if(tabs[i].body->attach_side == AttachSide::TOP) { tabs[i].body->select_first_item(); } else if(tabs[i].body->attach_side == AttachSide::BOTTOM) { tabs[i].body->reverse_items(); tabs[i].body->select_last_item(); } associated_data.fetched_page = 0; associated_data.fetching_next_page_failed = false; if(fetch_result.result != PluginResult::OK) associated_data.search_result_text.set_string("Search failed!"); else if(tabs[i].body->get_num_items() == 0) associated_data.search_result_text.set_string("No results found"); else associated_data.search_result_text.set_string(""); idle_active_handler(); } else { associated_data.fetch_future.get(); } associated_data.fetch_status = FetchStatus::NONE; } if(associated_data.fetch_status == FetchStatus::LOADING && associated_data.fetch_type == FetchType::LAZY && associated_data.fetch_future.ready()) { LazyFetchPage *lazy_fetch_page = static_cast(tabs[i].page.get()); associated_data.lazy_fetch_finished = true; FetchResult fetch_result = associated_data.fetch_future.get(); tabs[i].body->set_items(std::move(fetch_result.body_items)); if(tabs[i].search_bar && tabs[i].page->search_is_filter()) { tabs[i].body->filter_search_fuzzy(tabs[i].search_bar->get_text()); } if(lazy_fetch_page->reseek_to_body_item_by_url()) { const auto &tab_ass = tab_associated_data[i]; const int item_index = tabs[i].body->find_item_index([&tab_ass](const std::shared_ptr &item) { return item->visible && item->url == tab_ass.body_item_url_before_refresh; }); if(item_index != -1) tabs[i].body->set_selected_item(item_index); } else { if(tabs[i].body->attach_side == AttachSide::TOP) { tabs[i].body->select_first_item(); } if(tabs[i].body->attach_side == AttachSide::BOTTOM) { tabs[i].body->reverse_items(); tabs[i].body->select_last_item(); } } tab_associated_data[i].body_item_url_before_refresh.clear(); if(fetch_result.result != PluginResult::OK) associated_data.search_result_text.set_string("Failed to fetch page!"); else if(tabs[i].body->get_num_items() == 0 && !lazy_fetch_page->lazy_fetch_is_loader()) associated_data.search_result_text.set_string("No results found"); else associated_data.search_result_text.set_string(""); associated_data.fetch_status = FetchStatus::NONE; idle_active_handler(); } } if(content_storage_file_modified) { content_storage_file_modified = false; if(content_storage_json.isObject()) { const Json::Value &chapters_json = content_storage_json["chapters"]; if(chapters_json.isObject()) json_chapters = &chapters_json; else json_chapters = &Json::Value::nullSingleton(); } else { json_chapters = &Json::Value::nullSingleton(); } } for(size_t i = 0; i < tabs.size(); ++i) { Tab &tab = tabs[i]; if(tab.page) ui_tabs.set_text(i, tab.page->get_title()); } window.clear(get_theme().background_color); page_loop_render(window, tabs, selected_tab, tab_associated_data[selected_tab], json_chapters, ui_tabs); { std::lock_guard lock(login_inputs_mutex); if(!tabs[selected_tab].login_inputs.inputs.empty()) { login_inputs_background.draw(window); for(auto &login_input : tabs[selected_tab].login_inputs.inputs) { login_input->update(); login_input->draw(window, login_inputs_background.get_size() - mgl::vec2f(login_input_padding_x * 2.0f, 0.0f), false); } } } if(tabs[selected_tab].body->get_num_items() > 0) { if(tabs[selected_tab].body->attach_side == AttachSide::TOP && !tabs[selected_tab].body->is_bottom_cut_off()) on_reached_end(); else if(tabs[selected_tab].body->attach_side == AttachSide::BOTTOM && !tabs[selected_tab].body->is_top_cut_off()) on_reached_end(); } AsyncImageLoader::get_instance().update(); window.display(); if(go_to_previous_page) { go_to_previous_page = false; return true; } } return false; } static Json::Value* history_get_item_by_id(Json::Value &history_json, const char *id) { assert(history_json.isArray()); for(Json::Value &item : history_json) { if(!item.isObject()) continue; const Json::Value &id_json = item["id"]; if(!id_json.isString()) continue; if(strcmp(id, id_json.asCString()) == 0) return &item; } return nullptr; } enum class WindowFullscreenState { UNSET, SET, TOGGLE }; static bool window_set_fullscreen(Display *display, Window window, WindowFullscreenState state) { Atom wm_state_atom = XInternAtom(display, "_NET_WM_STATE", False); Atom wm_state_fullscreen_atom = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); if(wm_state_atom == False || wm_state_fullscreen_atom == False) { fprintf(stderr, "Failed to fullscreen the window\n"); return false; } XEvent xev; xev.type = ClientMessage; xev.xclient.window = window; xev.xclient.message_type = wm_state_atom; xev.xclient.format = 32; xev.xclient.data.l[0] = (int)state; xev.xclient.data.l[1] = wm_state_fullscreen_atom; xev.xclient.data.l[2] = 0; xev.xclient.data.l[3] = 1; xev.xclient.data.l[4] = 0; if(!XSendEvent(display, XDefaultRootWindow(display), 0, SubstructureRedirectMask | SubstructureNotifyMask, &xev)) { fprintf(stderr, "Failed to fullscreen window\n"); return false; } XFlush(display); return true; } static bool window_is_fullscreen(Display *display, Window window) { Atom wm_state_atom = XInternAtom(display, "_NET_WM_STATE", False); Atom wm_state_fullscreen_atom = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); if(wm_state_atom == False || wm_state_fullscreen_atom == False) { fprintf(stderr, "Failed to get window atoms\n"); return false; } Atom type; int format = 0; unsigned long num_items = 0; unsigned long bytes_after = 0; unsigned char *properties = nullptr; if(XGetWindowProperty(display, window, wm_state_atom, 0, 1024, False, XA_ATOM, &type, &format, &num_items, &bytes_after, &properties) < Success) { fprintf(stderr, "Failed to get window wm state property\n"); return false; } if(!properties) return false; bool is_fullscreen = false; Atom *atoms = (Atom*)properties; for(unsigned long i = 0; i < num_items; ++i) { if(atoms[i] == wm_state_fullscreen_atom) { is_fullscreen = true; break; } } XFree(properties); return is_fullscreen; } static const char *useragent_str = "user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36"; static int accumulate_string_limit_head(char *data, int size, void *userdata) { std::string *str = (std::string*)userdata; str->append(data, size); if(str->size() >= 42) return 1; return 0; } static bool video_url_is_non_streamable_mp4(const char *url) { std::string result; const char *args[] = { "curl", "-sLf", "-r", "0-40", "--no-buffer", "-H", useragent_str, "--", url, nullptr }; exec_program(args, accumulate_string_limit_head, &result, 42); return (result.size() >= 42) && (memcmp(&result[4], "ftypisom", 8) == 0 || memcmp(&result[4], "ftypiso5", 8) == 0 || memcmp(&result[4], "ftypmp42", 8) == 0 || memcmp(&result[4], "ftypM4V", 7) == 0 || memcmp(&result[4], "ftymp42", 7) == 0 || memcmp(&result[4], "ftyp3gp4", 8) == 0 || memcmp(&result[4], "ftyp3gp5", 8) == 0 || memcmp(&result[4], "fty3gp5", 7) == 0 || memcmp(&result[4], "ftypqt", 6) == 0) && (memmem(&result[0], result.size(), "moov", 4) == NULL); } const char* Program::get_plugin_name() const { return plugin_name; } TaskResult Program::run_task_with_loading_screen(std::function callback) { if(running_task_with_loading_screen) return callback() ? TaskResult::TRUE : TaskResult::FALSE; running_task_with_loading_screen = true; assert(std::this_thread::get_id() == main_thread_id); idle_active_handler(); AsyncTask task = callback; TaskResult task_result = TaskResult::TRUE; window_size.x = window.get_size().x; window_size.y = window.get_size().y; mgl::Event event; while(window.is_open()) { while(window.poll_event(event)) { common_event_handler(event); if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; } else if(event.type == mgl::Event::KeyPressed && (event.key.code == mgl::Keyboard::Escape || event.key.code == mgl::Keyboard::Backspace)) { task.cancel(); task_result = TaskResult::CANCEL; goto task_end; } } handle_x11_events(); if(window_closed) { task.cancel(); task_result = TaskResult::CANCEL; goto task_end; } if(task.ready()) { task_result = task.get() ? TaskResult::TRUE : TaskResult::FALSE; goto task_end; } window.clear(get_theme().background_color); load_sprite.set_position(mgl::vec2f(window_size.x * 0.5f, window_size.y * 0.5f)); load_sprite.set_rotation(load_sprite_timer.get_elapsed_time_seconds() * 400.0); window.draw(load_sprite); window.display(); } task_end: running_task_with_loading_screen = false; return task_result; } static bool video_url_supports_timestamp(const std::string &url) { std::string dummy_id; if(youtube_url_extract_id(url, dummy_id)) return true; if(url.find("pornhub.com/view_video.php") != std::string::npos) return true; return false; } // TODO: Remove when youtube-dl is no longer required to download soundcloud music static bool is_soundcloud(const std::string &url) { return url.find("soundcloud.com") != std::string::npos; } static bool url_should_download_with_youtube_dl(const std::string &url) { return url.find("pornhub.com") != std::string::npos || url.find("xhamster.com") != std::string::npos || url.find("spankbang.com") != std::string::npos // TODO: Remove when youtube-dl is no longer required to download soundcloud music || is_soundcloud(url); } static Window get_input_focus(Display *display) { Window focused_window = None; Atom net_active_window_atom = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); Atom type; unsigned long len, bytes_left; int format; unsigned char *properties = NULL; if(XGetWindowProperty(display, DefaultRootWindow(display), net_active_window_atom, 0, 1024, False, XA_WINDOW, &type, &format, &len, &bytes_left, &properties) == Success) { if(properties) { if(len > 0) focused_window = *(Window*)properties; XFree(properties); } } if(!focused_window) { int rev; if(!XGetInputFocus(display, &focused_window, &rev)) focused_window = None; } return focused_window; } void Program::redirect_focus_to_video_player_window(mgl::WindowHandle video_player_window) { Window focused_window = get_input_focus(disp); if(focused_window != window.get_system_handle()) return; XRaiseWindow(disp, video_player_window); XSetInputFocus(disp, video_player_window, RevertToParent, CurrentTime); XSync(disp, False); XFlush(disp); } void Program::show_video_player_window(mgl::WindowHandle video_player_window) { XMapWindow(disp, video_player_window); XSync(disp, False); redirect_focus_to_video_player_window(video_player_window); } void Program::video_page_download_video(const std::string &url, const std::string &filename, mgl::WindowHandle video_player_window, bool download_no_dialog) { bool separate_audio_option = url_should_download_with_youtube_dl(url); std::string video_id; separate_audio_option |= youtube_url_extract_id(url, video_id); if(is_soundcloud(url)) separate_audio_option = false; if(!separate_audio_option) { download_async_gui(url, file_manager_start_dir.string(), no_video, filename, download_no_dialog); return; } bool audio_only = false; auto body = create_body(); auto options_page = std::make_unique(this, "Select download option"); options_page->add_option(body.get(), "Download video and audio", "", [&audio_only](){ audio_only = false; }); options_page->add_option(body.get(), "Download only audio", "", [&audio_only](){ audio_only = true; }); if(video_player_window) { XUnmapWindow(disp, video_player_window); XSync(disp, False); } std::vector tabs; tabs.push_back(Tab{ std::move(body), std::move(options_page), nullptr }); bool selected = page_loop(tabs); if(video_player_window) show_video_player_window(video_player_window); if(!selected) return; download_async_gui(url, file_manager_start_dir.string(), audio_only, filename, download_no_dialog); } bool Program::video_download_if_non_streamable(std::string &video_url, std::string &audio_url, bool &is_audio_only, bool &has_embedded_audio, PageType previous_page) { Path video_cache_dir = get_cache_dir().join("media"); Path video_path = video_cache_dir; SHA256 sha256; sha256.add(video_url.data(), video_url.size()); video_path.join(sha256.getHash()); if(get_file_type(video_path) == FileType::REGULAR) { fprintf(stderr, "%s is found in cache. Playing from cache...\n", video_url.c_str()); video_url = std::move(video_path.data); audio_url.clear(); if(no_video) { is_audio_only = true; has_embedded_audio = false; } else { is_audio_only = false; has_embedded_audio = true; } } else { TaskResult video_is_not_streamble_result = run_task_with_loading_screen([video_url]() { return video_url_is_non_streamable_mp4(video_url.c_str()); }); if(video_is_not_streamble_result == TaskResult::TRUE) { fprintf(stderr, "%s is detected to be a non-streamable mp4 file, downloading it before playing it...\n", video_url.c_str()); if(create_directory_recursive(video_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create video cache directory", Urgency::CRITICAL); current_page = previous_page; return false; } TaskResult download_file_result = run_task_with_loading_screen([&video_path, video_url]() { return download_to_file(video_url, video_path.data, {}, true) == DownloadResult::OK; }); switch(download_file_result) { case TaskResult::TRUE: { video_url = std::move(video_path.data); audio_url.clear(); if(no_video) { is_audio_only = true; has_embedded_audio = false; } else { is_audio_only = false; has_embedded_audio = true; } break; } case TaskResult::FALSE: { show_notification("QuickMedia", "Failed to download " + video_url, Urgency::CRITICAL); current_page = previous_page; return false; } case TaskResult::CANCEL: { current_page = previous_page; return false; } } } else if(video_is_not_streamble_result == TaskResult::CANCEL) { current_page = previous_page; return false; } } return true; } static bool youtube_url_is_live_stream(const std::string &url) { return url.find("yt_live_broadcast") != std::string::npos || url.find("manifest/") != std::string::npos; } int Program::video_get_max_height() { if(video_max_height > 0) return video_max_height; if(get_config().video.max_height > 0) return get_config().video.max_height; return get_largest_monitor_height(disp); } #define CLEANMASK(mask) ((mask) & (ShiftMask|ControlMask|Mod1Mask|Mod4Mask|Mod5Mask)) void Program::video_content_page(Page *parent_page, VideoPage *video_page, std::string video_title, bool download_if_streaming_fails, Body *parent_body, int play_index, int *parent_body_page, const std::string &parent_page_search) { PageType previous_page = pop_page_stack(); bool video_loaded = false; double video_time_pos = 0.0; // Time in media in seconds. Updates every 5 seconds and when starting to watch the video and when seeking. VideoInfo video_info; // Duration time in seconds. 0 if unknown bool successfully_fetched_video_duration = false; bool successfully_fetched_time_pos = false; bool update_time_pos = false; bool update_duration = false; bool update_window_focus = false; bool update_window_focus_timer = false; mgl::Clock video_time_pos_clock; mgl::Clock update_window_focus_time; // HACK! std::string youtube_video_id_dummy; const bool is_youtube = youtube_url_extract_id(video_page->get_url(), youtube_video_id_dummy); const bool is_matrix = strcmp(plugin_name, "matrix") == 0; const bool is_youtube_plugin = strcmp(plugin_name, "youtube") == 0; bool added_recommendations = false; mgl::Clock time_watched_timer; idle_active_handler(); video_player.reset(); BodyItems related_videos; bool move_in_parent = false; if(parent_body && video_page->autoplay_next_item() && video_page->should_autoplay() && play_index + 1 >= 0 && play_index + 1 < (int)parent_body->get_num_items()) { parent_body->copy_range(play_index + 1, (size_t)-1, related_videos); move_in_parent = true; } mgl::WindowHandle video_player_window = None; auto on_window_create = [&](mgl::WindowHandle _video_player_window) mutable { video_player_window = _video_player_window; XSelectInput(disp, video_player_window, KeyPressMask | PointerMotionMask | FocusChangeMask | StructureNotifyMask); redirect_focus_to_video_player_window(video_player_window); XSync(disp, False); SubtitleData subtitle_data; video_page->get_subtitles(subtitle_data); if(!subtitle_data.url.empty()) video_player->add_subtitle(subtitle_data.url, subtitle_data.title, "eng"); update_time_pos = true; update_window_focus_timer = true; update_window_focus_time.restart(); }; AsyncTask related_videos_task; EventCallbackFunc video_event_callback; bool go_to_previous_page = false; std::string video_url; std::string audio_url; bool has_embedded_audio = true; auto update_video_duration_handler = [&]() { if(!video_player) return; if(update_duration) { update_duration = false; successfully_fetched_video_duration = false; double file_duration = 0.0; video_player->get_duration_in_file(&file_duration); video_info.duration = std::max(video_info.duration, file_duration); if(video_info.duration > 0.001) successfully_fetched_video_duration = true; } }; auto update_time_pos_handler = [&](bool force) { if(!video_player) return; if(force) { video_time_pos_clock.restart(); update_time_pos = true; } if(video_time_pos_clock.get_elapsed_time_seconds() >= 5.0) { video_time_pos_clock.restart(); update_time_pos = true; } if(update_time_pos) { update_time_pos = false; const double prev_video_time_pos = video_time_pos; if(video_player->get_time_in_file(&video_time_pos) == VideoPlayer::Error::OK) { successfully_fetched_time_pos = true; if(successfully_fetched_video_duration && fabs(video_time_pos - prev_video_time_pos) >= 1.0) video_page->set_watch_progress(video_time_pos, video_info.duration); } } update_video_duration_handler(); }; auto load_video_error_check = [&](std::string start_time = "", bool reuse_media_source = false) mutable { video_player.reset(); video_info.channel_url.clear(); video_loaded = false; successfully_fetched_video_duration = false; successfully_fetched_time_pos = false; video_player_window = None; video_info.duration = 0.0; video_time_pos_clock.restart(); update_time_pos = false; video_time_pos = 0.0; bool is_audio_only = no_video; const int video_max_height = video_get_max_height(); if(!reuse_media_source) { std::string new_title; video_url.clear(); audio_url.clear(); has_embedded_audio = true; SubmitArgs submit_args; if(parent_body) { BodyItem *selected = parent_body->get_selected(); if(selected) { submit_args.title = selected->get_title(); submit_args.url = selected->url; submit_args.thumbnail_url = selected->thumbnail_url; submit_args.userdata = selected->userdata; submit_args.extra = selected->extra; } } std::string err_str; const int num_retries = is_youtube ? 3 : 1; bool load_successful = false; for(int i = 0; i < num_retries; ++i) { bool cancelled = false; TaskResult load_result = run_task_with_loading_screen([&]() { video_info.duration = 0.0; video_info.chapters.clear(); video_info.referer.clear(); if(video_page->load(submit_args, video_info, err_str) != PluginResult::OK) return false; new_title = video_info.title; if(video_info.duration > 0.001) successfully_fetched_video_duration = true; std::string ext; if(!no_video) video_url = video_page->get_video_url(video_max_height, has_embedded_audio, ext); if(video_url.empty() || no_video) { video_url = video_page->get_audio_url(ext); if(video_url.empty()) { video_url = video_page->get_url(); has_embedded_audio = true; } else { is_audio_only = true; has_embedded_audio = false; } } else if(!has_embedded_audio) { audio_url = video_page->get_audio_url(ext); } if(!is_youtube && download_if_streaming_fails) { if(!video_download_if_non_streamable(video_url, audio_url, is_audio_only, has_embedded_audio, previous_page)) return false; } return true; }); if(!new_title.empty()) video_title = std::move(new_title); if(load_result == TaskResult::CANCEL || cancelled) { current_page = previous_page; go_to_previous_page = true; return; } else if(load_result == TaskResult::FALSE) { continue; } load_successful = true; break; } if(!load_successful) { show_notification("QuickMedia", "Failed to load media" + (err_str.empty() ? "" : ", error: " + err_str), Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; return; } } const bool is_resume_go_back = !start_time.empty(); if(start_time.empty()) start_time = video_page->get_url_timestamp(); watched_videos.insert(video_page->get_url()); // TODO: Sync sequences //audio_url.clear(); //video_url.clear(); //is_audio_only = true; std::string v = video_url; std::string a = audio_url; if(is_youtube) { if(!v.empty() && !youtube_url_is_live_stream(v)) v = "qm-yt://" + v; if(!a.empty() && !youtube_url_is_live_stream(a)) a = "qm-yt://" + a; } VideoPlayer::StartupArgs startup_args; startup_args.path = v; startup_args.audio_path = a; startup_args.parent_window = window.get_system_handle(); startup_args.no_video = is_audio_only; startup_args.use_system_mpv_config = get_config().use_system_mpv_config || video_page->is_local(); startup_args.use_system_input_config = video_page->is_local(); startup_args.keep_open = is_matrix && !is_youtube; startup_args.resume = false; startup_args.resource_root = resources_root; startup_args.monitor_height = video_max_height; startup_args.use_youtube_dl = use_youtube_dl && !video_page->is_local(); startup_args.title = video_title; startup_args.start_time = start_time; startup_args.chapters = std::move(video_info.chapters); startup_args.plugin_name = plugin_name; startup_args.cache_on_disk = !video_page->is_local(); startup_args.referer = video_info.referer; startup_args.fullscreen = window_is_fullscreen(disp, window.get_system_handle()); video_player = std::make_unique(std::move(startup_args), video_event_callback, on_window_create); VideoPlayer::Error err = video_player->load_video(); if(err != VideoPlayer::Error::OK) { std::string err_msg = "Failed to play url: "; err_msg += video_page->get_url(); show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; } else { const bool autoplay_next_item = video_page->autoplay_next_item(); std::string url = video_page->get_url(); if(autoplay_next_item) { related_videos_task = AsyncTask([url, video_page]() { video_page->get_related_media(url); }); } else { related_videos.clear(); related_videos_task = AsyncTask([&related_videos, url, video_page]() { video_page->mark_watched(); related_videos = video_page->get_related_media(url); }); } // TODO: Make this also work for other video plugins if(strcmp(plugin_name, "youtube") != 0 || is_resume_go_back) return; std::string video_id; if(!youtube_url_extract_id(video_page->get_url(), video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += video_page->get_url(); err_msg += ", video wont be saved in history"; show_notification("QuickMedia", err_msg.c_str(), Urgency::LOW); return; } Json::Value video_history_json = load_history_json(); time_t time_now = time(NULL); Json::Value *json_item = history_get_item_by_id(video_history_json, video_id.c_str()); if(json_item) { (*json_item)["timestamp"] = (Json::Int64)time_now; } else { Json::Value new_content_object(Json::objectValue); new_content_object["id"] = video_id; new_content_object["title"] = video_title; new_content_object["timestamp"] = (Json::Int64)time_now; video_history_json.append(std::move(new_content_object)); } Path video_history_filepath = get_history_filepath(plugin_name); save_json_to_file_atomic(video_history_filepath, video_history_json); } }; video_event_callback = [&](const char *event_name, const std::vector &args) mutable { if(strcmp(event_name, "pause") == 0) { //double time_remaining = 9999.0; //if(video_player->get_time_remaining(&time_remaining) == VideoPlayer::Error::OK && time_remaining <= 1.0) // end_of_file = true; } else if(strcmp(event_name, "playback-restart") == 0) { //video_player->set_paused(false); } else if(strcmp(event_name, "start-file") == 0) { update_duration = true; added_recommendations = false; time_watched_timer.restart(); video_loaded = true; update_time_pos = true; update_window_focus = true; } else if(strcmp(event_name, "file-loaded") == 0) { video_loaded = true; update_window_focus = true; } else if(strcmp(event_name, "video-reconfig") == 0 || strcmp(event_name, "audio-reconfig") == 0) { video_loaded = true; update_window_focus = true; } else if(strcmp(event_name, "seek") == 0) { update_time_pos = true; } else if(strcmp(event_name, "fullscreen") == 0 && args.size() == 1) { window_set_fullscreen(disp, window.get_system_handle(), args[0] == "yes" ? WindowFullscreenState::SET : WindowFullscreenState::UNSET); } else if(strcmp(event_name, "end-file") == 0) { if(successfully_fetched_time_pos && successfully_fetched_video_duration) video_page->set_watch_progress(video_time_pos, video_info.duration); } //fprintf(stderr, "event name: %s\n", event_name); }; load_video_error_check(); mgl::Event event; XEvent xev; bool cursor_visible = true; mgl::Clock cursor_hide_timer; auto save_video_url_to_clipboard = [this, video_page]() { std::string url = video_page->get_download_url(video_get_max_height()); if(video_url_supports_timestamp(url)) { double time_in_file = 0.0; if(video_player && (video_player->get_time_in_file(&time_in_file) != VideoPlayer::Error::OK)) time_in_file = 0.0; std::string clipboard = std::move(url); if((int)time_in_file > 0) clipboard += "&t=" + std::to_string((int)time_in_file); set_clipboard(clipboard); } else { set_clipboard(url); } }; idle_active_handler(); while (current_page == PageType::VIDEO_CONTENT && window.is_open() && !go_to_previous_page) { while (window.poll_event(event)) { common_event_handler(event); if(event.type == mgl::Event::GainedFocus) { update_window_focus = true; } else if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; } else if(event.type == mgl::Event::KeyPressed && (event.key.code == mgl::Keyboard::Escape || event.key.code == mgl::Keyboard::Backspace)) { // To be able to close the video player while the video is loading if(window_is_fullscreen(disp, window.get_system_handle())) { if(video_player && video_player_window && event.key.code != mgl::Keyboard::Escape) video_player->cycle_fullscreen(); } else { current_page = previous_page; go_to_previous_page = true; } } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::Q) { current_page = previous_page; go_to_previous_page = true; } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::C && event.key.control && !video_page->is_local()) { save_video_url_to_clipboard(); } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::F5 && !video_page->is_local()) { load_video_error_check(); } } handle_x11_events(); if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, MapNotify, &xev)) { update_window_focus = true; } if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, FocusIn, &xev)) {} if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, FocusOut, &xev)) { update_window_focus = true; } if(video_player_window && update_window_focus) { update_window_focus = false; redirect_focus_to_video_player_window(video_player_window); } if(video_player_window && update_window_focus_timer && update_window_focus_time.get_elapsed_time_seconds() >= 0.5) { update_window_focus_timer = false; update_window_focus = false; redirect_focus_to_video_player_window(video_player_window); } if(video_player && video_player_window && XCheckTypedWindowEvent(disp, video_player_window, KeyPress, &xev)/* && xev.xkey.subwindow == video_player_window*/) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" KeySym pressed_keysym = XKeycodeToKeysym(disp, xev.xkey.keycode, 0); #pragma GCC diagnostic pop const bool pressing_ctrl = (CLEANMASK(xev.xkey.state) & ControlMask); const bool pressing_shift = (CLEANMASK(xev.xkey.state) & ShiftMask); if(pressed_keysym == XK_q && pressing_ctrl) { window.close(); } else if(pressed_keysym == XK_Escape || pressed_keysym == XK_BackSpace) { if(window_is_fullscreen(disp, window.get_system_handle())) { if(pressed_keysym != XK_Escape) video_player->cycle_fullscreen(); } else { current_page = previous_page; go_to_previous_page = true; break; } } else if(pressed_keysym == XK_q && !pressing_ctrl) { current_page = previous_page; go_to_previous_page = true; break; } else if(pressed_keysym == XK_f && pressing_ctrl) { video_player->cycle_fullscreen(); } else if(pressed_keysym == XK_s && pressing_ctrl && !video_page->is_local()) { const bool download_no_dialog = pressing_shift; video_page_download_video(video_page->get_download_url(video_get_max_height()), video_page->get_filename(), video_player_window, download_no_dialog); } else if(pressed_keysym == XK_F5 && !video_page->is_local()) { double resume_start_time = 0.0; video_player->get_time_in_file(&resume_start_time); load_video_error_check(std::to_string((int)resume_start_time)); } else if(pressed_keysym == XK_r && pressing_ctrl && !video_page->is_local()) { cursor_hide_timer.restart(); if(!cursor_visible) window.set_cursor_visible(true); cursor_visible = true; bool cancelled = false; if(related_videos_task.valid()) { XUnmapWindow(disp, video_player_window); XSync(disp, False); XFlush(disp); TaskResult task_result = run_task_with_loading_screen([&]() { while(!program_is_dead_in_current_thread()) { if(related_videos_task.ready()) { related_videos_task.get(); return true; } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } return true; }); if(task_result == TaskResult::CANCEL) { show_video_player_window(video_player_window); cancelled = true; } } if(!cancelled) { XUnmapWindow(disp, video_player_window); XSync(disp, False); XFlush(disp); std::vector related_pages; TaskResult related_pages_result = run_task_with_loading_screen([&video_page, &related_videos, &video_info, &related_pages]{ return video_page->get_related_pages(related_videos, video_info.channel_url, related_pages) == PluginResult::OK; }); if(related_pages_result == TaskResult::FALSE) { show_video_player_window(video_player_window); show_notification("QuickMedia", "Failed to get related pages", Urgency::CRITICAL); } else if(related_pages_result == TaskResult::TRUE && !related_pages.empty()) { update_time_pos_handler(true); if(successfully_fetched_time_pos && successfully_fetched_video_duration) video_page->set_watch_progress(video_time_pos, video_info.duration); bool page_changed = false; double resume_start_time = 0.0; page_loop(related_pages, video_page->get_related_pages_first_tab(), [&](const std::vector &new_tabs) { if(!page_changed && new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::VIDEO) { video_player->get_time_in_file(&resume_start_time); video_player.reset(); page_changed = true; } }); if(!window.is_open() || current_page == PageType::EXIT) { video_player.reset(); return; } if(!video_player) { current_page = PageType::VIDEO_CONTENT; load_video_error_check(resume_start_time > 0.1 ? std::to_string((int)resume_start_time) : ""); } else { show_video_player_window(video_player_window); } } else { show_video_player_window(video_player_window); } } } else if(pressed_keysym == XK_c && pressing_ctrl) { save_video_url_to_clipboard(); } } if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, MotionNotify, &xev)) { while(XCheckTypedWindowEvent(disp, video_player_window, MotionNotify, &xev)); cursor_hide_timer.restart(); if(!cursor_visible) window.set_cursor_visible(true); cursor_visible = true; } /* Only save recommendations for the video if we have been watching it for 15 seconds */ if(is_youtube_plugin && video_loaded && !added_recommendations && time_watched_timer.get_elapsed_time_seconds() >= 15.0) { added_recommendations = true; save_recommendations_from_related_videos(plugin_name, video_page->get_url(), video_title, related_videos); } VideoPlayer::Error update_err = video_player ? video_player->update() : VideoPlayer::Error::OK; if(update_err == VideoPlayer::Error::FAIL_TO_CONNECT_TIMEOUT) { show_notification("QuickMedia", "Failed to connect to mpv ipc after 10 seconds", Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; break; } else if(update_err == VideoPlayer::Error::EXITED && video_player->exit_status == 0 && (!is_matrix || is_youtube)) { std::string new_video_url; if(!video_page->should_autoplay()) { current_page = previous_page; go_to_previous_page = true; break; } if(related_videos_task.valid()) { TaskResult task_result = run_task_with_loading_screen([&]() { while(!program_is_dead_in_current_thread()) { if(related_videos_task.ready()) { related_videos_task.get(); return true; } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } return true; }); if(task_result == TaskResult::CANCEL) { current_page = previous_page; go_to_previous_page = true; break; } } // Find video that hasn't been played before in this video session auto find_next_video = [this, parent_body, move_in_parent, &related_videos, &video_page, &new_video_url]() { for(auto it = related_videos.begin(), end = related_videos.end(); it != end; ++it) { if((*it)->visible && !(*it)->url.empty() && watched_videos.find((*it)->url) == watched_videos.end() && !video_page->video_should_be_skipped((*it)->url)) { if(parent_body && move_in_parent) parent_body->set_selected_item(it->get()); new_video_url = (*it)->url; related_videos.erase(it); break; } } }; find_next_video(); if(new_video_url.empty() && parent_page && parent_body_page && video_page->autoplay_next_item()) { BodyItems new_body_items; const int fetch_page = (*parent_body_page) + 1; TaskResult load_next_page_result = run_task_with_loading_screen([parent_page, parent_page_search, fetch_page, &new_body_items] { if(parent_page->get_page(parent_page_search, fetch_page, new_body_items) != PluginResult::OK) { fprintf(stderr, "Failed to get next page (page %d)\n", fetch_page); return false; } return true; }); fprintf(stderr, "Finished fetching page %d, num new items: %zu\n", fetch_page, new_body_items.size()); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { if(parent_body) parent_body->append_items(new_body_items); (*parent_body_page)++; related_videos = std::move(new_body_items); find_next_video(); } if(load_next_page_result == TaskResult::CANCEL) { current_page = previous_page; go_to_previous_page = true; break; } } // If there are no videos to play, then dont play any... if(new_video_url.empty()) { if(!video_page->is_local()) show_notification("QuickMedia", "No more related videos to play"); current_page = previous_page; go_to_previous_page = true; break; } TaskResult get_playable_url_result = run_task_with_loading_screen([video_page, &new_video_url]() { video_page->set_url(video_page->url_get_playable_url(new_video_url)); return true; }); if(get_playable_url_result == TaskResult::CANCEL) { current_page = previous_page; go_to_previous_page = true; break; } else if(get_playable_url_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to get playable url", Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; break; } load_video_error_check(); } else if(update_err != VideoPlayer::Error::OK) { show_notification("QuickMedia", "Failed to play the video (error code " + std::to_string((int)update_err) + ")", Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; break; } AsyncImageLoader::get_instance().update(); if(matrix) matrix->update(); if(!video_loaded) { window.clear(get_theme().background_color); load_sprite.set_position(mgl::vec2f(window_size.x * 0.5f, window_size.y * 0.5f)); load_sprite.set_rotation(load_sprite_timer.get_elapsed_time_seconds() * 400.0); window.draw(load_sprite); window.display(); if(!cursor_visible) { cursor_visible = true; window.set_cursor_visible(true); } cursor_hide_timer.restart(); continue; } if(video_player) update_time_pos_handler(false); if(video_player_window) { if(!cursor_visible) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } const double UI_HIDE_TIMEOUT_SEC = 2.5; if(cursor_hide_timer.get_elapsed_time_seconds() > UI_HIDE_TIMEOUT_SEC) { cursor_visible = false; window.set_cursor_visible(false); } } std::this_thread::sleep_for(std::chrono::milliseconds(50)); } video_player.reset(); window.set_cursor_visible(true); window_set_fullscreen(disp, window.get_system_handle(), WindowFullscreenState::UNSET); auto window_size_u = window.get_size(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; if(successfully_fetched_time_pos && successfully_fetched_video_duration) video_page->set_watch_progress(video_time_pos, video_info.duration); } void Program::select_episode(BodyItem *item, bool start_from_beginning) { image_index = 0; switch(image_view_mode) { case ImageViewMode::SINGLE: current_page = PageType::IMAGES; break; case ImageViewMode::SCROLL: current_page = PageType::IMAGES_CONTINUOUS; break; } if(start_from_beginning) return; const Json::Value &json_chapters = content_storage_json["chapters"]; if(json_chapters.isObject()) { const Json::Value &json_chapter = json_chapters[item->get_title()]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) image_index = current.asInt() - 1; } } } // TODO: Remove PageType Program::pop_page_stack() { if(!page_stack.empty()) { PageType previous_page = page_stack.top(); page_stack.pop(); return previous_page; } return PageType::EXIT; } // TODO: Do the same for thumbnails? static bool is_symlink_valid(const char *filepath) { struct stat buf; return lstat(filepath, &buf) != -1; } // TODO: Optimize this somehow. One image alone uses more than 20mb ram! Total ram usage for viewing one image // becomes 40mb (private memory, almost 100mb in total!) Unacceptable! Program::LoadImageResult Program::load_image_by_index(int image_index, mgl::Texture &image_texture, std::string &error_message) { Path image_path = content_cache_dir; image_path.join(std::to_string(image_index + 1)); bool upscaled_ok = true; if(upscale_image_action != UpscaleImageAction::NO) { Path image_filepath_upcaled = image_path; image_filepath_upcaled.append(".upscaled"); if(get_file_type(image_filepath_upcaled) == FileType::FILE_NOT_FOUND && image_index < (int)image_upscale_status.size() && image_upscale_status[image_index] == 0) upscaled_ok = false; } if(get_file_type(image_path) == FileType::REGULAR && is_symlink_valid(image_path.data.c_str()) && upscaled_ok) { if(image_texture.load_from_file(image_path.data.c_str())) { return LoadImageResult::OK; } else { show_notification("QuickMedia", "Failed to load image for page " + std::to_string(image_index + 1) + ". Image filepath: " + image_path.data, Urgency::CRITICAL); error_message = std::string("Failed to load image for page ") + std::to_string(image_index + 1); return LoadImageResult::FAILED; } } else { error_message = "Downloading page " + std::to_string(image_index + 1) + "..."; return LoadImageResult::DOWNLOAD_IN_PROGRESS; } } void Program::download_chapter_images_if_needed(MangaImagesPage *images_page) { if(downloading_chapter_url == images_page->get_url()) return; downloading_chapter_url = images_page->get_url(); image_download_cancel = true; image_download_future.cancel(); image_download_cancel = false; num_manga_pages = 0; std::promise num_manga_pages_promise; num_manga_pages_future = num_manga_pages_promise.get_future(); Path content_cache_dir_ = content_cache_dir; image_download_future = AsyncTask>([images_page, content_cache_dir_, this](std::promise num_manga_pages_promise) { int num_pages = 0; if(images_page->update_image_urls(num_pages) != ImageResult::OK) { num_manga_pages_promise.set_value(0); if(!image_download_cancel) show_notification("QuickMedia", "Failed to fetch page images", Urgency::CRITICAL); return; } else { num_manga_pages_promise.set_value(num_pages); image_upscale_status.resize(num_pages, 0); } if(num_pages == 0) return; // TODO: Download images in parallel int page = 1; images_page->for_each_page_in_chapter([this, images_page, &page, content_cache_dir_](const std::string &url) { if(image_download_cancel) return false; int image_index = page - 1; // TODO: Save image with the file extension that url says it has? right now the file is saved without any extension Path image_filepath = content_cache_dir_; image_filepath.join(std::to_string(page++)); bool upscaled_ok = true; if(upscale_image_action != UpscaleImageAction::NO) { Path image_filepath_upcaled = image_filepath; image_filepath_upcaled.append(".upscaled"); if(get_file_type(image_filepath_upcaled) == FileType::FILE_NOT_FOUND && image_upscale_status[image_index] == 0) upscaled_ok = false; } if(get_file_type(image_filepath) != FileType::FILE_NOT_FOUND && is_symlink_valid(image_filepath.data.c_str()) && upscaled_ok) return true; std::vector extra_args; const bool is_manganelo = (strcmp(images_page->get_service_name(), "manganelo") == 0); const bool is_mangakatana = (strcmp(images_page->get_service_name(), "mangakatana") == 0); const char *website_url = images_page->get_website_url(); if(is_manganelo) { extra_args = { CommandArg { "-H", "accept: image/jpeg,image/png,image/*,*/*;q=0.8" }, CommandArg { "-H", "sec-fetch-site: cross-site" }, CommandArg { "-H", "sec-fetch-mode: no-cors" }, CommandArg { "-H", "sec-fetch-dest: image" }, CommandArg { "-H", "referer: https://manganelo.com/" }, CommandArg { "-m", "30" }, CommandArg { "--connect-timeout", "30" } }; } else if(website_url && website_url[0] != '\0') { std::string website_url_str = website_url; if(!website_url_str.empty() && website_url_str.back() != '/') website_url_str.push_back('/'); extra_args = { CommandArg { "-H", "referer: " + std::move(website_url_str) }, }; } Path image_filepath_tmp(image_filepath.data + ".tmpz"); // TODO: Move to page if(images_page->is_local()) { int res = symlink(url.c_str(), image_filepath_tmp.data.c_str()); if(res == -1 && errno != EEXIST) { show_notification("QuickMedia", "Failed to symlink " + image_filepath_tmp.data + " to " + url); return true; } } else { int64_t file_size = 0; bool download_success = download_to_file(url, image_filepath_tmp.data, extra_args, true) == DownloadResult::OK; if(!download_success) { remove(image_filepath_tmp.data.c_str()); if(!image_download_cancel) show_notification("QuickMedia", "Failed to download image: " + url, Urgency::CRITICAL); return true; } else if(is_manganelo && file_get_size(image_filepath_tmp, &file_size) == 0 && file_size < 255) { remove(image_filepath_tmp.data.c_str()); if(!image_download_cancel) show_notification("QuickMedia", "Failed to download image: " + url, Urgency::CRITICAL); return true; } else if(is_mangakatana && file_get_size(image_filepath_tmp, &file_size) == 0 && file_size < 255) { remove(image_filepath_tmp.data.c_str()); std::string new_url = url; string_replace_all(new_url, "://i3", "://i2"); download_success = download_to_file(new_url, image_filepath_tmp.data, extra_args, true) == DownloadResult::OK; if(!download_success) { if(!image_download_cancel) show_notification("QuickMedia", "Failed to download image: " + url, Urgency::CRITICAL); return true; } } } bool rename_immediately = true; if(upscale_image_action == UpscaleImageAction::LOW_RESOLUTION) { int screen_width, screen_height; get_screen_resolution(disp, &screen_width, &screen_height); int image_width, image_height; if(image_get_resolution(image_filepath_tmp, &image_width, &image_height)) { if(image_height < screen_height * 0.65) { rename_immediately = false; CopyOp copy_op; copy_op.source = image_filepath_tmp; copy_op.destination = image_filepath; images_to_upscale_queue.push(std::move(copy_op)); } else { fprintf(stderr, "Info: not upscaling %s because the file is already large on your monitor (screen height: %d, image height: %d)\n", image_filepath_tmp.data.c_str(), screen_height, image_height); image_upscale_status[image_index] = 1; } } else { fprintf(stderr, "Warning: failed to upscale %s because QuickMedia failed to recognize the resolution of the image\n", image_filepath_tmp.data.c_str()); image_upscale_status[image_index] = 1; } } else if(upscale_image_action == UpscaleImageAction::FORCE) { rename_immediately = false; CopyOp copy_op; copy_op.source = image_filepath_tmp; copy_op.destination = image_filepath; images_to_upscale_queue.push(std::move(copy_op)); } if(rename_immediately) { if(rename_atomic(image_filepath_tmp.data.c_str(), image_filepath.data.c_str()) != 0) { perror(image_filepath_tmp.data.c_str()); show_notification("QuickMedia", "Failed to save image to file: " + image_filepath.data, Urgency::CRITICAL); return true; } } return true; }); }, std::move(num_manga_pages_promise)); mgl::Event event; PageType current_manga_page = current_page; while (current_page == current_manga_page && window.is_open()) { while(window.poll_event(event)) { common_event_handler(event); if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::Escape) { current_page = pop_page_stack(); } } handle_x11_events(); if(num_manga_pages_future.valid() && num_manga_pages_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { num_manga_pages = num_manga_pages_future.get(); break; } window.clear(get_theme().background_color); load_sprite.set_position(mgl::vec2f(window_size.x * 0.5f, window_size.y * 0.5f)); load_sprite.set_rotation(load_sprite_timer.get_elapsed_time_seconds() * 400.0); window.draw(load_sprite); AsyncImageLoader::get_instance().update(); window.display(); } } void Program::update_manga_history(const std::string &manga_id, const std::string &thumbnail_url) { Json::Value history_json = load_history_json(); const time_t time_now = time(NULL); Json::Value *json_item = history_get_item_by_id(history_json, manga_id.c_str()); if(json_item) { (*json_item)["timestamp"] = (Json::Int64)time_now; if(!thumbnail_url.empty()) (*json_item)["thumbnail_url"] = thumbnail_url; } else { Json::Value new_content_object(Json::objectValue); new_content_object["id"] = manga_id; new_content_object["timestamp"] = (Json::Int64)time_now; if(!thumbnail_url.empty()) new_content_object["thumbnail_url"] = thumbnail_url; history_json.append(std::move(new_content_object)); } Path history_filepath = get_history_filepath(plugin_name); save_json_to_file_atomic(history_filepath, history_json); } void Program::save_manga_progress(MangaImagesPage *images_page, Json::Value &json_chapters, Json::Value &json_chapter, int &latest_read) { image_index = std::max(0, std::min(image_index, num_manga_pages)); json_chapters = content_storage_json["chapters"]; latest_read = image_index + 1; if(json_chapters.isObject()) { json_chapter = json_chapters[images_page->get_chapter_name()]; if(!json_chapter.isObject()) json_chapter = Json::Value(Json::objectValue); } else { json_chapters = Json::Value(Json::objectValue); json_chapter = Json::Value(Json::objectValue); } json_chapter["current"] = std::min(latest_read, num_manga_pages); json_chapter["total"] = num_manga_pages; json_chapter["url"] = images_page->get_url(); json_chapters[images_page->get_chapter_name()] = json_chapter; content_storage_json["chapters"] = json_chapters; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } } int Program::image_page(MangaImagesPage *images_page, Body *chapters_body, bool &continue_left_off) { int page_navigation = 0; image_download_cancel = false; continue_left_off = false; content_cache_dir = get_cache_dir().join(images_page->get_service_name()).join(manga_id_base64).join(base64_url_encode(images_page->get_chapter_name())); if(create_directory_recursive(content_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = pop_page_stack(); return 0; } mgl::Texture image_texture; mgl::Sprite image; mgl::Text error_message("", *FontLoader::get_font(FontLoader::FontType::LATIN, get_config().body.loading_text_font_size * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); error_message.set_color(get_theme().text_color); bool download_in_progress = false; mgl::Event event; download_chapter_images_if_needed(images_page); if(num_manga_pages == 0) { current_page = pop_page_stack(); return 0; } if(current_page != PageType::IMAGES || !window.is_open()) return 0; // TODO: Dont do this every time we change page? Json::Value json_chapters; Json::Value json_chapter; int latest_read; save_manga_progress(images_page, json_chapters, json_chapter, latest_read); update_manga_history(manga_id, images_page->thumbnail_url); if(image_index < num_manga_pages) { std::string error_msg; LoadImageResult load_image_result = load_image_by_index(image_index, image_texture, error_msg); if(load_image_result == LoadImageResult::OK) image.set_texture(&image_texture); else if(load_image_result == LoadImageResult::DOWNLOAD_IN_PROGRESS) download_in_progress = true; error_message.set_string(std::move(error_msg)); } else if(image_index == num_manga_pages) { error_message.set_string("End of " + images_page->get_chapter_name()); } bool error = !error_message.get_string().empty(); bool redraw = true; const int chapter_text_character_size = 14 * get_config().scale * get_config().font_scale * get_config().font.scale.latin; mgl::Text chapter_text(images_page->manga_name + " | " + images_page->get_chapter_name() + " | Page " + std::to_string(image_index + 1) + "/" + std::to_string(num_manga_pages), *FontLoader::get_font(FontLoader::FontType::LATIN, chapter_text_character_size)); if(image_index == num_manga_pages) chapter_text.set_string(images_page->manga_name + " | " + images_page->get_chapter_name() + " | End"); chapter_text.set_color(get_theme().text_color); mgl::Rectangle chapter_text_background; chapter_text_background.set_color(get_theme().shade_color); mgl::vec2i texture_size; mgl::vec2f texture_size_f; if(!error) { texture_size = image.get_texture()->get_size(); texture_size_f = mgl::vec2f(texture_size.x, texture_size.y); } mgl::Clock check_downloaded_timer; const double check_downloaded_timeout_sec = 0.5; malloc_trim(0); mgl::Clock force_redraw_timer; window.set_framerate_limit(20); idle = true; // TODO: Show to user if a certain page is missing (by checking page name (number) and checking if some are skipped) while (current_page == PageType::IMAGES && window.is_open()) { while(window.poll_event(event)) { common_event_handler(event); if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; redraw = true; } else if(event.type == mgl::Event::GainedFocus) { redraw = true; } else if(event.type == mgl::Event::KeyPressed) { if((!event.key.control && event.key.code == mgl::Keyboard::Up) || (!event.key.alt && event.key.code == mgl::Keyboard::K)) { if(image_index > 0) { --image_index; goto end_of_images_page; } else if(image_index == 0 && chapters_body->get_selected_item() < (int)chapters_body->get_num_items() - 1) { page_navigation = -1; goto end_of_images_page; } } else if((!event.key.control && event.key.code == mgl::Keyboard::Down) || (!event.key.alt && event.key.code == mgl::Keyboard::J)) { if(image_index < num_manga_pages) { ++image_index; goto end_of_images_page; } else if(image_index == num_manga_pages && chapters_body->get_selected_item() > 0) { page_navigation = 1; goto end_of_images_page; } } else if(event.key.code == mgl::Keyboard::PageUp) { if(image_index > 0) { image_index = std::max(0, image_index - 10); goto end_of_images_page; } } else if(event.key.code == mgl::Keyboard::PageDown) { if(image_index < num_manga_pages) { image_index = std::min(num_manga_pages, image_index + 10); goto end_of_images_page; } } else if(event.key.code == mgl::Keyboard::Home) { if(image_index > 0) { image_index = 0; goto end_of_images_page; } } else if(event.key.code == mgl::Keyboard::End) { if(image_index < num_manga_pages) { image_index = num_manga_pages; goto end_of_images_page; } } else if(event.key.code == mgl::Keyboard::Escape) { current_page = pop_page_stack(); } else if(event.key.code == mgl::Keyboard::I) { current_page = PageType::IMAGES_CONTINUOUS; image_view_mode = ImageViewMode::SCROLL; } else if(event.key.code == mgl::Keyboard::F) { fit_image_to_window = !fit_image_to_window; redraw = true; } } } handle_x11_events(); if(download_in_progress && check_downloaded_timer.get_elapsed_time_seconds() >= check_downloaded_timeout_sec) { std::string error_msg; LoadImageResult load_image_result = load_image_by_index(image_index, image_texture, error_msg); if(load_image_result == LoadImageResult::OK) { image.set_texture(&image_texture); download_in_progress = false; error = false; texture_size = image.get_texture()->get_size(); texture_size_f = mgl::vec2f(texture_size.x, texture_size.y); } else if(load_image_result == LoadImageResult::FAILED) { download_in_progress = false; error = true; } error_message.set_string(std::move(error_msg)); redraw = true; check_downloaded_timer.restart(); } const float font_height = chapter_text_character_size + 8.0f; const float bottom_panel_height = font_height + 6.0f; mgl::vec2f content_size; content_size.x = window_size.x; content_size.y = window_size.y - bottom_panel_height; // TODO: Track x11 window damage instead if(force_redraw_timer.get_elapsed_time_seconds() >= 1.0f) { force_redraw_timer.restart(); redraw = true; } if(redraw) { redraw = false; if(error) { auto bounds = error_message.get_bounds(); error_message.set_position(vec2f_floor(content_size.x * 0.5f - bounds.size.x * 0.5f, content_size.y * 0.5f - bounds.size.y)); } else { mgl::vec2f image_scale; if(fit_image_to_window) image_scale = get_ratio(texture_size_f, wrap_to_size(texture_size_f, content_size)); else image_scale = get_ratio(texture_size_f, clamp_to_size(texture_size_f, content_size)); image.set_scale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; image.set_position(vec2f_floor(content_size.x * 0.5f - image_size.x * 0.5f, content_size.y * 0.5f - image_size.y * 0.5f)); } window.clear(get_theme().background_color); if(error) { window.draw(error_message); } else { window.draw(image); } chapter_text_background.set_size(mgl::vec2f(window_size.x, bottom_panel_height)); chapter_text_background.set_position(mgl::vec2f(0.0f, std::floor(window_size.y - bottom_panel_height))); window.draw(chapter_text_background); auto text_bounds = chapter_text.get_bounds(); chapter_text.set_position(vec2f_floor(window_size.x * 0.5f - text_bounds.size.x * 0.5f, window_size.y - bottom_panel_height * 0.5f - font_height * 0.55f)); window.draw(chapter_text); window.display(); } else { std::this_thread::sleep_for(std::chrono::milliseconds(50)); } } end_of_images_page: return page_navigation; } int Program::image_continuous_page(MangaImagesPage *images_page) { int page_navigation = 0; image_download_cancel = false; content_cache_dir = get_cache_dir().join(images_page->get_service_name()).join(manga_id_base64).join(base64_url_encode(images_page->get_chapter_name())); if(create_directory_recursive(content_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = pop_page_stack(); return page_navigation; } download_chapter_images_if_needed(images_page); if(num_manga_pages == 0) { current_page = pop_page_stack(); return page_navigation; } if(current_page != PageType::IMAGES_CONTINUOUS || !window.is_open()) return page_navigation; Json::Value json_chapters; Json::Value json_chapter; int current_read_page; save_manga_progress(images_page, json_chapters, json_chapter, current_read_page); ImageViewer image_viewer(&window, num_manga_pages, images_page->manga_name, images_page->get_chapter_name(), std::min(num_manga_pages - 1, image_index), content_cache_dir, &fit_image_to_window); idle_active_handler(); while(current_page == PageType::IMAGES_CONTINUOUS && window.is_open()) { handle_x11_events(); window.clear(get_theme().background_color); ImageViewerAction action = image_viewer.draw(); switch(action) { case ImageViewerAction::NONE: break; case ImageViewerAction::RETURN: current_page = pop_page_stack(); break; case ImageViewerAction::SWITCH_TO_SINGLE_IMAGE_MODE: image_view_mode = ImageViewMode::SINGLE; current_page = PageType::IMAGES; break; case ImageViewerAction::PREVIOUS_CHAPTER: { page_navigation = -1; goto end_of_continuous_images_page; } case ImageViewerAction::NEXT_CHAPTER: { page_navigation = 1; goto end_of_continuous_images_page; } } AsyncImageLoader::get_instance().update(); window.display(); int focused_page = image_viewer.get_focused_page(); image_index = focused_page - 1; if(focused_page != current_read_page) { current_read_page = focused_page; json_chapter["current"] = std::max(0, std::min(current_read_page, num_manga_pages)); json_chapters[images_page->get_chapter_name()] = json_chapter; content_storage_json["chapters"] = json_chapters; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } update_manga_history(manga_id, images_page->thumbnail_url); } } end_of_continuous_images_page: window_size.x = window.get_size().x; window_size.y = window.get_size().y; return page_navigation; } static bool get_image_board_last_posted_filepath(const char *plugin_name, Path &path) { Path dir = get_storage_dir().join(plugin_name); if(create_directory_recursive(dir) != 0) return false; path = std::move(dir); path.join("last_posted_time"); return true; } struct ImageControl { float zoom = 1.0f; bool pressed = false; bool moved = false; mgl::vec2f offset; // relative to center mgl::vec2i prev_mouse_pos; }; static mgl::vec2f floor(mgl::vec2f vec) { return { std::floor(vec.x), std::floor(vec.y) }; } void Program::image_board_thread_page(ImageBoardThreadPage *thread_page, Body *thread_body) { AsyncImageLoader::get_instance().update(); BodyItems result_items; const TaskResult load_result = run_task_with_loading_screen([&]() { return thread_page->lazy_fetch(result_items) == PluginResult::OK; }); if(load_result == TaskResult::CANCEL) { current_page = pop_page_stack(); return; } else if(load_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to load thread", Urgency::CRITICAL); current_page = pop_page_stack(); return; } thread_body->show_drop_shadow = false; thread_body->set_items(std::move(result_items)); std::deque comment_navigation_stack; std::deque comment_page_scroll_stack; auto focus_selected_post = [&]() { BodyItem *selected_item = thread_body->get_selected(); if(!selected_item) return; thread_body->for_each_item([](std::shared_ptr &body_item) { body_item->visible = false; }); selected_item->visible = true; ImageBoardBodyItemData *image_board_post_data = static_cast(selected_item->extra.get()); for(size_t reply_to_index : image_board_post_data->replies_to) { thread_body->get_item_by_index(reply_to_index)->visible = true; } for(size_t reply_index : image_board_post_data->replies) { thread_body->get_item_by_index(reply_index)->visible = true; } comment_navigation_stack.push_back(thread_body->get_selected_item()); comment_page_scroll_stack.push_back(thread_body->get_page_scroll()); //thread_body->clamp_selection(); thread_body->set_page_scroll(0.0f); }; int64_t navigate_to_post_id = 0; if(!thread_page->post_id.empty() && to_num(thread_page->post_id.c_str(), thread_page->post_id.size(), navigate_to_post_id)) { const int found_body_item_index = thread_body->find_item_index([&](auto &body_item) { const ImageBoardBodyItemData *image_board_post_data = static_cast(body_item->extra.get()); return image_board_post_data->post_id == navigate_to_post_id; }); if(found_body_item_index == -1) { show_notification("QuickMedia", "Could not find the post " + thread_page->post_id + " in the thread. It either doesn't exist or it has been deleted", Urgency::NORMAL); } else { thread_body->set_selected_item(found_body_item_index); } } // TODO: Instead of using stage here, use different pages for each stage enum class NavigationStage { VIEWING_COMMENTS, REPLYING, REQUESTING_CAPTCHA, SOLVING_POST_CAPTCHA, POSTING_COMMENT, VIEWING_ATTACHED_IMAGE }; thread_body->title_mark_urls = true; NavigationStage navigation_stage = NavigationStage::VIEWING_COMMENTS; mgl::Texture captcha_texture; mgl::Sprite captcha_sprite; mgl::Texture captcha_bg_texture; mgl::Sprite captcha_bg_sprite; bool has_captcha_bg = false; float captcha_slide = 0.0f; std::string attached_image_url; ImageBoardCaptchaChallenge captcha_challenge; ImageControl image_control; const float captcha_slide_padding_x = std::floor(4.0f * get_config().scale * get_config().spacing_scale); const float captcha_slide_padding_y = std::floor(4.0f * get_config().scale * get_config().spacing_scale); mgl::Color background_color_darker = get_theme().background_color; background_color_darker.r = std::max(0, (int)background_color_darker.r - 20); background_color_darker.g = std::max(0, (int)background_color_darker.g - 20); background_color_darker.b = std::max(0, (int)background_color_darker.b - 20); RoundedRectangle captcha_slide_bg(mgl::vec2f(1.0f, 1.0f), std::floor(10.0f * get_config().scale), background_color_darker, &rounded_rectangle_shader); RoundedRectangle captcha_slide_fg(mgl::vec2f(1.0f, 1.0f), std::floor(10.0f * get_config().scale - captcha_slide_padding_y), get_theme().loading_bar_color, &rounded_rectangle_shader); auto attached_image_texture = std::make_unique(); mgl::Sprite attached_image_sprite; std::string captcha_post_id; std::string captcha_solution; std::string comment_to_post; const int captcha_solution_text_height = 18 * get_config().scale * get_config().font_scale * get_config().font.scale.latin_bold; mgl::Text captcha_solution_text("", *FontLoader::get_font(FontLoader::FontType::LATIN_BOLD, captcha_solution_text_height)); captcha_solution_text.set_color(get_theme().text_color); int solved_captcha_ttl = 0; int64_t last_posted_time = time(nullptr); int64_t seconds_until_post_again = 60; // TODO: Timeout for other imageboards bool has_post_timeout = thread_page->get_pass_id().empty(); bool has_posted_before = false; Path last_posted_time_filepath; if(get_image_board_last_posted_filepath(plugin_name, last_posted_time_filepath)) { std::string d; if(file_get_content(last_posted_time_filepath, d) == 0) { last_posted_time = strtoll(d.c_str(), nullptr, 10); has_posted_before = true; } } if(!has_posted_before) last_posted_time -= (seconds_until_post_again + 1); bool redraw = true; Entry comment_input("Press i to start writing a comment...", &rounded_rectangle_shader); comment_input.set_editable(false); comment_input.set_padding_scale(1.5f); std::string selected_file_for_upload; auto post_comment = [this, &comment_input, &selected_file_for_upload, &navigation_stage, &thread_page, &captcha_post_id, &captcha_solution, &last_posted_time, &has_post_timeout](std::string comment_to_post, std::string file_to_upload) { comment_input.set_editable(false); PostResult post_result = thread_page->post_comment(captcha_post_id, captcha_solution, comment_to_post, file_to_upload); if(post_result.type == PostResult::Type::OK) { show_notification("QuickMedia", "Comment posted!"); navigation_stage = NavigationStage::VIEWING_COMMENTS; comment_input.set_text(""); // TODO: Append posted comment to the thread so the user can see their posted comment. // TODO: Asynchronously update the thread periodically to show new comments. selected_file_for_upload.clear(); // TODO: Remove from here, this is async has_post_timeout = thread_page->get_pass_id().empty(); last_posted_time = time(nullptr); Path last_posted_time_filepath; if(get_image_board_last_posted_filepath(plugin_name, last_posted_time_filepath)) file_overwrite_atomic(last_posted_time_filepath, std::to_string(last_posted_time)); } else if(post_result.type == PostResult::Type::TRY_AGAIN) { show_notification("QuickMedia", "Please wait before posting again"); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result.type == PostResult::Type::INVALID_CAPTCHA) { show_notification("QuickMedia", "Invalid captcha, please try again"); navigation_stage = NavigationStage::REQUESTING_CAPTCHA; // TODO: Need to wait before requesting need captcha? } else if(post_result.type == PostResult::Type::BANNED) { show_notification("QuickMedia", "Failed to post comment because you are banned", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; //} else if(post_result == PostResult::FILE_TOO_LARGE) { // show_notification("QuickMedia", "Failed to post comment because the file you are trying to upload is larger than " + std::to_string((double)thread_page->get_max_upload_file_size() * 1024.0 * 1024.0) + " mb", Urgency::CRITICAL); // navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result.type == PostResult::Type::NO_SUCH_FILE) { show_notification("QuickMedia", "Failed to post comment because the file you are trying to upload no longer exists", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result.type == PostResult::Type::FILE_TYPE_NOT_ALLOWED) { show_notification("QuickMedia", "Failed to post comment because you are trying to upload a file of a type that is not allowed", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result.type == PostResult::Type::UPLOAD_FAILED) { show_notification("QuickMedia", "Failed to post comment because file upload failed", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result.type == PostResult::Type::ERR) { show_notification("QuickMedia", "Failed to post comment, reason: " + post_result.err_msg, Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else { assert(false && "Unhandled post result"); show_notification("QuickMedia", "Failed to post comment. Unknown error", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }; bool frame_skip_text_entry = false; comment_input.on_submit_callback = [&frame_skip_text_entry, &comment_input, &navigation_stage, &comment_to_post, &selected_file_for_upload, &thread_page, &has_post_timeout, &seconds_until_post_again, &last_posted_time](std::string text) -> bool { if(text.empty() && selected_file_for_upload.empty()) return false; if(has_post_timeout && seconds_until_post_again - (time(nullptr) - last_posted_time) > 0) return false; comment_input.set_editable(false); frame_skip_text_entry = true; assert(navigation_stage == NavigationStage::REPLYING); comment_to_post = std::move(text); if(thread_page->get_pass_id().empty()) { navigation_stage = NavigationStage::REQUESTING_CAPTCHA; } else { navigation_stage = NavigationStage::POSTING_COMMENT; } return false; }; mgl::Sprite logo_sprite(&plugin_logo); logo_sprite.set_scale(mgl::vec2f(0.8f * get_config().scale, 0.8f * get_config().scale)); mgl::vec2f logo_size(std::floor(plugin_logo.get_size().x * logo_sprite.get_scale().x), std::floor(plugin_logo.get_size().y * logo_sprite.get_scale().y)); mgl::Sprite file_to_upload_sprite; bool sprite_applied_texture = false; std::shared_ptr file_to_upload_thumbnail_data; const float logo_file_to_upload_spacing = std::floor(10.0f * get_config().scale * get_config().spacing_scale); float prev_chat_height = comment_input.get_height(); float chat_input_height_full = 0.0f; const float logo_padding_x = std::floor(15.0f * get_config().scale * get_config().spacing_scale); const float chat_input_padding_x = std::floor(10.0f * get_config().scale * get_config().spacing_scale); const float chat_input_padding_y = std::floor(10.0f * get_config().scale * get_config().spacing_scale); mgl::vec2f body_pos; mgl::vec2f body_size; mgl::Event event; bool moving_captcha_left = false; bool moving_captcha_right = false; mgl::Clock frame_timer; while (current_page == PageType::IMAGE_BOARD_THREAD && window.is_open()) { const float frame_elapsed_time_sec = frame_timer.restart(); while (window.poll_event(event)) { common_event_handler(event); if(navigation_stage == NavigationStage::REPLYING || navigation_stage == NavigationStage::VIEWING_COMMENTS) { if(thread_body->on_event(window, event, navigation_stage == NavigationStage::VIEWING_COMMENTS)) idle_active_handler(); } event_idle_handler(event); if(!frame_skip_text_entry) comment_input.process_event(window, event); if(navigation_stage == NavigationStage::REPLYING && !frame_skip_text_entry) { if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::Escape) { comment_input.set_editable(false); navigation_stage = NavigationStage::VIEWING_COMMENTS; break; } } if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; idle_active_handler(); } if(event.type == mgl::Event::KeyPressed) { if(event.key.code == mgl::Keyboard::Left) moving_captcha_left = true; if(event.key.code == mgl::Keyboard::Right) moving_captcha_right = true; } else if(event.type == mgl::Event::KeyReleased) { if(event.key.code == mgl::Keyboard::Left) moving_captcha_left = false; if(event.key.code == mgl::Keyboard::Right) moving_captcha_right = false; } if(event.type == mgl::Event::Resized || event.type == mgl::Event::GainedFocus) redraw = true; else if(navigation_stage == NavigationStage::VIEWING_COMMENTS && event.type == mgl::Event::KeyPressed) { if(event.key.code == mgl::Keyboard::Escape) { current_page = pop_page_stack(); } else if(event.key.code == mgl::Keyboard::P) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->url.empty()) { if(is_url_video(selected_item->url)) { page_stack.push(PageType::IMAGE_BOARD_THREAD); current_page = PageType::VIDEO_CONTENT; watched_videos.clear(); ImageBoardVideoPage video_page(this); video_page.set_url(selected_item->url); // TODO: Use real title video_content_page(thread_page, &video_page, "", true, thread_body, thread_body->get_selected_item()); redraw = true; idle_active_handler(); } else { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->url.empty()) { attached_image_url = selected_item->url; mgl::Image image; TaskResult task_result = run_task_with_loading_screen([&attached_image_url, &image]{ SHA256 sha256; sha256.add(attached_image_url.data(), attached_image_url.size()); Path media_file_path = get_cache_dir().join("media").join(sha256.getHash()); if(get_file_type(media_file_path) == FileType::FILE_NOT_FOUND && download_to_file(attached_image_url, media_file_path.data, {}, true) != DownloadResult::OK) { show_notification("QuickMedia", "Failed to download image: " + attached_image_url, Urgency::CRITICAL); return false; } if(!image.load_from_file(media_file_path.data.c_str())) { show_notification("QuickMedia", "Failed to load image: " + attached_image_url, Urgency::CRITICAL); return false; } return true; }); if(task_result == TaskResult::TRUE) { attached_image_texture = std::make_unique(); if(attached_image_texture->load_from_image(image)) { attached_image_sprite.set_texture(attached_image_texture.get()); attached_image_sprite.set_origin(mgl::vec2f(0.0f, 0.0f)); attached_image_sprite.set_scale(mgl::vec2f(1.0f, 1.0f)); navigation_stage = NavigationStage::VIEWING_ATTACHED_IMAGE; image_control.zoom = 1.0f; image_control.offset = attached_image_texture->get_size().to_vec2f() * 0.5f; image_control.pressed = window.is_mouse_button_pressed(mgl::Mouse::Left); image_control.pressed = false; image_control.moved = false; if(image_control.pressed) image_control.prev_mouse_pos = window.get_mouse_position(); } else { show_notification("QuickMedia", "Failed to load image: " + attached_image_url, Urgency::CRITICAL); } } } } } } else if(event.key.code == mgl::Keyboard::U) { std::filesystem::path &fm_dir = file_manager_start_dir; auto file_manager_page = std::make_unique(this, (FileManagerMimeType)(FILE_MANAGER_MIME_TYPE_IMAGE|FILE_MANAGER_MIME_TYPE_VIDEO)); file_manager_page->set_current_directory(fm_dir.string()); auto file_manager_body = create_body(false, get_config().file_manager.grid_view); BodyItems body_items; file_manager_page->get_files_in_directory(body_items); file_manager_body->set_items(std::move(body_items)); std::vector file_manager_tabs; file_manager_tabs.push_back(Tab{std::move(file_manager_body), std::move(file_manager_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); mgl::Event event; while(window.poll_event(event)) { common_event_handler(event); } selected_files.clear(); page_loop(file_manager_tabs); if(selected_files.empty()) { fprintf(stderr, "No files selected!\n"); } else { selected_file_for_upload = selected_files[0]; } redraw = true; frame_skip_text_entry = true; } else if(event.key.code == mgl::Keyboard::D && event.key.control) { selected_file_for_upload.clear(); } else if(event.key.code == mgl::Keyboard::C && event.key.control) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item) thread_page->copy_to_clipboard(selected_item); } else if(event.key.code == mgl::Keyboard::I && event.key.control) { BodyItem *selected_item = thread_body->get_selected(); if(show_info_page(selected_item, true)) { redraw = true; frame_skip_text_entry = true; } } else if(event.key.code == mgl::Keyboard::S && event.key.control) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->url.empty()) download_async_gui(selected_item->url, file_manager_start_dir.string(), false, "", event.key.shift); } BodyItem *selected_item = thread_body->get_selected(); ImageBoardBodyItemData *image_board_post_data = static_cast(selected_item->extra.get()); if(event.key.code == mgl::Keyboard::Enter && selected_item && (comment_navigation_stack.empty() || thread_body->get_selected_item() != comment_navigation_stack.back()) && (!image_board_post_data->replies_to.empty() || !image_board_post_data->replies.empty())) { focus_selected_post(); } else if(event.key.code == mgl::Keyboard::Backspace && !comment_navigation_stack.empty()) { size_t previous_selected = comment_navigation_stack.back(); float previous_page_scroll = comment_page_scroll_stack.back(); comment_navigation_stack.pop_back(); comment_page_scroll_stack.pop_back(); if(comment_navigation_stack.empty()) { thread_body->for_each_item([](std::shared_ptr &body_item) { body_item->visible = true; }); thread_body->set_selected_item(previous_selected); thread_body->clamp_selection(); } else { thread_body->for_each_item([](std::shared_ptr &body_item) { body_item->visible = false; }); thread_body->set_selected_item(previous_selected); selected_item = thread_body->get_item_by_index(comment_navigation_stack.back()).get(); selected_item->visible = true; image_board_post_data = static_cast(selected_item->extra.get()); for(size_t reply_to_index : image_board_post_data->replies_to) { thread_body->get_item_by_index(reply_to_index)->visible = true; } for(size_t reply_index : image_board_post_data->replies) { thread_body->get_item_by_index(reply_index)->visible = true; } thread_body->clamp_selection(); } thread_body->set_page_scroll(previous_page_scroll); } else if(event.key.code == mgl::Keyboard::R && selected_item) { std::string text_to_add = ">>" + std::to_string(image_board_post_data->post_id) + "\n"; comment_input.insert_text_at_caret_position(std::move(text_to_add)); comment_input.move_caret_to_end(); } if(event.key.code == mgl::Keyboard::I && !event.key.control) { frame_skip_text_entry = true; navigation_stage = NavigationStage::REPLYING; comment_input.set_editable(true); comment_input.move_caret_to_end(); } } if(event.type == mgl::Event::KeyPressed && navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA && !frame_skip_text_entry) { if(event.key.code == mgl::Keyboard::Escape) { navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(event.key.code == mgl::Keyboard::Enter) { navigation_stage = NavigationStage::POSTING_COMMENT; captcha_solution = captcha_solution_text.get_string(); } else if(event.key.code == mgl::Keyboard::Backspace) { std::string str = captcha_solution_text.get_string(); if(!str.empty()) { str.erase(str.size() - 1); captcha_solution_text.set_string(std::move(str)); } } else { const int alpha = (int)event.key.code - (int)mgl::Keyboard::A; const int num = (int)event.key.code - (int)mgl::Keyboard::Num0; const int numpad = (int)event.key.code - (int)mgl::Keyboard::Numpad0; if(alpha >= 0 && alpha <= mgl::Keyboard::Z - mgl::Keyboard::A) { captcha_solution_text.set_string(captcha_solution_text.get_string() + (char)to_upper(alpha + 'a')); } else if(num >= 0 && num <= mgl::Keyboard::Num9 - mgl::Keyboard::Num0) { captcha_solution_text.set_string(captcha_solution_text.get_string() + (char)(num + '0')); } else if(numpad >= 0 && numpad <= mgl::Keyboard::Numpad9 - mgl::Keyboard::Numpad0) { captcha_solution_text.set_string(captcha_solution_text.get_string() + (char)(numpad + '0')); } } } if(navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { if(event.type == mgl::Event::MouseWheelScrolled) { image_control.zoom *= (1.0f + event.mouse_wheel_scroll.delta * 0.1f); if(image_control.zoom < 0.01f) image_control.zoom = 0.01f; image_control.moved = true; } else if(event.type == mgl::Event::MouseButtonPressed && event.mouse_button.button == mgl::Mouse::Left) { image_control.pressed = true; image_control.prev_mouse_pos.x = event.mouse_button.x; image_control.prev_mouse_pos.y = event.mouse_button.y; } else if(event.type == mgl::Event::MouseButtonReleased && event.mouse_button.button == mgl::Mouse::Left) { image_control.pressed = false; } else if(event.type == mgl::Event::MouseMoved && image_control.pressed) { const mgl::vec2i mouse_diff = mgl::vec2i(event.mouse_move.x, event.mouse_move.y) - image_control.prev_mouse_pos; image_control.prev_mouse_pos.x = event.mouse_move.x; image_control.prev_mouse_pos.y = event.mouse_move.y; image_control.offset -= (mouse_diff.to_vec2f() / image_control.zoom); image_control.moved = true; idle_active_handler(); } else if(event.type == mgl::Event::KeyPressed) { if(event.key.code == mgl::Keyboard::W) { image_control.zoom = 1.0f; image_control.moved = false; image_control.offset = attached_image_texture->get_size().to_vec2f() * 0.5f; attached_image_sprite.set_origin(mgl::vec2f(0.0f, 0.0f)); attached_image_sprite.set_scale(mgl::vec2f(1.0f, 1.0f)); } } } if(event.type == mgl::Event::KeyPressed && navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { if(event.key.code == mgl::Keyboard::Escape || event.key.code == mgl::Keyboard::Backspace) { navigation_stage = NavigationStage::VIEWING_COMMENTS; attached_image_texture.reset(new mgl::Texture()); redraw = true; } else if(event.key.code == mgl::Keyboard::I && event.key.control && !attached_image_url.empty()) { std::vector saucenao_tabs; saucenao_tabs.push_back(Tab{create_body(), std::make_unique(this, attached_image_url, false), nullptr}); page_loop(saucenao_tabs); redraw = true; frame_skip_text_entry = true; } else if(event.key.code == mgl::Keyboard::S && event.key.control) { download_async_gui(attached_image_url, file_manager_start_dir.string(), false, "", event.key.shift); } } } frame_skip_text_entry = false; update_idle_state(); handle_x11_events(); if(navigation_stage == NavigationStage::REQUESTING_CAPTCHA) { captcha_challenge = ImageBoardCaptchaChallenge(); TaskResult task_result = run_task_with_loading_screen([thread_page, &captcha_challenge]{ return thread_page->request_captcha_challenge(captcha_challenge) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) { comment_input.set_editable(false); captcha_post_id = std::move(captcha_challenge.challenge_id); solved_captcha_ttl = 0;// captcha_challenge.ttl; // TODO: Support ttl to not have to solve captcha again captcha_solution_text.set_string(""); captcha_slide = 0.0f; if(captcha_post_id == "noop") { // TODO: Fix for other imageboard than 4chan in the future captcha_solution.clear(); navigation_stage = NavigationStage::POSTING_COMMENT; } else { captcha_texture.clear(); captcha_bg_texture.clear(); bool failed = false; mgl::Image image; if(!image.load_from_memory((const unsigned char*)captcha_challenge.img_data.data(), captcha_challenge.img_data.size()) || !captcha_texture.load_from_image(image)) { show_notification("QuickMedia", "Failed to load captcha image", Urgency::CRITICAL); failed = true; } captcha_challenge.img_data = std::string(); has_captcha_bg = !failed && !captcha_challenge.bg_data.empty(); mgl::Image bg_image; if(has_captcha_bg && (!bg_image.load_from_memory((const unsigned char*)captcha_challenge.bg_data.data(), captcha_challenge.bg_data.size()) || !captcha_bg_texture.load_from_image(bg_image))) { show_notification("QuickMedia", "Failed to load captcha image", Urgency::CRITICAL); failed = true; } captcha_challenge.bg_data = std::string(); if(failed) { navigation_stage = NavigationStage::VIEWING_COMMENTS; } else { navigation_stage = NavigationStage::SOLVING_POST_CAPTCHA; captcha_sprite.set_texture(&captcha_texture); if(has_captcha_bg) captcha_bg_sprite.set_texture(&captcha_bg_texture); } } } else if(task_result == TaskResult::CANCEL) { comment_input.set_editable(false); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(task_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to get captcha", Urgency::CRITICAL); comment_input.set_editable(false); navigation_stage = NavigationStage::VIEWING_COMMENTS; } } else if(navigation_stage == NavigationStage::POSTING_COMMENT) { TaskResult task_result = run_task_with_loading_screen([&post_comment, &comment_to_post, &selected_file_for_upload]{ post_comment(comment_to_post, selected_file_for_upload); return true; }); if(task_result == TaskResult::CANCEL) { comment_input.set_editable(false); navigation_stage = NavigationStage::VIEWING_COMMENTS; } } if(selected_file_for_upload.empty()) { if(file_to_upload_thumbnail_data) { file_to_upload_thumbnail_data.reset(); redraw = true; } } else { file_to_upload_thumbnail_data = AsyncImageLoader::get_instance().get_thumbnail(selected_file_for_upload, true, mgl::vec2i(logo_size.x, logo_size.y * 4)); } if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING) { if(!file_to_upload_thumbnail_data->texture.load_from_image(*file_to_upload_thumbnail_data->image)) fprintf(stderr, "Warning: failed to load texture for attached file\n"); //room_avatar_thumbnail_data->texture.generateMipmap(); file_to_upload_thumbnail_data->image.reset(); file_to_upload_thumbnail_data->loading_state = LoadingState::APPLIED_TO_TEXTURE; sprite_applied_texture = false; } if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE && !sprite_applied_texture) { file_to_upload_sprite.set_texture(&file_to_upload_thumbnail_data->texture); sprite_applied_texture = true; mgl::vec2f texture_size_f(file_to_upload_thumbnail_data->texture.get_size().x, file_to_upload_thumbnail_data->texture.get_size().y); mgl::vec2f image_scale = get_ratio(texture_size_f, clamp_to_size_x(texture_size_f, logo_size.x)); file_to_upload_sprite.set_scale(image_scale); redraw = true; } float chat_input_height_full_images = logo_size.y; if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE) { const float file_to_upload_height = std::floor(logo_file_to_upload_spacing + file_to_upload_sprite.get_texture()->get_size().y * file_to_upload_sprite.get_scale().y); chat_input_height_full_images += file_to_upload_height; } chat_input_height_full = chat_input_padding_y + std::max(comment_input.get_height(), chat_input_height_full_images) + chat_input_padding_y; const float chat_height = comment_input.get_height(); if(std::abs(chat_height - prev_chat_height) > 1.0f) { prev_chat_height = chat_height; redraw = true; } if(redraw) { redraw = false; comment_input.set_max_width(window_size.x - (logo_padding_x + logo_size.x + chat_input_padding_x + logo_padding_x)); comment_input.set_position(vec2f_floor(logo_padding_x + logo_size.x + chat_input_padding_x, chat_input_padding_y)); const float body_width = window_size.x; body_pos = mgl::vec2f(0.0f, chat_input_height_full); body_size = mgl::vec2f(body_width, window_size.y - chat_input_height_full); logo_sprite.set_position(mgl::vec2f(logo_padding_x, chat_input_padding_y)); file_to_upload_sprite.set_position(logo_sprite.get_position() + mgl::vec2f(0.0f, logo_size.y + logo_file_to_upload_spacing)); } //comment_input.update(); window.clear(get_theme().background_color); if(navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA && captcha_texture.is_valid()) { const float slide_speed = 0.5f; const bool window_has_focus = window.has_focus(); if(window_has_focus && moving_captcha_left) { captcha_slide -= (slide_speed * frame_elapsed_time_sec); if(captcha_slide < 0.0f) captcha_slide = 0.0f; } else if(window_has_focus && moving_captcha_right) { captcha_slide += (slide_speed * frame_elapsed_time_sec); if(captcha_slide > 1.0f) captcha_slide = 1.0f; } mgl::vec2f content_size = window_size.to_vec2f(); int image_height = 0; mgl::vec2i captcha_texture_size = captcha_texture.get_size(); mgl::vec2f captcha_texture_size_f(captcha_texture_size.x, captcha_texture_size.y); auto image_scale = get_ratio(captcha_texture_size_f, clamp_to_size(captcha_texture_size_f, content_size)); captcha_sprite.set_scale(image_scale); auto captcha_image_size = captcha_texture_size_f; captcha_image_size.x *= image_scale.x; captcha_image_size.y *= image_scale.y; captcha_sprite.set_position(vec2f_floor(content_size.x * 0.5f - captcha_image_size.x * 0.5f, content_size.y * 0.5f - captcha_image_size.y * 0.5f)); image_height = (int)captcha_image_size.y; if(has_captcha_bg && captcha_bg_texture.is_valid()) { mgl::vec2f content_size = window_size.to_vec2f(); mgl::vec2i captcha_bg_texture_size = captcha_bg_texture.get_size(); mgl::vec2f captcha_bg_texture_size_f(captcha_bg_texture_size.x, captcha_bg_texture_size.y); auto image_scale = get_ratio(captcha_bg_texture_size_f, clamp_to_size(captcha_bg_texture_size_f, content_size)); captcha_bg_sprite.set_scale(image_scale); auto image_size = captcha_bg_texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; const float width_diff = image_size.x - captcha_image_size.x; captcha_bg_sprite.set_position(vec2f_floor(captcha_sprite.get_position().x + width_diff*1.0f - captcha_slide*(width_diff + width_diff*2.0f), captcha_sprite.get_position().y)); window.draw(captcha_bg_sprite); image_height = std::max(image_height, (int)image_size.y); } window.draw(captcha_sprite); // TODO: Cut off ends with sf::View instead mgl::Rectangle cut_off_rectangle(captcha_image_size); cut_off_rectangle.set_color(get_theme().background_color); cut_off_rectangle.set_position(captcha_sprite.get_position() - mgl::vec2f(cut_off_rectangle.get_size().x, 0.0f)); window.draw(cut_off_rectangle); cut_off_rectangle.set_position(captcha_sprite.get_position() + mgl::vec2f(captcha_image_size.x, 0.0f)); window.draw(cut_off_rectangle); const float captcha_slide_bg_height = std::floor(20.0f * get_config().scale); captcha_slide_bg.set_size(mgl::vec2f(captcha_image_size.x, captcha_slide_bg_height)); captcha_slide_bg.set_position(mgl::vec2f(captcha_sprite.get_position().x, captcha_sprite.get_position().y + image_height + 10.0f)); const mgl::vec2f captcha_slide_fg_size = captcha_slide_bg.get_size() - mgl::vec2f(captcha_slide_padding_x * 2.0f, captcha_slide_padding_y * 2.0f); captcha_slide_fg.set_size(vec2f_floor(captcha_slide_fg_size.x * captcha_slide, captcha_slide_fg_size.y)); captcha_slide_fg.set_position(captcha_slide_bg.get_position() + mgl::vec2f(captcha_slide_padding_x, captcha_slide_padding_y)); if(has_captcha_bg) { captcha_slide_bg.draw(window); captcha_slide_fg.draw(window); } captcha_solution_text.set_position( mgl::vec2f( std::floor(window_size.x * 0.5f - captcha_solution_text.get_bounds().size.x * 0.5f), std::floor(captcha_slide_bg.get_position().y + captcha_slide_bg.get_size().y + 10.0f))); window.draw(captcha_solution_text); } else if(navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { if(attached_image_texture->is_valid()) { if(image_control.moved) { attached_image_sprite.set_origin(floor(image_control.offset)); attached_image_sprite.set_scale(mgl::vec2f(image_control.zoom, image_control.zoom)); attached_image_sprite.set_position(floor(window_size.to_vec2f() * 0.5f)); window.draw(attached_image_sprite); } else { auto content_size = window_size.to_vec2f(); mgl::vec2i texture_size = attached_image_texture->get_size(); mgl::vec2f texture_size_f(texture_size.x, texture_size.y); auto image_scale = get_ratio(texture_size_f, clamp_to_size(texture_size_f, content_size)); image_control.zoom = std::min(image_scale.x, image_scale.y); attached_image_sprite.set_scale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; attached_image_sprite.set_position(vec2f_floor(content_size.x * 0.5f - image_size.x * 0.5f, content_size.y * 0.5f - image_size.y * 0.5f)); window.draw(attached_image_sprite); } } else { mgl::Rectangle rect(mgl::vec2f(640.0f, 480.0f)); rect.set_color(get_theme().image_loading_background_color); auto content_size = window_size.to_vec2f(); auto rect_size = clamp_to_size(rect.get_size(), content_size); rect.set_size(rect_size); rect.set_position(vec2f_floor(content_size.x * 0.5f - rect_size.x * 0.5f, content_size.y * 0.5f - rect_size.y * 0.5f)); window.draw(rect); load_sprite.set_position(mgl::vec2f(window_size.x * 0.5f, window_size.y * 0.5f)); load_sprite.set_rotation(load_sprite_timer.get_elapsed_time_seconds() * 400.0); window.draw(load_sprite); } } else if(navigation_stage == NavigationStage::REPLYING) { window.draw(logo_sprite); if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE) window.draw(file_to_upload_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } else if(navigation_stage == NavigationStage::VIEWING_COMMENTS) { window.draw(logo_sprite); if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE) window.draw(file_to_upload_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } if((navigation_stage == NavigationStage::REPLYING || navigation_stage == NavigationStage::VIEWING_COMMENTS) && has_post_timeout) { int64_t time_left_until_post_again = seconds_until_post_again - (time(nullptr) - last_posted_time); if(time_left_until_post_again > 0) { mgl::Rectangle time_left_bg(mgl::vec2f(window_size.x, chat_input_height_full)); time_left_bg.set_color(mgl::Color(0, 0, 0, 100)); window.draw(time_left_bg); mgl::Text time_left_text("Wait " + std::to_string(time_left_until_post_again) + " second(s) before posting again", *FontLoader::get_font(FontLoader::FontType::LATIN, 18 * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); time_left_text.set_color(get_theme().text_color); time_left_text.set_position(time_left_bg.get_position() + mgl::vec2f( std::floor(time_left_bg.get_size().x * 0.5f - time_left_text.get_bounds().size.x * 0.5f), std::floor(time_left_bg.get_size().y * 0.5f - time_left_text.get_bounds().size.y * 0.6f))); window.draw(time_left_text); } } AsyncImageLoader::get_instance().update(); window.display(); } } class MatrixLoginPage : public LoginPage { public: MatrixLoginPage(Program *program, std::string title, Matrix *matrix) : LoginPage(program), title(std::move(title)), matrix(matrix) {} const char* get_title() const override { return title.c_str(); } PluginResult submit(const SubmitArgs&, std::vector&) override { for(const auto &login_input : login_inputs->inputs) { if(login_input->get_text().empty()) { show_notification("QuickMedia", "All fields need to be filled in", Urgency::CRITICAL); return PluginResult::OK; } } std::string homeserver = login_inputs->inputs[2]->get_text(); if(!string_starts_with(homeserver, "http://") && !string_starts_with(homeserver, "https://")) homeserver = "https://" + homeserver; std::string err_msg; std::string username = strip(login_inputs->inputs[0]->get_text()); std::string username_matrix_id = extract_user_name_from_user_id(username); if(username_matrix_id.empty()) { username_matrix_id = extract_user_name_from_email(username); if(username_matrix_id.empty()) username_matrix_id = username; } if(matrix->login(username_matrix_id, login_inputs->inputs[1]->get_text(), homeserver, err_msg) == PluginResult::OK) { login_finish(); return PluginResult::OK; } else { // TODO: Do a proper check for this if(err_msg.find("Failed to parse") != std::string::npos) show_notification("QuickMedia", "Failed to login, error: " + err_msg + ". Did you perhaps specify an invalid homeserver?", Urgency::CRITICAL); else show_notification("QuickMedia", "Failed to login, error: " + err_msg, Urgency::CRITICAL); return PluginResult::OK; } } const LoginInputs *login_inputs; private: std::string title; Matrix *matrix; }; void Program::chat_login_page() { assert(strcmp(plugin_name, "matrix") == 0); LoginInputs login_inputs; login_inputs.inputs.push_back(std::make_unique(nullptr, &rounded_rectangle_shader, "Username", SearchBarType::Text)); login_inputs.inputs.push_back(std::make_unique(nullptr, &rounded_rectangle_shader, "Password", SearchBarType::Password)); login_inputs.inputs.push_back(std::make_unique(nullptr, &rounded_rectangle_shader, "Homeserver", SearchBarType::Text)); auto login_page = std::make_unique(this, "Matrix login", matrix); MatrixLoginPage *login_page_ptr = login_page.get(); std::vector tabs; tabs.push_back(Tab{ create_body(), std::move(login_page), nullptr, std::move(login_inputs) }); login_page_ptr->login_inputs = &tabs.back().login_inputs; page_loop(tabs); if(login_page_ptr->logged_in()) current_page = PageType::CHAT; } struct ChatTab { std::unique_ptr body; AsyncTask future; }; static const mgl::vec2i CHAT_MESSAGE_THUMBNAIL_MAX_SIZE(600, 337); static Message* get_original_message(Message *message) { while(message) { if(!message->replaces) break; message = message->replaces; } return message; } // TODO: Optimize. // Note: finds the original message if it's an edit static std::shared_ptr find_body_item_by_event_id(const std::shared_ptr *body_items, size_t num_body_items, const std::string &event_id, size_t *index_result = nullptr) { if(event_id.empty()) return nullptr; for(size_t i = 0; i < num_body_items; ++i) { auto &body_item = body_items[i]; Message *message = static_cast(body_item->userdata); if(message && message->event_id == event_id) { const Message *original_message = get_original_message(message); if(original_message == message) { if(index_result) *index_result = i; return body_item; } else { return find_body_item_by_event_id(body_items, num_body_items, original_message->event_id, index_result); } } } return nullptr; } static std::shared_ptr find_body_item_by_event_id(BodyItemList body_items, const std::string &event_id, int *index_result) { size_t index_result_z = 0; auto result = find_body_item_by_event_id(body_items.data(), body_items.size(), event_id, &index_result_z); if(index_result) *index_result = index_result_z; return result; } // Returns true if cached and loaded static bool load_cached_related_embedded_item(BodyItem *body_item, Message *message, UserInfo *me, const std::string &my_display_name, const std::string &my_user_id, const BodyItemList &message_body_items) { // Check if we already have the referenced message as a body item, so we dont create a new one. // TODO: Optimize from linear search to hash map auto related_body_item = find_body_item_by_event_id(message_body_items.data(), message_body_items.size(), message->related_event_id); if(!related_body_item) return false; body_item->embedded_item = BodyItem::create(""); *body_item->embedded_item = *related_body_item; if(related_body_item->extra) { MatrixChatBodyItemData *other_item_data = static_cast(related_body_item->extra.get()); if(other_item_data->decrypt_state != MatrixChatBodyItemData::DecryptState::DECRYPTED) body_item->embedded_item->extra = std::make_shared(other_item_data->matrix, other_item_data->text_to_decrypt); } body_item->embedded_item->embedded_item = nullptr; body_item->embedded_item->reactions.clear(); if(message->user->user_id != my_user_id && ((related_body_item->userdata && static_cast(related_body_item->userdata)->user.get() == me) || message_contains_user_mention(body_item, my_display_name, my_user_id))) body_item->set_description_color(get_theme().attention_alert_text_color, true); else body_item->set_description_color(get_theme().text_color); body_item->embedded_item_status = FetchStatus::FINISHED_LOADING; return true; } static bool load_cached_related_embedded_item(BodyItem *body_item, Message *message, const std::shared_ptr &me, RoomData *current_room, const BodyItemList &message_body_items) { return load_cached_related_embedded_item(body_item, message, me.get(), current_room->get_user_display_name(me), me->user_id, message_body_items); } static void matrix_body_set_text_decrypt_if_needed(Matrix *matrix, BodyItem *body_item, std::string text) { // TODO: Check if gpg is installed if(!get_config().matrix.gpg_user_id.empty() && text.find("-----BEGIN PGP MESSAGE-----") != std::string::npos && text.find("-----END PGP MESSAGE-----") != std::string::npos) { body_item->extra = std::make_shared(matrix, std::move(text)); body_item->set_description("🔒 Decrypting message..."); } else { body_item->set_description(std::move(text)); } } static std::shared_ptr message_to_body_item(Matrix *matrix, RoomData *room, Message *message, const std::string &my_display_name, const std::string &my_user_id) { Message *latest_message = get_latest_message_in_edit_chain(message); auto body_item = BodyItem::create(""); body_item->set_author(extract_first_line_remove_newline_elipses(room->get_user_display_name(message->user), AUTHOR_MAX_LENGTH)); //body_item->set_description(matrix_decrypt_gpg_message_if_needed(strip(message_to_qm_text(matrix, latest_message)))); matrix_body_set_text_decrypt_if_needed(matrix, body_item.get(), strip(message_to_qm_text(matrix, latest_message))); body_item->set_timestamp(message->timestamp); if(!message->thumbnail_url.empty()) { body_item->thumbnail_url = message->thumbnail_url; body_item->thumbnail_size = message->thumbnail_size; } else if(!message->url.empty() && message->type == MessageType::IMAGE) { body_item->thumbnail_url = message->url; body_item->thumbnail_size = message->thumbnail_size; } else { body_item->thumbnail_url = room->get_user_avatar_url(message->user); if(body_item->thumbnail_url.empty()) { body_item->thumbnail_url = get_resource_loader_root_path() + get_no_avatar_image_path(); body_item->thumbnail_is_local = true; } body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; // if construct is not configured to use ImageMagic then it wont give thumbnails of size 32x32 even when requested and the spec says that the server SHOULD do that body_item->thumbnail_size = AVATAR_THUMBNAIL_SIZE; } // TODO: Show image thumbnail inline instead of url to image and showing it as the thumbnail of the body item body_item->url = message->url; body_item->set_author_color(message->user->display_name_color); body_item->userdata = (void*)message; // Note: message has to be valid as long as body_item is used! if(message->related_event_type == RelatedEventType::REDACTION || message->related_event_type == RelatedEventType::EDIT || message->related_event_type == RelatedEventType::REACTION) body_item->visible = false; if(is_system_message_type(message->type)) { body_item->set_author("System"); body_item->set_author_color(get_theme().text_color); body_item->set_description_color(get_theme().faded_text_color); body_item->thumbnail_url.clear(); } if(message->user->user_id != my_user_id && message_contains_user_mention(body_item.get(), my_display_name, my_user_id)) body_item->set_description_color(get_theme().attention_alert_text_color, true); return body_item; } static BodyItems messages_to_body_items(Matrix *matrix, RoomData *room, const Messages &messages, const std::string &my_display_name, const std::string &my_user_id) { BodyItems result_items(messages.size()); for(size_t i = 0; i < messages.size(); ++i) { result_items[i] = message_to_body_item(matrix, room, messages[i].get(), my_display_name, my_user_id); } return result_items; } static void messages_load_cached_related_embedded_item(BodyItems &new_body_items, const BodyItemList &all_body_items, const std::shared_ptr &me, RoomData *current_room) { std::string my_display_name = current_room->get_user_display_name(me); for(auto &body_item : new_body_items) { Message *message = static_cast(body_item->userdata); if(message) load_cached_related_embedded_item(body_item.get(), message, me.get(), my_display_name, me->user_id, all_body_items); } } static bool is_state_message_type(const Message *message) { if(!message) return true; switch(message->type) { case MessageType::TEXT: return false; case MessageType::IMAGE: return false; case MessageType::VIDEO: return false; case MessageType::AUDIO: return false; case MessageType::FILE: return false; default: return true; } return true; } struct PinnedEventData { std::string event_id; FetchStatus status = FetchStatus::NONE; Message *message = nullptr; }; static void user_update_display_info(BodyItem *body_item, RoomData *room, Message *message) { if(is_system_message_type(message->type)) return; body_item->set_author(extract_first_line_remove_newline_elipses(room->get_user_display_name(message->user), AUTHOR_MAX_LENGTH)); if(!is_visual_media_message_type(message->type)) { body_item->thumbnail_url = room->get_user_avatar_url(message->user); if(body_item->thumbnail_url.empty()) { body_item->thumbnail_url = get_resource_loader_root_path() + get_no_avatar_image_path(); body_item->thumbnail_is_local = true; } body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; // if construct is not configured to use ImageMagic then it wont give thumbnails of size 32x32 even when requested and the spec says that the server SHOULD do that body_item->thumbnail_size = AVATAR_THUMBNAIL_SIZE; } if(body_item->embedded_item_status == FetchStatus::FINISHED_LOADING && body_item->embedded_item && body_item->userdata) user_update_display_info(body_item->embedded_item.get(), room, (Message*)body_item->embedded_item->userdata); } bool Program::chat_page(MatrixChatPage *matrix_chat_page, RoomData *current_room) { assert(current_room); assert(strcmp(plugin_name, "matrix") == 0); if(!current_room) { show_notification("QuickMedia", "Bug: current room empty", Urgency::CRITICAL); abort(); } window.set_title(("QuickMedia - matrix - " + current_room->get_name()).c_str()); bool move_room = false; // Year 2500.. The year that humanity is wiped out. Also we want our local message to appear at the bottom even if time is not synced with ntp, until it's replaced by the server constexpr int64_t timestamp_provisional_event = 16755030000LL * 1000LL; const float room_name_text_height = std::floor(get_config().matrix.room_name_font_size * get_config().scale * get_config().font_scale * get_config().font.scale.latin_bold); mgl::Text room_name_text("", *FontLoader::get_font(FontLoader::FontType::LATIN_BOLD, room_name_text_height)); room_name_text.set_color(get_theme().text_color); const float room_name_text_padding_y = std::floor(10.0f * get_config().scale); const float room_name_total_height = room_name_text_height + room_name_text_padding_y * 2.0f; const float room_avatar_height = 32.0f; const float room_topic_text_height = std::floor(get_config().matrix.room_description_font_size * get_config().scale * get_config().font_scale * get_config().font.scale.latin); mgl::Text room_topic_text("", *FontLoader::get_font(FontLoader::FontType::LATIN, room_topic_text_height)); room_topic_text.set_color(get_theme().faded_text_color); mgl::Text room_label(matrix_chat_page->rooms_page->get_title(), *FontLoader::get_font(FontLoader::FontType::LATIN_BOLD, get_config().matrix.room_name_font_size * get_config().scale * get_config().font_scale * get_config().font.scale.latin_bold)); room_label.set_color(get_theme().text_color); room_label.set_position(mgl::vec2f(15.0f, room_name_text_padding_y + 4.0f)); mgl::Sprite room_avatar_sprite; auto room_avatar_thumbnail_data = std::make_shared(); bool avatar_applied = false; std::vector tabs; ChatTab pinned_tab; pinned_tab.body = create_body(true); pinned_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; pinned_tab.body->attach_side = AttachSide::BOTTOM; tabs.push_back(std::move(pinned_tab)); ChatTab messages_tab; messages_tab.body = create_body(true); messages_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; messages_tab.body->attach_side = AttachSide::BOTTOM; tabs.push_back(std::move(messages_tab)); ChatTab users_tab; users_tab.body = create_body(true); users_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; users_tab.body->attach_side = AttachSide::TOP; tabs.push_back(std::move(users_tab)); for(ChatTab &tab : tabs) { tab.body->show_drop_shadow = false; } Tabs ui_tabs(&rounded_rectangle_shader, is_touch_enabled() ? mgl::Color(0, 0, 0, 0) : get_theme().background_color); const int PINNED_TAB_INDEX = ui_tabs.add_tab("Pinned messages (0)", tabs[0].body.get()); const int MESSAGES_TAB_INDEX = ui_tabs.add_tab("Messages", tabs[1].body.get()); const int USERS_TAB_INDEX = ui_tabs.add_tab("Users (0)", tabs[2].body.get()); ui_tabs.set_selected(MESSAGES_TAB_INDEX); matrix_chat_page->chat_body = tabs[MESSAGES_TAB_INDEX].body.get(); matrix_chat_page->messages_tab_visible = true; matrix_chat_page->set_current_room(current_room, tabs[USERS_TAB_INDEX].body.get(), [&](const MatrixEventRoomInfo &room_info) { if(room_info.name) { window.set_title(("QuickMedia - matrix - " + room_info.name.value()).c_str()); std::string room_name = current_room->get_name(); string_replace_all(room_name, '\n', ' '); room_name_text.set_string(std::move(room_name)); } if(room_info.topic) { std::string room_topic = current_room->get_topic(); string_replace_all(room_topic, '\n', ' '); room_topic_text.set_string(std::move(room_topic)); } if(room_info.avatar_url) { room_avatar_sprite.set_texture(nullptr); room_avatar_thumbnail_data = std::make_shared(); avatar_applied = false; } }); size_t prev_num_users_in_room = 0; bool redraw = true; mgl::Clock read_marker_timer; double read_marker_timeout_sec = 0; AsyncTask set_read_marker_future; bool setting_read_marker = false; mgl::Clock start_typing_timer; const double typing_timeout_seconds = 5.0; bool typing = false; MessageQueue typing_state_queue; auto typing_state_handler = [this, ¤t_room, &typing_state_queue]() { while(true) { std::optional state_opt = typing_state_queue.pop_wait(); if(!state_opt) break; bool state = state_opt.value(); if(state) matrix->on_start_typing(current_room); else matrix->on_stop_typing(current_room); } }; std::thread typing_state_thread(typing_state_handler); ui_tabs.on_change_tab = [matrix_chat_page, &redraw, &typing, &typing_state_queue, &read_marker_timer, &tabs, MESSAGES_TAB_INDEX](int prev_tab, int new_tab) { tabs[prev_tab].body->clear_cache(); if(new_tab == MESSAGES_TAB_INDEX) matrix_chat_page->messages_tab_visible = true; else matrix_chat_page->messages_tab_visible = false; read_marker_timer.restart(); redraw = true; if(typing) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } }; bool is_window_focused = window.has_focus(); enum class ChatState { NAVIGATING, TYPING_MESSAGE, REPLYING, EDITING, URL_SELECTION }; PageType new_page = PageType::CHAT; ChatState chat_state = ChatState::NAVIGATING; std::shared_ptr currently_operating_on_item; mgl::Text replying_to_text("Replying to:", *FontLoader::get_font(FontLoader::FontType::LATIN, 18 * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); replying_to_text.set_color(get_theme().text_color); bool draw_room_list = show_room_side_panel; // TODO: What if these never end up referencing events? clean up automatically after a while? Messages unreferenced_events; auto set_body_as_deleted = [¤t_room](Message *message, BodyItem *body_item) { //body_item->embedded_item = nullptr; //body_item->embedded_item_status = FetchStatus::NONE; message->type = MessageType::REDACTION; //message->related_event_id.clear(); //message->related_event_type = RelatedEventType::NONE; Message *original_message = static_cast(body_item->userdata); if(original_message && !is_system_message_type(original_message->type)) { body_item->thumbnail_url = current_room->get_user_avatar_url(original_message->user); if(body_item->thumbnail_url.empty()) { body_item->thumbnail_url = get_resource_loader_root_path() + get_no_avatar_image_path(); body_item->thumbnail_is_local = true; } body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; } body_item->set_description("Message deleted"); if(original_message && is_system_message_type(original_message->type)) body_item->set_description_color(get_theme().faded_text_color); else body_item->set_description_color(get_theme().text_color); body_item->thumbnail_size = AVATAR_THUMBNAIL_SIZE; body_item->url.clear(); }; // TODO: Optimize with hash map? auto resolve_unreferenced_events_with_body_items = [this, ¤t_room, &set_body_as_deleted, &unreferenced_events](std::shared_ptr *body_items, size_t num_body_items) { if(num_body_items == 0) return; auto me = matrix->get_me(current_room); auto my_display_name = current_room->get_user_display_name(me); for(auto it = unreferenced_events.begin(); it != unreferenced_events.end(); ) { auto &message = *it; // TODO: Make redacted/edited events as (redacted)/(edited) in the body if(message->related_event_type == RelatedEventType::REDACTION || message->related_event_type == RelatedEventType::EDIT) { auto body_item = find_body_item_by_event_id(body_items, num_body_items, message->related_event_id); if(body_item) { // TODO: Append the new message to the body item so the body item should have a list of edit events //body_item->userdata = message.get(); if(message->related_event_type == RelatedEventType::REDACTION) { set_body_as_deleted(message.get(), body_item.get()); } else { // TODO: Properly check reply message objects for mention of user instead of message data, but only when synapse fixes that notifications // are not triggered by reply to a message with our display name/user id. Message *edited_message_ref = get_latest_message_in_edit_chain(static_cast(body_item->userdata)); if(message->timestamp > edited_message_ref->timestamp) { std::string qm_formatted_text = message_to_qm_text(matrix, message.get()); //body_item->set_description(matrix_decrypt_gpg_message_if_needed(std::move(qm_formatted_text))); matrix_body_set_text_decrypt_if_needed(matrix, body_item.get(), std::move(qm_formatted_text)); if(message->user != me && message_contains_user_mention(matrix, message.get(), my_display_name, me->user_id)) body_item->set_description_color(get_theme().attention_alert_text_color, true); else body_item->set_description_color(get_theme().text_color); message->replaces = edited_message_ref; edited_message_ref->replaced_by = message; } } it = unreferenced_events.erase(it); } else { ++it; } } else { ++it; } } }; // TODO: Optimize find_body_item_by_event_id hash map? auto modify_related_messages_in_current_room = [this, ¤t_room, &set_body_as_deleted, &unreferenced_events, &tabs, MESSAGES_TAB_INDEX](Messages &messages) { if(messages.empty()) return; auto me = matrix->get_me(current_room); auto my_display_name = current_room->get_user_display_name(me); auto body_items = tabs[MESSAGES_TAB_INDEX].body->get_items(); for(auto &message : messages) { // TODO: Make redacted/edited events as (redacted)/(edited) in the body if(message->related_event_type == RelatedEventType::REDACTION || message->related_event_type == RelatedEventType::EDIT) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), message->related_event_id); if(body_item) { // TODO: Append the new message to the body item so the body item should have a list of edit events //body_item->userdata = message.get(); if(message->related_event_type == RelatedEventType::REDACTION) { set_body_as_deleted(message.get(), body_item.get()); } else { // TODO: Properly check reply message objects for mention of user instead of message data, but only when synapse fixes that notifications // are not triggered by reply to a message with our display name/user id. Message *edited_message_ref = get_latest_message_in_edit_chain(static_cast(body_item->userdata)); if(message->timestamp > edited_message_ref->timestamp) { std::string qm_formatted_text = formatted_text_to_qm_text(matrix, message->body.c_str(), message->body.size(), true); //body_item->set_description(matrix_decrypt_gpg_message_if_needed(std::move(qm_formatted_text))); matrix_body_set_text_decrypt_if_needed(matrix, body_item.get(), std::move(qm_formatted_text)); if(message->user != me && message_contains_user_mention(matrix, message.get(), my_display_name, me->user_id)) body_item->set_description_color(get_theme().attention_alert_text_color, true); else body_item->set_description_color(get_theme().text_color); message->replaces = edited_message_ref; edited_message_ref->replaced_by = message; } } } else { unreferenced_events.push_back(message); } } } }; std::vector> unresolved_reactions; // TODO: Optimize find_body_item_by_event_id hash map? auto process_reactions = [&tabs, &unresolved_reactions, ¤t_room, MESSAGES_TAB_INDEX](Messages &messages) { if(messages.empty()) return; auto body_items = tabs[MESSAGES_TAB_INDEX].body->get_items(); // TODO: Check in |messages| instead for(auto it = unresolved_reactions.begin(); it != unresolved_reactions.end();) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), (*it)->related_event_id); if(body_item) { body_item->add_reaction(extract_first_line_remove_newline_elipses(current_room->get_user_display_name((*it)->user), AUTHOR_MAX_LENGTH) + ": " + (*it)->body, (*it).get()); it = unresolved_reactions.erase(it); } else { ++it; } } for(auto &message : messages) { if(message->type == MessageType::REACTION) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), message->related_event_id); if(body_item) body_item->add_reaction(extract_first_line_remove_newline_elipses(current_room->get_user_display_name(message->user), AUTHOR_MAX_LENGTH) + ": " + message->body, message.get()); else unresolved_reactions.push_back(message); } else if(message->type == MessageType::REDACTION) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), message->related_event_id); if(body_item && static_cast(body_item->userdata)) { Message *reaction_message = static_cast(body_item->userdata); if(reaction_message->type == MessageType::REACTION) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), reaction_message->related_event_id); if(body_item) body_item->remove_reaction_by_userdata(reaction_message); } } else { for(auto it = unresolved_reactions.begin(); it != unresolved_reactions.end(); ++it) { if(message->related_event_id == (*it)->event_id) { unresolved_reactions.erase(it); break; } } } } } }; auto pinned_body_items_contains_event = [&tabs, PINNED_TAB_INDEX](const std::string &event_id) { const int found_item_index = tabs[PINNED_TAB_INDEX].body->find_item_index([&event_id](std::shared_ptr &body_item) { return static_cast(body_item->userdata)->event_id == event_id; }); return found_item_index != -1; }; auto process_pinned_events = [&tabs, &ui_tabs, &pinned_body_items_contains_event, PINNED_TAB_INDEX](const std::optional> &pinned_events) { if(!pinned_events) return; bool empty_before = tabs[PINNED_TAB_INDEX].body->get_num_items() == 0; int selected_before = tabs[PINNED_TAB_INDEX].body->get_selected_item(); auto prev_pinned_body_items = tabs[PINNED_TAB_INDEX].body->get_items_copy(); tabs[PINNED_TAB_INDEX].body->clear_items(); // TODO: Add message to rooms messages when there are new pinned events for(const std::string &event : pinned_events.value()) { if(pinned_body_items_contains_event(event)) continue; auto body = BodyItem::create(""); body->set_description("Loading message..."); PinnedEventData *event_data = new PinnedEventData(); event_data->event_id = event; event_data->status = FetchStatus::NONE; event_data->message = nullptr; body->userdata = event_data; tabs[PINNED_TAB_INDEX].body->append_item(std::move(body)); } for(auto &prev_body_item : prev_pinned_body_items) { if(!pinned_body_items_contains_event(static_cast(prev_body_item->userdata)->event_id)) delete (PinnedEventData*)prev_body_item->userdata; } if(empty_before || ui_tabs.get_selected() != PINNED_TAB_INDEX) tabs[PINNED_TAB_INDEX].body->select_last_item(); else tabs[PINNED_TAB_INDEX].body->set_selected_item(selected_before); ui_tabs.set_text(PINNED_TAB_INDEX, "Pinned messages (" + std::to_string(tabs[PINNED_TAB_INDEX].body->get_num_items()) + ")"); }; Body url_selection_body(BODY_THEME_MINIMAL, loading_icon, &rounded_rectangle_shader, &rounded_rectangle_mask_shader); std::unordered_set fetched_messages_set; auto filter_existing_messages = [&fetched_messages_set](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { if((*it)->event_id.empty()) { ++it; continue; } auto res = fetched_messages_set.insert((*it)->event_id); if(!res.second) it = messages.erase(it); else ++it; } }; Messages all_messages; matrix->get_all_synced_room_messages(current_room, all_messages); for(auto &message : all_messages) { fetched_messages_set.insert(message->event_id); } auto me = matrix->get_me(current_room); auto new_body_items = messages_to_body_items(matrix, current_room, all_messages, current_room->get_user_display_name(me), me->user_id); messages_load_cached_related_embedded_item(new_body_items, tabs[MESSAGES_TAB_INDEX].body->get_items(), me, current_room); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(std::move(new_body_items)); modify_related_messages_in_current_room(all_messages); process_reactions(all_messages); tabs[MESSAGES_TAB_INDEX].body->select_last_item(); if(!all_messages.empty() && current_room->initial_prev_messages_fetch) { current_room->initial_prev_messages_fetch = false; } std::vector pinned_events; matrix->get_all_pinned_events(current_room, pinned_events); process_pinned_events(std::move(pinned_events)); tabs[PINNED_TAB_INDEX].body->select_last_item(); std::string room_name = current_room->get_name(); std::string room_topic = current_room->get_topic(); string_replace_all(room_name, '\n', ' '); string_replace_all(room_topic, '\n', ' '); room_name_text.set_string(std::move(room_name)); room_topic_text.set_string(std::move(room_topic)); read_marker_timeout_sec = 0; redraw = true; Entry chat_input("Press i to start writing a message...", &rounded_rectangle_shader); chat_input.set_editable(false); chat_input.set_padding_scale(1.5f); struct ProvisionalMessage { std::shared_ptr body_item; std::shared_ptr message; std::string event_id; }; std::unordered_map pending_sent_events; // This is needed to keep the message shared ptr alive. TODO: Remove this shit, maybe even use raw pointers. std::unordered_map sent_messages; // |event_id| is always empty in this. Use |message->event_id| instead std::optional provisional_message; MessageQueue provisional_message_queue; MessageQueue> post_task_queue; auto post_thread_handler = [&provisional_message_queue, &post_task_queue]() { while(true) { std::optional> post_task_opt = post_task_queue.pop_wait(); if(!post_task_opt) break; provisional_message_queue.push(post_task_opt.value()()); } }; std::thread post_thread(post_thread_handler); auto upload_file = [this, ¤t_room, &tabs, &ui_tabs, &chat_state](const std::string &filepath, const std::string &filename) { const int selected_tab = ui_tabs.get_selected(); std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); void *message_to_reply_to = chat_state == ChatState::REPLYING ? selected->userdata : nullptr; run_task_with_loading_screen([this, ¤t_room, filepath, filename, message_to_reply_to]() { std::string filepath_mod = filepath; if(string_starts_with(filepath_mod, "file://")) filepath_mod.erase(filepath_mod.begin(), filepath_mod.begin() + 7); std::string event_id_response; std::string err_msg; if(matrix->post_file(current_room, filepath_mod, filename, event_id_response, err_msg, message_to_reply_to) == PluginResult::OK) { return true; } else { show_notification("QuickMedia", "Failed to upload media to room, error: " + err_msg, Urgency::CRITICAL); return false; } }); }; struct Mention { mgl::Clock filter_timer; bool visible = false; bool filter_updated = false; std::string filter; Body *users_tab_body = nullptr; void show() { visible = true; } void hide() { visible = false; filter_updated = false; filter.clear(); users_tab_body->filter_search_fuzzy(""); users_tab_body->select_first_item(); users_tab_body->clear_cache(); } void handle_event(const mgl::Event &event) { if(visible) { if(event.type == mgl::Event::TextEntered) { filter_timer.restart(); if(event.text.codepoint > 32) { filter.append(event.text.str, event.text.size); filter_updated = true; } else if(event.text.codepoint == ' ' || event.text.codepoint == '\t') { hide(); } } else if(event.type == mgl::Event::KeyPressed) { if(event.key.code == mgl::Keyboard::Up || (event.key.control && event.key.code == mgl::Keyboard::K)) { users_tab_body->select_previous_item(true); } else if(event.key.code == mgl::Keyboard::Down || (event.key.control && event.key.code == mgl::Keyboard::J)) { users_tab_body->select_next_item(true); } else if(event.key.code == mgl::Keyboard::Enter && event.key.shift) { hide(); } else if(event.key.code == mgl::Keyboard::Backspace) { if(filter.empty()) { hide(); } else { filter.erase(filter.size() - 1, 1); filter_updated = true; } } } } if(event.type == mgl::Event::TextEntered && event.text.codepoint == '@' && !visible) show(); } void update() { if(visible && filter_updated && filter_timer.get_elapsed_time_seconds() > 0.05) { filter_updated = false; users_tab_body->filter_search_fuzzy(filter); users_tab_body->select_first_item(); } } }; Mention mention; mention.users_tab_body = tabs[USERS_TAB_INDEX].body.get(); const float user_mention_body_height = std::floor(300.0f * get_config().scale); bool frame_skip_text_entry = false; chat_input.on_submit_callback = [&](std::string text) mutable { if(mention.visible) { BodyItem *selected_mention_item = tabs[USERS_TAB_INDEX].body->get_selected(); if(selected_mention_item) { std::string str_to_append = Text::to_printable_string(selected_mention_item->get_description()); str_to_append += " "; const int filter_size = (int)mention.filter.size() + 1; int start_index = chat_input.get_caret_index() - filter_size; chat_input.replace(std::max(0, start_index), filter_size, str_to_append); mention.hide(); } return false; } frame_skip_text_entry = true; const int selected_tab = ui_tabs.get_selected(); int num_items = tabs[MESSAGES_TAB_INDEX].body->get_num_items(); bool scroll_to_end = num_items == 0; if(tabs[MESSAGES_TAB_INDEX].body->is_selected_item_last_visible_item() && selected_tab == MESSAGES_TAB_INDEX) scroll_to_end = true; if(selected_tab == MESSAGES_TAB_INDEX) { if(text.empty()) return false; std::string msgtype; if(chat_state == ChatState::TYPING_MESSAGE && text[0] == '/') { if(text == "/upload") { new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(strncmp(text.c_str(), "/join ", 6) == 0) { text.erase(text.begin(), text.begin() + 6); text = strip(text); if(text.empty()) { return false; } else { TaskResult task_result = run_task_with_loading_screen([this, &text] { return matrix->join_room(text) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) { show_notification("QuickMedia", "You joined " + text, Urgency::NORMAL); chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else { return false; } } } else if(text == "/invite") { new_page = PageType::CHAT_INVITE; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(text == "/logout") { new_page = PageType::CHAT_LOGIN; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(text == "/leave") { TaskResult task_result = run_task_with_loading_screen([this, ¤t_room]() { return matrix->leave_room(current_room->id) == PluginResult::OK; }); if(task_result != TaskResult::FALSE) { go_to_previous_page = true; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; } return true; } else if(text == "/help") { auto message = std::make_shared(); message->type = MessageType::SYSTEM; message->user = me; message->body = "/upload: Bring up the file manager and select a file to upload to the room, `Esc` to cancel.\n" "/join [room]: Join a room by name or id.\n" "/invite: Invite a user to the room.\n" "/logout: Logout.\n" "/leave: Leave the current room.\n" "/me [text]: Send a message of type \"m.emote\".\n" "/react [text]: React to the selected message (also works if you are replying to a message).\n" "/id: Show the room id.\n" "/encrypt [text]: Send a message encrypted with gpg. gpg needs to be installed to do this. Uses the gpg key specified by the user id in your config variable \"matrix.gpg_user_id\"."; message->timestamp = time(nullptr) * 1000; // TODO: What if the user has broken local time? matrix->append_system_message(current_room, std::move(message)); chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(text == "/id") { auto message = std::make_shared(); message->type = MessageType::SYSTEM; message->user = me; message->body = current_room->id; message->timestamp = time(nullptr) * 1000; // TODO: What if the user has broken local time? matrix->append_system_message(current_room, std::move(message)); chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(strncmp(text.c_str(), "/me ", 4) == 0) { msgtype = "m.emote"; text.erase(text.begin(), text.begin() + 4); } else if(strncmp(text.c_str(), "/react ", 7) == 0) { msgtype = "m.reaction"; text.erase(text.begin(), text.begin() + 7); } else if(strncmp(text.c_str(), "/encrypt ", 9) != 0) { show_notification("QuickMedia", "Error: invalid command: " + text + ", type /help to see a list of valid commands.", Urgency::NORMAL); return false; } } else if(chat_state == ChatState::REPLYING && text[0] == '/') { if(strncmp(text.c_str(), "/react ", 7) == 0) { msgtype = "m.reaction"; text.erase(text.begin(), text.begin() + 7); } } if((chat_state == ChatState::TYPING_MESSAGE || chat_state == ChatState::EDITING || chat_state == ChatState::REPLYING) && msgtype.empty() && text[0] == '/' && strncmp(text.c_str(), "/encrypt ", 9) == 0) { text.erase(text.begin(), text.begin() + 9); if(!is_program_executable_by_name("gpg")) { show_notification("QuickMedia", "GPG needs to be installed to use the /encrypt command", Urgency::CRITICAL); return false; } if(get_config().matrix.gpg_user_id.empty()) { show_notification("QuickMedia", "The config variable matrix.gpg_user_id needs to be set to use the /encrypt command", Urgency::CRITICAL); return false; } std::string encrypted_string; if(!matrix_gpg_encrypt_for_each_user_in_room(matrix, current_room, get_config().matrix.gpg_user_id, text, encrypted_string)) { show_notification("QuickMedia", "Failed to encrypt message with gpg. Make sure you used the correct gpg user id in the config variable matrix.gpg_user_id", Urgency::CRITICAL); return false; } text = std::move(encrypted_string); } auto message = std::make_shared(); message->body_is_formatted = true; message->user = matrix->get_me(current_room); if(msgtype == "m.emote") { message->body = "*" + current_room->get_user_display_name(me) + "* " + matrix->body_to_formatted_body(current_room, text); } else if(msgtype == "m.reaction") { message->body = text; message->body_is_formatted = false; } else { message->body = matrix->body_to_formatted_body(current_room, text); } message->type = MessageType::TEXT; message->timestamp = timestamp_provisional_event; const std::string transaction_id = create_transaction_id(); message->transaction_id = transaction_id; if(chat_state == ChatState::TYPING_MESSAGE || (chat_state == ChatState::REPLYING && msgtype == "m.reaction")) { BodyItem *selected_item = tabs[MESSAGES_TAB_INDEX].body->get_selected(); if(chat_state == ChatState::REPLYING) selected_item = currently_operating_on_item.get(); if(msgtype == "m.reaction" && selected_item) { void *related_to_message = selected_item->userdata; if(chat_state == ChatState::REPLYING) related_to_message = currently_operating_on_item->userdata; message->type = MessageType::REACTION; message->related_event_type = RelatedEventType::REACTION; message->related_event_id = static_cast(related_to_message)->event_id; auto body_item = message_to_body_item(matrix, current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); load_cached_related_embedded_item(body_item.get(), message.get(), me, current_room, tabs[MESSAGES_TAB_INDEX].body->get_items()); tabs[MESSAGES_TAB_INDEX].body->append_item(body_item); ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; pending_sent_events[message->transaction_id] = std::move(provisional_message); Messages messages; messages.push_back(message); process_reactions(messages); post_task_queue.push([this, ¤t_room, text, body_item, message, related_to_message, transaction_id]() { ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; if(matrix->post_reaction(current_room, text, related_to_message, provisional_message.event_id, transaction_id) != PluginResult::OK) fprintf(stderr, "Failed to post matrix reaction\n"); return provisional_message; }); } else { auto body_item = message_to_body_item(matrix, current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); body_item->set_description_color(get_theme().provisional_message_color); load_cached_related_embedded_item(body_item.get(), message.get(), me, current_room, tabs[MESSAGES_TAB_INDEX].body->get_items()); tabs[MESSAGES_TAB_INDEX].body->append_item(body_item); ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; pending_sent_events[message->transaction_id] = std::move(provisional_message); post_task_queue.push([this, ¤t_room, text, msgtype, body_item, message, transaction_id]() { ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; if(matrix->post_message(current_room, text, provisional_message.event_id, std::nullopt, std::nullopt, msgtype, transaction_id) != PluginResult::OK) fprintf(stderr, "Failed to post matrix message\n"); return provisional_message; }); } chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; if(scroll_to_end) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); return true; } else if(chat_state == ChatState::REPLYING) { void *related_to_message = currently_operating_on_item->userdata; message->related_event_type = RelatedEventType::REPLY; message->related_event_id = static_cast(related_to_message)->event_id; auto body_item = message_to_body_item(matrix, current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); body_item->set_description_color(get_theme().provisional_message_color); load_cached_related_embedded_item(body_item.get(), message.get(), me, current_room, tabs[MESSAGES_TAB_INDEX].body->get_items()); tabs[MESSAGES_TAB_INDEX].body->append_item(body_item); ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; pending_sent_events[message->transaction_id] = std::move(provisional_message); post_task_queue.push([this, ¤t_room, text, related_to_message, body_item, message, transaction_id]() { ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; if(matrix->post_reply(current_room, text, related_to_message, provisional_message.event_id, transaction_id) != PluginResult::OK) fprintf(stderr, "Failed to post matrix reply\n"); return provisional_message; }); chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; if(scroll_to_end) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); return true; } else if(chat_state == ChatState::EDITING) { Message *related_to_message = (Message*)currently_operating_on_item->userdata; message->related_event_type = RelatedEventType::EDIT; message->related_event_id = related_to_message->event_id; //Message *latest_message_related_to = get_latest_message_in_edit_chain(related_to_message); //latest_message_related_to->replaced_by = message; //message->replaces = latest_message_related_to; size_t body_item_index = 0; auto body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->get_items().data(), tabs[MESSAGES_TAB_INDEX].body->get_items().size(), message->related_event_id, &body_item_index); if(body_item) { const std::string formatted_text = matrix->body_to_formatted_body(current_room, text); std::string qm_formatted_text = formatted_text_to_qm_text(matrix, formatted_text.c_str(), formatted_text.size(), true); auto body_item_shared_ptr = tabs[MESSAGES_TAB_INDEX].body->get_item_by_index(body_item_index); //body_item_shared_ptr->set_description(matrix_decrypt_gpg_message_if_needed(std::move(qm_formatted_text))); matrix_body_set_text_decrypt_if_needed(matrix, body_item_shared_ptr.get(), std::move(qm_formatted_text)); body_item_shared_ptr->set_description_color(get_theme().provisional_message_color); //auto edit_body_item = message_to_body_item(matrix, current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); //edit_body_item->visible = false; //load_cached_related_embedded_item(edit_body_item.get(), message.get(), me, current_room, tabs[MESSAGES_TAB_INDEX].body->get_items()); //tabs[MESSAGES_TAB_INDEX].body->append_item(edit_body_item); //unreferenced_events.push_back(message); post_task_queue.push([this, ¤t_room, text, related_to_message, message, body_item_shared_ptr, transaction_id]() { ProvisionalMessage provisional_message; provisional_message.message = message; provisional_message.body_item = body_item_shared_ptr; if(matrix->post_edit(current_room, text, related_to_message, provisional_message.event_id, transaction_id) != PluginResult::OK) fprintf(stderr, "Failed to post matrix edit\n"); return provisional_message; }); chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; return true; } else { show_notification("QuickMedia", "Failed to edit message. Message refers to a non-existing message", Urgency::CRITICAL); return false; } } } return false; }; struct FetchMessagesResult { Messages messages; MessageDirection message_dir; bool reached_end = false; bool success = true; }; AsyncTask fetch_messages_future; MessageDirection fetch_messages_dir = MessageDirection::BEFORE; enum class FetchMessageType { MESSAGE, USER_UPDATE }; struct FetchMessageResult { FetchMessageType type; std::shared_ptr message; }; //const int num_fetch_message_threads = 4; AsyncTask fetch_users_future; AsyncTask fetch_message_future; Message *fetch_message = nullptr; std::shared_ptr fetch_body_item = nullptr; int fetch_message_tab = -1; // TODO: How about instead fetching all messages we have, not only the visible ones? also fetch with multiple threads. tabs[PINNED_TAB_INDEX].body->body_item_render_callback = [this, ¤t_room, &me, &fetch_message_future, &tabs, &fetch_body_item, &fetch_message_tab, PINNED_TAB_INDEX, MESSAGES_TAB_INDEX](std::shared_ptr &body_item) { if(fetch_message_future.valid()) return; PinnedEventData *event_data = static_cast(body_item->userdata); if(!event_data) return; // Fetch replied to message if(event_data->status == FetchStatus::FINISHED_LOADING && event_data->message) { if(event_data->message->related_event_id.empty() || event_data->message->related_event_type != RelatedEventType::REPLY || (body_item->embedded_item_status != FetchStatus::NONE && body_item->embedded_item_status != FetchStatus::QUEUED_LOADING)) return; if(load_cached_related_embedded_item(body_item.get(), event_data->message, me, current_room, tabs[MESSAGES_TAB_INDEX].body->get_items())) return; std::string message_event_id = event_data->message->related_event_id; fetch_body_item = body_item; body_item->embedded_item_status = FetchStatus::LOADING; fetch_message_tab = MESSAGES_TAB_INDEX; // TODO: Check if the message is already cached before calling async? is this needed? is async creation expensive? fetch_message_future = AsyncTask([this, ¤t_room, message_event_id]() { return FetchMessageResult{FetchMessageType::MESSAGE, matrix->get_message_by_id(current_room, message_event_id)}; }); return; } if(event_data->status != FetchStatus::NONE) return; // Fetch embed message // Check if we already have the referenced message as a body item in the messages list, so we dont create a new one. // TODO: Optimize from linear search to hash map auto related_body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->get_items().data(), tabs[MESSAGES_TAB_INDEX].body->get_items().size(), event_data->event_id); if(related_body_item) { *body_item = *related_body_item; if(related_body_item->extra) { MatrixChatBodyItemData *other_item_data = static_cast(related_body_item->extra.get()); if(other_item_data->decrypt_state != MatrixChatBodyItemData::DecryptState::DECRYPTED) body_item->extra = std::make_shared(other_item_data->matrix, other_item_data->text_to_decrypt); } body_item->reactions.clear(); if(message_contains_user_mention(related_body_item.get(), current_room->get_user_display_name(me), me->user_id)) body_item->set_description_color(get_theme().attention_alert_text_color, true); else body_item->set_description_color(get_theme().text_color); event_data->status = FetchStatus::FINISHED_LOADING; event_data->message = static_cast(related_body_item->userdata); body_item->userdata = event_data; return; } std::string message_event_id = event_data->event_id; fetch_body_item = body_item; event_data->status = FetchStatus::LOADING; fetch_message_tab = PINNED_TAB_INDEX; // TODO: Check if the message is already cached before calling async? is this needed? is async creation expensive? fetch_message_future = AsyncTask([this, ¤t_room, message_event_id]() { return FetchMessageResult{FetchMessageType::MESSAGE, matrix->get_message_by_id(current_room, message_event_id)}; }); }; // TODO: How about instead fetching all messages we have, not only the visible ones? also fetch with multiple threads. tabs[MESSAGES_TAB_INDEX].body->body_item_render_callback = [this, ¤t_room, &me, &fetch_message_future, &tabs, &fetch_body_item, &fetch_message_tab, MESSAGES_TAB_INDEX](std::shared_ptr &body_item) { Message *message = static_cast(body_item->userdata); if(!message) return; if(message->related_event_id.empty() || message->related_event_type != RelatedEventType::REPLY || (body_item->embedded_item_status != FetchStatus::NONE && body_item->embedded_item_status != FetchStatus::QUEUED_LOADING)) return; if(fetch_message_future.valid()) { body_item->embedded_item_status = FetchStatus::QUEUED_LOADING; return; } if(load_cached_related_embedded_item(body_item.get(), message, me, current_room, tabs[MESSAGES_TAB_INDEX].body->get_items())) return; std::string message_event_id = message->related_event_id; fetch_body_item = body_item; body_item->embedded_item_status = FetchStatus::LOADING; fetch_message_tab = MESSAGES_TAB_INDEX; // TODO: Check if the message is already cached before calling async? is this needed? is async creation expensive? fetch_message_future = AsyncTask([this, ¤t_room, message_event_id]() { return FetchMessageResult{FetchMessageType::MESSAGE, matrix->get_message_by_id(current_room, message_event_id)}; }); }; tabs[MESSAGES_TAB_INDEX].body->body_item_merge_handler = [](BodyItem *prev_item, BodyItem *this_item) { Message *message = static_cast(this_item->userdata); if(!message || !prev_item || !prev_item->userdata) return false; if(is_visual_media_message_type(message->type) && !this_item->thumbnail_url.empty()) return false; Message *prev_message = static_cast(prev_item->userdata); if(is_system_message_type(prev_message->type) && is_system_message_type(message->type)) return true; else if(is_system_message_type(prev_message->type) || is_system_message_type(message->type)) return false; if(is_visual_media_message_type(prev_message->type) && !prev_item->thumbnail_url.empty()) return false; if(message->user == prev_message->user) return true; return false; }; mgl::vec2f body_pos; mgl::vec2f body_size; mgl::Event event; const float gradient_height = 5.0f; mgl::Vertex gradient_points[4]; double gradient_inc = 0; std::string before_token; std::string after_token; bool fetched_enough_messages_top = false; bool fetched_enough_messages_bottom = false; bool has_unread_messages = false; mgl::Rectangle more_messages_below_rect; more_messages_below_rect.set_color(get_theme().new_items_alert_color); mgl::Rectangle chat_input_shade; chat_input_shade.set_color(get_theme().shade_color); float tab_vertical_offset = 0.0f; mgl::Clock frame_timer; float prev_chat_height = chat_input.get_height(); float chat_input_height_full = 0.0f; const float chat_input_padding_x = std::floor(10.0f * get_config().scale * get_config().spacing_scale); const float chat_input_padding_y = std::floor(10.0f * get_config().scale * get_config().spacing_scale); auto jump_to_message = [&](const std::string &event_id) { const int selected_tab = ui_tabs.get_selected(); if(selected_tab != MESSAGES_TAB_INDEX || event_id.empty()) return false; int body_item_index = -1; auto body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->get_items(), event_id, &body_item_index); if(body_item) { tabs[MESSAGES_TAB_INDEX].body->set_selected_item(body_item_index, false); return true; } else { std::shared_ptr fetched_message; Messages before_messages; Messages after_messages; std::string new_before_token; std::string new_after_token; TaskResult task_result = run_task_with_loading_screen([this, current_room, &event_id, &fetched_message, &before_messages, &after_messages, &new_before_token, &new_after_token]{ std::string message_to_fetch = event_id; for(int i = 0; i < 10; ++i) { // TODO: Delay between tries? auto message = matrix->get_message_by_id(current_room, message_to_fetch); if(!message) return false; message_to_fetch = message->event_id; if(message->related_event_type != RelatedEventType::EDIT) break; } return matrix->get_message_context(current_room, message_to_fetch, fetched_message, before_messages, after_messages, new_before_token, new_after_token) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) { Messages this_message = { fetched_message }; Messages *messages[3] = { &before_messages, &this_message, &after_messages }; before_token = std::move(new_before_token); after_token = std::move(new_after_token); fetch_messages_future.cancel(); fetched_enough_messages_top = false; fetched_enough_messages_bottom = false; unresolved_reactions.clear(); unreferenced_events.clear(); sent_messages.clear(); fetched_messages_set.clear(); tabs[MESSAGES_TAB_INDEX].body->clear_items(); matrix_chat_page->is_regular_navigation = false; matrix->clear_previous_messages_token(current_room); for(Messages *message_list : messages) { for(auto &message : *message_list) { fetched_messages_set.insert(message->event_id); } all_messages.insert(all_messages.end(), message_list->begin(), message_list->end()); auto new_body_items = messages_to_body_items(matrix, current_room, *message_list, current_room->get_user_display_name(me), me->user_id); messages_load_cached_related_embedded_item(new_body_items, tabs[MESSAGES_TAB_INDEX].body->get_items(), me, current_room); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(std::move(new_body_items)); modify_related_messages_in_current_room(*message_list); process_reactions(*message_list); } int fetched_message_index = -1; find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->get_items(), fetched_message->event_id, &fetched_message_index); tabs[MESSAGES_TAB_INDEX].body->set_selected_item(fetched_message_index, false); return true; } else { return false; } } }; auto launch_url = [this, matrix_chat_page, &tabs, MESSAGES_TAB_INDEX, &redraw, &avatar_applied](std::string url) mutable { if(url.empty()) return; url = invidious_url_to_youtube_url(url); std::string youtube_channel_id; std::string youtube_channel_url; std::string video_id; std::string board_id, thread_id, post_id; if(youtube_url_extract_channel_id(url, youtube_channel_id, youtube_channel_url)) { std::vector tabs; YoutubeChannelPage::create_each_type(this, youtube_channel_url, "", "Channel", tabs); page_loop(tabs); redraw = true; avatar_applied = false; } else if(youtube_url_extract_id(url, video_id)) { watched_videos.clear(); page_stack.push(PageType::CHAT); current_page = PageType::VIDEO_CONTENT; auto youtube_video_page = std::make_unique(this, std::move(url), false); // TODO: Use real title video_content_page(matrix_chat_page, youtube_video_page.get(), "", false, tabs[MESSAGES_TAB_INDEX].body.get(), tabs[MESSAGES_TAB_INDEX].body->get_selected_item()); redraw = true; avatar_applied = false; } else if(fourchan_extract_url(url, board_id, thread_id, post_id)) { auto body = create_body(); auto thread_page = std::make_unique(this, std::move(board_id), std::move(thread_id), std::move(post_id), ""); page_stack.push(current_page); current_page = PageType::IMAGE_BOARD_THREAD; image_board_thread_page(thread_page.get(), body.get()); redraw = true; avatar_applied = false; } else { const char *launch_program = "xdg-open"; if(!is_program_executable_by_name("xdg-open")) { launch_program = getenv("BROWSER"); if(!launch_program) { show_notification("QuickMedia", "xdg-utils which provides xdg-open needs to be installed to open urls. Alternatively set the $BROWSER environment variable to a browser", Urgency::CRITICAL); return; } } std::string url_modified = url; if(strncmp(url.c_str(), "http://", 7) != 0 && strncmp(url.c_str(), "https://", 8) != 0) url_modified = "https://" + url; const char *args[] = { launch_program, url_modified.c_str(), nullptr }; exec_program_async(args, nullptr); } }; auto filter_provisional_messages = [&](Messages &messages) { auto &messages_body = tabs[MESSAGES_TAB_INDEX].body; for(auto message_it = messages.begin(); message_it != messages.end();) { auto &message = *message_it; auto it = pending_sent_events.find(message->transaction_id); if(it != pending_sent_events.end()) { if(message->type == MessageType::REACTION) { message_it = messages.erase(message_it); pending_sent_events.erase(it); continue; } messages_body->erase_item([&](auto &body_item) { return body_item == it->second.body_item; }); pending_sent_events.erase(it); } ++message_it; } }; auto add_new_messages_to_current_room = [this, &me, &tabs, &ui_tabs, ¤t_room, MESSAGES_TAB_INDEX, &after_token](Messages &messages) { if(messages.empty()) return; const int selected_tab = ui_tabs.get_selected(); int num_items = tabs[MESSAGES_TAB_INDEX].body->get_num_items(); bool scroll_to_end = num_items == 0; if(selected_tab == MESSAGES_TAB_INDEX && (tabs[MESSAGES_TAB_INDEX].body->is_selected_item_last_visible_item() || !tabs[MESSAGES_TAB_INDEX].body->get_selected())) scroll_to_end = true; if(current_room->initial_prev_messages_fetch) { current_room->initial_prev_messages_fetch = false; if(selected_tab == MESSAGES_TAB_INDEX) scroll_to_end = true; } if(!after_token.empty()) scroll_to_end = false; auto new_body_items = messages_to_body_items(matrix, current_room, messages, current_room->get_user_display_name(me), me->user_id); messages_load_cached_related_embedded_item(new_body_items, tabs[MESSAGES_TAB_INDEX].body->get_items(), me, current_room); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(std::move(new_body_items)); if(scroll_to_end) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); }; auto display_url_or_image = [this, matrix_chat_page, &ui_tabs, &redraw, &launch_url, &chat_state, &url_selection_body, &avatar_applied, PINNED_TAB_INDEX, MESSAGES_TAB_INDEX](BodyItem *selected) { if(!selected) return false; const int selected_tab = ui_tabs.get_selected(); Message *selected_item_message = nullptr; if(selected_tab == MESSAGES_TAB_INDEX) { selected_item_message = static_cast(selected->userdata); } else if(selected_tab == PINNED_TAB_INDEX && static_cast(selected->userdata)->status == FetchStatus::FINISHED_LOADING) { selected_item_message = static_cast(selected->userdata)->message; } if(selected_item_message) { MessageType message_type = selected_item_message->type; if(!selected->url.empty()) { const char *video_prefix = "🎥 Play "; const char *music_prefix = "🎵 Play "; const char *file_prefix = "💾 Download "; std::string filename = message_to_qm_text(matrix, selected_item_message, false); if(string_starts_with(filename, video_prefix)) filename.erase(filename.begin(), filename.begin() + strlen(video_prefix)); else if(string_starts_with(filename, music_prefix)) filename.erase(filename.begin(), filename.begin() + strlen(music_prefix)); else if(string_starts_with(filename, file_prefix)) filename.erase(filename.begin(), filename.begin() + strlen(file_prefix)); if(message_type == MessageType::VIDEO || message_type == MessageType::IMAGE || message_type == MessageType::AUDIO) { page_stack.push(PageType::CHAT); watched_videos.clear(); current_page = PageType::VIDEO_CONTENT; bool is_audio = (message_type == MessageType::AUDIO); bool prev_no_video = no_video; no_video = is_audio; auto video_page = std::make_unique(this, filename); video_page->set_url(selected->url); video_content_page(matrix_chat_page, video_page.get(), filename, message_type == MessageType::VIDEO || message_type == MessageType::AUDIO, nullptr, 0); no_video = prev_no_video; redraw = true; avatar_applied = false; return true; } else if(message_type == MessageType::FILE) { download_async_gui(selected->url, file_manager_start_dir.string(), no_video, filename, false); return true; } launch_url(selected->url); return true; } } std::vector room_ids = matrix_extract_room_ids(selected->get_description()); // TODO: If content type is a file, show file-manager prompt where it should be saved and asynchronously save it instead std::vector urls = ranges_get_strings(selected->get_description(), extract_urls(selected->get_description())); if(urls.size() == 1 && room_ids.empty()) { launch_url(urls[0]); return true; } else if(!urls.empty() || !room_ids.empty()) { chat_state = ChatState::URL_SELECTION; url_selection_body.clear_items(); for(const std::string &url : urls) { auto body_item = BodyItem::create("Open " + url + " in a browser"); body_item->url = url; url_selection_body.append_item(std::move(body_item)); } for(const std::string &room_id : room_ids) { auto body_item = BodyItem::create("Join " + room_id); body_item->url = room_id; url_selection_body.append_item(std::move(body_item)); } return true; } return false; }; auto download_selected_item = [this, &ui_tabs, PINNED_TAB_INDEX, MESSAGES_TAB_INDEX](BodyItem *selected, bool no_dialog) { if(!selected) return false; const int selected_tab = ui_tabs.get_selected(); Message *selected_item_message = nullptr; if(selected_tab == MESSAGES_TAB_INDEX) { selected_item_message = static_cast(selected->userdata); } else if(selected_tab == PINNED_TAB_INDEX && static_cast(selected->userdata)->status == FetchStatus::FINISHED_LOADING) { selected_item_message = static_cast(selected->userdata)->message; } if(selected_item_message) { MessageType message_type = selected_item_message->type; if(!selected->url.empty() && message_type >= MessageType::IMAGE && message_type <= MessageType::FILE) { const char *video_prefix = "🎥 Play "; const char *music_prefix = "🎵 Play "; const char *file_prefix = "💾 Download "; std::string filename = remove_reply_formatting(matrix, selected_item_message); if(string_starts_with(filename, video_prefix)) filename.erase(filename.begin(), filename.begin() + strlen(video_prefix)); else if(string_starts_with(filename, music_prefix)) filename.erase(filename.begin(), filename.begin() + strlen(music_prefix)); else if(string_starts_with(filename, file_prefix)) filename.erase(filename.begin(), filename.begin() + strlen(file_prefix)); download_async_gui(selected->url, file_manager_start_dir.string(), no_video, filename, no_dialog); return true; } } return false; }; auto update_pinned_messages_author = [&tabs, ¤t_room, PINNED_TAB_INDEX](const std::shared_ptr &user) { fprintf(stderr, "updated pinned messages author for user: %s\n", user->user_id.c_str()); tabs[PINNED_TAB_INDEX].body->for_each_item([¤t_room, &user](std::shared_ptr &pinned_body_item) { Message *message = static_cast(pinned_body_item->userdata)->message; // Its fine if we dont set it now. When the message is fetches, it will have updated user info since its fetched later if(!message || message->user != user) return; user_update_display_info(pinned_body_item.get(), current_room, message); }); }; auto update_messages_author = [&tabs, ¤t_room, MESSAGES_TAB_INDEX](const std::shared_ptr &user) { fprintf(stderr, "updated messages author for user: %s\n", user->user_id.c_str()); tabs[MESSAGES_TAB_INDEX].body->for_each_item([¤t_room, &user](std::shared_ptr &message_body_items) { Message *message = static_cast(message_body_items->userdata); if(!message || message->user != user) return; user_update_display_info(message_body_items.get(), current_room, message); }); }; // TODO: Optimize auto update_pinned_messages_authors = [&tabs, ¤t_room, PINNED_TAB_INDEX]() { fprintf(stderr, "updated pinned messages author for all users in room: %s\n", current_room->id.c_str()); tabs[PINNED_TAB_INDEX].body->for_each_item([¤t_room](std::shared_ptr &pinned_body_item) { Message *message = static_cast(pinned_body_item->userdata)->message; // Its fine if we dont set it now. When the message is fetches, it will have updated user info since its fetched later if(!message) return; user_update_display_info(pinned_body_item.get(), current_room, message); }); }; // TODO: Optimize auto update_messages_authors = [&tabs, ¤t_room, MESSAGES_TAB_INDEX]() { fprintf(stderr, "updated messages author for all users in room: %s\n", current_room->id.c_str()); tabs[MESSAGES_TAB_INDEX].body->for_each_item([¤t_room](std::shared_ptr &message_body_items) { Message *message = static_cast(message_body_items->userdata); if(!message) return; user_update_display_info(message_body_items.get(), current_room, message); }); }; auto cleanup_tasks = [&]() { set_read_marker_future.cancel(); fetch_message_future.cancel(); fetch_users_future.cancel(); typing_state_queue.close(); if(typing_state_thread.joinable()) { program_kill_in_thread(typing_state_thread.get_id()); typing_state_thread.join(); } post_task_queue.close(); if(post_thread.joinable()) { program_kill_in_thread(post_thread.get_id()); post_thread.join(); } provisional_message_queue.clear(); fetched_messages_set.clear(); sent_messages.clear(); pending_sent_events.clear(); //unreferenced_event_by_room.clear(); if(!tabs.empty()) { tabs[MESSAGES_TAB_INDEX].body->clear_items(); tabs[PINNED_TAB_INDEX].body->for_each_item([](std::shared_ptr &pinned_body_item) { delete (PinnedEventData*)pinned_body_item->userdata; pinned_body_item->userdata = nullptr; }); tabs[PINNED_TAB_INDEX].body->clear_items(); tabs[USERS_TAB_INDEX].body->clear_items(); } //tabs.clear(); matrix->on_exit_room(current_room); }; // TODO: Remove this once synapse bug has been resolved where /sync does not include user info for new messages when using message filter that limits number of messages for initial sync, // and then only call this when viewing the users tab for the first time. // Note that this is not needed when new users join the room, as those will be included in the sync timeline (with membership events) if(current_room->users_fetched) { //TODO BLABLA //update_ } else { // TODO: Race condition? maybe use matrix /members instead which has a since parameter to make the members list match current sync fetch_users_future = AsyncTask([this, ¤t_room]() { matrix->update_room_users(current_room); return true; }); } float tab_shade_height = 0.0f; SyncData sync_data; matrix_chat_page->rooms_page->body->body_item_select_callback = [&move_room](BodyItem *body_item) { move_room = true; }; mgl::Clock fetch_previous_messages_retry_timer; double fetch_previous_messages_timeout = 0.0; std::function on_top_reached = [&] { const int selected_tab = ui_tabs.get_selected(); if(fetched_enough_messages_top || fetch_messages_future.valid() || selected_tab != MESSAGES_TAB_INDEX) return; if(fetch_previous_messages_retry_timer.get_elapsed_time_seconds() < fetch_previous_messages_timeout) return; fetch_previous_messages_retry_timer.restart(); gradient_inc = 0; if(before_token.empty() && after_token.empty()) { fetch_messages_dir = MessageDirection::BEFORE; fetch_messages_future = AsyncTask([&]() { FetchMessagesResult messages; messages.message_dir = MessageDirection::BEFORE; messages.reached_end = false; if(matrix->get_previous_room_messages(current_room, messages.messages, false, &messages.reached_end) != PluginResult::OK) { fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", current_room->id.c_str()); messages.success = false; } else { messages.success = true; } return messages; }); } else if(!before_token.empty()) { fetch_messages_dir = MessageDirection::BEFORE; fetch_messages_future = AsyncTask([&]() { std::string token = before_token; FetchMessagesResult messages; messages.message_dir = MessageDirection::BEFORE; messages.reached_end = false; if(matrix->get_messages_in_direction(current_room, token, MessageDirection::BEFORE, messages.messages, before_token) != PluginResult::OK) { fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", current_room->id.c_str()); messages.success = false; } else { messages.success = true; } messages.reached_end = before_token.empty(); return messages; }); } }; std::function on_bottom_reached = [&] { const int selected_tab = ui_tabs.get_selected(); if(!fetched_enough_messages_bottom && !after_token.empty() && !fetch_messages_future.valid() && selected_tab == MESSAGES_TAB_INDEX) { fetch_messages_dir = MessageDirection::AFTER; gradient_inc = 0; fetch_messages_future = AsyncTask([&]() { std::string token = after_token; FetchMessagesResult messages; messages.message_dir = MessageDirection::AFTER; messages.reached_end = false; if(matrix->get_messages_in_direction(current_room, token, MessageDirection::AFTER, messages.messages, after_token) != PluginResult::OK) { fprintf(stderr, "Failed to get next matrix messages in room: %s\n", current_room->id.c_str()); messages.success = false; } else { messages.success = true; } messages.reached_end = after_token.empty(); return messages; }); return; } }; for(size_t i = 0; i < tabs.size(); ++i) { tabs[i].body->on_top_reached = on_top_reached; tabs[i].body->on_bottom_reached = on_bottom_reached; } if(!matrix_chat_page->jump_to_event_id.empty()) { if(!jump_to_message(matrix_chat_page->jump_to_event_id)) goto chat_page_end; } while (current_page == PageType::CHAT && window.is_open() && !move_room) { int32_t frame_time_ms = frame_timer.restart() * 1000.0; while (window.poll_event(event)) { common_event_handler(event); const int selected_tab = ui_tabs.get_selected(); if(chat_state == ChatState::NAVIGATING) ui_tabs.on_event(event); if(chat_state == ChatState::URL_SELECTION) { if(url_selection_body.on_event(window, event, true, false)) idle_active_handler(); } else { if(tabs[selected_tab].body->on_event(window, event, chat_state == ChatState::NAVIGATING, false)) idle_active_handler(); } base_event_handler(event, PageType::EXIT, tabs[selected_tab].body.get(), nullptr, false, false); event_idle_handler(event); if(!frame_skip_text_entry) { const bool move_arrows = (event.key.code == mgl::Keyboard::Up || event.key.code == mgl::Keyboard::Down || event.key.code == mgl::Keyboard::Left || event.key.code == mgl::Keyboard::Right); const bool move_vim_keys = (event.key.control && (event.key.code == mgl::Keyboard::J || event.key.code != mgl::Keyboard::K)); if(!mention.visible || event.type != mgl::Event::KeyPressed || (!move_arrows && !move_vim_keys)) chat_input.process_event(window, event); if(chat_input.is_editable()) mention.handle_event(event); } if(draw_room_list) { if(matrix_chat_page->rooms_page->body->on_event(window, event, false, false)) idle_active_handler(); } if(event.type == mgl::Event::KeyPressed && event.key.alt && (chat_state == ChatState::NAVIGATING || chat_state == ChatState::URL_SELECTION)) { if(event.key.code == mgl::Keyboard::Up || event.key.code == mgl::Keyboard::K) { matrix_chat_page->rooms_page->body->select_previous_item(true); move_room = true; goto chat_page_end; } else if(event.key.code == mgl::Keyboard::Down || event.key.code == mgl::Keyboard::J) { matrix_chat_page->rooms_page->body->select_next_item(true); move_room = true; goto chat_page_end; } else if(event.key.code == mgl::Keyboard::PageUp) { matrix_chat_page->rooms_page->body->select_previous_page(); move_room = true; goto chat_page_end; } else if(event.key.code == mgl::Keyboard::PageDown) { matrix_chat_page->rooms_page->body->select_next_page(); move_room = true; goto chat_page_end; } else if(event.key.code == mgl::Keyboard::Home) { matrix_chat_page->rooms_page->body->select_first_item(false); move_room = true; goto chat_page_end; } else if(event.key.code == mgl::Keyboard::End) { matrix_chat_page->rooms_page->body->select_last_item(); move_room = true; goto chat_page_end; } else if(event.key.code == mgl::Keyboard::Escape) { move_room = false; goto chat_page_end; } continue; } if(event.type == mgl::Event::GainedFocus) { is_window_focused = true; redraw = true; } else if(event.type == mgl::Event::LostFocus) { is_window_focused = false; } else if(event.type == mgl::Event::Resized) { redraw = true; idle_active_handler(); } else if(event.type == mgl::Event::KeyPressed && chat_state == ChatState::NAVIGATING) { if(event.key.code == mgl::Keyboard::Escape) { goto chat_page_end; } else if(event.key.code == mgl::Keyboard::I && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item && selected_item->url.empty()) selected_item = selected_item->embedded_item.get(); if(selected_item && !selected_item->url.empty() && !selected_item->thumbnail_url.empty()) { Message *selected_item_message = nullptr; if(selected_tab == MESSAGES_TAB_INDEX) { selected_item_message = static_cast(selected_item->userdata); } else if(selected_tab == PINNED_TAB_INDEX && static_cast(selected_item->userdata)->status == FetchStatus::FINISHED_LOADING) { selected_item_message = static_cast(selected_item->userdata)->message; } if(selected_item_message && (selected_item_message->type == MessageType::IMAGE || selected_item_message->type == MessageType::VIDEO)) { std::string image_url = selected_item->url; if(selected_item_message->type == MessageType::VIDEO) image_url = selected_item->thumbnail_url; image_url = pantalaimon_url_to_homeserver_url(matrix, image_url); std::vector saucenao_tabs; saucenao_tabs.push_back(Tab{create_body(), std::make_unique(this, image_url, false), nullptr}); page_loop(saucenao_tabs); redraw = true; frame_skip_text_entry = true; } } } if((selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) && !frame_skip_text_entry) { if(event.key.code == mgl::Keyboard::Enter) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!display_url_or_image(selected)) display_url_or_image(selected->embedded_item.get()); } } else if(event.key.code == mgl::Keyboard::S && event.key.control) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!download_selected_item(selected, event.key.shift)) download_selected_item(selected->embedded_item.get(), event.key.shift); } } } if(event.key.control && event.key.code == mgl::Keyboard::C) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { set_clipboard(Text::to_printable_string(selected->get_description())); } } if(selected_tab == MESSAGES_TAB_INDEX) { if(event.key.code == mgl::Keyboard::U) { frame_skip_text_entry = true; new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); } if(event.key.code == mgl::Keyboard::I && !event.key.control) { frame_skip_text_entry = true; chat_input.set_editable(true); chat_state = ChatState::TYPING_MESSAGE; } if(event.key.control && event.key.code == mgl::Keyboard::V) { frame_skip_text_entry = true; // TODO: Upload multiple files. bool first_part = true; std::string clipboard_text; char tmp_filename[] = "/tmp/quickmedia_clipboard_XXXXXX"; int tmp_file = -1; std::string file_ext; const bool clipboard_success = window.get_clipboard([&first_part, &clipboard_text, &tmp_filename, &tmp_file, &file_ext](const unsigned char *data, size_t size, mgl_clipboard_type clipboard_type) { if(first_part) { first_part = false; if(clipboard_type != MGL_CLIPBOARD_TYPE_STRING) { tmp_file = mkstemp(tmp_filename); if(tmp_file == -1) { show_notification("QuickMedia", "Failed to create temporary file " + std::string(tmp_filename) + " from clipboard (failed to create file)", Urgency::CRITICAL); return false; } if(clipboard_type == MGL_CLIPBOARD_TYPE_IMAGE_PNG) file_ext = ".png"; else if(clipboard_type == MGL_CLIPBOARD_TYPE_IMAGE_JPG) file_ext = ".jpg"; else if(clipboard_type == MGL_CLIPBOARD_TYPE_IMAGE_GIF) file_ext = ".gif"; } } if(tmp_file == -1) { clipboard_text.append((char*)data, size); } else { const ssize_t bytes_written = write(tmp_file, data, size); if(bytes_written != (ssize_t)size) { show_notification("QuickMedia", "Failed to create temporary file " + std::string(tmp_filename) + " from clipboard (failed to write data to file)", Urgency::CRITICAL); return false; } } return true; }); if(tmp_file != -1) clipboard_text = tmp_filename; if(clipboard_success && !clipboard_text.empty() && get_file_type(clipboard_text) == FileType::REGULAR) { const time_t now = time(nullptr); struct tm t; localtime_r(&now, &t); char filename[256] = {0}; if(tmp_file != -1) { const int num_bytes_written = strftime(filename, sizeof(filename)-1, "Clipboard_%Y-%m-%d_%H-%M-%S", &t); if((int)sizeof(filename) - (num_bytes_written + file_ext.size()) >= 1) strcat(filename, file_ext.c_str()); } upload_file(clipboard_text, filename); } if(tmp_file != -1) { close(tmp_file); remove(tmp_filename); } } if(event.key.code == mgl::Keyboard::R && tabs[selected_tab].body->get_selected_shared()) { std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); Message *selected_message = static_cast(selected->userdata); const bool go_to_replied_message = event.key.control; if(!is_state_message_type(selected_message)) { if(go_to_replied_message && selected_message->related_event_type == RelatedEventType::REPLY) { int replied_to_body_item_index = -1; auto replied_to_body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->get_items(), selected_message->related_event_id, &replied_to_body_item_index); if(replied_to_body_item) { assert(replied_to_body_item->userdata); Message *orig_message = get_original_message(static_cast(replied_to_body_item->userdata)); jump_to_message(orig_message->event_id); } else { jump_to_message(selected_message->related_event_id); } } else if(!go_to_replied_message) { if(static_cast(selected->userdata)->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't reply to a message that hasn't been sent yet"); } else { chat_state = ChatState::REPLYING; currently_operating_on_item = selected; chat_input.set_editable(true); replying_to_text.set_string("Replying to:"); frame_skip_text_entry = true; } } } } if(event.key.code == mgl::Keyboard::B && event.key.control) { // Reload room, goes to latest message l0l move_room = true; goto chat_page_end; } if(event.key.code == mgl::Keyboard::E) { frame_skip_text_entry = true; std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); if(selected) { if(!is_state_message_type(static_cast(selected->userdata))) { if(static_cast(selected->userdata)->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't edit a message that hasn't been sent yet"); } else if(!selected->url.empty()) { // cant edit messages that are image/video posts // TODO: Show inline notification show_notification("QuickMedia", "You can't edit messages with files attached to them"); } else if(!matrix->was_message_posted_by_me(selected->userdata)) { // TODO: Show inline notification show_notification("QuickMedia", "You can't edit a message that was posted by somebody else"); } else { const std::string body_text_unformatted = Text::to_printable_string(selected->get_description()); chat_state = ChatState::EDITING; currently_operating_on_item = selected; chat_input.set_editable(true); chat_input.set_text(std::move(body_text_unformatted)); // TODO: Description? it may change in the future, in which case this should be edited chat_input.move_caret_to_end(); replying_to_text.set_string("Editing message:"); } } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for editing"); } } if(event.key.control && event.key.code == mgl::Keyboard::D && !chat_input.is_editable()) { frame_skip_text_entry = true; BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!is_state_message_type(static_cast(selected->userdata))) { if(static_cast(selected->userdata)->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't delete a message that hasn't been sent yet"); } else { //set_body_as_deleted(static_cast(selected->userdata), selected); void *selected_message = selected->userdata; post_task_queue.push([this, ¤t_room, selected_message]() { ProvisionalMessage provisional_message; std::string err_msg; if(matrix->delete_message(current_room, selected_message, err_msg) != PluginResult::OK) { // TODO: Show inline notification fprintf(stderr, "Failed to delete message, reason: %s\n", err_msg.c_str()); } return provisional_message; }); } } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for deletion"); } } if(event.key.control && event.key.code == mgl::Keyboard::P && !chat_input.is_editable()) { frame_skip_text_entry = true; BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { Message *selected_message = static_cast(selected->userdata); if(!is_state_message_type(selected_message)) { if(selected_message->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't pin a message that hasn't been sent yet"); } else { run_task_with_loading_screen([this, current_room, selected_message] { return matrix->pin_message(current_room, selected_message->event_id) == PluginResult::OK; }); } } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for pinning"); } } } else if(selected_tab == PINNED_TAB_INDEX) { if(event.key.control && event.key.code == mgl::Keyboard::D && !chat_input.is_editable()) { frame_skip_text_entry = true; BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { PinnedEventData *selected_pinned_event_data = static_cast(selected->userdata); if(selected_pinned_event_data) { run_task_with_loading_screen([this, current_room, selected_pinned_event_data] { return matrix->unpin_message(current_room, selected_pinned_event_data->event_id) == PluginResult::OK; }); } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for unpinning"); } } if(event.key.code == mgl::Keyboard::R && event.key.control && tabs[selected_tab].body->get_selected_shared()) { std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); PinnedEventData *selected_event_data = static_cast(selected->userdata); if(selected_event_data && !selected_event_data->event_id.empty()) { ui_tabs.set_selected(MESSAGES_TAB_INDEX); jump_to_message(selected_event_data->event_id); } } } } else if(event.type == mgl::Event::KeyPressed && chat_state == ChatState::URL_SELECTION) { if(event.key.code == mgl::Keyboard::Escape) { url_selection_body.clear_items(); chat_state = ChatState::NAVIGATING; } else if(event.key.code == mgl::Keyboard::Enter) { BodyItem *selected_item = url_selection_body.get_selected(); if(!selected_item) continue; if(!selected_item->url.empty() && (selected_item->url[0] == '#' || selected_item->url[0] == '!')) { TaskResult task_result = run_task_with_loading_screen([this, selected_item] { return matrix->join_room(selected_item->url) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) show_notification("QuickMedia", "You joined " + selected_item->url, Urgency::NORMAL); } else { launch_url(selected_item->url); } } } else if(event.type == mgl::Event::KeyPressed && chat_state == ChatState::REPLYING) { if(selected_tab == MESSAGES_TAB_INDEX) { if(event.key.code == mgl::Keyboard::U && event.key.control) { frame_skip_text_entry = true; new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); } } } if((chat_state == ChatState::TYPING_MESSAGE || chat_state == ChatState::REPLYING || chat_state == ChatState::EDITING) && selected_tab == MESSAGES_TAB_INDEX && !frame_skip_text_entry) { frame_skip_text_entry = false; if(event.type == mgl::Event::TextEntered) { // TODO: Also show typing event when ctrl+v pasting? if(event.text.codepoint != 13) { // Return key start_typing_timer.restart(); if(!typing && current_room) { fprintf(stderr, "Started typing\n"); typing_state_queue.push(true); } typing = true; } } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::Escape) { if(mention.visible) { mention.hide(); } else { chat_input.set_editable(false); chat_input.set_text(""); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; if(typing && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } } } } } frame_skip_text_entry = false; update_idle_state(); handle_x11_events(); matrix->update(); mention.update(); const size_t num_users_in_room = matrix_chat_page->get_num_users_in_current_room(); if(num_users_in_room != prev_num_users_in_room) { prev_num_users_in_room = num_users_in_room; ui_tabs.set_text(USERS_TAB_INDEX, "Users (" + std::to_string(num_users_in_room) + ")"); } while((provisional_message = provisional_message_queue.pop_if_available()) != std::nullopt) { if(!provisional_message->body_item || !provisional_message->message) continue; if(!provisional_message->event_id.empty()) { provisional_message->message->event_id = std::move(provisional_message->event_id); provisional_message->body_item->set_description_color(get_theme().text_color); sent_messages[provisional_message->message->event_id] = std::move(provisional_message.value()); } else if(provisional_message->body_item) { provisional_message->body_item->set_description("Failed to send: " + provisional_message->body_item->get_description()); provisional_message->body_item->set_description_color(get_theme().failed_text_color); provisional_message->body_item->userdata = nullptr; } } switch(new_page) { case PageType::FILE_MANAGER: { new_page = PageType::CHAT; for(ChatTab &tab : tabs) { tab.body->clear_cache(); } std::filesystem::path &fm_dir = file_manager_start_dir; auto file_manager_page = std::make_unique(this); file_manager_page->set_current_directory(fm_dir.string()); auto file_manager_body = create_body(false, get_config().file_manager.grid_view); BodyItems body_items; file_manager_page->get_files_in_directory(body_items); file_manager_body->set_items(std::move(body_items)); std::vector file_manager_tabs; file_manager_tabs.push_back(Tab{std::move(file_manager_body), std::move(file_manager_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); selected_files.clear(); page_loop(file_manager_tabs); if(selected_files.empty()) { fprintf(stderr, "No files selected!\n"); } else { // TODO: Upload multiple files. upload_file(selected_files[0], ""); } redraw = true; avatar_applied = false; if(selected_files.empty()) { if(chat_state == ChatState::REPLYING) chat_input.set_editable(true); } else { mention.hide(); chat_input.set_editable(false); chat_input.set_text(""); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; if(typing && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } } break; } case PageType::CHAT_LOGIN: { matrix_chat_page->set_current_room(nullptr, nullptr, nullptr); fetch_messages_future.cancel(); cleanup_tasks(); tabs.clear(); unreferenced_events.clear(); unresolved_reactions.clear(); all_messages.clear(); new_page = PageType::CHAT; matrix->stop_sync(); matrix->logout(); delete matrix; matrix = new Matrix(matrix_instance_already_running); // TODO: Instead of doing this, exit this current function and navigate to chat login page instead. //delete current_plugin; //current_plugin = new Matrix(); window.set_title("QuickMedia - matrix"); current_page = PageType::CHAT_LOGIN; chat_login_page(); if(current_page == PageType::CHAT) after_matrix_login_page(); exit(exit_code); break; } case PageType::CHAT_INVITE: { new_page = PageType::CHAT; for(ChatTab &tab : tabs) { tab.body->clear_cache(); } std::vector new_tabs; new_tabs.push_back(Tab{create_body(), std::make_unique(this, matrix, current_room->id), create_search_bar("Search...", 350)}); page_loop(new_tabs); redraw = true; avatar_applied = false; break; } default: break; } if(typing && start_typing_timer.get_elapsed_time_seconds() >= typing_timeout_seconds && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } if(!current_room->body_item->thumbnail_url.empty()) room_avatar_thumbnail_data = AsyncImageLoader::get_instance().get_thumbnail(current_room->body_item->thumbnail_url, false, AVATAR_THUMBNAIL_SIZE); if(room_avatar_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING && room_avatar_thumbnail_data->image->get_size().x > 0 && room_avatar_thumbnail_data->image->get_size().y > 0) { if(!room_avatar_thumbnail_data->texture.load_from_image(*room_avatar_thumbnail_data->image)) fprintf(stderr, "Warning: failed to load texture for room avatar\n"); //room_avatar_thumbnail_data->texture.generateMipmap(); room_avatar_thumbnail_data->image.reset(); room_avatar_thumbnail_data->loading_state = LoadingState::APPLIED_TO_TEXTURE; avatar_applied = false; } if(room_avatar_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE && !avatar_applied) { avatar_applied = true; room_avatar_sprite.set_texture(&room_avatar_thumbnail_data->texture); auto texture_size = room_avatar_sprite.get_texture()->get_size(); if(texture_size.x > 0 && texture_size.y > 0) { float width_ratio = (float)texture_size.x / (float)texture_size.y; float height_scale = room_avatar_height / (float)texture_size.y; float width_scale = height_scale * width_ratio; room_avatar_sprite.set_scale(mgl::vec2f(width_scale * get_config().scale, height_scale * get_config().scale)); } redraw = true; } const int selected_tab = ui_tabs.get_selected(); float room_name_padding_y = 0.0f; if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX || selected_tab == USERS_TAB_INDEX) room_name_padding_y = room_name_total_height; chat_input_height_full = chat_input.get_height() + chat_input_padding_y * 2.0f; if(selected_tab != MESSAGES_TAB_INDEX) chat_input_height_full = 0.0f; const float chat_height = chat_input.get_height(); if(std::abs(chat_height - prev_chat_height) > 1.0f) { prev_chat_height = chat_height; redraw = true; } if(redraw) { redraw = false; if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX || selected_tab == USERS_TAB_INDEX) { tab_vertical_offset = std::floor(10.0f * get_config().scale); } tab_shade_height = std::floor(tab_vertical_offset) + Tabs::get_shade_height() + room_name_padding_y; const float body_width = window_size.x; this->body_pos = mgl::vec2f(0.0f, tab_shade_height); if(window_size.x > 900.0f * get_config().scale && show_room_side_panel) { this->body_size = vec2f_floor(300.0f * get_config().scale, window_size.y - tab_shade_height); draw_room_list = true; } else { this->body_size = mgl::vec2f(0.0f, 0.0f); draw_room_list = false; } body_pos = mgl::vec2f(this->body_pos.x + this->body_size.x, tab_shade_height); body_size = mgl::vec2f(body_width - this->body_pos.x - this->body_size.x, window_size.y - chat_input_height_full - tab_shade_height); chat_input_shade.set_size(mgl::vec2f(window_size.x - body_pos.x, chat_input_height_full)); chat_input_shade.set_position(mgl::vec2f(body_pos.x, window_size.y - chat_input_shade.get_size().y)); chat_input.set_max_width(window_size.x - body_pos.x - chat_input_padding_x * 2.0f); chat_input.set_position(vec2f_floor(body_pos.x + chat_input_padding_x, window_size.y - chat_height - chat_input_padding_y)); more_messages_below_rect.set_size(mgl::vec2f(chat_input_shade.get_size().x, gradient_height)); more_messages_below_rect.set_position(mgl::vec2f(chat_input_shade.get_position().x, std::floor(window_size.y - chat_input_height_full - gradient_height))); } sync_data.messages.clear(); sync_data.pinned_events = std::nullopt; matrix->get_room_sync_data(current_room, sync_data); if(!sync_data.messages.empty() && after_token.empty()) { all_messages.insert(all_messages.end(), sync_data.messages.begin(), sync_data.messages.end()); filter_existing_messages(sync_data.messages); } filter_provisional_messages(sync_data.messages); if(after_token.empty()) { add_new_messages_to_current_room(sync_data.messages); modify_related_messages_in_current_room(sync_data.messages); process_reactions(sync_data.messages); has_unread_messages = false; } else { auto it = std::find_if(sync_data.messages.begin(), sync_data.messages.end(), [](const std::shared_ptr &message) { return message->related_event_type == RelatedEventType::NONE || message->related_event_type == RelatedEventType::REPLY; }); if(it != sync_data.messages.end()) has_unread_messages = true; } process_pinned_events(sync_data.pinned_events); if(set_read_marker_future.ready()) { set_read_marker_future.get(); read_marker_timer.restart(); setting_read_marker = false; } if(fetch_messages_future.ready()) { FetchMessagesResult new_messages_result = fetch_messages_future.get(); all_messages.insert(all_messages.end(), new_messages_result.messages.begin(), new_messages_result.messages.end()); if(new_messages_result.success) fetch_previous_messages_timeout = 0.0; else fetch_previous_messages_timeout = 4.0; if(new_messages_result.message_dir == MessageDirection::BEFORE) fetched_enough_messages_top = new_messages_result.reached_end; if(new_messages_result.message_dir == MessageDirection::AFTER) fetched_enough_messages_bottom = new_messages_result.reached_end; filter_provisional_messages(new_messages_result.messages); filter_existing_messages(new_messages_result.messages); size_t num_new_messages = new_messages_result.messages.size(); if(num_new_messages > 0) { add_new_messages_to_current_room(new_messages_result.messages); modify_related_messages_in_current_room(new_messages_result.messages); process_reactions(new_messages_result.messages); // TODO: Do not loop all items, only loop the new items resolve_unreferenced_events_with_body_items(tabs[MESSAGES_TAB_INDEX].body->get_items().data(), tabs[MESSAGES_TAB_INDEX].body->get_items().size()); } if(num_new_messages > 0 && current_room->initial_prev_messages_fetch) { current_room->initial_prev_messages_fetch = false; if(selected_tab == MESSAGES_TAB_INDEX) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } } if(fetch_users_future.ready()) { fetch_users_future.get(); current_room->users_fetched = true; update_pinned_messages_authors(); update_messages_authors(); } if(fetch_message_future.ready()) { FetchMessageResult fetch_message_result = fetch_message_future.get(); if(fetch_message_result.type == FetchMessageType::USER_UPDATE) { update_pinned_messages_author(fetch_message->user); update_messages_author(fetch_message->user); fetch_message = nullptr; } else if(fetch_message_result.type == FetchMessageType::MESSAGE && fetch_body_item) { fprintf(stderr, "Finished fetching message: %s\n", fetch_message_result.message ? fetch_message_result.message->event_id.c_str() : "(null)"); if(fetch_message_tab == PINNED_TAB_INDEX) { PinnedEventData *event_data = static_cast(fetch_body_item->userdata); if(fetch_message_result.message) { *fetch_body_item = *message_to_body_item(matrix, current_room, fetch_message_result.message.get(), current_room->get_user_display_name(me), me->user_id); event_data->status = FetchStatus::FINISHED_LOADING; event_data->message = fetch_message_result.message.get(); fetch_body_item->userdata = event_data; } else { fetch_body_item->set_description("Failed to load message!"); event_data->status = FetchStatus::FAILED_TO_LOAD; } } else if(fetch_message_tab == MESSAGES_TAB_INDEX) { if(fetch_message_result.message) { fetch_body_item->embedded_item = message_to_body_item(matrix, current_room, fetch_message_result.message.get(), current_room->get_user_display_name(me), me->user_id); fetch_body_item->embedded_item_status = FetchStatus::FINISHED_LOADING; if(fetch_message_result.message->user == me) fetch_body_item->set_description_color(get_theme().attention_alert_text_color, true); } else { fetch_body_item->embedded_item_status = FetchStatus::FAILED_TO_LOAD; } } fetch_body_item = nullptr; } fetch_message_tab = -1; } window.clear(get_theme().background_color); if(chat_state == ChatState::URL_SELECTION) { url_selection_body.draw(window, body_pos, body_size); } else { tabs[selected_tab].body->draw(window, body_pos, body_size); if(selected_tab == MESSAGES_TAB_INDEX && mention.visible && chat_state == ChatState::TYPING_MESSAGE) { mgl::Rectangle user_mention_background(mgl::vec2f(body_size.x, user_mention_body_height)); user_mention_background.set_position(mgl::vec2f(body_pos.x, body_pos.y + body_size.y - user_mention_body_height)); user_mention_background.set_color(get_theme().shade_color); window.draw(user_mention_background); tabs[USERS_TAB_INDEX].body->draw(window, user_mention_background.get_position(), user_mention_background.get_size()); } } //tab_shade.set_size(mgl::vec2f(window_size.x, tab_shade_height)); //window.draw(tab_shade); if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX || selected_tab == USERS_TAB_INDEX) { float room_name_text_offset_x = std::floor(10.0f * get_config().scale); if(room_avatar_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE && room_avatar_sprite.get_texture() && room_avatar_sprite.get_texture()->is_valid()) { auto room_avatar_texture_size = room_avatar_sprite.get_texture()->get_size(); room_avatar_texture_size.x *= room_avatar_sprite.get_scale().x; room_avatar_texture_size.y *= room_avatar_sprite.get_scale().y; room_avatar_sprite.set_position(mgl::vec2f(body_pos.x + std::floor(10.0f * get_config().scale), room_name_total_height * 0.5f - room_avatar_texture_size.y * 0.5f + 5.0f)); if(circle_mask_shader.is_valid()) circle_mask_shader.set_uniform("resolution", mgl::vec2f(room_avatar_texture_size.x, room_avatar_texture_size.y)); window.draw(room_avatar_sprite, &circle_mask_shader); room_name_text_offset_x += room_avatar_texture_size.x + 10.0f; } room_name_text.set_position(mgl::vec2f(body_pos.x + room_name_text_offset_x, room_name_text_padding_y - 3.0f)); window.draw(room_name_text); room_topic_text.set_position(mgl::vec2f(room_name_text.get_position().x + room_name_text.get_bounds().size.x + 15.0f, room_name_text_padding_y - 1.0f + room_name_text_height * 0.5f - room_topic_text_height * 0.5f)); window.draw(room_topic_text); } if(draw_room_list) { mgl::Rectangle room_list_background(mgl::vec2f(this->body_size.x, window_size.y)); //room_list_background.set_position(this->body_pos); room_list_background.set_color(get_theme().shade_color); window.draw(room_list_background); window.draw(room_label); const float tab_y = std::floor(tab_vertical_offset) + room_name_padding_y; matrix_chat_page->rooms_page->body->draw(window, mgl::vec2f(0.0f, tab_y), mgl::vec2f(this->body_size.x, window_size.y - tab_y), Json::Value::nullSingleton()); } ui_tabs.draw(window, mgl::vec2f(body_pos.x, std::floor(tab_vertical_offset) + room_name_padding_y), body_size.x); if(chat_state == ChatState::REPLYING || chat_state == ChatState::EDITING) { const float margin = 5.0f; const float replying_to_text_height = replying_to_text.get_bounds().size.y + margin; float item_height = std::min(body_size.y - replying_to_text_height - margin, tabs[MESSAGES_TAB_INDEX].body->get_item_height(currently_operating_on_item.get(), body_size.x) + margin); if(item_height < 0.0f) item_height = 0.0f; float extra_user_mention_height = 0.0f; if(mention.visible) extra_user_mention_height = user_mention_body_height; mgl::Rectangle overlay(mgl::vec2f(window_size.x, window_size.y - chat_input_height_full - extra_user_mention_height)); overlay.set_color(mgl::Color(0, 0, 0, 240)); window.draw(overlay); const float padding_x = std::floor(10.0f * get_config().scale * get_config().spacing_scale); mgl::vec2f body_item_pos(body_pos.x + padding_x, window_size.y - chat_input_height_full - item_height); mgl::vec2f body_item_size(body_size.x - padding_x * 2.0f, item_height); mgl::Rectangle item_background(mgl::vec2f(window_size.x, body_item_size.y + chat_input_height_full + replying_to_text_height + margin)); item_background.set_position(mgl::vec2f(0.0f, window_size.y - (body_item_size.y + chat_input_height_full + replying_to_text_height + margin))); item_background.set_color(get_theme().background_color); window.draw(item_background); if(mention.visible) { mgl::Rectangle user_mention_background(mgl::vec2f(window_size.x, user_mention_body_height)); user_mention_background.set_position(mgl::vec2f(0.0f, item_background.get_position().y - user_mention_body_height)); user_mention_background.set_color(get_theme().shade_color); window.draw(user_mention_background); tabs[USERS_TAB_INDEX].body->draw(window, mgl::vec2f(body_pos.x + padding_x, item_background.get_position().y - user_mention_body_height), mgl::vec2f(body_size.x - padding_x * 2.0f, user_mention_body_height)); } replying_to_text.set_position(mgl::vec2f(body_item_pos.x, body_item_pos.y - replying_to_text_height)); window.draw(replying_to_text); tabs[MESSAGES_TAB_INDEX].body->draw_item(window, currently_operating_on_item, body_item_pos, body_item_size); } if(selected_tab == MESSAGES_TAB_INDEX && current_room && current_room->body_item && (!current_room->last_message_read || has_unread_messages) && matrix->is_initial_sync_finished()) { if(after_token.empty() && !tabs[selected_tab].body->is_bottom_cut_off() && is_window_focused && chat_state != ChatState::URL_SELECTION && !setting_read_marker && read_marker_timer.get_elapsed_time_seconds() >= read_marker_timeout_sec) { // TODO: Only set read marker once every second if the message is not the last message in the room auto body_items = tabs[selected_tab].body->get_items(); int last_timeline_message = (int)body_items.size() - 1; for(; last_timeline_message >= 0; --last_timeline_message) { BodyItem *item = body_items[last_timeline_message].get(); Message *message = static_cast(item->userdata); if(message && message_is_timeline(message) && !message->event_id.empty() && message->timestamp != timestamp_provisional_event) break; } if(last_timeline_message != -1 && (!current_room->body_item->extra || static_cast(current_room->body_item->extra.get())->decrypt_state == MatrixChatBodyItemData::DecryptState::DECRYPTED)) { if(current_room->offset_to_latest_message_text < (int)current_room->body_item->get_description().size()) current_room->body_item->set_description(current_room->body_item->get_description().substr(current_room->offset_to_latest_message_text)); current_room->body_item->set_description_color(get_theme().faded_text_color); // TODO: Show a line like nheko instead for unread messages, or something else current_room->body_item->set_title_color(get_theme().text_color); current_room->last_message_read = true; // TODO: Maybe set this instead when the mention is visible on the screen? Message *read_message = get_latest_message_in_edit_chain(static_cast(body_items[last_timeline_message]->userdata)); // TODO: What if two messages have the same timestamp? // Oops, fuckup. If provisional timestamp then overwrite it. if(!read_message->event_id.empty() && (read_message->timestamp > current_room->last_read_message_timestamp || current_room->last_message_timestamp == timestamp_provisional_event)) { matrix_chat_page->set_room_as_read(current_room); current_room->last_read_message_timestamp = read_message->timestamp; // TODO: What if the message is no longer valid? setting_read_marker = true; RoomData *room = current_room; std::string event_id = read_message->event_id; int64_t event_timestamp = read_message->timestamp; set_read_marker_future = AsyncTask([this, room, event_id, event_timestamp]() mutable { if(matrix->set_read_marker(room, event_id, event_timestamp) != PluginResult::OK) { fprintf(stderr, "Warning: failed to set read marker to %s\n", event_id.c_str()); } }); } } } else if(tabs[selected_tab].body->is_bottom_cut_off() || has_unread_messages) { window.draw(more_messages_below_rect); } } // TODO: Have one for each room. Also add bottom one? for fetching new messages (currently not implemented, is it needed?) if(fetch_messages_future.valid() && selected_tab == MESSAGES_TAB_INDEX) { // TODO: fetch_messages_dir double progress = 0.5 + std::sin(std::fmod(gradient_inc, 360.0) * 0.017453292519943295 - 1.5707963267948966*0.5) * 0.5; gradient_inc += (frame_time_ms * 0.5); mgl::Color top_color = interpolate_colors(get_theme().background_color, get_theme().loading_page_color, progress); gradient_points[0].position.x = chat_input_shade.get_position().x; gradient_points[0].position.y = tab_shade_height; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = tab_shade_height; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = tab_shade_height + gradient_height; gradient_points[3].position.x = chat_input_shade.get_position().x; gradient_points[3].position.y = tab_shade_height + gradient_height; gradient_points[0].color = top_color; gradient_points[1].color = top_color; gradient_points[2].color = get_theme().background_color; gradient_points[3].color = get_theme().background_color; if(fetch_messages_dir == MessageDirection::AFTER) { gradient_points[0].position.y = more_messages_below_rect.get_position().y; gradient_points[1].position.y = more_messages_below_rect.get_position().y; gradient_points[2].position.y = more_messages_below_rect.get_position().y + gradient_height; gradient_points[3].position.y = more_messages_below_rect.get_position().y + gradient_height; gradient_points[0].color = get_theme().background_color; gradient_points[1].color = get_theme().background_color; gradient_points[2].color = top_color; gradient_points[3].color = top_color; } window.draw(gradient_points, 4, mgl::PrimitiveType::Quads); // Note: mgl::PrimitiveType::Quads doesn't work with egl } if(selected_tab == MESSAGES_TAB_INDEX) { //window.draw(chat_input_shade); chat_input.draw(window); //chat_input.draw(window, false); } if(matrix && !matrix->is_initial_sync_finished()) { std::string err_msg; if(matrix->did_initial_sync_fail(err_msg)) { matrix_chat_page->set_current_room(nullptr, nullptr, nullptr); fetch_messages_future.cancel(); cleanup_tasks(); tabs.clear(); unreferenced_events.clear(); unresolved_reactions.clear(); all_messages.clear(); show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); matrix->logout(); delete matrix; matrix = new Matrix(matrix_instance_already_running); current_page = PageType::CHAT_LOGIN; chat_login_page(); after_matrix_login_page(); window.close(); goto chat_page_end; } } AsyncImageLoader::get_instance().update(); window.display(); if(selected_tab == MESSAGES_TAB_INDEX) { if(!tabs[selected_tab].body->is_top_cut_off()) tabs[selected_tab].body->on_top_reached(); if(!tabs[selected_tab].body->is_bottom_cut_off()) tabs[selected_tab].body->on_bottom_reached(); } if(go_to_previous_page) { go_to_previous_page = false; goto chat_page_end; } } chat_page_end: matrix_chat_page->set_current_room(nullptr, nullptr, nullptr); fetch_messages_future.cancel(); cleanup_tasks(); window.set_title("QuickMedia - matrix"); return move_room; } static void add_body_item_unique_title(BodyItems &body_items, const std::string &title) { for(auto &body_item : body_items) { if(body_item->get_title() == title) return; } body_items.push_back(BodyItem::create(title)); } void Program::after_matrix_login_page() { if(!window.is_open()) exit(exit_code); auto rooms_tags_body = create_body(); auto matrix_rooms_tag_page = std::make_unique(this, rooms_tags_body.get()); auto rooms_body = create_body(true); auto rooms_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_rooms_page = std::make_unique(this, rooms_body.get(), "All rooms", nullptr, rooms_page_search_bar.get()); auto notifications_body = create_body(); //notifications_body->attach_side = AttachSide::BOTTOM; auto matrix_notifications_page = std::make_unique(this, matrix, notifications_body.get(), matrix_rooms_page.get()); auto invites_body = create_body(); auto matrix_invites_page = std::make_unique(this, matrix, invites_body.get()); BodyItems room_dir_body_items; add_body_item_unique_title(room_dir_body_items, matrix->get_homeserver_domain()); for(const std::string &known_homeserver : get_config().matrix.known_homeservers) { add_body_item_unique_title(room_dir_body_items, known_homeserver); } auto room_directory_body = create_body(); room_directory_body->set_items(std::move(room_dir_body_items)); auto matrix_room_directory_page = std::make_unique(this, matrix); auto settings_body = create_body(); auto custom_emoji_body_item = BodyItem::create("Custom emoji"); custom_emoji_body_item->url = "emoji"; settings_body->append_item(std::move(custom_emoji_body_item)); auto join_body_item = BodyItem::create("Join room"); join_body_item->url = "join"; settings_body->append_item(std::move(join_body_item)); auto logout_body_item = BodyItem::create("Logout"); logout_body_item->url = "logout"; settings_body->append_item(std::move(logout_body_item)); auto matrix_settings_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_settings_page = std::make_unique(this, matrix); MatrixQuickMedia matrix_handler(this, matrix, matrix_rooms_page.get(), matrix_rooms_tag_page.get(), matrix_invites_page.get(), matrix_notifications_page.get()); bool sync_cached = false; if(!matrix->start_sync(&matrix_handler, sync_cached)) { show_notification("QuickMedia", "Failed to start sync", Urgency::CRITICAL); exit_code = 1; return; } is_login_sync = !sync_cached; std::vector tabs; tabs.push_back(Tab{std::move(notifications_body), std::move(matrix_notifications_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{std::move(rooms_tags_body), std::move(matrix_rooms_tag_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{std::move(rooms_body), std::move(matrix_rooms_page), std::move(rooms_page_search_bar)}); tabs.push_back(Tab{std::move(invites_body), std::move(matrix_invites_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{std::move(room_directory_body), std::move(matrix_room_directory_page), create_search_bar("Server to search on...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{std::move(settings_body), std::move(matrix_settings_page), std::move(matrix_settings_page_search_bar)}); const bool go_to_login_page = page_loop(tabs, 2, nullptr, false); matrix->stop_sync(); if(go_to_login_page) { delete matrix; matrix = new Matrix(matrix_instance_already_running); current_page = PageType::CHAT_LOGIN; chat_login_page(); after_matrix_login_page(); window.close(); exit(exit_code); } } static std::string get_unique_filename(Path directory, Path filename) { Path full_filepath = directory; full_filepath.join(filename); if(get_file_type(full_filepath) == FileType::FILE_NOT_FOUND) return full_filepath.data; for(int i = 1; i < 100; ++i) { std::string filename_unique = filename.filename_no_ext() + " (" + std::to_string(i) + ")" + filename.ext(); Path full_filepath = directory; full_filepath.join(filename_unique); if(get_file_type(full_filepath) == FileType::FILE_NOT_FOUND) return full_filepath.data; } full_filepath = directory; char buffer[8]; if(generate_random_characters(buffer, sizeof(buffer), "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 62)) { std::string new_filename = filename.filename_no_ext() + " ("; new_filename.append(buffer, sizeof(buffer)); new_filename += ")"; new_filename += filename.ext(); full_filepath.join(new_filename); } else { full_filepath.join(filename); } return full_filepath.data; } void Program::download_page(std::string url, std::string download_filename, bool no_dialog) { window.set_title(("QuickMedia - Select where you want to save " + std::string(url)).c_str()); url = invidious_url_to_youtube_url(url); const bool download_use_youtube_dl = url_should_download_with_youtube_dl(url); std::string filename; std::string video_id; const bool url_is_youtube = youtube_url_extract_id(url, video_id); //const bool url_is_youtube = false; std::unique_ptr youtube_video_page; std::string video_url; std::string audio_url; int64_t video_content_length = 0; int64_t audio_content_length = 0; TaskResult task_result = TaskResult::TRUE; if(download_use_youtube_dl) { if(is_program_executable_by_name("yt-dlp")) { yt_dl_name = "yt-dlp"; } else if(is_program_executable_by_name("youtube-dl")) { yt_dl_name = "youtube-dl"; } else { show_notification("QuickMedia", "yt-dlp or youtube-dl needs to be installed to download the video/music", Urgency::CRITICAL); exit(10); } task_result = run_task_with_loading_screen([this, url, &filename]{ std::string json_str; std::vector args = { yt_dl_name, "--skip-download", "--print-json", "--no-warnings" }; if(no_video) { args.push_back("-f"); args.push_back("bestaudio/best"); args.push_back("-x"); } else { args.push_back("-f"); args.push_back("bestvideo+bestaudio/best"); } args.insert(args.end(), { "--", url.c_str(), nullptr }); if(exec_program(args.data(), accumulate_string, &json_str) != 0) return false; Json::Value result; Json::CharReaderBuilder json_builder; std::unique_ptr json_reader(json_builder.newCharReader()); std::string json_errors; if(!json_reader->parse(json_str.data(), json_str.data() + json_str.size(), &result, &json_errors)) { fprintf(stderr, "Failed to json response, error: %s\n", json_errors.c_str()); return false; } const Json::Value &title_json = result["title"]; const Json::Value &ext_json = result["ext"]; if(title_json.isString()) filename = title_json.asString(); if(ext_json.isString()) { if(ext_json.asCString()[0] != '.' && (filename.empty() || filename.back() != '.')) filename += "."; filename += ext_json.asString(); } return !filename.empty(); }); } else if(url_is_youtube) { youtube_video_page = std::make_unique(this, url); bool cancelled = false; bool load_successful = false; const int video_max_height = video_get_max_height(); std::string err_str; for(int i = 0; i < 3; ++i) { task_result = run_task_with_loading_screen([&]{ VideoInfo video_info; SubmitArgs submit_args; if(youtube_video_page->load(submit_args, video_info, err_str) != PluginResult::OK) return false; filename = video_info.title; std::string ext; bool has_embedded_audio = true; video_url = no_video ? "" : youtube_video_page->get_video_url(video_max_height, has_embedded_audio, ext); audio_url.clear(); if(!has_embedded_audio || no_video) audio_url = youtube_video_page->get_audio_url(ext); if(video_url.empty() && audio_url.empty()) return false; if(!youtube_url_is_live_stream(video_url) && !youtube_url_is_live_stream(audio_url)) { video_content_length = 0; audio_content_length = 0; std::string new_video_url = video_url; std::string new_audio_url = audio_url; auto current_thread_id = std::this_thread::get_id(); if(!youtube_custom_redirect(new_video_url, new_audio_url, video_content_length, audio_content_length, [current_thread_id]{ return !program_is_dead_in_thread(current_thread_id); })) { if(program_is_dead_in_current_thread()) cancelled = true; return false; } video_url = std::move(new_video_url); audio_url = std::move(new_audio_url); } if(!video_url.empty() && !audio_url.empty()) filename += ".mkv"; else filename += ext; return true; }); if(task_result == TaskResult::CANCEL || cancelled) { exit_code = 1; return; } else if(task_result == TaskResult::FALSE) { continue; } load_successful = true; break; } if(!load_successful) { show_notification("QuickMedia", "Download failed" + (err_str.empty() ? "" : ", error: " + err_str), Urgency::CRITICAL); exit_code = 1; return; } if(youtube_url_is_live_stream(video_url) || youtube_url_is_live_stream(audio_url)) { show_notification("QuickMedia", "Downloading youtube live streams is currently not supported", Urgency::CRITICAL); exit_code = 1; return; } } else { if(download_filename.empty()) { task_result = run_task_with_loading_screen([url, &filename]{ return url_get_remote_name(url, filename, true) == DownloadResult::OK; }); } else { filename = std::move(download_filename); } } if(task_result == TaskResult::CANCEL) { exit_code = 1; return; } else if(task_result == TaskResult::FALSE) { show_notification("QuickMedia", "Download failed", Urgency::CRITICAL); exit_code = 1; return; } string_replace_all(filename, '/', '_'); std::string output_filepath; if(no_dialog) { std::string filename_ext = Path(filename).ext(); if(is_video_ext(filename_ext.c_str())) output_filepath = get_config().download.video_directory; else if(is_image_ext(filename_ext.c_str())) output_filepath = get_config().download.image_directory; else if(is_music_ext(filename_ext.c_str())) output_filepath = get_config().download.music_directory; else output_filepath = get_config().download.file_directory; output_filepath = get_unique_filename(output_filepath, filename); } else { window.set_visible(true); output_filepath = file_save_page(filename); if(!window.is_open() || output_filepath.empty()) { exit_code = 1; return; } } mgl::vec2i monitor_size; mgl::vec2i focused_monitor_center = get_focused_monitor_center(disp, monitor_size); window_size.x = std::min(monitor_size.x - 100, (int)(300.0f + 380.0f * get_config().scale)); window_size.y = std::min(monitor_size.y - 100, (int)(50.0f + 130.0f * get_config().scale)); window.set_size(mgl::vec2i(window_size.x, window_size.y)); window.set_size_limits(window_size, window_size); window.set_position(mgl::vec2i(focused_monitor_center.x - window_size.x * 0.5f, focused_monitor_center.y - window_size.y * 0.5f)); std::string window_title = "QuickMedia - downloading "; window_title += Path(output_filepath).filename(); window.set_title(window_title.c_str()); window.set_visible(true); std::string output_filepath_s = output_filepath; char *output_dir = dirname(output_filepath_s.data()); if(create_directory_recursive(output_dir) != 0) { show_notification("QuickMedia", std::string("Failed to download ") + url + " to " + output_filepath + " (failed to create directory)", Urgency::CRITICAL); exit_code = 1; return; } idle_active_handler(); window.clear(get_theme().background_color); window.display(); const float loading_bar_padding_x = std::floor(4.0f * get_config().scale * get_config().spacing_scale); const float loading_bar_padding_y = std::floor(4.0f * get_config().scale * get_config().spacing_scale); RoundedRectangle loading_bar_background(mgl::vec2f(1.0f, 1.0f), std::floor(10.0f * get_config().scale), get_theme().background_color, &rounded_rectangle_shader); RoundedRectangle loading_bar(mgl::vec2f(1.0f, 1.0f), std::floor(10.0f * get_config().scale - loading_bar_padding_y), get_theme().loading_bar_color, &rounded_rectangle_shader); const float padding_x = std::floor(30.0f * get_config().scale * get_config().spacing_scale); const float spacing_y = std::floor(15.0f * get_config().scale * get_config().spacing_scale); const float loading_bar_height = std::floor(20.0f * get_config().scale); mgl::Text progress_text("0kb/Unknown", *FontLoader::get_font(FontLoader::FontType::LATIN, 20.0f * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); progress_text.set_color(get_theme().text_color); mgl::Text status_text("Downloading", *FontLoader::get_font(FontLoader::FontType::LATIN, 20.0f * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); status_text.set_color(get_theme().text_color); mgl::Text filename_text(filename.c_str(), *FontLoader::get_font(FontLoader::FontType::LATIN, 14.0f * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); filename_text.set_color(get_theme().faded_text_color); mgl::Text download_speed_text("0 bytes/s", *FontLoader::get_font(FontLoader::FontType::LATIN, 14.0f * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); download_speed_text.set_color(get_theme().faded_text_color); bool redraw = true; mgl::Event event; std::unique_ptr downloader; if(download_use_youtube_dl) { downloader = std::make_unique(yt_dl_name, url, output_filepath, no_video); } else if(url_is_youtube) { MediaMetadata video_metadata; video_metadata.url = std::move(video_url); video_metadata.content_length = video_content_length; MediaMetadata audio_metadata; audio_metadata.url = std::move(audio_url); audio_metadata.content_length = audio_content_length; downloader = std::make_unique(video_metadata, audio_metadata, output_filepath); } else { downloader = std::make_unique(url, output_filepath); } if(!downloader->start()) { show_notification("QuickMedia", std::string("Failed to download ") + url + " to " + output_filepath, Urgency::CRITICAL); exit_code = 1; return; } mgl::Clock frame_timer; mgl::Clock progress_update_timer; bool download_completed = false; float progress = 0.0f; float ui_progress = 0.0f; while(window.is_open()) { while (window.poll_event(event)) { common_event_handler(event); if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; redraw = true; } } handle_x11_events(); if(window_closed) { show_notification("QuickMedia", "Download cancelled!"); downloader->stop(false); exit_code = 1; exit(exit_code); } if(progress_update_timer.get_elapsed_time_seconds() >= 1.0f) { progress_update_timer.restart(); DownloadUpdateStatus update_status = downloader->update(); switch(update_status) { case DownloadUpdateStatus::DOWNLOADING: break; case DownloadUpdateStatus::FINISHED: download_completed = true; goto cleanup; case DownloadUpdateStatus::ERROR: fprintf(stderr, "Download error on update\n"); goto cleanup; } progress = downloader->get_progress(); progress = std::max(0.0f, std::min(1.0f, progress)); progress_text.set_string(downloader->get_progress_text()); download_speed_text.set_string(downloader->get_download_speed_text()); redraw = true; } if(redraw) { redraw = false; loading_bar_background.set_size(mgl::vec2f(window_size.x - padding_x * 2.0f, loading_bar_height)); loading_bar_background.set_position(window_size.to_vec2f() * 0.5f - loading_bar_background.get_size() * 0.5f + mgl::vec2f(0.0f, download_speed_text.get_bounds().size.y * 0.5f)); loading_bar_background.set_position(vec2f_floor(loading_bar_background.get_position().x, loading_bar_background.get_position().y)); loading_bar.set_position(loading_bar_background.get_position() + mgl::vec2f(loading_bar_padding_x, loading_bar_padding_y)); filename_text.set_position( loading_bar_background.get_position() + mgl::vec2f(0.0f, -(filename_text.get_bounds().size.y + spacing_y))); progress_text.set_position( filename_text.get_position() + mgl::vec2f(loading_bar_background.get_size().x - progress_text.get_bounds().size.x, -(progress_text.get_bounds().size.y + spacing_y))); status_text.set_position( filename_text.get_position() + mgl::vec2f(0.0f, -(status_text.get_bounds().size.y + spacing_y))); download_speed_text.set_position( loading_bar_background.get_position() + mgl::vec2f(0.0f, loading_bar_height + spacing_y)); } const float progress_diff = progress - ui_progress; const float progress_move = frame_timer.get_elapsed_time_seconds() * 2500.0f * std::abs(progress_diff); if(std::abs(progress_diff) < progress_move) { ui_progress = progress; } else { if(progress_diff > 0.0f) ui_progress += progress_move; else ui_progress -= progress_move; } loading_bar.set_size(mgl::vec2f( std::floor((loading_bar_background.get_size().x - loading_bar_padding_x * 2.0f) * ui_progress), loading_bar_height - loading_bar_padding_y * 2.0f)); window.clear(get_theme().shade_color); loading_bar_background.draw(window); loading_bar.draw(window); window.draw(progress_text); window.draw(status_text); window.draw(filename_text); window.draw(download_speed_text); AsyncImageLoader::get_instance().update(); window.display(); frame_timer.restart(); } cleanup: const bool stop_successful = downloader->stop(download_completed); if(download_completed && stop_successful) { show_notification("QuickMedia", std::string("Download finished! Downloaded ") + Path(filename).filename() + " to " + output_filepath); exit_code = 0; } else { show_notification("QuickMedia", std::string("Failed to download ") + url + " to " + output_filepath, Urgency::CRITICAL); exit_code = 1; } exit(exit_code); } std::string Program::file_save_page(const std::string &filename) { mgl::vec2f body_pos; mgl::vec2f body_size; bool redraw = true; mgl::Event event; auto file_manager_page = std::make_unique(this); file_manager_page->set_current_directory(file_manager_start_dir); auto file_manager_body = create_body(false, get_config().file_manager.grid_view); BodyItems body_items; file_manager_page->get_files_in_directory(body_items); file_manager_body->set_items(std::move(body_items)); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); Tabs ui_tabs(&rounded_rectangle_shader); const int tab_path_index = ui_tabs.add_tab(file_manager_start_dir, file_manager_body.get()); search_bar->onTextUpdateCallback = [&file_manager_body](const std::string &text) { file_manager_body->filter_search_fuzzy(text); file_manager_body->select_first_item(); }; search_bar->onTextSubmitCallback = [this, &search_bar, &file_manager_body, &file_manager_page, &ui_tabs, tab_path_index](const std::string&) { if(window.is_key_pressed(mgl::Keyboard::LControl) || window.is_key_pressed(mgl::Keyboard::RControl)) return; auto selected = file_manager_body->get_selected_shared(); if(!selected) return; std::vector new_tabs; TaskResult task_result = run_task_with_loading_screen([&]() { SubmitArgs submit_args; if(selected) { submit_args.title = selected->get_title(); submit_args.url = selected->url; submit_args.thumbnail_url = selected->thumbnail_url; submit_args.userdata = selected->userdata; submit_args.extra = selected->extra; } return file_manager_page->submit(submit_args, new_tabs) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) { if(!new_tabs.empty()) { file_manager_body = std::move(new_tabs[0].body); search_bar->clear(); } } else if(task_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to change directory", Urgency::CRITICAL); } ui_tabs.set_text(tab_path_index, file_manager_page->get_current_directory().string()); idle_active_handler(); }; const float bottom_panel_padding = std::floor(10.0f * get_config().spacing_scale); const float bottom_panel_spacing = std::floor(10.0f * get_config().spacing_scale); Button cancel_button("Cancel", FontLoader::get_font(FontLoader::FontType::LATIN, 16 * get_config().scale), 100.0f, &rounded_rectangle_shader, get_config().scale * get_config().font_scale * get_config().font.scale.latin); cancel_button.set_background_color(get_theme().cancel_button_background_color); Button save_button("Save", FontLoader::get_font(FontLoader::FontType::LATIN, 16 * get_config().scale), 100.0f, &rounded_rectangle_shader, get_config().scale * get_config().font_scale * get_config().font.scale.latin); save_button.set_background_color(get_theme().confirm_button_background_color); mgl::Text file_name_label("File name:", *FontLoader::get_font(FontLoader::FontType::LATIN, 16.0f * get_config().scale * get_config().font_scale * get_config().font.scale.latin)); file_name_label.set_color(get_theme().text_color); Entry file_name_entry("", &rounded_rectangle_shader); file_name_entry.set_text(filename); file_name_entry.set_single_line(true); file_name_entry.set_editable(false); file_name_entry.set_background_color(get_theme().selected_color); mgl::Rectangle bottom_panel_background; bottom_panel_background.set_color(get_theme().shade_color); const float gradient_height = 5.0f; mgl::Vertex gradient_points[4]; auto save_file = [this, &file_name_entry, &file_manager_page]() -> std::string { const std::string *filename = &file_name_entry.get_text(); Path filename_full_path = file_manager_page->get_current_directory().string(); filename_full_path.join(*filename); if(filename->empty()) { show_notification("QuickMedia", "The file name can't be empty", Urgency::CRITICAL); } else if(*filename == "." || *filename == ".." || filename->find('/') != std::string::npos) { show_notification("QuickMedia", "Invalid file name. File can't be ., .. or contain /", Urgency::CRITICAL); } else if(filename->size() >= 255 || filename_full_path.data.size() >= 4096) { show_notification("QuickMedia", "The file name has to be less than 255 characters and the full path has to be less than 4096 characters", Urgency::CRITICAL); } else { if(std::filesystem::exists(filename_full_path.data)) { bool overwrite = false; auto body = create_body(); auto options_page = std::make_unique(this, "Are you sure you want to overwrite " + filename_full_path.data + "?"); options_page->add_option(body.get(), "No", "", [&overwrite](){ overwrite = false; }); options_page->add_option(body.get(), "Yes", "", [&overwrite](){ overwrite = true; }); std::vector tabs; tabs.push_back(Tab{ std::move(body), std::move(options_page), nullptr }); page_loop(tabs); if(overwrite) return std::move(filename_full_path.data); } else { return std::move(filename_full_path.data); } } return ""; }; float prev_entry_height = file_name_entry.get_height(); while (window.is_open()) { while (window.poll_event(event)) { common_event_handler(event); if(file_manager_body->on_event(window, event, !file_name_entry.is_editable())) idle_active_handler(); else event_idle_handler(event); search_bar->on_event(window, event); if(cancel_button.on_event(event) & BUTTON_EVENT_CLICKED) return ""; if(save_button.on_event(event) & BUTTON_EVENT_CLICKED) { std::string save_path = save_file(); if(!save_path.empty()) return save_path; } file_name_entry.process_event(window, event); if(event.type == mgl::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; redraw = true; idle_active_handler(); } else if(event.type == mgl::Event::GainedFocus) { redraw = true; } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::Tab) { file_name_entry.set_editable(!file_name_entry.is_editable()); search_bar->set_editable(!search_bar->is_editable()); } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::Enter && event.key.control) { std::string save_path = save_file(); if(!save_path.empty()) return save_path; } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::Escape) { window.close(); } } update_idle_state(); handle_x11_events(); search_bar->update(); if(std::abs(file_name_entry.get_height() - prev_entry_height) >= 1.0f) { prev_entry_height = file_name_entry.get_height(); redraw = true; } if(redraw) { redraw = false; get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); body_pos.y += Tabs::get_shade_height(); body_size.y -= Tabs::get_shade_height(); save_button.set_position(window_size.to_vec2f() - mgl::vec2f(save_button.get_width(), save_button.get_height()) - mgl::vec2f(bottom_panel_padding, bottom_panel_padding)); cancel_button.set_position(save_button.get_position() - mgl::vec2f(cancel_button.get_width() + bottom_panel_spacing, 0.0f)); file_name_label.set_position(mgl::vec2f(bottom_panel_spacing, std::floor(window_size.y - bottom_panel_padding - file_name_entry.get_height() * 0.5f - file_name_label.get_bounds().size.y * 0.5f - 1.0f * get_config().scale))); file_name_entry.set_position(mgl::vec2f(file_name_label.get_position().x + file_name_label.get_bounds().size.x + bottom_panel_spacing, window_size.y - file_name_entry.get_height() - bottom_panel_padding)); file_name_entry.set_max_width(std::floor(cancel_button.get_position().x - bottom_panel_spacing - file_name_label.get_bounds().size.x - bottom_panel_spacing - bottom_panel_spacing)); bottom_panel_background.set_position(mgl::vec2f(0.0f, window_size.y - std::floor(bottom_panel_padding * 2.0f + file_name_entry.get_height()))); bottom_panel_background.set_size(mgl::vec2f(window_size.x, std::floor(bottom_panel_padding * 2.0f + file_name_entry.get_height()))); const mgl::Color color(0, 0, 0, 50); gradient_points[0] = mgl::Vertex(bottom_panel_background.get_position() + mgl::vec2f(0.0f, -gradient_height), mgl::Color(color.r, color.g, color.b, 0)); gradient_points[1] = mgl::Vertex(bottom_panel_background.get_position() + mgl::vec2f(bottom_panel_background.get_size().x, -gradient_height), mgl::Color(color.r, color.g, color.b, 0)); gradient_points[2] = mgl::Vertex(bottom_panel_background.get_position() + mgl::vec2f(bottom_panel_background.get_size().x, 0.0f), color); gradient_points[3] = mgl::Vertex(bottom_panel_background.get_position() + mgl::vec2f(0.0f, 0.0f), color); } window.clear(get_theme().background_color); ui_tabs.draw(window, mgl::vec2f(0.0f, search_bar->getBottomWithoutShadow()), window_size.x); search_bar->draw(window, window_size.to_vec2f(), true); file_manager_body->draw(window, body_pos, body_size - mgl::vec2f(0.0f, bottom_panel_background.get_size().y)); window.draw(bottom_panel_background); window.draw(gradient_points, 4, mgl::PrimitiveType::Quads); window.draw(file_name_label); cancel_button.draw(window); save_button.draw(window); file_name_entry.draw(window); AsyncImageLoader::get_instance().update(); window.display(); } return ""; } }