#include "../include/QuickMedia.hpp" #include "../plugins/Manganelo.hpp" #include "../plugins/Mangadex.hpp" #include "../plugins/MangaGeneric.hpp" #include "../plugins/MangaCombined.hpp" #include "../plugins/MediaGeneric.hpp" #include "../plugins/Youtube.hpp" #include "../plugins/Fourchan.hpp" #include "../plugins/NyaaSi.hpp" #include "../plugins/Matrix.hpp" #include "../plugins/Pleroma.hpp" #include "../plugins/Spotify.hpp" #include "../plugins/Soundcloud.hpp" #include "../plugins/FileManager.hpp" #include "../plugins/Pipe.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/SfmlFixes.hpp" #include "../include/ResourceLoader.hpp" #include "../include/Utils.hpp" #include "../include/Tabs.hpp" #include "../external/hash-library/sha256.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static const sf::Color back_color(21, 25, 30); static const std::string fourchan_google_captcha_api_key = "6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc"; static int FPS_IDLE = 2; static const double IDLE_TIMEOUT_SEC = 2.0; static const sf::Vector2i AVATAR_THUMBNAIL_SIZE(std::floor(32), std::floor(32)); 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"), std::make_pair("mangakatana", "mangakatana_logo.png"), std::make_pair("mangadex", "mangadex_logo.png"), std::make_pair("manga", nullptr), std::make_pair("youtube", "yt_logo_rgb_dark_small.png"), std::make_pair("spotify", "spotify_logo.png"), std::make_pair("soundcloud", "soundcloud_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"), std::make_pair("matrix", "matrix_logo.png"), std::make_pair("mastodon", "pleroma_logo.png"), std::make_pair("pleroma", "pleroma_logo.png"), std::make_pair("file-manager", nullptr), std::make_pair("stdin", nullptr) }; 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 valid_plugin.second; } return nullptr; } // 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->connection == RR_Connected) { XRRCrtcInfo *crt_info = XRRGetCrtcInfo(display, screen_res, out_info->crtc); if(crt_info) { const XRRModeInfo *mode_info = get_mode_info(screen_res, crt_info->mode); if(mode_info) callback_func(mode_info); XRRFreeCrtcInfo(crt_info); } } if(out_info) XRRFreeOutputInfo(out_info); } XRRFreeScreenResources(screen_res); } static int get_monitor_max_hz(Display *display) { unsigned long max_hz = 0; for_each_active_monitor_output(display, [&max_hz](const XRRModeInfo *mode_info) { unsigned long total = mode_info->hTotal * mode_info->vTotal; if(total > 0) max_hz = std::max(max_hz, (unsigned long)std::round((double)mode_info->dotClock / (double)total)); }); if(max_hz == 0) return 60; return std::min(max_hz, 144UL); } static int get_largest_monitor_height(Display *display) { int max_height = 0; for_each_active_monitor_output(display, [&max_height](const XRRModeInfo *mode_info) { // 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)mode_info->width, (int)mode_info->height); max_height = std::max(max_height, width_or_height); }); if(max_height == 0) return 720; return std::max(max_height, 480); } static void get_screen_resolution(Display *display, int *width, int *height) { *width = DefaultScreenOfDisplay(display)->width; *height = DefaultScreenOfDisplay(display)->height; } static bool has_gl_ext(Display *disp, const char *ext) { const char *extensions = glXQueryExtensionsString(disp, DefaultScreen(disp)); if(!extensions) return false; int ext_len = strlen(ext); while(true) { const char *loc = strstr(extensions, ext); if(!loc) return false; const char *terminator = loc + ext_len; if((loc == extensions || *(loc - 1) == ' ') && (*terminator == ' ' || *terminator == '\0')) return true; extensions = terminator; } } static PFNGLXSWAPINTERVALMESAPROC glXSwapIntervalMESA = nullptr; static PFNGLXSWAPINTERVALSGIPROC glXSwapIntervalSGI = nullptr; static PFNGLXSWAPINTERVALEXTPROC glXSwapIntervalEXT = nullptr; static bool vsync_loaded = false; static bool vsync_set = false; static bool test_vsync(Display *disp, Window window) { unsigned int swap = 0; glXQueryDrawable(disp, window, GLX_SWAP_INTERVAL_EXT, &swap); fprintf(stderr, "The swap interval is %u\n", swap); return swap == 1; } static bool enable_vsync(Display *disp, Window window) { if(vsync_loaded) { if(glXSwapIntervalMESA) return glXSwapIntervalMESA(1) == 0; if(glXSwapIntervalSGI) return glXSwapIntervalSGI(1) == 0; if(glXSwapIntervalEXT) { glXSwapIntervalEXT(disp, window, 1); return true; } return false; } vsync_loaded = true; if(has_gl_ext(disp, "GLX_MESA_swap_control")) { fprintf(stderr, "vsync method: GLX_MESA_swap_control\n"); glXSwapIntervalMESA = (PFNGLXSWAPINTERVALMESAPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalMESA"); if(glXSwapIntervalMESA && glXSwapIntervalMESA(1) == 0 && test_vsync(disp, window)) return true; } if(has_gl_ext(disp, "GLX_SGI_swap_control")) { fprintf(stderr, "vsync method: GLX_SGI_swap_control\n"); glXSwapIntervalSGI = (PFNGLXSWAPINTERVALSGIPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalSGI"); if(glXSwapIntervalSGI && glXSwapIntervalSGI(1) == 0 && test_vsync(disp, window)) return true; } if(has_gl_ext(disp, "GLX_EXT_swap_control")) { fprintf(stderr, "vsync method: GLX_EXT_swap_control\n"); glXSwapIntervalEXT = (PFNGLXSWAPINTERVALEXTPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalEXT"); if(glXSwapIntervalEXT) { glXSwapIntervalEXT(disp, window, 1); return test_vsync(disp, window); } } fprintf(stderr, "vsync method: none\n"); return false; } static sf::Color interpolate_colors(sf::Color source, sf::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 sf::Color( source.r + diff_r * progress, source.g + diff_g * progress, source.b + diff_b * progress, source.a + diff_a * progress); } namespace QuickMedia { // Returns index to item or -1 if not found static int get_body_item_by_url(Body *body, const std::string &url) { if(url.empty()) return -1; for(size_t i = 0; i < body->items.size(); ++i) { auto &body_item = body->items[i]; if(body_item->url == url) return i; } return -1; } 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"); } // TODO: Make asynchronous static void 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()) { Json::Value recommended_item = recommended_json[member_name]; if(recommended_item.isObject()) { Json::Value recommended_timestamp_json = recommended_item.get("recommended_timestamp", Json::Value::nullSingleton()); Json::Value watched_timestamp_json = recommended_item.get("watched_timestamp", Json::Value::nullSingleton()); 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) { Json::Value &a_timestamp_json = a.second["recommended_timestamp"]; 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(); Json::Value &a_recommended_count_json = a.second["recommended_count"]; 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; 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 + "/hqdefault.jpg"; body_item->thumbnail_size = sf::Vector2i(175, 131); body_items.push_back(std::move(body_item)); // We dont want more than 150 recommendations if(body_items.size() == 150) break; } std::random_shuffle(body_items.begin(), body_items.end()); } enum class HistoryType { YOUTUBE, MANGA }; class HistoryPage : public Page { public: HistoryPage(Program *program, Page *search_page, SearchBar *search_bar, HistoryType history_type) : Page(program), search_page(search_page), search_bar(search_bar), history_type(history_type) {} const char* get_title() const override { return "History"; } PluginResult submit(const std::string &title, const std::string &url, std::vector &result_tabs) override { return search_page->submit(title, url, result_tabs); } void on_navigate_to_page(Body *body) override { std::string selected_item_url = body->get_selected() ? body->get_selected()->url : ""; body->clear_items(); switch(history_type) { case HistoryType::YOUTUBE: program->youtube_get_watch_history(body->items); break; case HistoryType::MANGA: program->manga_get_watch_history(program->get_plugin_name(), body->items); break; } body->filter_search_fuzzy(search_bar->get_text()); int item_to_revert_selection_to = get_body_item_by_url(body, selected_item_url); if(item_to_revert_selection_to != -1) body->set_selected_item(item_to_revert_selection_to, false); } private: Page *search_page; SearchBar *search_bar; HistoryType history_type; }; class RecommendedPage : public Page { public: RecommendedPage(Program *program, Page *search_page, SearchBar *search_bar, const char *plugin_name) : Page(program), search_page(search_page), search_bar(search_bar), plugin_name(plugin_name) {} const char* get_title() const override { return "Recommended"; } PluginResult submit(const std::string &title, const std::string &url, std::vector &result_tabs) override { return search_page->submit(title, url, result_tabs); } void on_navigate_to_page(Body *body) override { std::string selected_item_url = body->get_selected() ? body->get_selected()->url : ""; body->clear_items(); fill_recommended_items_from_json(plugin_name, program->load_recommended_json(), body->items); body->filter_search_fuzzy(search_bar->get_text()); int item_to_revert_selection_to = get_body_item_by_url(body, selected_item_url); if(item_to_revert_selection_to != -1) body->set_selected_item(item_to_revert_selection_to, false); } private: Page *search_page; SearchBar *search_bar; const char *plugin_name; }; Program::Program() : disp(nullptr), window_size(1280, 720), 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 [--no-video] [--use-system-mpv-config] [--dir ] [-e ]\n"); fprintf(stderr, "OPTIONS:\n"); fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, pornhub, spankbang, xvideos, xhamster, youtube, spotify, soundcloud, nyaa.si, matrix, file-manager or stdin\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --use-system-mpv-config Use system mpv config instead of no config. 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\n"); fprintf(stderr, " -e Embed QuickMedia into another window\n"); fprintf(stderr, "EXAMPLES:\n"); fprintf(stderr, " quickmedia launcher\n"); fprintf(stderr, " quickmedia --upscale-images-always manganelo\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; } static std::shared_ptr create_launcher_body_item(const char *title, const char *plugin_name, const std::string &thumbnail_url) { auto body_item = BodyItem::create(title); body_item->url = plugin_name; if(!thumbnail_url.empty()) { body_item->thumbnail_url = thumbnail_url; body_item->thumbnail_is_local = true; body_item->thumbnail_size.x = 32; body_item->thumbnail_size.y = 32; } return body_item; } int Program::run(int argc, char **argv) { if(argc < 2) { usage(); return -1; } const char *start_dir = nullptr; Window parent_window = None; std::vector tabs; for(int i = 1; i < argc; ++i) { if(!plugin_name) { for(const auto &valid_plugin : valid_plugins) { if(strcmp(argv[i], valid_plugin.first) == 0) { plugin_name = argv[i]; break; } } } if(strcmp(argv[i], "--no-video") == 0) { force_no_video = true; } else if(strcmp(argv[i], "--use-system-mpv-config") == 0) { use_system_mpv_config = 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], "--dir") == 0) { if(i < argc - 1) { 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], "-e") == 0) { if(i < argc - 1) { parent_window = strtol(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 to embed into after -e argument\n"); usage(); return -1; } } else if(argv[i][0] == '-') { fprintf(stderr, "Invalid option %s\n", argv[i]); usage(); return -1; } } if(!plugin_name) { fprintf(stderr, "Missing plugin argument\n"); usage(); return -1; } if(low_cpu_mode) FPS_IDLE = 2; 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 manganelo, manganelos, mangatown and mangadex\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"); } }); } if(strcmp(plugin_name, "file-manager") != 0 && start_dir) { fprintf(stderr, "Option --dir is only valid with file-manager\n"); usage(); return -1; } int start_tab_index = 0; init(parent_window); load_plugin_by_name(tabs, start_dir, start_tab_index); 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(); return exit_code; } page_loop(tabs, start_tab_index); tabs.clear(); if(strcmp(plugin_name, "launcher") == 0) { plugin_name = pipe_selected_text.c_str(); load_plugin_by_name(tabs, start_dir, start_tab_index); } } return exit_code; } void Program::init(Window parent_window) { disp = XOpenDisplay(NULL); if (!disp) throw std::runtime_error("Failed to open display to X11 server"); wm_delete_window_atom = XInternAtom(disp, "WM_DELETE_WINDOW", False); int screen = DefaultScreen(disp); int screen_center_x = (DisplayWidth(disp, screen) - window_size.x) / 2; int screen_center_y = (DisplayHeight(disp, screen) - window_size.y) / 2; x11_window = XCreateWindow(disp, parent_window ? parent_window : DefaultRootWindow(disp), screen_center_x, screen_center_y, window_size.x, window_size.y, 0, DefaultDepth(disp, screen), InputOutput, DefaultVisual(disp, screen), 0, nullptr); if(!x11_window) throw std::runtime_error("Failed to create window"); XStoreName(disp, x11_window, "QuickMedia"); XMapWindow(disp, x11_window); XFlush(disp); window.create(x11_window); resources_root = "/usr/share/quickmedia/"; if(get_file_type("../../../images/manganelo_logo.png") == FileType::REGULAR) { resources_root = "../../../"; } set_resource_loader_root_path(resources_root.c_str()); if(!circle_mask_shader.loadFromFile(resources_root + "shaders/circle_mask.glsl", sf::Shader::Type::Fragment)) { fprintf(stderr, "Failed to load %s/shaders/circle_mask.glsl\n", resources_root.c_str()); abort(); } if(!rounded_rectangle_shader.loadFromFile(resources_root + "shaders/rounded_rectangle.glsl", sf::Shader::Type::Fragment)) { fprintf(stderr, "Failed to load %s/shaders/rounded_rectangle.glsl\n", resources_root.c_str()); abort(); } if(!loading_icon.loadFromFile(resources_root + "images/loading_icon.png")) { fprintf(stderr, "Failed to load %s/images/loading_icon.png\n", resources_root.c_str()); abort(); } loading_icon.setSmooth(true); load_sprite.setTexture(loading_icon, true); sf::Vector2u loading_icon_size = loading_icon.getSize(); load_sprite.setOrigin(loading_icon_size.x * 0.5f, loading_icon_size.y * 0.5f); 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); window.setVerticalSyncEnabled(true); monitor_hz = get_monitor_max_hz(disp); window.setFramerateLimit(FPS_IDLE); idle = true; vsync_set = false; /* if(enable_vsync(disp, window.getSystemHandle())) { vsync_set = true; } else { fprintf(stderr, "Failed to enable vsync, fallback to frame limiting\n"); window.setFramerateLimit(monitor_hz); } */ fprintf(stderr, "Monitor hz: %d\n", monitor_hz); if(create_directory_recursive(get_cache_dir().join("thumbnails")) != 0) throw std::runtime_error("Failed to create thumbnails directory"); 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(); } // 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://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 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} }) .authors_handler({{"//div[id='single_book']//a[class='author']", "text", "href", "/author/"}}) .list_chapters_handler("//div[class='chapters']//div[class='chapter']//a", "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; size_t sources_start = html_source.find("ytaw=["); if(sources_start == std::string::npos) return urls; sources_start += 5; // just before [ size_t json_end = find_end_of_json_array(html_source.c_str(), sources_start, html_source.size()); if(json_end == size_t(-1)) return urls; sources_start += 1; json_end -= 1; std::string urls_str = html_source.substr(sources_start, json_end - sources_start); string_replace_all(urls_str, "'", ""); string_split(urls_str, ',', [&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; }); return urls; }) .manga_id_handler("/manga/", 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='content']//div[class='thumb-under']//a", "title", "href", "/video"}}) .thumbnail_handler({{"//div[id='content']//div[class='thumb']//img", "data-src", "/videos/"}}) .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() || !thumbnail_url_json.isString()) continue; MediaRelatedItem related_item; related_item.title = title_json.asString(); related_item.url = url_json.asString(); 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[class='video-thumb-info']//a", "text", "href", "/videos/"}}) .thumbnail_handler({{"//img", "src", "/thumb-"}}) .related_media_text_handler({{"//div[class='video-thumb-info']//a", "text", "href", "/videos/"}}) .related_media_thumbnail_handler({{"//img", "src", "/thumb-"}}); } void Program::load_plugin_by_name(std::vector &tabs, const char *start_dir, int &start_tab_index) { if(!plugin_name || plugin_name[0] == '\0') return; window.setTitle("QuickMedia - " + std::string(plugin_name)); 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 = sf::Texture(); if(!plugin_logo_path.empty()) { if(!plugin_logo.loadFromFile(plugin_logo_path)) fprintf(stderr, "Failed to load plugin logo, path: %s\n", plugin_logo_path.c_str()); plugin_logo.generateMipmap(); plugin_logo.setSmooth(true); } if(strcmp(plugin_name, "launcher") == 0) { auto pipe_body = create_body(); pipe_body->items.push_back(create_launcher_body_item("4chan", "4chan", resources_root + "icons/4chan_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Manga (all)", "manga", "")); pipe_body->items.push_back(create_launcher_body_item("Mangadex", "mangadex", resources_root + "icons/mangadex_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Mangakatana", "mangakatana", resources_root + "icons/mangakatana_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Manganelo", "manganelo", resources_root + "icons/manganelo_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Manganelos", "manganelos", resources_root + "icons/manganelos_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Mangatown", "mangatown", resources_root + "icons/mangatown_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Matrix", "matrix", resources_root + "icons/matrix_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Nyaa.si", "nyaa.si", resources_root + "icons/nyaa_si_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Soundcloud", "soundcloud", resources_root + "icons/soundcloud_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("Spotify", "spotify", resources_root + "icons/spotify_launcher.png")); pipe_body->items.push_back(create_launcher_body_item("YouTube", "youtube", resources_root + "icons/yt_launcher.png")); pipe_body->items.push_back(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) { tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 400)}); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), std::move(search_bar)}); } 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::move(search_page), create_search_bar("Search...", 400)}); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), std::move(search_bar)}); } 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::move(search_page), create_search_bar("Search...", 400)}); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), std::move(search_bar)}); } 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()); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 400)}); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), std::move(search_bar)}); } else if(strcmp(plugin_name, "mangadex") == 0) { tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 400)}); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), std::move(search_bar)}); } else if(strcmp(plugin_name, "manga") == 0) { 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()); std::vector pages; pages.push_back({std::move(manganelo), "Manganelo", "manganelo", resources_root + "images/" + get_plugin_logo_name("manganelo")}); pages.push_back({std::move(manganelos), "Manganelos", "manganelos", resources_root + "images/" + get_plugin_logo_name("manganelos")}); pages.push_back({std::move(mangatown), "Mangatown", "mangatown", resources_root + "images/" + get_plugin_logo_name("mangatown")}); pages.push_back({std::move(mangakatana), "Mangakatana", "mangakatana", resources_root + "images/" + get_plugin_logo_name("mangakatana")}); // TODO: Add mangadex 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 category_page = std::make_unique(this); auto categories_body = create_body(); category_page->get_categories(categories_body->items); tabs.push_back(Tab{std::move(categories_body), std::move(category_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "4chan") == 0) { auto boards_page = std::make_unique(this, resources_root); auto boards_body = create_body(); boards_page->get_boards(boards_body->items); tabs.push_back(Tab{std::move(boards_body), std::move(boards_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "file-manager") == 0) { auto file_manager_page = std::make_unique(this); if(start_dir && !file_manager_page->set_current_directory(start_dir)) { fprintf(stderr, "Invalid directory provided with --dir: %s\n", start_dir); exit_code = -3; return; } auto file_manager_body = create_body(); file_manager_page->get_files_in_directory(file_manager_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(); PipePage::load_body_items_from_stdin(pipe_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) { start_tab_index = 1; tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 350)}); auto history_body = create_body(); auto history_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), history_search_bar.get(), HistoryType::YOUTUBE); tabs.push_back(Tab{std::move(history_body), std::move(history_page), std::move(history_search_bar)}); auto recommended_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto recommended_page = std::make_unique(this, tabs.front().page.get(), recommended_search_bar.get(), plugin_name); tabs.push_back(Tab{create_body(), std::move(recommended_page), std::move(recommended_search_bar)}); } else if(strcmp(plugin_name, "pornhub") == 0) { auto search_page = std::make_unique(this, "https://www.pornhub.com/", sf::Vector2i(320/1.5f, 180/1.5f)); add_pornhub_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "spankbang") == 0) { auto search_page = std::make_unique(this, "https://spankbang.com/", sf::Vector2i(500/2.5f, 281/2.5f)); add_spankbang_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "xvideos") == 0) { auto search_page = std::make_unique(this, "https://www.xvideos.com/", sf::Vector2i(352/1.5f, 198/1.5f)); add_xvideos_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "xhamster") == 0) { auto search_page = std::make_unique(this, "https://xhamster.com/", sf::Vector2i(240, 135)); add_xhamster_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "spotify") == 0) { tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 350)}); no_video = true; } else if(strcmp(plugin_name, "soundcloud") == 0) { tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 500)}); no_video = true; } else if(strcmp(plugin_name, "mastodon") == 0 || strcmp(plugin_name, "pleroma") == 0) { auto pleroma = std::make_shared(); tabs.push_back(Tab{create_body(), std::make_unique(this, pleroma), create_search_bar("Search...", 350)}); } else if(strcmp(plugin_name, "matrix") == 0) { assert(!matrix); matrix = new Matrix(); } } bool Program::handle_window_close() { if(wm_delete_window_atom && XCheckTypedWindowEvent(disp, x11_window, ClientMessage, &xev) && (Atom)xev.xclient.data.l[0] == wm_delete_window_atom) { current_page = PageType::EXIT; window.close(); return true; } return false; } void Program::base_event_handler(sf::Event &event, PageType previous_page, Body *body, SearchBar *search_bar, bool handle_keypress, bool handle_searchbar) { if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(handle_keypress && event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { body->select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { body->select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { body->select_first_item(); } else if(event.key.code == sf::Keyboard::End) { body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = previous_page; } } else if(handle_searchbar) { assert(search_bar); search_bar->on_event(event); } } void Program::event_idle_handler(const sf::Event &event) { if(event.type == sf::Event::KeyPressed || event.type == sf::Event::TextEntered) idle_active_handler(); } void Program::idle_active_handler() { if(idle) window.setFramerateLimit(monitor_hz); idle = false; idle_timer.restart(); } void Program::update_idle_state() { if(idle) return; if(idle_timer.getElapsedTime().asSeconds() > IDLE_TIMEOUT_SEC) { window.setFramerateLimit(FPS_IDLE); idle = true; } } static std::string base64_encode(const std::string &data) { return cppcodec::base64_url::encode(data); } static std::string base64_decode(const std::string &data) { return cppcodec::base64_url::decode(data); } enum class SearchSuggestionTab { ALL, HISTORY, RECOMMENDED, LOGIN }; // Returns relative time as a string (approximation) static std::string seconds_to_relative_time_str(time_t seconds) { seconds = std::max(0L, seconds); time_t minutes = seconds / 60; time_t hours = minutes / 60; time_t days = hours / 24; time_t months = days / 30; time_t years = days / 365; if(years >= 1) return std::to_string(years) + " year" + (years == 1 ? "" : "s") + " ago"; else if(months >= 1) return std::to_string(months) + " month" + (months == 1 ? "" : "s") + " ago"; else if(days >= 1) return std::to_string(days) + " day" + (days == 1 ? "" : "s") + " ago"; else if(hours >= 1) return std::to_string(hours) + " hour" + (hours == 1 ? "" : "s") + " ago"; else if(minutes >= 1) return std::to_string(minutes) + " minute" + (minutes == 1 ? "" : "s") + " ago"; else return std::to_string(seconds) + " second" + (seconds == 1 ? "" : "s") + " ago"; } // TODO: Make asynchronous static void fill_history_items_from_json(const Json::Value &history_json, BodyItems &history_items) { assert(history_json.isArray()); BodyItems body_items; time_t time_now = time(NULL); for(const Json::Value &item : history_json) { if(!item.isObject()) continue; const Json::Value &video_id = item["id"]; if(!video_id.isString()) continue; std::string video_id_str = video_id.asString(); const Json::Value &title = item["title"]; if(!title.isString()) continue; std::string title_str = title.asString(); const Json::Value ×tamp = item["timestamp"]; if(!timestamp.isNumeric()) continue; 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 + "/hqdefault.jpg"; body_item->set_description("Watched " + seconds_to_relative_time_str(time_now - timestamp.asInt64())); body_item->thumbnail_size = sf::Vector2i(175, 131); body_items.push_back(std::move(body_item)); } for(auto it = body_items.rbegin(), end = body_items.rend(); it != end; ++it) { history_items.push_back(std::move(*it)); } } static Path get_video_history_filepath(const char *plugin_name) { Path video_history_dir = get_storage_dir().join("history"); if(create_directory_recursive(video_history_dir) != 0) { std::string err_msg = "Failed to create video history 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"); } // 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_video_history_json() { Path video_history_filepath = get_video_history_filepath(plugin_name); Json::Value json_result; if(!read_file_as_json(video_history_filepath, json_result) || !json_result.isArray()) json_result = Json::Value(Json::arrayValue); return json_result; } // 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_recommended_json() { 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::manga_get_watch_history(const char *plugin_name, BodyItems &history_items) { // 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); exit(1); } Path credentials_storage_dir = get_storage_dir().join("credentials"); if(create_directory_recursive(credentials_storage_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + credentials_storage_dir.data, Urgency::CRITICAL); exit(1); } // TODO: Make asynchronous for_files_in_dir_sort_last_modified(content_storage_dir, [&history_items, plugin_name](const std::filesystem::path &filepath) { // 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(filepath.extension() == ".tmp") return true; Path fullpath(filepath.c_str()); Json::Value body; if(!read_file_as_json(fullpath, body) || !body.isObject()) { fprintf(stderr, "Failed to read json file: %s\n", fullpath.data.c_str()); return true; } // TODO: Manga combined auto filename = filepath.filename(); const Json::Value &manga_name = body["name"]; if(!filename.empty() && manga_name.isString()) { // TODO: Add thumbnail auto body_item = BodyItem::create(manga_name.asString()); if(strcmp(plugin_name, "manganelo") == 0) body_item->url = "https://manganelo.com/manga/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "manganelos") == 0) body_item->url = "http://manganelos.com/manga/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "mangadex") == 0) body_item->url = "https://mangadex.org/title/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "mangatown") == 0) body_item->url = "https://mangatown.com/manga/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "mangakatana") == 0) body_item->url = "https://mangakatana.com/manga/" + base64_decode(filename.string()); 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_history_items_from_json(load_video_history_json(), history_items); } static void get_body_dimensions(const sf::Vector2f &window_size, SearchBar *search_bar, sf::Vector2f &body_pos, sf::Vector2f &body_size, bool has_tabs = false) { float body_padding_horizontal = 10.0f; float body_padding_vertical = std::floor(10.0f); float body_width = window_size.x - body_padding_horizontal * 2.0f; /*if(body_width <= 480.0f) { body_width = window_size.x; body_padding_horizontal = 10.0f; }*/ float tab_h = Tabs::get_shade_height(); if(!search_bar) tab_h += std::floor(10.0f * get_ui_scale()); if(!has_tabs) tab_h = 0.0f; float search_bottom = search_bar ? search_bar->getBottomWithoutShadow() : 0.0f; body_pos = sf::Vector2f(body_padding_horizontal, search_bottom + body_padding_vertical + tab_h); body_size = sf::Vector2f(body_width, window_size.y - search_bottom - body_padding_vertical - tab_h); } std::unique_ptr Program::create_body() { auto body = std::make_unique(this, loading_icon, &rounded_rectangle_shader); 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); search_bar->text_autosearch_delay = search_delay; return search_bar; } bool Program::load_manga_content_storage(const char *service_name, const std::string &manga_title, const std::string &manga_id) { Path content_storage_dir = get_storage_dir().join(service_name); manga_id_base64 = base64_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(); } else { result = true; } if(!content_storage_json.isObject()) content_storage_json = Json::Value(Json::objectValue); content_storage_json["name"] = manga_title; return result; } void Program::select_file(const std::string &filepath) { puts(filepath.c_str()); selected_files.push_back(filepath); } bool Program::is_window_focused() { return window.hasFocus(); } 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; } void Program::page_loop_render(sf::RenderWindow &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, false); float shade_extra_height = 0.0f; if(!tabs[selected_tab].search_bar) { shade_extra_height = std::floor(10.0f * get_ui_scale()); sf::RectangleShape shade_top(sf::Vector2f(window_size.x, shade_extra_height)); shade_top.setFillColor(sf::Color(33, 37, 44)); 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, sf::Vector2f(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, sf::Quads); // Note: sf::Quads doesn't work with egl if(!tab_associated_data.search_result_text.getString().isEmpty()) { auto search_result_text_bounds = tab_associated_data.search_result_text.getLocalBounds(); tab_associated_data.search_result_text.setPosition( std::floor(body_pos.x + body_size.x * 0.5f - search_result_text_bounds.width * 0.5f), std::floor(body_pos.y + body_size.y * 0.5f - search_result_text_bounds.height * 0.5f)); window.draw(tab_associated_data.search_result_text); } if(matrix && !matrix->is_initial_sync_finished()) { // if(is_login_sync) { load_sprite.setPosition(body_pos.x + body_size.x * 0.5f, body_pos.y + body_size.y * 0.5f); load_sprite.setRotation(load_sprite_timer.getElapsedTime().asSeconds() * 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(); current_page = PageType::CHAT_LOGIN; chat_login_page(); after_matrix_login_page(); window.close(); exit(exit_code); } } } void Program::page_loop(std::vector &tabs, int start_tab_index, PageLoopSubmitHandler after_submit_handler) { if(tabs.empty()) { show_notification("QuickMedia", "No tabs provided!", Urgency::CRITICAL); return; } window.setFramerateLimit(FPS_IDLE); malloc_trim(0); idle = true; bool loop_running = true; bool redraw = true; for(Tab &tab : tabs) { tab.body->thumbnail_max_size = tab.page->get_thumbnail_max_size(); 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()); } ui_tabs.set_selected(start_tab_index); ui_tabs.on_change_tab = [&tabs, &redraw](int selected_tab) { tabs[selected_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 = sf::Text("", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(30 * get_ui_scale())); tab_associated_data.push_back(std::move(data)); } //std::string autocomplete_text; //bool autocomplete_running = false; double gradient_inc = 0.0; const float gradient_height = 5.0f; auto window_size_u = window.getSize(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; std::function submit_handler; submit_handler = [this, &submit_handler, &after_submit_handler, &tabs, &tab_associated_data, &ui_tabs, &loop_running, &redraw](const std::string &search_text) { const int selected_tab = ui_tabs.get_selected(); auto selected_item = tabs[selected_tab].body->get_selected_shared(); if(!selected_item && !tabs[selected_tab].page->allow_submit_no_selection()) return; std::vector new_tabs; auto prev_selected_item = tabs[selected_tab].page->submit_body_item; tabs[selected_tab].page->submit_body_item = selected_item; PluginResult submit_result = tabs[selected_tab].page->submit(selected_item ? selected_item->get_title() : search_text, selected_item ? selected_item->url : "", new_tabs); if(submit_result != PluginResult::OK) { // 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; } 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 { 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].search_bar->clear(); if(new_tabs.size() == 1) tabs[selected_tab].body = std::move(new_tabs[0].body); else loop_running = false; tabs[selected_tab].page->submit_body_item = prev_selected_item; return; } if(new_tabs.empty()) { tabs[selected_tab].page->submit_body_item = prev_selected_item; return; } if(after_submit_handler) after_submit_handler(new_tabs); for(Tab &tab : tabs) { tab.body->clear_cache(); } hide_virtual_keyboard(); 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) { select_episode(selected_item.get(), false); Body *chapters_body = tabs[selected_tab].body.get(); 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.setKeyRepeatEnabled(false); downloading_chapter_url.clear(); while(window.isOpen() && (current_page == PageType::IMAGES || current_page == PageType::IMAGES_CONTINUOUS)) { if(current_page == PageType::IMAGES) { window.setFramerateLimit(20); while(current_page == PageType::IMAGES) { int page_navigation = image_page(manga_images_page, chapters_body); if(page_navigation == -1) { // TODO: Make this work if the list is sorted differently than from newest to oldest. chapters_body->select_next_item(); select_episode(chapters_body->get_selected(), true); 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(page_navigation == 1) { // TODO: Make this work if the list is sorted differently than from newest to oldest. chapters_body->select_previous_item(); select_episode(chapters_body->get_selected(), true); manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } } window.setFramerateLimit(FPS_IDLE); idle = true; } else if(current_page == PageType::IMAGES_CONTINUOUS) { image_continuous_page(manga_images_page); } } 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.setKeyRepeatEnabled(true); malloc_trim(0); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::IMAGE_BOARD_THREAD) { 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) { 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()), selected_item->get_title(), false, tabs[selected_tab].body->items, 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) { //body_set_selected_item(tabs[selected_tab].body.get(), selected_item.get()); current_page = PageType::CHAT; current_chat_room = matrix->get_room_by_id(selected_item->url); MatrixRoomsPage *rooms_page = static_cast(tabs[selected_tab].page.get()); while(window.isOpen()) { auto matrix_chat_page = std::make_unique(this, current_chat_room->id, rooms_page); bool move_room = chat_page(matrix_chat_page.get(), current_chat_room, tabs, selected_tab); if(!move_room) break; BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(!selected_item) break; current_chat_room = matrix->get_room_by_id(selected_item->url); } tabs[selected_tab].body->body_item_select_callback = [&submit_handler](BodyItem *body_item) { submit_handler(body_item->get_title()); }; //select_body_item_by_room(tabs[selected_tab].body.get(), current_chat_room); current_chat_room = nullptr; } else { page_loop(new_tabs, 0, after_submit_handler); } for(Tab &tab : tabs) { tab.page->on_navigate_to_page(tab.body.get()); } tabs[selected_tab].page->submit_body_item = prev_selected_item; redraw = true; hide_virtual_keyboard(); }; 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()); }; TabAssociatedData &associated_data = tab_associated_data[i]; if(!tab.search_bar) continue; // tab.search_bar->autocomplete_search_delay = current_plugin->get_autocomplete_delay(); // tab.search_bar->onAutocompleteRequestCallback = [this, &tabs, &selected_tab, &autocomplete_text](const std::string &text) { // if(tabs[selected_tab].body == body && !current_plugin->search_is_filter()) // autocomplete_text = text; // }; 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); tabs[i].body->select_first_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); }; } sf::Event event; sf::Clock frame_timer; while (window.isOpen() && loop_running) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(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 == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); idle_active_handler(); } if(tabs[selected_tab].search_bar) { tabs[selected_tab].search_bar->on_event(event); } ui_tabs.on_event(event); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; else if(event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Down || event.key.code == sf::Keyboard::PageDown || event.key.code == sf::Keyboard::End || (event.key.control && event.key.code == sf::Keyboard::J)) { bool hit_bottom = false; switch(event.key.code) { case sf::Keyboard::Down: hit_bottom = !tabs[selected_tab].body->select_next_item(); break; case sf::Keyboard::J: hit_bottom = !tabs[selected_tab].body->select_next_item(); break; case sf::Keyboard::PageDown: hit_bottom = !tabs[selected_tab].body->select_next_page(); break; case sf::Keyboard::End: tabs[selected_tab].body->select_last_item(); hit_bottom = true; break; default: hit_bottom = false; break; } if(hit_bottom && tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].fetching_next_page_running && tabs[selected_tab].page && (!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, 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; }); } } else if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { tabs[selected_tab].body->select_previous_item(); } else if(event.key.code == sf::Keyboard::PageUp) { tabs[selected_tab].body->select_previous_page(); } else if(event.key.code == sf::Keyboard::Home) { tabs[selected_tab].body->select_first_item(); } else if(event.key.code == sf::Keyboard::Escape) { goto page_end; } else if(event.key.code == sf::Keyboard::Tab) { if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->set_to_autocomplete(); } else if(event.key.code == sf::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 == sf::Keyboard::T && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item && tabs[selected_tab].page && tabs[selected_tab].page->is_trackable()) { TrackablePage *trackable_page = dynamic_cast(tabs[selected_tab].page.get()); trackable_page->track(selected_item->get_title()); } } } } update_idle_state(); handle_window_close(); if(!loop_running || !window.isOpen()) break; const int selected_tab = ui_tabs.get_selected(); if(redraw) { redraw = false; if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->onWindowResize(window_size); // 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); 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; } 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); sf::Color bottom_color = interpolate_colors(back_color, sf::Color(175, 180, 188), progress); gradient_points[0].color = back_color; gradient_points[1].color = back_color; gradient_points[2].color = bottom_color; gradient_points[3].color = bottom_color; } if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->update(); if(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.setString("Fetching page..."); 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]; tabs[i].page->update(); if(associated_data.fetching_next_page_running && associated_data.next_page_future.ready()) { 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()); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { tabs[i].body->append_items(std::move(new_body_items)); associated_data.fetched_page++; } associated_data.fetching_next_page_running = false; } if(associated_data.search_text_updated && associated_data.fetch_status == FetchStatus::NONE && !associated_data.fetching_next_page_running) { std::string update_search_text = associated_data.update_search_text; associated_data.search_text_updated = false; associated_data.fetch_status = FetchStatus::LOADING; associated_data.fetch_type = FetchType::SEARCH; associated_data.search_result_text.setString("Searching..."); Page *page = tabs[i].page.get(); associated_data.fetch_future = AsyncTask([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(!associated_data.search_text_updated) { FetchResult fetch_result = associated_data.fetch_future.get(); tabs[i].body->items = std::move(fetch_result.body_items); tabs[i].body->select_first_item(); associated_data.fetched_page = 0; if(fetch_result.result != PluginResult::OK) associated_data.search_result_text.setString("Search failed!"); else if(tabs[i].body->items.empty()) associated_data.search_result_text.setString("No results found"); else associated_data.search_result_text.setString(""); } 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()) { associated_data.lazy_fetch_finished = true; FetchResult fetch_result = associated_data.fetch_future.get(); tabs[i].body->items = std::move(fetch_result.body_items); if(tabs[i].search_bar) tabs[i].body->filter_search_fuzzy(tabs[i].search_bar->get_text()); LazyFetchPage *lazy_fetch_page = static_cast(tabs[i].page.get()); if(fetch_result.result != PluginResult::OK) associated_data.search_result_text.setString("Failed to fetch page!"); else if(tabs[i].body->items.empty() && !lazy_fetch_page->lazy_fetch_is_loader()) associated_data.search_result_text.setString("No results found"); else associated_data.search_result_text.setString(""); associated_data.fetch_status = FetchStatus::NONE; } } 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(back_color); page_loop_render(window, tabs, selected_tab, tab_associated_data[selected_tab], json_chapters, ui_tabs); window.display(); if(go_to_previous_page) { go_to_previous_page = false; goto page_end; } } page_end: {} } static bool youtube_url_extract_id(const std::string &youtube_url, std::string &youtube_video_id) { size_t index = youtube_url.find("youtube.com/watch?v="); if(index != std::string::npos) { index += 20; size_t end_index = youtube_url.find("&", index); if(end_index == std::string::npos) end_index = youtube_url.size(); youtube_video_id = youtube_url.substr(index, end_index - index); return true; } index = youtube_url.find("youtu.be/"); if(index != std::string::npos) { index += 9; size_t end_index = youtube_url.find("?", index); if(end_index == std::string::npos) end_index = youtube_url.size(); youtube_video_id = youtube_url.substr(index, end_index - index); return true; } return false; } static int watch_history_get_item_by_id(const Json::Value &video_history_json, const char *id) { assert(video_history_json.isArray()); int index = -1; for(const Json::Value &item : video_history_json) { ++index; if(!item.isObject()) continue; const Json::Value &id_json = item["id"]; if(!id_json.isString()) continue; if(strcmp(id, id_json.asCString()) == 0) return index; } return -1; } 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; } void Program::save_recommendations_from_related_videos(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(); 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; } 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); } 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", "-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], "ftypmp42", 8) == 0 || memcmp(&result[4], "ftymp42", 7) == 0 || memcmp(&result[4], "ftyp3gp4", 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) { assert(std::this_thread::get_id() == main_thread_id); AsyncTask task = callback; window_size.x = window.getSize().x; window_size.y = window.getSize().y; sf::Event event; while(window.isOpen()) { while(window.pollEvent(event)) { if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { task.cancel(); return TaskResult::CANCEL; } } if(handle_window_close()) { task.cancel(); return TaskResult::CANCEL; } if(task.ready()) { if(task.get()) return TaskResult::TRUE; else return TaskResult::FALSE; } window.clear(back_color); load_sprite.setPosition(window_size.x * 0.5f, window_size.y * 0.5f); load_sprite.setRotation(load_sprite_timer.getElapsedTime().asSeconds() * 400.0); window.draw(load_sprite); window.display(); } return TaskResult::TRUE; } 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; } #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, BodyItems &next_play_items, int play_index, int *parent_body_page, const std::string &parent_page_search) { sf::Clock time_watched_timer; bool added_recommendations = false; bool video_loaded = false; const bool is_youtube = strcmp(plugin_name, "youtube") == 0; const bool is_matrix = strcmp(plugin_name, "matrix") == 0; PageType previous_page = pop_page_stack(); std::string video_url = video_page->get_url(); std::string original_video_url = video_url; bool video_url_is_local = false; if(download_if_streaming_fails) { 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); video_url_is_local = 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; } 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); video_url_is_local = true; break; } case TaskResult::FALSE: { show_notification("QuickMedia", "Failed to download " + video_url, Urgency::CRITICAL); current_page = previous_page; return; } case TaskResult::CANCEL: { current_page = previous_page; return; } } } else if(video_is_not_streamble_result == TaskResult::CANCEL) { current_page = previous_page; return; } } } window.setFramerateLimit(FPS_IDLE); idle = true; time_watched_timer.restart(); std::unique_ptr video_player; BodyItems related_videos; if(video_page->autoplay_next_item()) related_videos.insert(related_videos.end(), next_play_items.begin() + play_index + 1, next_play_items.end()); std::string channel_url; sf::WindowHandle video_player_window = None; auto on_window_create = [this, &video_player_window](sf::WindowHandle _video_player_window) mutable { video_player_window = _video_player_window; XSelectInput(disp, video_player_window, KeyPressMask | PointerMotionMask); XSync(disp, False); }; std::function video_event_callback; auto load_video_error_check = [this, &related_videos, &channel_url, &video_url, &video_title, &video_player, previous_page, &time_watched_timer, &video_loaded, &added_recommendations, video_page, &video_event_callback, &on_window_create, &video_player_window, is_matrix](bool resume_video) mutable { time_watched_timer.restart(); video_loaded = false; video_player_window = None; added_recommendations = false; watched_videos.insert(video_url); video_player = std::make_unique(no_video, use_system_mpv_config, resume_video, is_matrix, video_event_callback, on_window_create, resources_root, get_largest_monitor_height(disp)); VideoPlayer::Error err = video_player->load_video(video_url.c_str(), window.getSystemHandle(), plugin_name, video_title); if(err != VideoPlayer::Error::OK) { std::string err_msg = "Failed to play url: "; err_msg += video_url; show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); current_page = previous_page; } else { if(video_page->autoplay_next_item()) return; channel_url.clear(); // TODO: Remove this and use lazy_fetch instead related_videos = video_page->get_related_media(video_url, channel_url); // TODO: Make this also work for other video plugins if(strcmp(plugin_name, "youtube") != 0 || resume_video) return; 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 history"; show_notification("QuickMedia", err_msg.c_str(), Urgency::LOW); return; } Json::Value video_history_json = load_video_history_json(); int existing_index = watch_history_get_item_by_id(video_history_json, video_id.c_str()); if(existing_index != -1) { Json::Value removed; /* TODO: Optimize. This is slow */ video_history_json.removeIndex(existing_index, &removed); } time_t time_now = time(NULL); Json::Value new_content_object(Json::objectValue); new_content_object["id"] = video_id; new_content_object["title"] = video_title; new_content_object["timestamp"] = time_now; video_history_json.append(std::move(new_content_object)); Path video_history_filepath = get_video_history_filepath(plugin_name); save_json_to_file_atomic(video_history_filepath, video_history_json); } }; video_event_callback = [&video_player, &time_watched_timer, &video_loaded](const char *event_name) 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, "file-loaded") == 0) { time_watched_timer.restart(); if(!video_loaded) video_player->set_property("no-resume-playback", true); video_loaded = true; } else if(strcmp(event_name, "video-reconfig") == 0 || strcmp(event_name, "audio-reconfig") == 0) { if(!video_loaded) { video_loaded = true; time_watched_timer.restart(); } } //fprintf(stderr, "event name: %s\n", event_name); }; load_video_error_check(false); sf::Event event; XEvent xev; bool cursor_visible = true; sf::Clock cursor_hide_timer; auto save_video_url_to_clipboard = [&original_video_url, &video_player_window, &video_player]() { if(!video_player_window) return; if(video_url_supports_timestamp(original_video_url)) { // TODO: Remove timestamp (&t= or ?t=) from video_url double time_in_file; if(video_player->get_time_in_file(&time_in_file) != VideoPlayer::Error::OK) time_in_file = 0.0; std::string clipboard = original_video_url; if((int)time_in_file > 0) clipboard += "&t=" + std::to_string((int)time_in_file); sf::Clipboard::setString(sf::String::fromUtf8(clipboard.begin(), clipboard.end())); } else { sf::Clipboard::setString(sf::String::fromUtf8(original_video_url.begin(), original_video_url.end())); } }; while (current_page == PageType::VIDEO_CONTENT && window.isOpen()) { while (window.pollEvent(event)) { if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(event.type == sf::Event::KeyPressed && (event.key.code == sf::Keyboard::Escape || event.key.code == sf::Keyboard::Q || event.key.code == sf::Keyboard::Backspace)) { // To be able to close the video player while the video is loading current_page = previous_page; } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::C && event.key.control) { save_video_url_to_clipboard(); } } handle_window_close(); if(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 bool pressing_ctrl = (CLEANMASK(xev.xkey.state) == ControlMask); if(pressed_keysym == XK_Escape || pressed_keysym == XK_BackSpace || pressed_keysym == XK_q) { current_page = previous_page; break; } else if(pressed_keysym == XK_f && pressing_ctrl) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::TOGGLE); } else if(pressed_keysym == XK_r && pressing_ctrl) { if(!cursor_visible) window.setMouseCursorVisible(true); cursor_visible = true; int search_delay = 0; auto search_page = video_page->create_search_page(this, search_delay); auto comments_page = video_page->create_comments_page(this); auto related_videos_page = video_page->create_related_videos_page(this, video_url, video_title); auto channels_page = video_page->create_channels_page(this, channel_url); if(search_page || related_videos_page || channels_page) { XUnmapWindow(disp, video_player_window); XSync(disp, False); std::vector tabs; if(search_page) { tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", search_delay)}); } if(comments_page) { tabs.push_back(Tab{create_body(), std::move(comments_page), nullptr}); } if(related_videos_page) { auto related_videos_body = create_body(); related_videos_body->items = related_videos; tabs.push_back(Tab{std::move(related_videos_body), std::move(related_videos_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } if(channels_page) { tabs.push_back(Tab{create_body(), std::move(channels_page), create_search_bar("Search...", is_youtube ? 350 : SEARCH_DELAY_FILTER)}); } bool page_changed = false; page_loop(tabs, 1, [this, &video_player, &page_changed](const std::vector &new_tabs) { if(!page_changed && new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::VIDEO) { window.setMouseCursorVisible(true); if(video_player) { video_player->quit_and_save_watch_later(); while(true) { VideoPlayer::Error update_err = video_player->update(); if(update_err != VideoPlayer::Error::OK) break; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } video_player.reset(); } page_changed = true; } }); if(page_changed) { current_page = PageType::VIDEO_CONTENT; //video_player = std::make_unique(no_video, use_system_mpv_config, true, video_event_callback, on_window_create, resources_root); load_video_error_check(true); } else { XMapWindow(disp, video_player_window); XSync(disp, False); } } } 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.setMouseCursorVisible(true); cursor_visible = true; } VideoPlayer::Error update_err = video_player->update(); 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; break; } else if(update_err == VideoPlayer::Error::EXITED && video_player->exit_status == 0 && !is_matrix) { std::string new_video_url; std::string new_video_title; // Find video that hasn't been played before in this video session for(auto it = related_videos.begin(), end = related_videos.end(); it != end; ++it) { if(watched_videos.find((*it)->url) == watched_videos.end() && !video_page->video_should_be_skipped((*it)->url)) { new_video_url = (*it)->url; new_video_title = (*it)->get_title(); related_videos.erase(it); break; } } if(new_video_url.empty() && parent_page && parent_body_page) { BodyItems new_body_items; const int fetch_page = (*parent_body_page) + 1; 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) { next_play_items.insert(next_play_items.end(), new_body_items.begin(), new_body_items.end()); (*parent_body_page)++; related_videos = std::move(new_body_items); // Find video that hasn't been played before in this video session for(auto it = related_videos.begin(), end = related_videos.end(); it != end; ++it) { if(watched_videos.find((*it)->url) == watched_videos.end() && !video_page->video_should_be_skipped((*it)->url)) { new_video_url = (*it)->url; new_video_title = (*it)->get_title(); related_videos.erase(it); break; } } } } // If there are no videos to play, then dont play any... if(new_video_url.empty()) { show_notification("QuickMedia", "No more related videos to play"); current_page = previous_page; break; } video_url = video_page->url_get_playable_url(new_video_url); video_title = std::move(new_video_title); load_video_error_check(false); } 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; break; } if(!video_loaded) { window.clear(back_color); load_sprite.setPosition(window_size.x * 0.5f, window_size.y * 0.5f); load_sprite.setRotation(load_sprite_timer.getElapsedTime().asSeconds() * 400.0); window.draw(load_sprite); window.display(); if(!cursor_visible) { cursor_visible = true; window.setMouseCursorVisible(true); } cursor_hide_timer.restart(); continue; } /* Only save recommendations for the video if we have been watching it for 15 seconds */ if(is_youtube && video_loaded && !added_recommendations && time_watched_timer.getElapsedTime().asSeconds() >= 15) { added_recommendations = true; save_recommendations_from_related_videos(video_url, video_title, related_videos); } if(video_player_window) { if(!cursor_visible) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } const int UI_HIDE_TIMEOUT = 2500; if(cursor_hide_timer.getElapsedTime().asMilliseconds() > UI_HIDE_TIMEOUT) { cursor_visible = false; window.setMouseCursorVisible(false); } } std::this_thread::sleep_for(std::chrono::milliseconds(50)); } window.setMouseCursorVisible(true); window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::UNSET); auto window_size_u = window.getSize(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; } 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: 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, sf::Texture &image_texture, sf::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 && upscaled_ok) { sf::Image image; if(load_image_from_file(image, image_path.data)) { if(image_texture.loadFromImage(image)) { image_texture.setSmooth(true); //image_texture.generateMipmap(); return LoadImageResult::OK; } else { error_message = std::string("Failed to load image for page ") + std::to_string(image_index + 1); return LoadImageResult::FAILED; } } 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; 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->get_number_of_images(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 && upscaled_ok) return true; std::vector extra_args; const bool is_manganelo = (strcmp(images_page->get_service_name(), "manganelo") == 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.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 size_t file_size = 0; if(download_to_file(url, image_filepath_tmp.data, extra_args, true) != DownloadResult::OK || (is_manganelo && file_get_size(image_filepath_tmp, &file_size) == 0 && file_size < 255)) { 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)); sf::Event event; PageType current_manga_page = current_page; while (current_page == current_manga_page && window.isOpen()) { while(window.pollEvent(event)) { if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { current_page = pop_page_stack(); } } handle_window_close(); 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(back_color); load_sprite.setPosition(window_size.x * 0.5f, window_size.y * 0.5f); load_sprite.setRotation(load_sprite_timer.getElapsedTime().asSeconds() * 400.0); window.draw(load_sprite); window.display(); } } int Program::image_page(MangaImagesPage *images_page, Body *chapters_body) { 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_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; } sf::Texture image_texture; sf::Sprite image; sf::Text error_message("", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(30 * get_ui_scale())); error_message.setFillColor(sf::Color::White); bool download_in_progress = false; sf::Event event; download_chapter_images_if_needed(images_page); if(current_page != PageType::IMAGES || !window.isOpen()) return 0; image_index = std::min(image_index, num_manga_pages); if(image_index < num_manga_pages) { sf::String error_msg; LoadImageResult load_image_result = load_image_by_index(image_index, image_texture, error_msg); if(load_image_result == LoadImageResult::OK) image.setTexture(image_texture, true); else if(load_image_result == LoadImageResult::DOWNLOAD_IN_PROGRESS) download_in_progress = true; error_message.setString(error_msg); } else if(image_index == num_manga_pages) { error_message.setString("End of " + images_page->get_chapter_name()); } // TODO: Dont do this every time we change page? Json::Value &json_chapters = content_storage_json["chapters"]; Json::Value json_chapter; int latest_read = image_index + 1; if(json_chapters.isObject()) { json_chapter = json_chapters[images_page->get_chapter_name()]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) latest_read = std::max(latest_read, current.asInt()); } else { 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_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } bool error = !error_message.getString().isEmpty(); bool redraw = true; sf::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), std::floor(14 * get_ui_scale())); if(image_index == num_manga_pages) chapter_text.setString(images_page->manga_name + " | " + images_page->get_chapter_name() + " | End"); chapter_text.setFillColor(sf::Color::White); sf::RectangleShape chapter_text_background; chapter_text_background.setFillColor(sf::Color(0, 0, 0, 150)); sf::Vector2u texture_size; sf::Vector2f texture_size_f; if(!error) { texture_size = image.getTexture()->getSize(); texture_size_f = sf::Vector2f(texture_size.x, texture_size.y); } sf::Clock check_downloaded_timer; const sf::Int32 check_downloaded_timeout_ms = 500; malloc_trim(0); // 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.isOpen()) { while(window.pollEvent(event)) { if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); redraw = true; } else if(event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::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->items.size() - 1) { page_navigation = -1; goto end_of_images_page; } } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::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 == sf::Keyboard::Escape) { current_page = pop_page_stack(); } else if(event.key.code == sf::Keyboard::I) { current_page = PageType::IMAGES_CONTINUOUS; image_view_mode = ImageViewMode::SCROLL; } else if(event.key.code == sf::Keyboard::F) { fit_image_to_window = !fit_image_to_window; redraw = true; } } } handle_window_close(); if(download_in_progress && check_downloaded_timer.getElapsedTime().asMilliseconds() >= check_downloaded_timeout_ms) { sf::String error_msg; LoadImageResult load_image_result = load_image_by_index(image_index, image_texture, error_msg); if(load_image_result == LoadImageResult::OK) { image.setTexture(image_texture, true); download_in_progress = false; error = false; texture_size = image.getTexture()->getSize(); texture_size_f = sf::Vector2f(texture_size.x, texture_size.y); } else if(load_image_result == LoadImageResult::FAILED) { download_in_progress = false; error = true; } error_message.setString(error_msg); redraw = true; check_downloaded_timer.restart(); } const float font_height = chapter_text.getCharacterSize() + 8.0f; const float bottom_panel_height = font_height + 6.0f; sf::Vector2f content_size; content_size.x = window_size.x; content_size.y = window_size.y - bottom_panel_height; if(redraw) { redraw = false; if(error) { auto bounds = error_message.getLocalBounds(); error_message.setPosition(std::floor(content_size.x * 0.5f - bounds.width * 0.5f), std::floor(content_size.y * 0.5f - bounds.height)); } else { sf::Vector2f 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.setScale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; image.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(content_size.y * 0.5f - image_size.y * 0.5f)); } window.clear(back_color); if(error) { window.draw(error_message); } else { window.draw(image); } chapter_text_background.setSize(sf::Vector2f(window_size.x, bottom_panel_height)); chapter_text_background.setPosition(0.0f, std::floor(window_size.y - bottom_panel_height)); window.draw(chapter_text_background); auto text_bounds = chapter_text.getLocalBounds(); chapter_text.setPosition(std::floor(window_size.x * 0.5f - text_bounds.width * 0.5f), std::floor(window_size.y - bottom_panel_height * 0.5f - font_height * 0.5f)); window.draw(chapter_text); window.display(); } else { std::this_thread::sleep_for(std::chrono::milliseconds(50)); } } end_of_images_page: return page_navigation; } void Program::image_continuous_page(MangaImagesPage *images_page) { image_download_cancel = false; content_cache_dir = get_cache_dir().join(images_page->get_service_name()).join(manga_id_base64).join(base64_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; } download_chapter_images_if_needed(images_page); if(current_page != PageType::IMAGES_CONTINUOUS || !window.isOpen()) return; Json::Value &json_chapters = content_storage_json["chapters"]; Json::Value json_chapter; int latest_read = 1 + image_index; if(json_chapters.isObject()) { json_chapter = json_chapters[images_page->get_chapter_name()]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) latest_read = std::max(latest_read, current.asInt()); } else { json_chapter = Json::Value(Json::objectValue); } } else { json_chapters = Json::Value(Json::objectValue); json_chapter = Json::Value(Json::objectValue); } ImageViewer image_viewer(&window, num_manga_pages, images_page->manga_name, images_page->get_chapter_name(), image_index, content_cache_dir, &fit_image_to_window); json_chapter["current"] = std::min(latest_read, image_viewer.get_num_pages()); json_chapter["total"] = image_viewer.get_num_pages(); json_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } window.setFramerateLimit(monitor_hz); while(current_page == PageType::IMAGES_CONTINUOUS && window.isOpen()) { handle_window_close(); window.clear(back_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; } window.display(); int focused_page = image_viewer.get_focused_page(); image_index = focused_page - 1; if(focused_page > latest_read) { latest_read = focused_page; json_chapter["current"] = latest_read; json_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } } } window_size.x = window.getSize().x; window_size.y = window.getSize().y; window.setFramerateLimit(FPS_IDLE); idle = true; } static bool is_url_video(const std::string &url) { return string_ends_with(url, ".webm") || string_ends_with(url, ".mp4") || string_ends_with(url, ".gif"); } void Program::image_board_thread_page(ImageBoardThreadPage *thread_page, Body *thread_body) { // TODO: Instead of using stage here, use different pages for each stage enum class NavigationStage { VIEWING_COMMENTS, REPLYING, SOLVING_POST_CAPTCHA, POSTING_SOLUTION, POSTING_COMMENT, VIEWING_ATTACHED_IMAGE }; thread_body->title_mark_urls = true; NavigationStage navigation_stage = NavigationStage::VIEWING_COMMENTS; AsyncTask captcha_request_future; AsyncTask captcha_post_solution_future; AsyncTask post_comment_future; AsyncTask load_image_future; bool downloading_image = false; sf::Texture captcha_texture; sf::Sprite captcha_sprite; std::mutex captcha_image_mutex; auto attached_image_texture = std::make_unique(); sf::Sprite attached_image_sprite; GoogleCaptchaChallengeInfo challenge_info; sf::Text challenge_description_text("", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(24 * get_ui_scale())); challenge_description_text.setFillColor(sf::Color::White); const size_t captcha_num_columns = 3; const size_t captcha_num_rows = 3; std::array selected_captcha_images; for(size_t i = 0; i < selected_captcha_images.size(); ++i) { selected_captcha_images[i] = false; } sf::RectangleShape captcha_selection_rect; captcha_selection_rect.setOutlineThickness(5.0f); captcha_selection_rect.setOutlineColor(sf::Color::Red); // TODO: Draw only the outline instead of a transparent rectangle captcha_selection_rect.setFillColor(sf::Color::Transparent); // Valid for 2 minutes after solving a captcha std::string captcha_post_id; sf::Clock captcha_solved_time; std::string comment_to_post; // TODO: Show a white image with "Loading..." text while the captcha image is downloading // TODO: Make google captcha images load texture in the main thread, otherwise high cpu usage. I guess its fine right now because of only 1 image? // TODO: Make this work with other sites than 4chan auto request_google_captcha_image = [&captcha_texture, &captcha_image_mutex, &navigation_stage, &captcha_sprite, &challenge_description_text](GoogleCaptchaChallengeInfo &challenge_info) { std::string payload_image_data; DownloadResult download_image_result = download_to_string(challenge_info.payload_url, payload_image_data, {}); if(download_image_result == DownloadResult::OK) { std::lock_guard lock(captcha_image_mutex); sf::Image captcha_image; if(load_image_from_memory(captcha_image, payload_image_data.data(), payload_image_data.size()) && captcha_texture.loadFromImage(captcha_image)) { captcha_texture.setSmooth(true); captcha_sprite.setTexture(captcha_texture, true); challenge_description_text.setString(challenge_info.description); } else { show_notification("QuickMedia", "Failed to load downloaded captcha image", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } } else { show_notification("QuickMedia", "Failed to download captcha image", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }; auto request_new_google_captcha_challenge = [&selected_captcha_images, &navigation_stage, &captcha_request_future, &request_google_captcha_image, &challenge_info]() { fprintf(stderr, "Solving captcha!\n"); navigation_stage = NavigationStage::SOLVING_POST_CAPTCHA; for(size_t i = 0; i < selected_captcha_images.size(); ++i) { selected_captcha_images[i] = false; } const std::string referer = "https://boards.4chan.org/"; captcha_request_future = google_captcha_request_challenge(fourchan_google_captcha_api_key, referer, [&navigation_stage, &request_google_captcha_image, &challenge_info](std::optional new_challenge_info) { if(navigation_stage != NavigationStage::SOLVING_POST_CAPTCHA) return; if(new_challenge_info) { challenge_info = new_challenge_info.value(); request_google_captcha_image(challenge_info); } else { show_notification("QuickMedia", "Failed to get captcha challenge", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }); }; Entry comment_input("Press i to begin writing a comment...", &rounded_rectangle_shader); comment_input.draw_background = false; comment_input.set_editable(false); auto post_comment = [this, &comment_input, &navigation_stage, &thread_page, &captcha_post_id, &comment_to_post, &request_new_google_captcha_challenge]() { comment_input.set_editable(false); navigation_stage = NavigationStage::POSTING_COMMENT; PostResult post_result = thread_page->post_comment(captcha_post_id, comment_to_post); if(post_result == PostResult::OK) { show_notification("QuickMedia", "Comment posted!"); navigation_stage = NavigationStage::VIEWING_COMMENTS; // 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. } else if(post_result == PostResult::TRY_AGAIN) { show_notification("QuickMedia", "Error while posting, did the captcha expire? Please try again"); // TODO: Check if the response contains a new captcha instead of requesting a new one manually request_new_google_captcha_challenge(); } else if(post_result == PostResult::BANNED) { show_notification("QuickMedia", "Failed to post comment because you are banned", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::ERR) { show_notification("QuickMedia", "Failed to post comment. Is " + std::string(plugin_name) + " down or is your internet down?", 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, &post_comment_future, &navigation_stage, &request_new_google_captcha_challenge, &comment_to_post, &captcha_post_id, &captcha_solved_time, &post_comment, &thread_page](std::string text) -> bool { if(text.empty()) return false; comment_input.set_editable(false); frame_skip_text_entry = true; assert(navigation_stage == NavigationStage::REPLYING); comment_to_post = std::move(text); if(!captcha_post_id.empty() && captcha_solved_time.getElapsedTime().asSeconds() < 120) { post_comment_future = AsyncTask([&post_comment]() -> bool { post_comment(); return true; }); } else if(thread_page->get_pass_id().empty()) { request_new_google_captcha_challenge(); } else if(!thread_page->get_pass_id().empty()) { post_comment_future = AsyncTask([&post_comment]() -> bool { post_comment(); return true; }); } return true; }; sf::RectangleShape comment_input_shade; comment_input_shade.setFillColor(sf::Color(33, 37, 44)); sf::Sprite logo_sprite(plugin_logo); logo_sprite.setScale(0.8f * get_ui_scale(), 0.8f * get_ui_scale()); sf::Vector2f logo_size(plugin_logo.getSize().x * logo_sprite.getScale().x, plugin_logo.getSize().y * logo_sprite.getScale().y); 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_ui_scale()); const float chat_input_padding_x = std::floor(10.0f * get_ui_scale()); const float chat_input_padding_y = std::floor(10.0f * get_ui_scale()); sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; std::stack comment_navigation_stack; std::stack comment_page_scroll_stack; while (current_page == PageType::IMAGE_BOARD_THREAD && window.isOpen()) { while (window.pollEvent(event)) { if(navigation_stage == NavigationStage::REPLYING || navigation_stage == NavigationStage::VIEWING_COMMENTS) { if(thread_body->on_event(window, event)) idle_active_handler(); } event_idle_handler(event); if(!frame_skip_text_entry) comment_input.process_event(event); if(navigation_stage == NavigationStage::REPLYING && !frame_skip_text_entry) { if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { comment_input.set_editable(false); navigation_stage = NavigationStage::VIEWING_COMMENTS; break; } } if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); idle_active_handler(); } if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; else if(navigation_stage == NavigationStage::VIEWING_COMMENTS && event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { thread_body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { thread_body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { thread_body->select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { thread_body->select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { thread_body->select_first_item(); } else if(event.key.code == sf::Keyboard::End) { thread_body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = pop_page_stack(); } else if(event.key.code == sf::Keyboard::P) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->attached_content_url.empty()) { if(is_url_video(selected_item->attached_content_url)) { page_stack.push(PageType::IMAGE_BOARD_THREAD); current_page = PageType::VIDEO_CONTENT; watched_videos.clear(); thread_page->video_url = selected_item->attached_content_url; BodyItems next_items; // TODO: Use real title video_content_page(thread_page, thread_page, "", true, next_items, 0); redraw = true; } else { load_image_future.cancel(); downloading_image = true; navigation_stage = NavigationStage::VIEWING_ATTACHED_IMAGE; load_image_future = AsyncTask([&thread_body]() { std::string image_data; BodyItem *selected_item = thread_body->get_selected(); if(!selected_item || selected_item->attached_content_url.empty()) { return image_data; } if(download_to_string_cache(selected_item->attached_content_url, image_data, {}) != DownloadResult::OK) { show_notification("QuickMedia", "Failed to download image: " + selected_item->attached_content_url, Urgency::CRITICAL); image_data.clear(); } return image_data; }); } } } BodyItem *selected_item = thread_body->get_selected(); if(event.key.code == sf::Keyboard::Enter && selected_item && (comment_navigation_stack.empty() || thread_body->get_selected_item() != comment_navigation_stack.top()) && !selected_item->replies.empty()) { for(auto &body_item : thread_body->items) { body_item->visible = false; } selected_item->visible = true; for(size_t reply_index : selected_item->replies) { thread_body->items[reply_index]->visible = true; } comment_navigation_stack.push(thread_body->get_selected_item()); comment_page_scroll_stack.push(thread_body->get_page_scroll()); thread_body->clamp_selection(); thread_body->set_page_scroll(0.0f); } else if(event.key.code == sf::Keyboard::BackSpace && !comment_navigation_stack.empty()) { size_t previous_selected = comment_navigation_stack.top(); float previous_page_scroll = comment_page_scroll_stack.top(); comment_navigation_stack.pop(); comment_page_scroll_stack.pop(); if(comment_navigation_stack.empty()) { for(auto &body_item : thread_body->items) { body_item->visible = true; } thread_body->set_selected_item(previous_selected); thread_body->clamp_selection(); } else { for(auto &body_item : thread_body->items) { body_item->visible = false; } thread_body->set_selected_item(previous_selected); selected_item = thread_body->items[comment_navigation_stack.top()].get(); selected_item->visible = true; for(size_t reply_index : selected_item->replies) { thread_body->items[reply_index]->visible = true; } thread_body->clamp_selection(); } thread_body->set_page_scroll(previous_page_scroll); } else if(event.key.code == sf::Keyboard::R && selected_item) { std::string text_to_add = ">>" + selected_item->post_number + "\n"; comment_input.append_text(std::move(text_to_add)); comment_input.move_caret_to_end(); } if(event.key.code == sf::Keyboard::I) { frame_skip_text_entry = true; navigation_stage = NavigationStage::REPLYING; comment_input.set_editable(true); comment_input.move_caret_to_end(); } } if(event.type == sf::Event::KeyPressed && navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA && !frame_skip_text_entry) { int num = -1; if(event.key.code >= sf::Keyboard::Num1 && event.key.code <= sf::Keyboard::Num9) { num = event.key.code - sf::Keyboard::Num1; } else if(event.key.code >= sf::Keyboard::Numpad1 && event.key.code <= sf::Keyboard::Numpad9) { num = event.key.code - sf::Keyboard::Numpad1; } constexpr int select_map[9] = { 6, 7, 8, 3, 4, 5, 0, 1, 2 }; if(num != -1) { int index = select_map[num]; selected_captcha_images[index] = !selected_captcha_images[index]; } if(event.key.code == sf::Keyboard::Escape) { navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(event.key.code == sf::Keyboard::Enter) { navigation_stage = NavigationStage::POSTING_SOLUTION; captcha_post_solution_future = google_captcha_post_solution(fourchan_google_captcha_api_key, challenge_info.id, selected_captcha_images, [&navigation_stage, &captcha_post_id, &captcha_solved_time, &selected_captcha_images, &challenge_info, &request_google_captcha_image, &post_comment](std::optional new_captcha_post_id, std::optional new_challenge_info) { if(navigation_stage != NavigationStage::POSTING_SOLUTION) return; if(new_captcha_post_id) { captcha_post_id = new_captcha_post_id.value(); captcha_solved_time.restart(); post_comment(); } else if(new_challenge_info) { show_notification("QuickMedia", "Failed to solve captcha, please try again"); challenge_info = new_challenge_info.value(); navigation_stage = NavigationStage::SOLVING_POST_CAPTCHA; for(size_t i = 0; i < selected_captcha_images.size(); ++i) { selected_captcha_images[i] = false; } request_google_captcha_image(challenge_info); } }); } } if(event.type == sf::Event::KeyPressed && (navigation_stage == NavigationStage::POSTING_SOLUTION || navigation_stage == NavigationStage::POSTING_COMMENT)) { if(event.key.code == sf::Keyboard::Escape) { navigation_stage = NavigationStage::VIEWING_COMMENTS; } } if(event.type == sf::Event::KeyPressed && navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { if(event.key.code == sf::Keyboard::Escape || event.key.code == sf::Keyboard::BackSpace) { navigation_stage = NavigationStage::VIEWING_COMMENTS; attached_image_texture.reset(new sf::Texture()); } } } frame_skip_text_entry = false; update_idle_state(); handle_window_close(); chat_input_height_full = comment_input.get_height() + chat_input_padding_y * 2.0f; 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(sf::Vector2f(std::floor(logo_padding_x + logo_size.x + chat_input_padding_x), chat_input_padding_y)); float body_padding_horizontal = 10.0f; float body_padding_vertical = std::floor(10.0f); float body_width = window_size.x - body_padding_horizontal * 2.0f; /*if(body_width <= 480.0f) { body_width = window_size.x; body_padding_horizontal = 0.0f; }*/ comment_input_shade.setSize(sf::Vector2f(window_size.x, chat_input_height_full)); comment_input_shade.setPosition(0.0f, 0.0f); body_pos = sf::Vector2f(body_padding_horizontal, comment_input_shade.getSize().y + body_padding_vertical); body_size = sf::Vector2f(body_width, window_size.y - comment_input_shade.getSize().y - body_padding_vertical); logo_sprite.setPosition(logo_padding_x, std::floor(comment_input_shade.getSize().y * 0.5f - logo_size.y * 0.5f)); } //comment_input.update(); window.clear(back_color); if(navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA) { std::lock_guard lock(captcha_image_mutex); if(captcha_texture.getNativeHandle() != 0) { const float challenge_description_height = challenge_description_text.getCharacterSize() + 10.0f; sf::Vector2f content_size = window_size; content_size.y -= challenge_description_height; sf::Vector2u captcha_texture_size = captcha_texture.getSize(); sf::Vector2f 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.setScale(image_scale); auto image_size = captcha_texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; captcha_sprite.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(challenge_description_height + content_size.y * 0.5f - image_size.y * 0.5f)); window.draw(captcha_sprite); challenge_description_text.setPosition(captcha_sprite.getPosition() + sf::Vector2f(image_size.x * 0.5f, 0.0f) - sf::Vector2f(challenge_description_text.getLocalBounds().width * 0.5f, challenge_description_height)); window.draw(challenge_description_text); for(size_t column = 0; column < captcha_num_columns; ++column) { for(size_t row = 0; row < captcha_num_rows; ++row) { if(selected_captcha_images[column + captcha_num_columns * row]) { captcha_selection_rect.setPosition(captcha_sprite.getPosition() + sf::Vector2f(image_size.x / captcha_num_columns * column, image_size.y / captcha_num_rows * row)); captcha_selection_rect.setSize(sf::Vector2f(image_size.x / captcha_num_columns, image_size.y / captcha_num_rows)); window.draw(captcha_selection_rect); } } } } } else if(navigation_stage == NavigationStage::POSTING_SOLUTION) { // TODO: Show "Posting..." when posting solution } else if(navigation_stage == NavigationStage::POSTING_COMMENT) { // TODO: Show "Posting..." when posting comment } else if(navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { // TODO: Use image instead of data with string. texture->loadFromMemory creates a temporary image anyways that parses the string. std::string image_data; if(downloading_image && load_image_future.ready()) { downloading_image = false; image_data = load_image_future.get(); sf::Image attached_image; if(load_image_from_memory(attached_image, image_data.data(), image_data.size()) && attached_image_texture->loadFromImage(attached_image)) { attached_image_texture->setSmooth(true); //attached_image_texture->generateMipmap(); attached_image_sprite.setTexture(*attached_image_texture, true); } else { BodyItem *selected_item = thread_body->get_selected(); std::string selected_item_attached_url; if(selected_item) selected_item_attached_url = selected_item->attached_content_url; show_notification("QuickMedia", "Failed to load image downloaded from url: " + selected_item->attached_content_url, Urgency::CRITICAL); } } // TODO: Show a white image with the text "Downloading..." while the image is downloading and loading if(attached_image_texture->getNativeHandle() != 0) { auto content_size = window_size; sf::Vector2u texture_size = attached_image_texture->getSize(); sf::Vector2f 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)); attached_image_sprite.setScale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; attached_image_sprite.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(content_size.y * 0.5f - image_size.y * 0.5f)); window.draw(attached_image_sprite); } else { sf::RectangleShape rect(sf::Vector2f(640.0f, 480.0f)); rect.setFillColor(sf::Color::White); auto content_size = window_size; auto rect_size = clamp_to_size(rect.getSize(), content_size); rect.setSize(rect_size); rect.setPosition(std::floor(content_size.x * 0.5f - rect_size.x * 0.5f), std::floor(content_size.y * 0.5f - rect_size.y * 0.5f)); window.draw(rect); } } else if(navigation_stage == NavigationStage::REPLYING) { window.draw(comment_input_shade); window.draw(logo_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } else if(navigation_stage == NavigationStage::VIEWING_COMMENTS) { window.draw(comment_input_shade); window.draw(logo_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } window.display(); } } void Program::chat_login_page() { assert(strcmp(plugin_name, "matrix") == 0); SearchBar login_input(nullptr, &rounded_rectangle_shader, "Username"); SearchBar password_input(nullptr, &rounded_rectangle_shader, "Password", true); SearchBar homeserver_input(nullptr, &rounded_rectangle_shader, "Homeserver"); sf::Text status_text("", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(18 * get_ui_scale())); const int num_inputs = 3; SearchBar *inputs[num_inputs] = { &login_input, &password_input, &homeserver_input }; int focused_input = 0; auto text_submit_callback = [this, inputs, &status_text](const sf::String&) { for(int i = 0; i < num_inputs; ++i) { if(inputs[i]->get_text().empty()) { status_text.setString("All fields need to be filled in"); return; } } std::string err_msg; // TODO: Make asynchronous if(matrix->login(inputs[0]->get_text(), inputs[1]->get_text(), inputs[2]->get_text(), err_msg) == PluginResult::OK) { current_page = PageType::CHAT; } else { status_text.setString("Failed to login, error: " + err_msg); } return; }; for(int i = 0; i < num_inputs; ++i) { inputs[i]->caret_visible = false; inputs[i]->onTextSubmitCallback = text_submit_callback; } inputs[focused_input]->caret_visible = true; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; auto body = create_body(); while (current_page == PageType::CHAT_LOGIN && window.isOpen()) { while (window.pollEvent(event)) { if(body->on_event(window, event)) idle_active_handler(); else event_idle_handler(event); if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); redraw = true; idle_active_handler(); } else if(event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Tab) { for(int i = 0; i < num_inputs; ++i) { inputs[i]->caret_visible = false; } focused_input = (focused_input + 1) % num_inputs; inputs[focused_input]->caret_visible = true; } inputs[focused_input]->on_event(event); } update_idle_state(); handle_window_close(); if(redraw) { redraw = false; get_body_dimensions(window_size, nullptr, body_pos, body_size); } window.clear(back_color); body->draw(window, body_pos, body_size); float y = 0.0f; for(int i = 0; i < num_inputs; ++i) { inputs[i]->set_vertical_position(y); inputs[i]->update(); inputs[i]->draw(window); y += inputs[i]->getBottomWithoutShadow(); } status_text.setPosition(0.0f, y + 10.0f); window.draw(status_text); window.display(); } } struct ChatTab { std::unique_ptr body; AsyncTask future; }; static const sf::Vector2i CHAT_MESSAGE_THUMBNAIL_MAX_SIZE(600, 337); 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]; if(body_item->userdata && static_cast(body_item->userdata)->event_id == event_id) { if(index_result) *index_result = i; return body_item; } } return nullptr; } // 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 BodyItems &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 = std::make_shared(""); *body_item->embedded_item = *related_body_item; body_item->embedded_item->embedded_item = nullptr; body_item->embedded_item->reactions.clear(); if((related_body_item->userdata && static_cast(related_body_item->userdata)->user.get() == me) || message_contains_user_mention(body_item->get_description(), my_display_name) || message_contains_user_mention(body_item->get_description(), my_user_id)) body_item->set_description_color(sf::Color(255, 100, 100)); else body_item->set_description_color(sf::Color::White); 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 BodyItems &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 std::shared_ptr message_to_body_item(RoomData *room, Message *message, const std::string &my_display_name, const std::string &my_user_id) { 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(strip(message_get_body_remove_formatting(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); 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(message_contains_user_mention(body_item->get_description(), my_display_name) || message_contains_user_mention(body_item->get_description(), my_user_id)) body_item->set_description_color(sf::Color(255, 100, 100)); return body_item; } static BodyItems messages_to_body_items(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(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 BodyItems &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) { 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); 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, std::vector &room_tabs, int room_selected_tab) { assert(current_room); assert(strcmp(plugin_name, "matrix") == 0); if(!current_room) { show_notification("QuickMedia", "Bug: current room empty", Urgency::CRITICAL); abort(); } window.setTitle("QuickMedia - matrix - " + current_room->get_name()); auto video_page = std::make_unique(this); bool move_room = false; std::vector tabs; ChatTab pinned_tab; pinned_tab.body = create_body(); pinned_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; pinned_tab.body->attach_side = AttachSide::BOTTOM; pinned_tab.body->line_separator_color = sf::Color::Transparent; tabs.push_back(std::move(pinned_tab)); ChatTab messages_tab; messages_tab.body = create_body(); messages_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; messages_tab.body->attach_side = AttachSide::BOTTOM; messages_tab.body->line_separator_color = sf::Color::Transparent; tabs.push_back(std::move(messages_tab)); // ChatTab users_tab; // users_tab.body = create_body(); // users_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; // users_tab.body->attach_side = AttachSide::TOP; // //users_tab.body->line_separator_color = sf::Color::Transparent; // users_tab.text = sf::Text("Users", *FontLoader::get_font(FontLoader::FontType::LATIN), tab_text_size); // tabs.push_back(std::move(users_tab)); Tabs ui_tabs(&rounded_rectangle_shader, back_color); const int PINNED_TAB_INDEX = ui_tabs.add_tab("Pinned messages (0)"); const int MESSAGES_TAB_INDEX = ui_tabs.add_tab("Messages"); 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; bool redraw = true; sf::Clock read_marker_timer; const sf::Int32 read_marker_timeout_ms_default = 3000; sf::Int32 read_marker_timeout_ms = 0; AsyncTask set_read_marker_future; bool setting_read_marker = false; sf::Clock start_typing_timer; const double typing_timeout_seconds = 3.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 selected_tab) { tabs[selected_tab].body->clear_cache(); if(selected_tab == MESSAGES_TAB_INDEX) matrix_chat_page->messages_tab_visible = true; 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.hasFocus(); 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; sf::Text replying_to_text("Replying to:", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(18 * get_ui_scale())); sf::Sprite logo_sprite(plugin_logo); logo_sprite.setScale(0.8f * get_ui_scale(), 0.8f * get_ui_scale()); sf::Vector2f logo_size(plugin_logo.getSize().x * logo_sprite.getScale().x, plugin_logo.getSize().y * logo_sprite.getScale().y); const float room_name_text_height = std::floor(18.0f * get_ui_scale()); sf::Text room_name_text("", *FontLoader::get_font(FontLoader::FontType::LATIN_BOLD), room_name_text_height); const float room_name_text_padding_y = std::floor(10.0f * get_ui_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(12.0f * get_ui_scale()); sf::Text room_topic_text("", *FontLoader::get_font(FontLoader::FontType::LATIN), room_topic_text_height); room_topic_text.setFillColor(sf::Color(179, 179, 179)); sf::Text room_label(room_tabs[room_selected_tab].page->get_title(), *FontLoader::get_font(FontLoader::FontType::LATIN_BOLD), std::floor(18 * get_ui_scale())); room_label.setPosition(15.0f, room_name_text_padding_y + 4.0f); sf::Sprite room_avatar_sprite; auto room_avatar_thumbnail_data = std::make_shared(); 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) { body_item->thumbnail_url = current_room->get_user_avatar_url(original_message->user); body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; } body_item->set_description("Message deleted"); body_item->set_description_color(sf::Color::White); 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 { Message *orig_message = static_cast(body_item->userdata); body_item->set_description(message_get_body_remove_formatting(message.get())); if(message_contains_user_mention(body_item->get_description(), my_display_name) || message_contains_user_mention(body_item->get_description(), me->user_id) || (orig_message && orig_message->user == me && message->user != me)) body_item->set_description_color(sf::Color(255, 100, 100)); else body_item->set_description_color(sf::Color::White); orig_message->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->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 { Message *orig_message = static_cast(body_item->userdata); body_item->set_description(message_get_body_remove_formatting(message.get())); if(message_contains_user_mention(body_item->get_description(), my_display_name) || message_contains_user_mention(body_item->get_description(), me->user_id) || (orig_message && orig_message->user == me && message->user != me)) body_item->set_description_color(sf::Color(255, 100, 100)); else body_item->set_description_color(sf::Color::White); orig_message->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->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) { for(auto &body_item : tabs[PINNED_TAB_INDEX].body->items) { if(static_cast(body_item->userdata)->event_id == event_id) return true; } return false; }; 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->items.empty(); int selected_before = tabs[PINNED_TAB_INDEX].body->get_selected_item(); auto prev_pinned_body_items = tabs[PINNED_TAB_INDEX].body->items; 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->items.push_back(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) 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->items.size()) + ")"); }; Body url_selection_body(this, loading_icon, &rounded_rectangle_shader); std::unordered_set fetched_messages_set; auto filter_existing_messages = [&fetched_messages_set](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { 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(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->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(); room_name_text.setString(sf::String::fromUtf8(room_name.begin(), room_name.end())); room_topic_text.setString(sf::String::fromUtf8(room_topic.begin(), room_topic.end())); room_avatar_thumbnail_data = std::make_shared(); read_marker_timeout_ms = 0; redraw = true; Entry chat_input("Press i to begin writing a message...", &rounded_rectangle_shader); chat_input.draw_background = false; chat_input.set_editable(false); struct ProvisionalMessage { std::shared_ptr body_item; std::shared_ptr message; std::string event_id; }; std::unordered_map> pending_sent_replies; // 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 message_set_replaced_by = [&tabs, &pending_sent_replies, MESSAGES_TAB_INDEX](std::shared_ptr message) { if(message->related_event_type == RelatedEventType::EDIT) { auto body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size(), message->related_event_id); if(body_item) { Message *orig_message = static_cast(body_item->userdata); if(!orig_message) { show_notification("QuickMedia", "Unexpected error, failed to set replaced by message", Urgency::CRITICAL); return; } orig_message->replaced_by = message; } } else if(message->related_event_type == RelatedEventType::REPLY) { auto pending_sent_reply_it = pending_sent_replies.find(message->transaction_id); if(pending_sent_reply_it != pending_sent_replies.end()) { pending_sent_reply_it->second->replaced_by = message; pending_sent_replies.erase(pending_sent_reply_it); } } }; auto filter_sent_messages = [&sent_messages, &message_set_replaced_by](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { if(sent_messages.find((*it)->event_id) != sent_messages.end()) { message_set_replaced_by(*it); it = messages.erase(it); } else { ++it; } } }; auto upload_file = [this, &tabs, ¤t_room, MESSAGES_TAB_INDEX](const std::string &filepath) { TaskResult post_file_result = run_task_with_loading_screen([this, ¤t_room, filepath]() { std::string event_id_response; std::string err_msg; if(matrix->post_file(current_room, filepath, event_id_response, err_msg) == PluginResult::OK) { return true; } else { show_notification("QuickMedia", "Failed to upload media to room, error: " + err_msg, Urgency::CRITICAL); return false; } }); if(post_file_result == TaskResult::TRUE) { if(tabs[MESSAGES_TAB_INDEX].body->is_last_item_fully_visible()) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } }; bool frame_skip_text_entry = false; chat_input.on_submit_callback = [this, &frame_skip_text_entry, &tabs, &me, &chat_input, &ui_tabs, MESSAGES_TAB_INDEX, ¤t_room, &new_page, &chat_state, &pending_sent_replies, ¤tly_operating_on_item, &post_task_queue, &process_reactions](std::string text) mutable { if(!current_room) return false; frame_skip_text_entry = true; const int selected_tab = ui_tabs.get_selected(); 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(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(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 { show_notification("QuickMedia", "Error: invalid command: " + text + ", expected /upload, /logout, /me or /react", 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); } } auto message = std::make_shared(); message->user = matrix->get_me(current_room); if(msgtype == "m.emote") message->body = "*" + current_room->get_user_display_name(me) + "* " + text; else message->body = text; message->type = MessageType::TEXT; message->timestamp = time(NULL) * 1000; const sf::Color provisional_message_color(255, 255, 255, 150); int num_items = tabs[MESSAGES_TAB_INDEX].body->items.size(); 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(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(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->items); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({body_item}); Messages messages; messages.push_back(message); process_reactions(messages); post_task_queue.push([this, ¤t_room, text, body_item, message, related_to_message]() { 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) != PluginResult::OK) fprintf(stderr, "Failed to post matrix reaction\n"); return provisional_message; }); } else { auto body_item = message_to_body_item(current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); body_item->set_description_color(provisional_message_color); load_cached_related_embedded_item(body_item.get(), message.get(), me, current_room, tabs[MESSAGES_TAB_INDEX].body->items); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({body_item}); post_task_queue.push([this, ¤t_room, text, msgtype, body_item, message]() { 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) != 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) { std::string transaction_id = create_transaction_id(); pending_sent_replies[transaction_id] = message; 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(current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); body_item->set_description_color(provisional_message_color); load_cached_related_embedded_item(body_item.get(), message.get(), me, current_room, tabs[MESSAGES_TAB_INDEX].body->items); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({body_item}); 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) { void *related_to_message = currently_operating_on_item->userdata; message->related_event_type = RelatedEventType::EDIT; message->related_event_id = static_cast(related_to_message)->event_id; size_t body_item_index = 0; auto body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size(), message->related_event_id, &body_item_index); if(body_item) { auto body_item_shared_ptr = tabs[MESSAGES_TAB_INDEX].body->items[body_item_index]; body_item_shared_ptr->set_description(text); body_item_shared_ptr->set_description_color(provisional_message_color); auto edit_body_item = message_to_body_item(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->items); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({edit_body_item}); //unreferenced_events.push_back(message); post_task_queue.push([this, ¤t_room, text, related_to_message, message, body_item_shared_ptr]() { 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) != 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; }; AsyncTask previous_messages_future; 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; BodyItem *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](BodyItem *body_item) { if(fetch_message_future.valid()) return; PinnedEventData *event_data = static_cast(body_item->userdata); if(!event_data) return; #if 0 if(event_data->message->user->resolve_state == UserResolveState::NOT_RESOLVED) { fetch_message = event_data->message; event_data->message->user->resolve_state = UserResolveState::RESOLVING; std::string user_id = event_data->message->user->user_id; fetch_message_future = [this, ¤t_room, user_id]() { matrix->update_user_with_latest_state(current_room, user_id); return FetchMessageResult{FetchMessageType::USER_UPDATE, nullptr}; }; return; } else if(event_data->message->user->resolve_state == UserResolveState::RESOLVING) { return; } #endif // Fetch replied to message if(event_data->status == FetchStatus::FINISHED_LOADING && event_data->message) { if(event_data->message->related_event_id.empty() || (body_item->embedded_item_status != FetchStatus::NONE && body_item->embedded_item_status != FetchStatus::QUEUED_LOADING)) return; if(load_cached_related_embedded_item(body_item, event_data->message, me, current_room, tabs[MESSAGES_TAB_INDEX].body->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->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size(), event_data->event_id); if(related_body_item) { *body_item = *related_body_item; body_item->reactions.clear(); if(message_contains_user_mention(related_body_item->get_description(), current_room->get_user_display_name(me)) || message_contains_user_mention(related_body_item->get_description(), me->user_id)) body_item->set_description_color(sf::Color(255, 100, 100)); else body_item->set_description_color(sf::Color::White); 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)}; }); }; Message *last_visible_timeline_message = nullptr; // 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, &last_visible_timeline_message, &fetch_message_future, &tabs, &fetch_body_item, &fetch_message_tab, MESSAGES_TAB_INDEX](BodyItem *body_item) { Message *message = static_cast(body_item->userdata); if(!message) return; #if 0 if(message->user->resolve_state == UserResolveState::NOT_RESOLVED) { fetch_message = message; message->user->resolve_state = UserResolveState::RESOLVING; std::string user_id = message->user->user_id; fetch_message_future = AsyncTask([this, ¤t_room, user_id]() { matrix->update_user_with_latest_state(current_room, user_id); return FetchMessageResult{FetchMessageType::USER_UPDATE, nullptr}; }); return; } else if(message->user->resolve_state == UserResolveState::RESOLVING) { return; } #endif if(message_is_timeline(message) && (!last_visible_timeline_message || message->timestamp > last_visible_timeline_message->timestamp)) last_visible_timeline_message = message; if(message->related_event_id.empty() || (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, message, me, current_room, tabs[MESSAGES_TAB_INDEX].body->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_visual_media_message_type(prev_message->type) && !prev_item->thumbnail_url.empty()) return false; if(message->user == prev_message->user) return true; return false; }; sf::Vector2f body_pos; sf::Vector2f body_size; sf::Event event; const float gradient_height = 5.0f; sf::Vertex gradient_points[4]; double gradient_inc = 0; //tabs[MESSAGES_TAB_INDEX].body->set_page_scroll(window_size.y); bool fetched_enough_messages = false; auto fetch_more_previous_messages_if_needed = [this, &tabs, ¤t_room, &fetched_enough_messages, &previous_messages_future, MESSAGES_TAB_INDEX]() { if(!fetched_enough_messages && !previous_messages_future.valid()) { if(tabs[MESSAGES_TAB_INDEX].body->items.size() < 30) { previous_messages_future = AsyncTask([this, ¤t_room]() { Messages messages; if(matrix->get_previous_room_messages(current_room, messages) != PluginResult::OK) fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", current_room->id.c_str()); return messages; }); } } }; if(!matrix->is_initial_sync_finished()) { previous_messages_future = AsyncTask([this, ¤t_room]() { Messages messages; if(matrix->get_previous_room_messages(current_room, messages, true) != PluginResult::OK) fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", current_room->id.c_str()); return messages; }); } sf::RectangleShape more_messages_below_rect; more_messages_below_rect.setFillColor(sf::Color(128, 50, 50)); sf::RectangleShape chat_input_shade; chat_input_shade.setFillColor(sf::Color(33, 37, 44)); float tab_vertical_offset = 0.0f; sf::Clock frame_timer; float prev_chat_height = chat_input.get_height(); float chat_input_height_full = 0.0f; const float logo_padding_x = std::floor(15.0f * get_ui_scale()); const float chat_input_padding_x = std::floor(10.0f * get_ui_scale()); const float chat_input_padding_y = std::floor(10.0f * get_ui_scale()); auto launch_url = [this, matrix_chat_page, &video_page, &redraw](const std::string &url) mutable { if(url.empty()) return; std::string video_id; if(youtube_url_extract_id(url, video_id)) { page_stack.push(PageType::CHAT); watched_videos.clear(); current_page = PageType::VIDEO_CONTENT; video_page->url = url; BodyItems next_items; // TODO: Add title video_content_page(matrix_chat_page, video_page.get(), "", false, next_items, 0); redraw = true; } 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 = [&message_set_replaced_by](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { if(!(*it)->transaction_id.empty()) { message_set_replaced_by(*it); it = messages.erase(it); } else { ++it; } } }; auto add_new_messages_to_current_room = [&me, &tabs, &ui_tabs, ¤t_room, MESSAGES_TAB_INDEX](Messages &messages) { if(messages.empty()) return; const int selected_tab = ui_tabs.get_selected(); int num_items = tabs[MESSAGES_TAB_INDEX].body->items.size(); 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; } BodyItem *selected_item = tabs[MESSAGES_TAB_INDEX].body->get_selected(); auto new_body_items = messages_to_body_items(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->items, me, current_room); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(std::move(new_body_items)); if(selected_item && !scroll_to_end) { int selected_item_index = tabs[MESSAGES_TAB_INDEX].body->get_index_by_body_item(selected_item); if(selected_item_index != -1) tabs[MESSAGES_TAB_INDEX].body->set_selected_item(selected_item_index); } else if(scroll_to_end) { tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } }; auto display_url_or_image = [this, matrix_chat_page, &ui_tabs, &redraw, &video_page, &launch_url, &chat_state, &url_selection_body, 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()) { 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; video_page->url = selected->url; BodyItems next_items; // TODO: Add title video_content_page(matrix_chat_page, video_page.get(), "", message_type == MessageType::VIDEO || message_type == MessageType::AUDIO, next_items, 0); no_video = prev_no_video; redraw = true; return true; } launch_url(selected->url); return true; } } // 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) { launch_url(urls[0]); return true; } else if(urls.size() > 1) { chat_state = ChatState::URL_SELECTION; url_selection_body.clear_items(); for(const std::string &url : urls) { auto body_item = BodyItem::create(url); url_selection_body.items.push_back(std::move(body_item)); } 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()); for(auto &pinned_body_item : tabs[PINNED_TAB_INDEX].body->items) { 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) continue; 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()); for(auto &message_body_items : tabs[MESSAGES_TAB_INDEX].body->items) { Message *message = static_cast(message_body_items->userdata); if(!message || message->user != user) continue; 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()); for(auto &pinned_body_item : tabs[PINNED_TAB_INDEX].body->items) { 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) continue; 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()); for(auto &message_body_items : tabs[MESSAGES_TAB_INDEX].body->items) { Message *message = static_cast(message_body_items->userdata); if(!message) continue; user_update_display_info(message_body_items.get(), current_room, message); } }; auto cleanup_tasks = [&set_read_marker_future, &fetch_message_future, &fetch_users_future, &typing_state_queue, &typing_state_thread, &post_task_queue, &provisional_message_queue, &fetched_messages_set, &sent_messages, &pending_sent_replies, &post_thread, &tabs, PINNED_TAB_INDEX]() { 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_replies.clear(); //unreferenced_event_by_room.clear(); if(!tabs.empty()) { for(auto &body_item : tabs[PINNED_TAB_INDEX].body->items) { delete (PinnedEventData*)body_item->userdata; } tabs[PINNED_TAB_INDEX].body->clear_items(); } //tabs.clear(); }; // 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; room_tabs[room_selected_tab].body->body_item_select_callback = [&move_room](BodyItem *body_item) { move_room = true; }; while (current_page == PageType::CHAT && window.isOpen() && !move_room) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(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)) idle_active_handler(); } else { if(tabs[selected_tab].body->on_event(window, event)) 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) chat_input.process_event(event); if(draw_room_list) { if(room_tabs[room_selected_tab].body->on_event(window, event)) idle_active_handler(); } if(event.type == sf::Event::KeyPressed && event.key.control && event.key.alt && (chat_state == ChatState::NAVIGATING || chat_state == ChatState::URL_SELECTION)) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { room_tabs[room_selected_tab].body->select_previous_item(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { room_tabs[room_selected_tab].body->select_next_item(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::PageUp) { room_tabs[room_selected_tab].body->select_previous_page(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::PageDown) { room_tabs[room_selected_tab].body->select_next_page(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::Home) { room_tabs[room_selected_tab].body->select_first_item(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::End) { room_tabs[room_selected_tab].body->select_last_item(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::Escape) { move_room = false; goto chat_page_end; } continue; } if(event.type == sf::Event::GainedFocus) { is_window_focused = true; redraw = true; } else if(event.type == sf::Event::LostFocus) { is_window_focused = false; } else if(event.type == sf::Event::Resized) { redraw = true; idle_active_handler(); } else if(event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::NAVIGATING) { if(event.key.code == sf::Keyboard::Up || event.key.code == sf::Keyboard::PageUp || event.key.code == sf::Keyboard::Home || (event.key.control && event.key.code == sf::Keyboard::K)) { bool hit_top = false; switch(event.key.code) { case sf::Keyboard::Up: hit_top = !tabs[selected_tab].body.get()->select_previous_item(); break; case sf::Keyboard::K: hit_top = !tabs[selected_tab].body.get()->select_previous_item(); break; case sf::Keyboard::PageUp: hit_top = !tabs[selected_tab].body.get()->select_previous_page(); break; case sf::Keyboard::Home: tabs[selected_tab].body.get()->select_first_item(); hit_top = true; break; default: hit_top = false; break; } if(hit_top && !previous_messages_future.valid() && selected_tab == MESSAGES_TAB_INDEX && current_room) { gradient_inc = 0; previous_messages_future = AsyncTask([this, ¤t_room]() { Messages messages; if(matrix->get_previous_room_messages(current_room, messages) != PluginResult::OK) fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", current_room->id.c_str()); return messages; }); } } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { tabs[selected_tab].body.get()->select_next_item(); } else if(event.key.code == sf::Keyboard::PageDown) { tabs[selected_tab].body.get()->select_next_page(); } else if(event.key.code == sf::Keyboard::End) { tabs[selected_tab].body.get()->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { goto chat_page_end; } if((selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) && event.key.code == sf::Keyboard::Enter && !frame_skip_text_entry) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!display_url_or_image(selected)) display_url_or_image(selected->embedded_item.get()); } } if(current_room) { if(event.key.control && event.key.code == sf::Keyboard::C) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { auto desc = selected->get_description(); sf::Clipboard::setString(sf::String::fromUtf8(desc.begin(), desc.end())); } } } if(selected_tab == MESSAGES_TAB_INDEX && current_room) { if(event.key.code == sf::Keyboard::U) { frame_skip_text_entry = true; new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); } if(event.key.code == sf::Keyboard::I) { frame_skip_text_entry = true; chat_input.set_editable(true); chat_state = ChatState::TYPING_MESSAGE; } if(event.key.control && event.key.code == sf::Keyboard::V) { frame_skip_text_entry = true; // TODO: Upload multiple files. auto clipboard = sf::Clipboard::getString().toUtf8(); upload_file(std::string(clipboard.begin(), clipboard.end())); } if(event.key.code == sf::Keyboard::R) { 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 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.setString("Replying to:"); } } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for replying"); } } if(event.key.code == sf::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 { chat_state = ChatState::EDITING; currently_operating_on_item = selected; chat_input.set_editable(true); chat_input.set_text(selected->get_description()); // TODO: Description? it may change in the future, in which case this should be edited chat_input.move_caret_to_end(); replying_to_text.setString("Editing message:"); } } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for editing"); } } if(event.key.control && event.key.code == sf::Keyboard::D) { 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"); } } } } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::URL_SELECTION) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { url_selection_body.select_previous_item(); } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { url_selection_body.select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { url_selection_body.select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { url_selection_body.select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { url_selection_body.select_first_item(); } else if(event.key.code == sf::Keyboard::End) { url_selection_body.select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { url_selection_body.clear_items(); chat_state = ChatState::NAVIGATING; } else if(event.key.code == sf::Keyboard::Enter) { BodyItem *selected_item = url_selection_body.get_selected(); if(!selected_item) continue; launch_url(selected_item->get_title()); } } 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 == sf::Event::TextEntered) { // TODO: Also show typing event when ctrl+v pasting? if(event.text.unicode != 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 == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { 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_window_close(); matrix_chat_page->update(); switch(new_page) { case PageType::FILE_MANAGER: { new_page = PageType::CHAT; if(current_room) { for(ChatTab &tab : tabs) { tab.body->clear_cache(); } auto file_manager_page = std::make_unique(this); file_manager_page->set_current_directory(get_home_dir().data); auto file_manager_body = create_body(); file_manager_page->get_files_in_directory(file_manager_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; } break; } case PageType::CHAT_LOGIN: { previous_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(); // TODO: Instead of doing this, exit this current function and navigate to chat login page instead. //delete current_plugin; //current_plugin = new Matrix(); window.setTitle("QuickMedia - matrix"); current_page = PageType::CHAT_LOGIN; chat_login_page(); if(current_page == PageType::CHAT) after_matrix_login_page(); exit(exit_code); break; } default: break; } if(typing && start_typing_timer.getElapsedTime().asSeconds() >= typing_timeout_seconds && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } if(current_room && current_room->body_item && room_avatar_thumbnail_data->loading_state == LoadingState::NOT_LOADED) AsyncImageLoader::get_instance().load_thumbnail(current_room->body_item->thumbnail_url, false, AVATAR_THUMBNAIL_SIZE, room_avatar_thumbnail_data); if(room_avatar_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING && room_avatar_thumbnail_data->image->getSize().x > 0 && room_avatar_thumbnail_data->image->getSize().y > 0) { if(!room_avatar_thumbnail_data->texture.loadFromImage(*room_avatar_thumbnail_data->image)) fprintf(stderr, "Warning: failed to load texture for room avatar\n"); room_avatar_thumbnail_data->texture.setSmooth(true); //room_avatar_thumbnail_data->texture.generateMipmap(); room_avatar_thumbnail_data->image.reset(); room_avatar_thumbnail_data->loading_state = LoadingState::APPLIED_TO_TEXTURE; room_avatar_sprite.setTexture(room_avatar_thumbnail_data->texture, true); auto texture_size = room_avatar_sprite.getTexture()->getSize(); 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.setScale(width_scale * get_ui_scale(), height_scale * get_ui_scale()); } } 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) 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 || selected_tab == PINNED_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) { tab_vertical_offset = std::floor(10.0f * get_ui_scale()); } tab_shade_height = std::floor(tab_vertical_offset) + Tabs::get_height() + room_name_padding_y; float body_padding_horizontal = 10.0f; float body_padding_vertical = std::floor(10.0f); float body_width = window_size.x - body_padding_horizontal * 2.0f; /*if(body_width <= 480.0f) { body_width = window_size.x; body_padding_horizontal = 0.0f; }*/ this->body_pos = sf::Vector2f(0.0f, tab_shade_height); if(window_size.x > 900.0f && show_room_side_panel) { this->body_size = sf::Vector2f(std::floor(300.0f * get_ui_scale()), window_size.y - tab_shade_height); draw_room_list = true; } else { this->body_size = sf::Vector2f(0.0f, 0.0f); draw_room_list = false; } body_pos = sf::Vector2f(this->body_pos.x + this->body_size.x + body_padding_horizontal, body_padding_vertical + tab_shade_height); body_size = sf::Vector2f(body_width - this->body_pos.x - this->body_size.x, window_size.y - chat_input_height_full - body_padding_vertical - tab_shade_height); chat_input_shade.setSize(sf::Vector2f(window_size.x - (body_pos.x - body_padding_horizontal), chat_input_height_full)); chat_input_shade.setPosition(body_pos.x - body_padding_horizontal, window_size.y - chat_input_shade.getSize().y); chat_input.set_max_width(window_size.x - (logo_padding_x + logo_size.x + chat_input_padding_x + logo_padding_x + body_pos.x - body_padding_horizontal)); chat_input.set_position(sf::Vector2f(std::floor(body_pos.x - body_padding_horizontal + logo_padding_x + logo_size.x + chat_input_padding_x), window_size.y - chat_height - chat_input_padding_y)); more_messages_below_rect.setSize(sf::Vector2f(chat_input_shade.getSize().x, gradient_height)); more_messages_below_rect.setPosition(chat_input_shade.getPosition().x, std::floor(window_size.y - chat_input_height_full - gradient_height)); logo_sprite.setPosition(body_pos.x - body_padding_horizontal + logo_padding_x, std::floor(window_size.y - chat_input_height_full * 0.5f - logo_size.y * 0.5f)); } 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(sf::Color::White); 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(sf::Color::Red); provisional_message->body_item->userdata = nullptr; } } sync_data.messages.clear(); sync_data.pinned_events = std::nullopt; matrix->get_room_sync_data(current_room, sync_data); if(!sync_data.messages.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); add_new_messages_to_current_room(sync_data.messages); modify_related_messages_in_current_room(sync_data.messages); process_reactions(sync_data.messages); 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(previous_messages_future.ready()) { Messages new_messages = previous_messages_future.get(); all_messages.insert(all_messages.end(), new_messages.begin(), new_messages.end()); if(new_messages.empty()) fetched_enough_messages = true; filter_sent_messages(new_messages); filter_existing_messages(new_messages); fprintf(stderr, "Finished fetching older messages, num new messages: %zu\n", new_messages.size()); size_t num_new_messages = new_messages.size(); if(num_new_messages > 0) { add_new_messages_to_current_room(new_messages); modify_related_messages_in_current_room(new_messages); process_reactions(new_messages); // TODO: Do not loop all items, only loop the new items resolve_unreferenced_events_with_body_items(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->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) { 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(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(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; } else { fetch_body_item->embedded_item_status = FetchStatus::FAILED_TO_LOAD; } } } fetch_message_tab = -1; } window.clear(back_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); //tab_shade.setSize(sf::Vector2f(window_size.x, tab_shade_height)); //window.draw(tab_shade); if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) { float room_name_text_offset_x = 0.0f; if(room_avatar_sprite.getTexture() && room_avatar_sprite.getTexture()->getNativeHandle() != 0) { auto room_avatar_texture_size = room_avatar_sprite.getTexture()->getSize(); room_avatar_texture_size.x *= room_avatar_sprite.getScale().x; room_avatar_texture_size.y *= room_avatar_sprite.getScale().y; room_avatar_sprite.setPosition(body_pos.x, room_name_total_height * 0.5f - room_avatar_texture_size.y * 0.5f + 5.0f); circle_mask_shader.setUniform("resolution", sf::Vector2f(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.setPosition(body_pos.x + room_name_text_offset_x, room_name_text_padding_y); window.draw(room_name_text); room_topic_text.setPosition(room_name_text.getPosition().x + room_name_text.getLocalBounds().width + 15.0f, room_name_text_padding_y + 2.0f + room_name_text_height * 0.5f - room_topic_text_height * 0.5f); window.draw(room_topic_text); } if(draw_room_list) { sf::RectangleShape room_list_background(sf::Vector2f(this->body_size.x, window_size.y)); //room_list_background.setPosition(this->body_pos); room_list_background.setFillColor(sf::Color(33, 37, 44)); glEnable(GL_SCISSOR_TEST); glScissor(0.0f, 0.0f, this->body_size.x, window_size.y); window.draw(room_list_background); window.draw(room_label); const float padding_x = std::floor(10.0f * get_ui_scale()); const float tab_y = std::floor(tab_vertical_offset) + room_name_padding_y; room_tabs[room_selected_tab].body->draw(window, sf::Vector2f(padding_x, tab_y), sf::Vector2f(this->body_size.x - padding_x * 2.0f, window_size.y - tab_y), Json::Value::nullSingleton()); glDisable(GL_SCISSOR_TEST); } ui_tabs.draw(window, sf::Vector2f(body_pos.x, std::floor(tab_vertical_offset) + room_name_padding_y), body_size.x); // TODO: Have one for each room. Also add bottom one? for fetching new messages (currently not implemented, is it needed?) if(previous_messages_future.valid() && selected_tab == MESSAGES_TAB_INDEX) { 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); sf::Color top_color = interpolate_colors(back_color, sf::Color(175, 180, 188), progress); gradient_points[0].position.x = chat_input_shade.getPosition().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.getPosition().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 = back_color; gradient_points[3].color = back_color; window.draw(gradient_points, 4, sf::Quads); // Note: sf::Quads doesn't work with egl } if(chat_state == ChatState::REPLYING || chat_state == ChatState::EDITING) { const float margin = 5.0f; const float replying_to_text_height = replying_to_text.getLocalBounds().height + 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; sf::RectangleShape overlay(sf::Vector2f(window_size.x, window_size.y - chat_input_height_full)); overlay.setFillColor(sf::Color(0, 0, 0, 240)); window.draw(overlay); sf::Vector2f body_item_pos(body_pos.x, window_size.y - chat_input_height_full - item_height); sf::Vector2f body_item_size(body_size.x, item_height); sf::RectangleShape item_background(sf::Vector2f(window_size.x, body_item_size.y + chat_input_height_full + replying_to_text_height + margin)); item_background.setPosition(sf::Vector2f(0.0f, window_size.y - (body_item_size.y + chat_input_height_full + replying_to_text_height + margin))); item_background.setFillColor(back_color); window.draw(item_background); replying_to_text.setPosition(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.get(), body_item_pos, body_item_size); } if(selected_tab == MESSAGES_TAB_INDEX && current_room && current_room->body_item && !current_room->last_message_read && matrix->is_initial_sync_finished()) { if(last_visible_timeline_message && tabs[selected_tab].body->is_last_item_fully_visible() && is_window_focused && chat_state != ChatState::URL_SELECTION && !setting_read_marker && read_marker_timer.getElapsedTime().asMilliseconds() >= read_marker_timeout_ms) { std::string room_desc = current_room->body_item->get_description(); if(strncmp(room_desc.c_str(), "Unread: ", 8) == 0) room_desc = room_desc.substr(8); size_t last_line_start = room_desc.rfind('\n'); if(last_line_start != std::string::npos && last_line_start != room_desc.size()) { ++last_line_start; size_t last_line_size = room_desc.size() - last_line_start; if(last_line_size >= 23 && memcmp(&room_desc[last_line_start], "** ", 3) == 0 && memcmp(&room_desc[room_desc.size() - 20], "unread mention(s) **", 20) == 0) room_desc.erase(room_desc.begin() + last_line_start - 1, room_desc.end()); } current_room->body_item->set_description(std::move(room_desc)); current_room->body_item->set_description_color(sf::Color(179, 179, 179)); // TODO: Show a line like nheko instead for unread messages, or something else current_room->body_item->set_title_color(sf::Color::White); current_room->last_message_read = true; // TODO: Maybe set this instead when the mention is visible on the screen? current_room->unread_notification_count = 0; Message *read_message = last_visible_timeline_message; if(read_message->replaced_by) read_message = read_message->replaced_by.get(); // TODO: What if two messages have the same timestamp? if(!read_message->event_id.empty() && read_message->timestamp > current_room->last_read_message_timestamp) { //read_marker_timeout_ms = read_marker_timeout_ms_default; 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_last_item_fully_visible()) { window.draw(more_messages_below_rect); } } if(selected_tab == MESSAGES_TAB_INDEX && current_room) { //window.draw(chat_input_shade); chat_input.draw(window); //chat_input.draw(window, false); window.draw(logo_sprite); } if(matrix && !matrix->is_initial_sync_finished()) { 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(); current_page = PageType::CHAT_LOGIN; chat_login_page(); after_matrix_login_page(); window.close(); goto chat_page_end; } } window.display(); if(selected_tab == MESSAGES_TAB_INDEX) fetch_more_previous_messages_if_needed(); if(matrix_chat_page->should_clear_data) { matrix_chat_page->should_clear_data = false; std::string err_msg; while(!matrix->is_initial_sync_finished()) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); if(matrix->did_initial_sync_fail(err_msg)) { show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); matrix->logout(); current_page = PageType::CHAT_LOGIN; chat_login_page(); after_matrix_login_page(); window.close(); goto chat_page_end; } } //all_messages.clear(); tabs[MESSAGES_TAB_INDEX].body->clear_items(); Messages all_messages_new; matrix->get_all_synced_room_messages(current_room, all_messages_new); for(auto &message : all_messages_new) { fetched_messages_set.insert(message->event_id); } all_messages.insert(all_messages.end(), all_messages_new.begin(), all_messages_new.end()); //me = matrix->get_me(current_room); filter_sent_messages(all_messages_new); add_new_messages_to_current_room(all_messages_new); modify_related_messages_in_current_room(all_messages_new); unresolved_reactions.clear(); process_reactions(all_messages_new); if(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(); } std::vector pinned_events; matrix->get_all_pinned_events(current_room, pinned_events); process_pinned_events(std::move(pinned_events)); } if(go_to_previous_page) { go_to_previous_page = false; goto chat_page_end; } } chat_page_end: previous_messages_future.cancel(); cleanup_tasks(); window.setTitle("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.isOpen()) exit(exit_code); auto rooms_body = create_body(); auto matrix_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, matrix_rooms_page_search_bar.get()); auto rooms_tags_body = create_body(); auto matrix_rooms_tage_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_rooms_tag_page = std::make_unique(this, rooms_tags_body.get(), matrix_rooms_tage_page_search_bar.get()); auto invites_body = create_body(); auto matrix_invites_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_invites_page = std::make_unique(this, matrix, invites_body.get(), matrix_invites_page_search_bar.get()); auto room_directory_body = create_body(); add_body_item_unique_title(room_directory_body->items, matrix->get_homeserver_domain()); add_body_item_unique_title(room_directory_body->items, "midov.pl"); add_body_item_unique_title(room_directory_body->items, "matrix.org"); add_body_item_unique_title(room_directory_body->items, "kde.org"); add_body_item_unique_title(room_directory_body->items, "librem.one"); add_body_item_unique_title(room_directory_body->items, "maunium.net"); add_body_item_unique_title(room_directory_body->items, "halogen.city"); add_body_item_unique_title(room_directory_body->items, "gnome.org"); add_body_item_unique_title(room_directory_body->items, "shivering-isles.com"); add_body_item_unique_title(room_directory_body->items, "nerdsin.space"); add_body_item_unique_title(room_directory_body->items, "glowers.club"); add_body_item_unique_title(room_directory_body->items, "privacytools.io"); add_body_item_unique_title(room_directory_body->items, "linuxdelta.com"); add_body_item_unique_title(room_directory_body->items, "tchncs.de"); add_body_item_unique_title(room_directory_body->items, "jupiterbroadcasting.com"); auto matrix_room_directory_page = std::make_unique(this, matrix); MatrixQuickMedia matrix_handler(this, matrix, matrix_rooms_page.get(), matrix_rooms_tag_page.get(), matrix_invites_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(rooms_body), std::move(matrix_rooms_page), std::move(matrix_rooms_page_search_bar)}); tabs.push_back(Tab{std::move(rooms_tags_body), std::move(matrix_rooms_tag_page), std::move(matrix_rooms_tage_page_search_bar)}); tabs.push_back(Tab{std::move(invites_body), std::move(matrix_invites_page), std::move(matrix_invites_page_search_bar)}); 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)}); while(window.isOpen()) { page_loop(tabs); } matrix->stop_sync(); } }