#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/Soundcloud.hpp" #include "../plugins/FileManager.hpp" #include "../plugins/Pipe.hpp" #include "../plugins/Saucenao.hpp" #include "../plugins/Info.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 "../include/gui/Button.hpp" #include "../external/hash-library/sha256.h" #include #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("onimanga", nullptr), std::make_pair("readm", "readm_logo.png"), std::make_pair("manga", nullptr), std::make_pair("youtube", "yt_logo_rgb_dark_small.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("file-manager", nullptr), std::make_pair("stdin", nullptr), std::make_pair("saucenao", nullptr), std::make_pair("download", 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->crtc && out_info->connection == RR_Connected) { XRRCrtcInfo *crt_info = XRRGetCrtcInfo(display, screen_res, out_info->crtc); if(crt_info && crt_info->mode) { const XRRModeInfo *mode_info = get_mode_info(screen_res, crt_info->mode); if(mode_info) callback_func(crt_info, mode_info); } if(crt_info) XRRFreeCrtcInfo(crt_info); } if(out_info) XRRFreeOutputInfo(out_info); } XRRFreeScreenResources(screen_res); } static int get_monitor_max_hz(Display *display) { unsigned long max_hz = 0; for_each_active_monitor_output(display, [&max_hz](const XRRCrtcInfo*, 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 XRRCrtcInfo *crtc_info, const XRRModeInfo*) { // Need to get the min of width or height because we want to get the smallest size for monitors in portrait mode, for mobile devices such as pinephone int width_or_height = std::min((int)crtc_info->width, (int)crtc_info->height); max_height = std::max(max_height, width_or_height); }); if(max_height == 0) max_height = DefaultScreenOfDisplay(display)->height; return std::max(max_height, 240); } 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); } 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); } namespace QuickMedia { enum class HistoryType { YOUTUBE, MANGA }; class HistoryPage : public LazyFetchPage { public: HistoryPage(Program *program, Page *search_page, HistoryType history_type) : LazyFetchPage(program), search_page(search_page), 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); } PluginResult lazy_fetch(BodyItems &result_items) override { switch(history_type) { case HistoryType::YOUTUBE: program->youtube_get_watch_history(result_items); break; case HistoryType::MANGA: program->manga_get_watch_history(program->get_plugin_name(), result_items); break; } return PluginResult::OK; } bool reload_on_page_change() override { return true; } private: Page *search_page; HistoryType history_type; }; using OptionsPageHandler = std::function; class OptionsPage : public Page { public: OptionsPage(Program *program, std::string title) : Page(program), title(std::move(title)) {} const char* get_title() const override { return title.c_str(); } PluginResult submit(const std::string&, const std::string &url, std::vector&) override { const int handlers_index = atoi(url.c_str()); handlers[handlers_index](); program->set_go_to_previous_page(); return PluginResult::OK; } bool submit_is_async() override { return false; } void add_option(BodyItems &items, std::string title, std::string description, OptionsPageHandler handler) { assert(handler); auto body_item = BodyItem::create(std::move(title)); if(!description.empty()) { body_item->set_description(std::move(description)); body_item->set_description_color(sf::Color(179, 179, 179)); } body_item->url = std::to_string(handlers.size()); handlers.push_back(std::move(handler)); items.push_back(std::move(body_item)); } private: std::string title; std::vector handlers; }; 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 ] [youtube-url]\n"); fprintf(stderr, "OPTIONS:\n"); fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, youtube, soundcloud, nyaa.si, matrix, saucenao, file-manager, stdin, pornhub, spankbang, xvideos or xhamster\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. Default is the the users home directory\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, " quickmedia https://www.youtube.com/watch?v=jHg91NVHh3s\n"); fprintf(stderr, " echo -e \"hello\\nworld\" | quickmedia stdin\n"); fprintf(stderr, " tabbed -c -k quickmedia launcher -e\n"); } static bool is_manga_plugin(const char *plugin_name) { return strcmp(plugin_name, "manga") == 0 || strcmp(plugin_name, "manganelo") == 0 || strcmp(plugin_name, "manganelos") == 0 || strcmp(plugin_name, "mangatown") == 0 || strcmp(plugin_name, "mangakatana") == 0 || strcmp(plugin_name, "mangadex") == 0 || strcmp(plugin_name, "readm") == 0 || strcmp(plugin_name, "onimanga") == 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; } Window parent_window = None; std::vector tabs; const char *url = nullptr; bool download_use_youtube_dl = false; std::string program_path = dirname(argv[0]); for(int i = 1; i < argc; ++i) { if(!plugin_name) { std::string youtube_video_id_dummy; if(youtube_url_extract_id(argv[i], youtube_video_id_dummy)) { youtube_url = argv[i]; plugin_name = "youtube"; } 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) { file_manager_start_dir = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing directory after --dir argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "--low-cpu-mode") == 0) { low_cpu_mode = true; } else if(strcmp(argv[i], "--youtube-dl") == 0) { download_use_youtube_dl = true; } else if(strcmp(argv[i], "-u") == 0) { if(i < argc - 1) { url = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing url after -u argument\n"); usage(); return -1; } } else if(strcmp(argv[i], "-e") == 0) { if(i < argc - 1) { parent_window = 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 manga plugins\n"); return -2; } if(!is_program_executable_by_name("waifu2x-ncnn-vulkan")) { fprintf(stderr, "waifu2x-ncnn-vulkan needs to be installed when using the --upscale-images/--upscale-images-always option\n"); return -2; } image_upscale_thead = std::thread([this]{ std::optional copy_op_opt; while(true) { copy_op_opt = images_to_upscale_queue.pop_wait(); if(!copy_op_opt) break; CopyOp ©_op = copy_op_opt.value(); Path tmp_file = copy_op.source; tmp_file.append(".tmp.png"); fprintf(stderr, "Upscaling %s\n", copy_op.source.data.c_str()); const char *args[] = { "waifu2x-ncnn-vulkan", "-n", "3", "-i", copy_op.source.data.c_str(), "-o", tmp_file.data.c_str(), nullptr }; if(exec_program(args, nullptr, nullptr) != 0) { fprintf(stderr, "Warning: failed to upscale %s with waifu2x-ncnn-vulkan\n", copy_op.source.data.c_str()); // No conversion, but we need the file to have the destination name to see that the operation completed (and read it) if(rename_atomic(copy_op.source.data.c_str(), copy_op.destination.data.c_str()) != 0) perror(tmp_file.data.c_str()); continue; } if(rename_atomic(tmp_file.data.c_str(), copy_op.destination.data.c_str()) != 0) perror(tmp_file.data.c_str()); copy_op.destination.append(".upscaled"); file_overwrite(copy_op.destination.data.c_str(), "1"); } }); } std::filesystem::path home_path = get_home_dir().data; if(file_manager_start_dir.empty()) file_manager_start_dir = home_path; int start_tab_index = 0; FileManagerMimeType fm_mine_type = FILE_MANAGER_MIME_TYPE_ALL; FileSelectionHandler file_selection_handler = nullptr; FileSelectionHandler saucenao_file_selection_handler = [this](FileManagerPage*, const std::filesystem::path&) { std::vector tabs; tabs.push_back(Tab{create_body(), std::make_unique(this, selected_files[0], true), nullptr}); return tabs; }; no_video = force_no_video; init(parent_window, program_path); if(strcmp(plugin_name, "download") == 0) { if(!url) { fprintf(stderr, "-u argument has to be set when using the download plugin\n"); usage(); return -1; } download_page(url, download_use_youtube_dl); return exit_code; } if(strcmp(plugin_name, "saucenao") == 0) { plugin_name = "file-manager"; fm_mine_type = FILE_MANAGER_MIME_TYPE_IMAGE; file_selection_handler = std::move(saucenao_file_selection_handler); } load_plugin_by_name(tabs, start_tab_index, fm_mine_type, std::move(file_selection_handler)); 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(); if(strcmp(plugin_name, "saucenao") == 0) { plugin_name = "file-manager"; fm_mine_type = FILE_MANAGER_MIME_TYPE_IMAGE; file_selection_handler = std::move(saucenao_file_selection_handler); } load_plugin_by_name(tabs, start_tab_index, fm_mine_type, std::move(file_selection_handler)); } } return exit_code; } static sf::Vector2i get_focused_monitor_center(Display *disp, sf::Vector2i &monitor_size) { int screen = DefaultScreen(disp); monitor_size.x = DisplayWidth(disp, screen); monitor_size.y = DisplayWidth(disp, screen); int screen_center_x = monitor_size.x / 2; int screen_center_y = monitor_size.y / 2; sf::Vector2i focused_monitor_center(screen_center_x, screen_center_y); auto mouse_pos = sf::Mouse::getPosition(); for_each_active_monitor_output(disp, [&focused_monitor_center, mouse_pos, &monitor_size](const XRRCrtcInfo *crtc_info, const XRRModeInfo*){ if(sf::Rect(crtc_info->x, crtc_info->y, crtc_info->width, crtc_info->height).contains(mouse_pos)) { monitor_size.x = crtc_info->width; monitor_size.y = crtc_info->height; focused_monitor_center = sf::Vector2i(crtc_info->x + crtc_info->width/2, crtc_info->y + crtc_info->height/2); } }); return focused_monitor_center; } void Program::init(Window parent_window, std::string &program_path) { disp = XOpenDisplay(NULL); if (!disp) { show_notification("QuickMedia", "Failed to open display to X11 server", Urgency::CRITICAL); abort(); } XSetErrorHandler(x_error_handler); XSetIOErrorHandler(x_io_error_handler); wm_delete_window_atom = XInternAtom(disp, "WM_DELETE_WINDOW", False); int screen = DefaultScreen(disp); sf::Vector2i monitor_size; sf::Vector2i focused_monitor_center = get_focused_monitor_center(disp, monitor_size); if(strcmp(plugin_name, "download") == 0) { window_size.x = std::min(900, monitor_size.x); window_size.y = std::min(900, monitor_size.y); } x11_window = XCreateWindow(disp, parent_window ? parent_window : DefaultRootWindow(disp), focused_monitor_center.x - window_size.x * 0.5f, focused_monitor_center.y - window_size.y * 0.5f, window_size.x, window_size.y, 0, DefaultDepth(disp, screen), InputOutput, DefaultVisual(disp, screen), 0, nullptr); if(!x11_window) { show_notification("QuickMedia", "Failed to create window", Urgency::CRITICAL); abort(); } if(strcmp(plugin_name, "download") == 0) { XSizeHints *size_hints = XAllocSizeHints(); if(size_hints) { size_hints->width = window_size.x; size_hints->min_width = window_size.x; size_hints->max_width = window_size.x; size_hints->height = window_size.y; size_hints->min_height = window_size.y; size_hints->max_height = window_size.y; size_hints->flags = PSize | PMinSize | PMaxSize; XSetWMNormalHints(disp, x11_window, size_hints); XFree(size_hints); } } XStoreName(disp, x11_window, "QuickMedia"); XMapWindow(disp, x11_window); XFlush(disp); window.create(x11_window); if(!program_path.empty() && program_path.back() != '/') program_path += '/'; resources_root = "/usr/share/quickmedia/"; if(get_file_type(program_path + "../../../images/manganelo_logo.png") == FileType::REGULAR) { resources_root = program_path + "../../../"; } set_resource_loader_root_path(resources_root.c_str()); if(!is_touch_enabled()) { if(!circle_mask_shader.loadFromFile(resources_root + "shaders/circle_mask.glsl", sf::Shader::Type::Fragment)) { show_notification("QuickMedia", "Failed to load " + resources_root + "/shaders/circle_mask.glsl", Urgency::CRITICAL); abort(); } if(!rounded_rectangle_shader.loadFromFile(resources_root + "shaders/rounded_rectangle.glsl", sf::Shader::Type::Fragment)) { show_notification("QuickMedia", "Failed to load " + resources_root + "/shaders/rounded_rectangle.glsl", Urgency::CRITICAL); abort(); } if(!rounded_rectangle_mask_shader.loadFromFile(resources_root + "shaders/rounded_rectangle_mask.glsl", sf::Shader::Type::Fragment)) { show_notification("QuickMedia", "Failed to load " + resources_root + "/shaders/rounded_rectangle_mask.glsl", Urgency::CRITICAL); abort(); } } if(!loading_icon.loadFromFile(resources_root + "images/loading_icon.png")) { show_notification("QuickMedia", "Failed to load " + resources_root + "/images/loading_icon.png", Urgency::CRITICAL); 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(monitor_hz); idle = false; 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("media")) != 0) { show_notification("QuickMedia", "Failed to create media directory", Urgency::CRITICAL); abort(); } if(create_directory_recursive(get_cache_dir().join("thumbnails")) != 0) { show_notification("QuickMedia", "Failed to create thumbnails directory", Urgency::CRITICAL); abort(); } //if(create_directory_recursive(get_storage_dir().join("file-manager")) != 0) { // show_notification("QuickMedia", "Failed to create file-manager directory", Urgency::CRITICAL); // abort(); //} const char *qm_phone_factor = getenv("QM_PHONE_FACTOR"); if(qm_phone_factor && atoi(qm_phone_factor) == 1) show_room_side_panel = false; else show_room_side_panel = true; main_thread_id = std::this_thread::get_id(); auto window_size_u = window.getSize(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; } // Returns size_t(-1) if not found static size_t find_end_of_json_array(const char *str, size_t start, size_t size) { if(size <= start || str[start] != '[') return size_t(-1); bool inside_string = false; bool escape = false; int array_depth = 0; for(size_t i = start; i < size; ++i) { char c = str[i]; if(c == '"' && !escape) { inside_string = !inside_string; } else if(c == '\\') { escape = !escape; } else if(c == '[' && !inside_string && !escape) { ++array_depth; } else if(c == ']' && !inside_string && !escape) { --array_depth; if(array_depth == 0) return i + 1; } else { escape = false; } } return size_t(-1); } static void add_manganelos_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("http://manganelos.com/search?q=%s&page=%p", 1) .text_handler({{"//div[class='media-left cover-manga']//a", "title", "href", "/manga/"}}) .thumbnail_handler({{"//div[class='media-left cover-manga']//img[class='media-object']", "src", "/mangaimage/"}}) .list_chapters_handler("//section[id='examples']//div[class='chapter-list']//a", "text", "href", nullptr) .list_page_images_handler("//p[id='arraydata']", "text", nullptr, [](std::vector &urls) { if(urls.size() != 1) return; std::string urls_combined = urls.front(); urls.clear(); string_split(urls_combined, ',', [&urls](const char *str, size_t size) { std::string url(str, size); url = strip(url); if(!url.empty()) urls.push_back(std::move(url)); return true; }); }) .manga_id_handler("/manga/", "?"); } static void add_mangatown_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("https://www.mangatown.com/search?name=%s&page=%p", 1) .text_handler({{"//p[class='title']/a", "title", "href", "/manga/"}}) .thumbnail_handler({{"//a[class='manga_cover']/img", "src", nullptr}}) .authors_handler({ {"//div[class='detail_content']//a", "text", "href", "/author/"}, {"//div[class='detail_content']//a", "text", "href", "/artist/"} }) .list_chapters_handler("//ul[class='chapter_list']//a", "text", "href", "/manga/") .list_chapters_uploaded_time_handler("//ul[class='chapter_list']//span[class='time']", "text", nullptr) .list_page_images_pagination_handler( "//div[class='page_select']//option", "text", "//img[id='image']", "src", nullptr, "//a[class='next_page']", "href", nullptr) .manga_id_handler("/manga/", "/"); } static void add_mangakatana_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("https://mangakatana.com/page/%p?search=%s&search_by=book_name", 1) .text_handler({ {"//div[id='book_list']//h3[class='title']//a", "text", "href", "/manga/"}, {"//div[id='single_book']//h1[class='heading']", "text", nullptr, nullptr} }) .thumbnail_handler({ {"//div[id='book_list']//div[class='media']//img", "src", nullptr}, {"//div[id='single_book']//div[class='cover']//img", "src", nullptr} }) .description_handler({ {"//div[id='book_list']//div[class='*summary*']", "text"}, {"//div[id='single_book']//div[class='*summary*']", "text"} }) .authors_handler({{"//div[id='single_book']//a[class='author']", "text", "href", "/author/"}}) .list_chapters_handler("//div[class='chapters']//div[class='chapter']//a", "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_readm_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_post_handler("https://readm.org/service/search", {{"dataType", "json"}, {"phrase", "%s"}}, [](Json::Value &json_root) { BodyItems result_items; if(!json_root.isObject()) return result_items; const Json::Value &manga_json = json_root["manga"]; if(!manga_json.isArray()) return result_items; for(const Json::Value &item_json : manga_json) { if(!item_json.isObject()) continue; const Json::Value &title_json = item_json["title"]; const Json::Value &url_json = item_json["url"]; const Json::Value &image_json = item_json["image"]; if(!title_json.isString() || !url_json.isString()) continue; auto body_item = BodyItem::create(strip(title_json.asString())); body_item->url = strip(url_json.asString()); if(image_json.isString()) body_item->thumbnail_url = strip(image_json.asString()); result_items.push_back(std::move(body_item)); } return result_items; }) .list_chapters_handler("//div[class='episodes-list']//a", "text", "href", "/manga/") .list_chapters_uploaded_time_handler("//div[class='episodes-list']//td[class='episode-date']", "text", nullptr) .list_page_images_handler("//div[id='content']//img", "src", "/chapter_files/") .manga_id_handler("/manga/", "/"); } static void add_onimanga_handlers(MangaGenericSearchPage *manga_generic_search_page) { manga_generic_search_page->search_handler("https://onimanga.com/search?search=%s", 1) .text_handler({{"//li[class='manga-name']/a", "text", "href", nullptr}}) .list_chapters_handler("//div[class='manga-chapters']//div[class='chapter']//a", "text", "href", nullptr) .list_page_images_handler("//img[class='page']", "src", "/scans/") .manga_id_handler("/", nullptr); } static void add_pornhub_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://www.pornhub.com/video/search?search=%s&page=%p", 1) .text_handler({{"//div[class='nf-videos']//div[class='phimage']//a", "title", "href", "/view_video.php"}}) .thumbnail_handler({{"//div[class='nf-videos']//div[class='phimage']//img", "data-src", "/videos/"}}) .related_media_text_handler({{"//div[class='phimage']//a", "title", "href", "/view_video.php"}}) .related_media_thumbnail_handler({{"//div[class='phimage']//img", "data-src", nullptr}}); } static void add_spankbang_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://spankbang.com/s/%s/%p/", 1) .text_handler({{"//div[class='main_results']//div[class='video-item']//a[class='n']", "text", "href", "/video/"}}) .thumbnail_handler({{"//div[class='main_results']//div[class='video-item']//img", "data-src", nullptr}}) .related_media_text_handler({{"//div[class='right']//div[class='video-item']//a[class='n']", "text", "href", "/video/"}}) .related_media_thumbnail_handler({{"//div[class='right']//div[class='video-item']//img", "data-src", nullptr}}); } static void add_xvideos_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://www.xvideos.com/?k=%s&p=%p", 0) .text_handler({{"//div[id='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()) continue; MediaRelatedItem related_item; related_item.title = title_json.asString(); related_item.url = url_json.asString(); if(thumbnail_url_json.isString()) related_item.thumbnail_url = thumbnail_url_json.asString(); related_items.push_back(std::move(related_item)); } return related_items; }); } static void add_xhamster_handlers(MediaGenericSearchPage *media_generic_search_page) { media_generic_search_page->search_handler("https://xhamster.com/search/%s?page=%p", 1) .text_handler({{"//div[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-"}}); } static PluginResult upgrade_legacy_mangadex_ids(Program *program, Page *page) { Path content_storage_dir = get_storage_dir().join("mangadex"); if(create_directory_recursive(content_storage_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_storage_dir.data, Urgency::CRITICAL); abort(); } Path mangadex_upgraded = get_storage_dir().join("mangadex-upgraded"); if(get_file_type(mangadex_upgraded) == FileType::REGULAR) return PluginResult::OK; show_notification("QuickMedia", "Upgrading mangadex ids", Urgency::LOW); std::vector legacy_manga_ids; for_files_in_dir_sort_last_modified(content_storage_dir, [&legacy_manga_ids](const std::filesystem::path &filepath) { if(filepath.extension() == ".tmp") return true; std::string filename = filepath.filename(); if(filename.size() > 18) // Ignore new manga ids return true; std::string id_str = base64_decode(filename); char *endptr = nullptr; errno = 0; long id = strtol(id_str.c_str(), &endptr, 10); if(endptr != id_str.c_str() && errno == 0) legacy_manga_ids.push_back(id); return true; }); if(legacy_manga_ids.empty()) return PluginResult::OK; std::vector> new_manga_ids; TaskResult task_result = program->run_task_with_loading_screen([page, &legacy_manga_ids, &new_manga_ids]() { return legacy_mangadex_id_to_new_manga_id(page, legacy_manga_ids, new_manga_ids) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) { if(new_manga_ids.size() != legacy_manga_ids.size()) { show_notification("QuickMedia", "Failed to upgrade legacy mangadex ids", Urgency::CRITICAL); abort(); } for(const auto &it : new_manga_ids) { Path old_path = content_storage_dir; old_path.join(base64_encode(std::to_string(it.first))); Path new_path = content_storage_dir; new_path.join(base64_encode(it.second)); if(rename_atomic(old_path.data.c_str(), new_path.data.c_str()) != 0) { show_notification("QuickMedia", "Failed to upgrade legacy mangadex ids", Urgency::CRITICAL); abort(); } } if(file_overwrite_atomic(mangadex_upgraded, "1") != 0) { show_notification("QuickMedia", "Failed to upgrade legacy mangadex ids", Urgency::CRITICAL); abort(); } show_notification("QuickMedia", "Mangadex ids upgraded", Urgency::LOW); return PluginResult::OK; } else if(task_result == TaskResult::CANCEL) { exit(0); } else if(task_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to upgrade legacy mangadex ids", Urgency::CRITICAL); abort(); } return PluginResult::OK; } void Program::load_plugin_by_name(std::vector &tabs, int &start_tab_index, FileManagerMimeType fm_mime_type, FileSelectionHandler file_selection_handler) { 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(true); 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("Onimanga", "onimanga", "")); pipe_body->items.push_back(create_launcher_body_item("Readm", "readm", resources_root + "icons/readm_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("SauceNAO", "saucenao", "")); 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("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(false, true), std::make_unique(this), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } 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(false, true), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } 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(false, true), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } 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 history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "mangadex") == 0) { tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 400)}); upgrade_legacy_mangadex_ids(this, tabs.back().page.get()); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "readm") == 0) { auto search_page = std::make_unique(this, plugin_name, "https://readm.org/"); add_readm_handlers(search_page.get()); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "onimanga") == 0) { auto search_page = std::make_unique(this, plugin_name, "https://onimanga.com/"); add_onimanga_handlers(search_page.get()); tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", 400)}); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::MANGA); tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "manga") == 0) { auto mangadex = std::make_unique(this); upgrade_legacy_mangadex_ids(this, mangadex.get()); auto manganelo = std::make_unique(this); auto manganelos = std::make_unique(this, "manganelos", "http://manganelos.com/"); add_manganelos_handlers(manganelos.get()); auto mangatown = std::make_unique(this, "mangatown", "https://www.mangatown.com/"); add_mangatown_handlers(mangatown.get()); auto mangakatana = std::make_unique(this, "mangakatana", "https://mangakatana.com/", false); add_mangakatana_handlers(mangakatana.get()); auto onimanga = std::make_unique(this, "onimanga", "https://onimanga.com/"); add_onimanga_handlers(onimanga.get()); auto readm = std::make_unique(this, "readm", "https://readm.org/"); add_readm_handlers(readm.get()); // TODO: Use async task pool std::vector pages; pages.push_back({std::move(manganelo), "Manganelo", "manganelo"}); pages.push_back({std::move(manganelos), "Manganelos", "manganelos"}); pages.push_back({std::move(mangatown), "Mangatown", "mangatown"}); pages.push_back({std::move(mangakatana), "Mangakatana", "mangakatana"}); pages.push_back({std::move(onimanga), "Onimanga", "onimanga"}); pages.push_back({std::move(readm), "Readm", "readm"}); pages.push_back({std::move(mangadex), "Mangadex", "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 categories_nyaa_si_body = create_body(); get_nyaa_si_categories(categories_nyaa_si_body->items); tabs.push_back(Tab{std::move(categories_nyaa_si_body), std::make_unique(this, false), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); auto categories_sukebei_body = create_body(); get_sukebei_categories(categories_sukebei_body->items); tabs.push_back(Tab{std::move(categories_sukebei_body), std::make_unique(this, true), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "4chan") == 0) { 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, fm_mime_type, file_selection_handler); if(!file_manager_page->set_current_directory(file_manager_start_dir)) fprintf(stderr, "Warning: Invalid directory provided with --dir\n"); auto file_manager_body = create_body(); 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) { if(youtube_url.empty()) { start_tab_index = 1; tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 350)}); auto recommended_page = std::make_unique(this); tabs.push_back(Tab{create_body(false, true), std::move(recommended_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); auto history_body = create_body(false, true); auto history_page = std::make_unique(this, tabs.front().page.get(), HistoryType::YOUTUBE); tabs.push_back(Tab{std::move(history_body), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else { current_page = PageType::VIDEO_CONTENT; auto youtube_video_page = std::make_unique(this, youtube_url); BodyItems body_items; video_content_page(nullptr, youtube_video_page.get(), "", false, nullptr, body_items, 0); } } 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(false, true), 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(false, true), 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(false, true), 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(false, true), std::move(search_page), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "soundcloud") == 0) { tabs.push_back(Tab{create_body(false, true), std::make_unique(this), create_search_bar("Search...", 500)}); no_video = true; } else if(strcmp(plugin_name, "matrix") == 0) { assert(!matrix); matrix = new Matrix(); } else { assert(false); } } 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::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; } } 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"; } 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 + "/mqdefault.jpg"; body_item->set_description("Watched " + seconds_to_relative_time_str(time_now - timestamp.asInt64())); body_item->set_description_color(sf::Color(179, 179, 179)); body_item->thumbnail_size = sf::Vector2i(192, 108); 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; FileType file_type = get_file_type(video_history_filepath); if(file_type == FileType::REGULAR) { if(!read_file_as_json(video_history_filepath, json_result) || !json_result.isArray()) { show_notification("QuickMedia", "Failed to read " + video_history_filepath.data, Urgency::CRITICAL); abort(); } } else { json_result = Json::Value(Json::arrayValue); } 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); } time_t now = time(NULL); for_files_in_dir_sort_last_modified(content_storage_dir, [&history_items, plugin_name, now](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(); if(filename.empty()) return true; const Json::Value &manga_name = body["name"]; if(!manga_name.isString()) return true; time_t last_modified_time = 0; file_get_last_modified_time_seconds(filepath.c_str(), &last_modified_time); // TODO: Add thumbnail auto body_item = BodyItem::create(manga_name.asString()); body_item->set_description("Last read " + seconds_to_relative_time_str(now - last_modified_time)); body_item->set_description_color(sf::Color(179, 179, 179)); 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 = 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 if(strcmp(plugin_name, "onimanga") == 0) body_item->url = "https://onimanga.com/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "readm") == 0) body_item->url = "https://readm.org/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) { const float body_width = window_size.x; 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(0.0f, search_bottom + tab_h); body_size = sf::Vector2f(body_width, window_size.y - search_bottom - tab_h); } std::unique_ptr Program::create_body(bool plain_text_list, bool prefer_card_view) { if(rounded_rectangle_mask_shader.getNativeHandle() == 0) plain_text_list = true; auto body = std::make_unique(plain_text_list ? BODY_THEME_MINIMAL : BODY_THEME_MODERN_SPACIOUS, loading_icon, &rounded_rectangle_shader, &rounded_rectangle_mask_shader); body->card_view = prefer_card_view; body->thumbnail_mask_shader = &circle_mask_shader; return body; } std::unique_ptr Program::create_search_bar(const std::string &placeholder, int search_delay) { auto search_bar = std::make_unique(&plugin_logo, &rounded_rectangle_shader, placeholder); 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(); if(!result) { show_notification("QuickMedia", "Failed to read " + content_storage_file.data, Urgency::CRITICAL); abort(); } } else { result = true; } if(!content_storage_json.isObject()) content_storage_json = Json::Value(Json::objectValue); content_storage_json["name"] = manga_title; return result; } void Program::select_file(const std::string &filepath) { puts(filepath.c_str()); selected_files.clear(); selected_files.push_back(filepath); } bool Program::is_window_focused() { return window.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; } static bool is_url_video(const std::string &url) { return string_ends_with(url, ".webm") || string_ends_with(url, ".mp4") || string_ends_with(url, ".mkv") || string_ends_with(url, ".gif"); } bool Program::show_info_page(BodyItem *body_item, bool include_reverse_image_search) { if(!body_item) return false; std::string title = body_item->get_title(); std::string description = body_item->get_description(); std::string text = std::move(title); if(!description.empty()) { if(!text.empty()) text += '\n'; text += std::move(description); } auto body = create_body(); if(include_reverse_image_search && !body_item->url.empty() && !body_item->thumbnail_url.empty()) { std::string image_url = body_item->url; if(is_url_video(body_item->url)) image_url = body_item->thumbnail_url; body->items.push_back(InfoPage::add_reverse_image_search(image_url)); } std::vector urls = ranges_get_strings(text, extract_urls(text)); for(const std::string &url : urls) { body->items.push_back(InfoPage::add_url(url)); } if(body->items.empty()) return false; std::vector info_tabs; info_tabs.push_back(Tab{std::move(body), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); page_loop(info_tabs); return true; } 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, window_size, true); 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(!tabs[selected_tab].page->is_ready()) { sf::Text loading_text("Loading...", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(30 * get_ui_scale())); auto text_bounds = loading_text.getLocalBounds(); loading_text.setPosition( std::floor(body_pos.x + body_size.x * 0.5f - text_bounds.width * 0.5f), std::floor(body_pos.y + body_size.y * 0.5f - text_bounds.height * 0.5f)); window.draw(loading_text); } if(matrix) matrix->update(); 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); } } } bool 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 false; } malloc_trim(0); idle_active_handler(); bool loop_running = true; bool redraw = true; for(Tab &tab : tabs) { if(tab.body->attach_side == AttachSide::BOTTOM) tab.body->select_last_item(); 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(), tab.body.get()); } 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 = [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(tabs[selected_tab].page->allow_submit_no_selection() && (sf::Keyboard::isKeyPressed(sf::Keyboard::LControl) || sf::Keyboard::isKeyPressed(sf::Keyboard::RControl))) selected_item = nullptr; if(!selected_item && (!tabs[selected_tab].page->allow_submit_no_selection() || search_text.empty())) return; hide_virtual_keyboard(); std::vector new_tabs; auto prev_selected_item = tabs[selected_tab].page->submit_body_item; tabs[selected_tab].page->submit_body_item = selected_item; auto plugin_submit_handler = [&tabs, selected_tab, &selected_item, &search_text, &new_tabs]() { PluginResult plugin_result = tabs[selected_tab].page->submit(selected_item ? selected_item->get_title() : search_text, selected_item ? selected_item->url : "", new_tabs); return plugin_result == PluginResult::OK; }; TaskResult submit_result; if(tabs[selected_tab].page->submit_is_async()) { submit_result = run_task_with_loading_screen(std::move(plugin_submit_handler)); } else { submit_result = plugin_submit_handler() ? TaskResult::TRUE : TaskResult::FALSE; } if(submit_result == TaskResult::CANCEL) { return; } else if(submit_result != TaskResult::TRUE) { // TODO: Show the exact cause of error (get error message from curl). show_notification("QuickMedia", std::string("Submit failed for page ") + tabs[selected_tab].page->get_title(), Urgency::CRITICAL); return; } idle_active_handler(); if(tabs[selected_tab].page->clear_search_after_submit() && tabs[selected_tab].search_bar) { if(!tabs[selected_tab].search_bar->get_text().empty()) { tabs[selected_tab].search_bar->clear(); tabs[selected_tab].search_bar->onTextUpdateCallback(""); } else if(tabs[selected_tab].body->attach_side == AttachSide::TOP) { int selected_item_index = tabs[selected_tab].body->get_selected_item(); tabs[selected_tab].body->select_first_item(); tabs[selected_tab].body->set_selected_item(selected_item_index, false); } } if(tabs[selected_tab].page->is_single_page()) { if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->clear(); if(new_tabs.size() == 1 && !new_tabs[0].page) { tabs[selected_tab].body = std::move(new_tabs[0].body); tabs[selected_tab].page->submit_body_item = prev_selected_item; return; } else if(new_tabs.empty()) { 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(size_t i = 0; i < tabs.size(); ++i) { tabs[i].body->clear_cache(); if(tabs[i].page->is_lazy_fetch_page() && static_cast(tabs[i].page.get())->reload_on_page_change()) { tab_associated_data[i].lazy_fetch_finished = false; tab_associated_data[i].fetched_page = 0; tabs[i].body->clear_items(); } } if(tabs[selected_tab].page->allow_submit_no_selection()) { page_loop(new_tabs, 0, after_submit_handler); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::MANGA_IMAGES) { page_stack.push(current_page); select_episode(selected_item.get(), false); Body *chapters_body = tabs[selected_tab].body.get(); tabs[selected_tab].search_bar->clear(); chapters_body->filter_search_fuzzy(""); // Needed (or not really) to go to the next chapter when reaching the last page of a chapter MangaImagesPage *manga_images_page = static_cast(new_tabs[0].page.get()); window.setKeyRepeatEnabled(false); downloading_chapter_url.clear(); while(window.isOpen() && (current_page == PageType::IMAGES || current_page == PageType::IMAGES_CONTINUOUS)) { if(current_page == PageType::IMAGES) { 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); } } } 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) { page_stack.push(current_page); current_page = PageType::IMAGE_BOARD_THREAD; image_board_thread_page(static_cast(new_tabs[0].page.get()), new_tabs[0].body.get()); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::VIDEO) { page_stack.push(current_page); current_page = PageType::VIDEO_CONTENT; int selected_index = tabs[selected_tab].body->get_selected_item(); video_content_page(tabs[selected_tab].page.get(), static_cast(new_tabs[0].page.get()), "", false, tabs[selected_tab].body.get(), 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) { MatrixChatPage *tmp_matrix_chat_page = static_cast(new_tabs[0].page.get()); MatrixRoomsPage *rooms_page = tmp_matrix_chat_page->rooms_page; Body *room_list_body = rooms_page->body; rooms_page->clear_search(); body_set_selected_item_by_url(room_list_body, tmp_matrix_chat_page->room_id); current_page = PageType::CHAT; current_chat_room = matrix->get_room_by_id(tmp_matrix_chat_page->room_id); rooms_page->body->show_drop_shadow = false; while(window.isOpen() && current_chat_room) { 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); matrix_chat_page->messages_tab_visible = false; if(!move_room) break; BodyItem *selected_item = room_list_body->get_selected(); if(!selected_item) break; current_chat_room = matrix->get_room_by_id(selected_item->url); } rooms_page->body->show_drop_shadow = true; room_list_body->body_item_select_callback = [&submit_handler](BodyItem *body_item) { submit_handler(body_item->get_title()); }; current_chat_room = nullptr; } else { page_loop(new_tabs, 0, after_submit_handler); } for(Tab &tab : tabs) { tab.page->on_navigate_to_page(tab.body.get()); } tabs[selected_tab].page->submit_body_item = prev_selected_item; redraw = true; idle_active_handler(); hide_virtual_keyboard(); }; std::function on_reached_end = [&ui_tabs, &tabs, &tab_associated_data, &gradient_inc] { const int selected_tab = ui_tabs.get_selected(); if(tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].fetching_next_page_running && !tab_associated_data[selected_tab].fetching_next_page_failed && (!tabs[selected_tab].search_bar || !tabs[selected_tab].page->search_is_filter() || tabs[selected_tab].search_bar->is_empty()) && tabs[selected_tab].body->get_num_visible_items() > 0 && tabs[selected_tab].page->is_ready() && (!tabs[selected_tab].page->is_lazy_fetch_page() || tab_associated_data[selected_tab].lazy_fetch_finished)) { gradient_inc = 0.0; tab_associated_data[selected_tab].fetching_next_page_running = true; int next_page = tab_associated_data[selected_tab].fetched_page + 1; Page *page = tabs[selected_tab].page.get(); std::string update_search_text = tab_associated_data[selected_tab].update_search_text; tab_associated_data[selected_tab].next_page_future = AsyncTask([update_search_text, next_page, page]() { BodyItems result_items; if(page->get_page(update_search_text, next_page, result_items) != PluginResult::OK) fprintf(stderr, "Failed to get next page (page %d)\n", next_page); return result_items; }); } }; for(size_t i = 0; i < tabs.size(); ++i) { Tab &tab = tabs[i]; tab.body->body_item_select_callback = [&submit_handler](BodyItem *body_item) { submit_handler(body_item->get_title()); }; if(tab.body->attach_side == AttachSide::TOP) tab.body->on_bottom_reached = on_reached_end; else if(tab.body->attach_side == AttachSide::BOTTOM) tab.body->on_top_reached = on_reached_end; TabAssociatedData &associated_data = tab_associated_data[i]; if(tab.search_bar) { // tab.search_bar->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); if(tabs[i].body->attach_side == AttachSide::TOP) tabs[i].body->select_first_item(); else if(tabs[i].body->attach_side == AttachSide::BOTTOM) tabs[i].body->select_last_item(); } associated_data.typing = false; }; tab.search_bar->onTextSubmitCallback = [&submit_handler, &associated_data](const std::string &search_text) { if(associated_data.typing) return; submit_handler(search_text); }; } } 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::Escape) { return false; } 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->is_trackable()) { TrackablePage *trackable_page = dynamic_cast(tabs[selected_tab].page.get()); trackable_page->track(selected_item->get_title()); } } else if(event.key.code == sf::Keyboard::C && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item) { std::string title = selected_item->get_title(); std::string description = selected_item->get_description(); std::string clipboard = std::move(title); if(!description.empty()) { if(!clipboard.empty()) clipboard += '\n'; clipboard += std::move(description); } if(!clipboard.empty()) sf::Clipboard::setString(sf::String::fromUtf8(clipboard.begin(), clipboard.end())); } } else if(event.key.code == sf::Keyboard::I && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(show_info_page(selected_item, false)) redraw = true; } } } 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); if(tabs[selected_tab].body->attach_side == AttachSide::TOP) { gradient_points[0].position.x = 0.0f; gradient_points[0].position.y = window_size.y - gradient_height; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = window_size.y - gradient_height; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = window_size.y; gradient_points[3].position.x = 0.0f; gradient_points[3].position.y = window_size.y; } else if(tabs[selected_tab].body->attach_side == AttachSide::BOTTOM) { gradient_points[0].position.x = 0.0f; gradient_points[0].position.y = body_pos.y; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = body_pos.y; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = body_pos.y + gradient_height; gradient_points[3].position.x = 0.0f; gradient_points[3].position.y = body_pos.y + gradient_height; } } 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); if(tabs[selected_tab].body->attach_side == AttachSide::TOP) { gradient_points[0].color = back_color; gradient_points[1].color = back_color; gradient_points[2].color = bottom_color; gradient_points[3].color = bottom_color; } else if(tabs[selected_tab].body->attach_side == AttachSide::BOTTOM) { gradient_points[0].color = bottom_color; gradient_points[1].color = bottom_color; gradient_points[2].color = back_color; gradient_points[3].color = back_color; } } if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->update(); if(tabs[selected_tab].page->is_ready() && tabs[selected_tab].page->is_lazy_fetch_page() && tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].lazy_fetch_finished) { tab_associated_data[selected_tab].fetch_status = FetchStatus::LOADING; tab_associated_data[selected_tab].fetch_type = FetchType::LAZY; tab_associated_data[selected_tab].search_result_text.setString("Loading..."); LazyFetchPage *lazy_fetch_page = static_cast(tabs[selected_tab].page.get()); tab_associated_data[selected_tab].fetch_future = AsyncTask([lazy_fetch_page]() { FetchResult fetch_result; fetch_result.result = lazy_fetch_page->lazy_fetch(fetch_result.body_items); return fetch_result; }); } for(size_t i = 0; i < tabs.size(); ++i) { TabAssociatedData &associated_data = tab_associated_data[i]; if(!tabs[i].page->is_ready()) continue; if(associated_data.fetching_next_page_running && associated_data.next_page_future.ready()) { const bool body_was_empty = tabs[i].body->items.empty(); BodyItems new_body_items = associated_data.next_page_future.get(); fprintf(stderr, "Finished fetching page %d, num new items: %zu\n", associated_data.fetched_page + 1, new_body_items.size()); int prev_selected_item = tabs[i].body->get_selected_item(); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { if(tabs[i].body->attach_side == AttachSide::TOP) tabs[i].body->append_items(std::move(new_body_items)); else if(tabs[i].body->attach_side == AttachSide::BOTTOM) tabs[i].body->prepend_items_reverse(std::move(new_body_items)); associated_data.fetched_page++; } else { associated_data.fetching_next_page_failed = true; } associated_data.fetching_next_page_running = false; if(tabs[i].body->attach_side == AttachSide::BOTTOM) { if(body_was_empty) { tabs[i].body->select_last_item(); } else { // TODO: Use select_next_item in a loop instead for |num_new_messages|? tabs[i].body->set_selected_item(prev_selected_item + num_new_messages, true); } } idle_active_handler(); } if(associated_data.search_text_updated && associated_data.fetch_status == FetchStatus::LOADING && associated_data.fetch_type == FetchType::SEARCH && associated_data.fetch_future.valid()) { associated_data.fetch_future.cancel(); associated_data.fetch_status = FetchStatus::NONE; } if(associated_data.search_text_updated && associated_data.fetch_status == FetchStatus::NONE && !associated_data.fetching_next_page_running) { std::string update_search_text = associated_data.update_search_text; associated_data.search_text_updated = false; tabs[i].body->clear_items(); 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); if(tabs[i].body->attach_side == AttachSide::TOP) { tabs[i].body->select_first_item(); } else if(tabs[i].body->attach_side == AttachSide::BOTTOM) { std::reverse(tabs[i].body->items.begin(), tabs[i].body->items.end()); tabs[i].body->select_last_item(); } associated_data.fetched_page = 0; associated_data.fetching_next_page_failed = false; if(fetch_result.result != PluginResult::OK) associated_data.search_result_text.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(""); idle_active_handler(); } else { associated_data.fetch_future.get(); } associated_data.fetch_status = FetchStatus::NONE; } if(associated_data.fetch_status == FetchStatus::LOADING && associated_data.fetch_type == FetchType::LAZY && associated_data.fetch_future.ready()) { 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()); if(tabs[i].body->attach_side == AttachSide::TOP) { tabs[i].body->select_first_item(); } if(tabs[i].body->attach_side == AttachSide::BOTTOM) { std::reverse(tabs[i].body->items.begin(), tabs[i].body->items.end()); tabs[i].body->select_last_item(); } 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; idle_active_handler(); } } if(content_storage_file_modified) { content_storage_file_modified = false; if(content_storage_json.isObject()) { const Json::Value &chapters_json = content_storage_json["chapters"]; if(chapters_json.isObject()) json_chapters = &chapters_json; else json_chapters = &Json::Value::nullSingleton(); } else { json_chapters = &Json::Value::nullSingleton(); } } for(size_t i = 0; i < tabs.size(); ++i) { Tab &tab = tabs[i]; if(tab.page) ui_tabs.set_text(i, tab.page->get_title()); } window.clear(back_color); page_loop_render(window, tabs, selected_tab, tab_associated_data[selected_tab], json_chapters, ui_tabs); AsyncImageLoader::get_instance().update(); window.display(); if(!tabs[selected_tab].body->items.empty()) { if(tabs[selected_tab].body->attach_side == AttachSide::TOP && !tabs[selected_tab].body->is_bottom_cut_off()) on_reached_end(); else if(tabs[selected_tab].body->attach_side == AttachSide::BOTTOM && !tabs[selected_tab].body->is_top_cut_off()) on_reached_end(); } if(go_to_previous_page) { go_to_previous_page = false; 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; } static bool window_is_fullscreen(Display *display, Window window) { Atom wm_state_atom = XInternAtom(display, "_NET_WM_STATE", False); Atom wm_state_fullscreen_atom = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); if(wm_state_atom == False || wm_state_fullscreen_atom == False) { fprintf(stderr, "Failed to get window atoms\n"); return false; } Atom type; int format = 0; unsigned long num_items = 0; unsigned long bytes_after = 0; unsigned char *properties = nullptr; if(XGetWindowProperty(display, window, wm_state_atom, 0, 1024, False, XA_ATOM, &type, &format, &num_items, &bytes_after, &properties) != Success || !properties) { fprintf(stderr, "Failed to get window wm state property\n"); return false; } bool is_fullscreen = false; Atom *atoms = (Atom*)properties; for(unsigned long i = 0; i < num_items; ++i) { if(atoms[i] == wm_state_fullscreen_atom) { is_fullscreen = true; break; } } XFree(properties); return is_fullscreen; } static const char *useragent_str = "user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36"; static int accumulate_string_limit_head(char *data, int size, void *userdata) { std::string *str = (std::string*)userdata; str->append(data, size); if(str->size() >= 42) return 1; return 0; } static bool video_url_is_non_streamable_mp4(const char *url) { std::string result; const char *args[] = { "curl", "-sLf", "-r", "0-40", "-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], "ftyp3gp5", 8) == 0 || memcmp(&result[4], "fty3gp5", 7) == 0 || memcmp(&result[4], "ftypqt", 6) == 0) && (memmem(&result[0], result.size(), "moov", 4) == NULL); } const char* Program::get_plugin_name() const { return plugin_name; } TaskResult Program::run_task_with_loading_screen(std::function callback) { assert(std::this_thread::get_id() == main_thread_id); idle_active_handler(); 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; } void Program::video_page_download_video(const std::string &url, bool use_youtube_dl, sf::WindowHandle video_player_window) { if(!use_youtube_dl) { download_async_gui(url, file_manager_start_dir.string(), use_youtube_dl, no_video); return; } bool audio_only = false; auto body = create_body(); auto options_page = std::make_unique(this, "Select download option"); options_page->add_option(body->items, "Download video and audio", "", [&audio_only](){ audio_only = false; }); options_page->add_option(body->items, "Download only audio", "", [&audio_only](){ audio_only = true; }); if(video_player_window) { XUnmapWindow(disp, video_player_window); XSync(disp, False); } std::vector tabs; tabs.push_back(Tab{ std::move(body), std::move(options_page), nullptr }); bool selected = page_loop(tabs); if(video_player_window) { XMapWindow(disp, video_player_window); XSync(disp, False); } if(!selected) return; download_async_gui(url, file_manager_start_dir.string(), true, audio_only); } bool Program::video_download_if_non_streamable(std::string &video_url, std::string &audio_url, bool &is_audio_only, bool &has_embedded_audio, PageType previous_page) { Path video_cache_dir = get_cache_dir().join("media"); Path video_path = video_cache_dir; SHA256 sha256; sha256.add(video_url.data(), video_url.size()); video_path.join(sha256.getHash()); if(get_file_type(video_path) == FileType::REGULAR) { fprintf(stderr, "%s is found in cache. Playing from cache...\n", video_url.c_str()); video_url = std::move(video_path.data); audio_url.clear(); if(no_video) { is_audio_only = true; has_embedded_audio = false; } else { is_audio_only = false; has_embedded_audio = true; } } else { TaskResult video_is_not_streamble_result = run_task_with_loading_screen([video_url]() { return video_url_is_non_streamable_mp4(video_url.c_str()); }); if(video_is_not_streamble_result == TaskResult::TRUE) { fprintf(stderr, "%s is detected to be a non-streamable mp4 file, downloading it before playing it...\n", video_url.c_str()); if(create_directory_recursive(video_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create video cache directory", Urgency::CRITICAL); current_page = previous_page; return false; } TaskResult download_file_result = run_task_with_loading_screen([&video_path, video_url]() { return download_to_file(video_url, video_path.data, {}, true) == DownloadResult::OK; }); switch(download_file_result) { case TaskResult::TRUE: { video_url = std::move(video_path.data); audio_url.clear(); if(no_video) { is_audio_only = true; has_embedded_audio = false; } else { is_audio_only = false; has_embedded_audio = true; } break; } case TaskResult::FALSE: { show_notification("QuickMedia", "Failed to download " + video_url, Urgency::CRITICAL); current_page = previous_page; return false; } case TaskResult::CANCEL: { current_page = previous_page; return false; } } } else if(video_is_not_streamble_result == TaskResult::CANCEL) { current_page = previous_page; return false; } } return true; } #define CLEANMASK(mask) ((mask) & (ShiftMask|ControlMask|Mod1Mask|Mod4Mask|Mod5Mask)) void Program::video_content_page(Page *parent_page, VideoPage *video_page, std::string video_title, bool download_if_streaming_fails, Body *parent_body, BodyItems &next_play_items, int play_index, int *parent_body_page, const std::string &parent_page_search) { PageType previous_page = pop_page_stack(); bool video_loaded = false; std::string youtube_video_id_dummy; const bool is_youtube = youtube_url_extract_id(video_page->get_url(), youtube_video_id_dummy); const bool is_matrix = strcmp(plugin_name, "matrix") == 0; idle_active_handler(); video_player.reset(); BodyItems related_videos; bool move_in_parent = false; if(video_page->autoplay_next_item() && play_index + 1 >= 0 && play_index + 1 < (int)next_play_items.size()) { related_videos.insert(related_videos.end(), next_play_items.begin() + play_index + 1, next_play_items.end()); move_in_parent = true; } 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::string channel_url; AsyncTask video_tasks; std::function video_event_callback; bool go_to_previous_page = false; std::string video_url; std::string audio_url; bool in_seeking = false; sf::Clock seeking_start_timer; const float seeking_restart_timeout_sec = 10.0f; // TODO: Test if this timeout is good on slow hardware such as pinephone and slow internet const int num_load_tries_max = 3; int load_try = 0; std::string prev_start_time; std::vector media_chapters; auto load_video_error_check = [this, &prev_start_time, &media_chapters, &in_seeking, &video_url, &audio_url, &video_title, &video_tasks, &channel_url, previous_page, &go_to_previous_page, &video_loaded, video_page, &video_event_callback, &on_window_create, &video_player_window, is_youtube, is_matrix, download_if_streaming_fails](std::string start_time = "", bool reuse_media_source = false) mutable { video_player.reset(); channel_url.clear(); video_loaded = false; in_seeking = false; video_player_window = None; bool is_audio_only = no_video; bool has_embedded_audio = true; const int largest_monitor_height = get_largest_monitor_height(disp); if(!reuse_media_source) { std::string new_title; TaskResult load_result = run_task_with_loading_screen([video_page, &new_title, &channel_url, &media_chapters]() { return video_page->load(new_title, channel_url, media_chapters) == PluginResult::OK; }); if(!new_title.empty()) video_title = std::move(new_title); if(load_result == TaskResult::CANCEL) { current_page = previous_page; go_to_previous_page = true; return; } else if(load_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to load media", Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; return; } video_url.clear(); audio_url.clear(); if(!no_video) video_url = video_page->get_video_url(largest_monitor_height, has_embedded_audio); if(video_url.empty() || no_video) { video_url = video_page->get_audio_url(); if(video_url.empty()) { video_url = video_page->get_url(); has_embedded_audio = true; } else { is_audio_only = true; has_embedded_audio = false; } } else if(!has_embedded_audio) { audio_url = video_page->get_audio_url(); } if(!is_youtube && download_if_streaming_fails) { if(!video_download_if_non_streamable(video_url, audio_url, is_audio_only, has_embedded_audio, previous_page)) { go_to_previous_page = true; return; } } } const bool is_resume_go_back = !start_time.empty(); if(start_time.empty()) start_time = video_page->get_url_timestamp(); prev_start_time = start_time; watched_videos.insert(video_page->get_url()); video_player = std::make_unique(is_audio_only, use_system_mpv_config, is_matrix && !is_youtube, video_event_callback, on_window_create, resources_root, largest_monitor_height); VideoPlayer::Error err = video_player->load_video(video_url.c_str(), audio_url.c_str(), window.getSystemHandle(), is_youtube, video_title, start_time, media_chapters); if(err != VideoPlayer::Error::OK) { std::string err_msg = "Failed to play url: "; err_msg += video_page->get_url(); show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; } else { if(video_page->autoplay_next_item()) return; if(!is_resume_go_back) { std::string url = video_page->get_url(); video_tasks = AsyncTask([video_page, url]() { BodyItems related_videos = video_page->get_related_media(url); video_page->mark_watched(); return related_videos; }); } // TODO: Make this also work for other video plugins if(strcmp(plugin_name, "youtube") != 0 || is_resume_go_back) return; std::string video_id; if(!youtube_url_extract_id(video_page->get_url(), video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += video_page->get_url(); err_msg + ", video wont be saved in history"; show_notification("QuickMedia", err_msg.c_str(), Urgency::LOW); return; } Json::Value video_history_json = load_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 = [&load_try, &video_loaded, &in_seeking, &seeking_start_timer](const char *event_name) mutable { if(strcmp(event_name, "seek") == 0) { in_seeking = true; seeking_start_timer.restart(); } 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); in_seeking = false; load_try = 0; } else if(strcmp(event_name, "file-loaded") == 0) { video_loaded = true; in_seeking = false; load_try = 0; } else if(strcmp(event_name, "video-reconfig") == 0 || strcmp(event_name, "audio-reconfig") == 0) { if(!video_loaded) { video_loaded = true; } in_seeking = false; load_try = 0; } //fprintf(stderr, "event name: %s\n", event_name); }; load_video_error_check(); sf::Event event; XEvent xev; bool cursor_visible = true; sf::Clock cursor_hide_timer; auto save_video_url_to_clipboard = [this, video_page]() { std::string url = video_page->get_url(); if(video_url_supports_timestamp(url)) { double time_in_file = 0.0; if(video_player && (video_player->get_time_in_file(&time_in_file) != VideoPlayer::Error::OK)) time_in_file = 0.0; std::string clipboard = std::move(url); if((int)time_in_file > 0) clipboard += "&t=" + std::to_string((int)time_in_file); sf::Clipboard::setString(sf::String::fromUtf8(clipboard.begin(), clipboard.end())); } else { sf::Clipboard::setString(sf::String::fromUtf8(url.begin(), url.end())); } }; while (current_page == PageType::VIDEO_CONTENT && window.isOpen() && !go_to_previous_page) { 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 if(window_is_fullscreen(disp, window.getSystemHandle())) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::UNSET); } else { current_page = previous_page; go_to_previous_page = true; } } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::F && event.key.control) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::TOGGLE); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::C && event.key.control) { save_video_url_to_clipboard(); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::F5) { load_video_error_check(); } } handle_window_close(); if(video_player && video_player_window && XCheckTypedWindowEvent(disp, video_player_window, KeyPress, &xev)/* && xev.xkey.subwindow == video_player_window*/) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" KeySym pressed_keysym = XKeycodeToKeysym(disp, xev.xkey.keycode, 0); #pragma GCC diagnostic pop bool pressing_ctrl = (CLEANMASK(xev.xkey.state) == ControlMask); if(pressed_keysym == XK_Escape || pressed_keysym == XK_q || pressed_keysym == XK_BackSpace) { if(window_is_fullscreen(disp, window.getSystemHandle())) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::UNSET); } else { current_page = previous_page; go_to_previous_page = true; break; } } else if(pressed_keysym == XK_f && pressing_ctrl) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::TOGGLE); } else if(pressed_keysym == XK_s && pressing_ctrl) { video_page_download_video(video_page->get_url(), !is_matrix || is_youtube, video_player_window); } else if(pressed_keysym == XK_F5) { in_seeking = false; double resume_start_time = 0.0; video_player->get_time_in_file(&resume_start_time); load_video_error_check(std::to_string((int)resume_start_time)); } else if(pressed_keysym == XK_r && pressing_ctrl) { bool cancelled = false; if(video_tasks.valid()) { XUnmapWindow(disp, video_player_window); XSync(disp, False); TaskResult task_result = run_task_with_loading_screen([&video_tasks, &related_videos]() { while(true) { if(program_is_dead_in_current_thread()) return false; if(video_tasks.ready()) { related_videos = video_tasks.get(); return true; } std::this_thread::sleep_for(std::chrono::milliseconds(50)); } }); XMapWindow(disp, video_player_window); XSync(disp, False); if(task_result == TaskResult::CANCEL || task_result == TaskResult::FALSE) cancelled = true; } if(!cancelled) { 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); 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(false, true), 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(false, true); 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(false, true), std::move(channels_page), create_search_bar("Search...", 350)}); } bool page_changed = false; double resume_start_time = 0.0; page_loop(tabs, 1, [this, &page_changed, &resume_start_time](const std::vector &new_tabs) { if(!page_changed && new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::VIDEO) { video_player->get_time_in_file(&resume_start_time); video_player.reset(); page_changed = true; } }); if(!window.isOpen() || current_page == PageType::EXIT) { video_player.reset(); return; } if(!video_player) { current_page = PageType::VIDEO_CONTENT; load_video_error_check(resume_start_time > 0.1 ? std::to_string((int)resume_start_time) : ""); } else { 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; } // TODO: Remove the need for this. This is needed right now because mpv sometimes gets stuck when playing youtube videos after seeking too much if(is_youtube && video_player && in_seeking && seeking_start_timer.getElapsedTime().asSeconds() >= seeking_restart_timeout_sec) { in_seeking = false; double resume_start_time = 0.0; if(video_player->get_time_in_file(&resume_start_time) == VideoPlayer::Error::OK) { fprintf(stderr, "Video seems to be stuck after seeking, reloading...\n"); // TODO: Set the second argument to false if the video url is no longer valid (or always?) load_video_error_check(std::to_string((int)resume_start_time), true); } } VideoPlayer::Error update_err = video_player ? video_player->update() : VideoPlayer::Error::OK; if(update_err == VideoPlayer::Error::FAIL_TO_CONNECT_TIMEOUT) { show_notification("QuickMedia", "Failed to connect to mpv ipc after 10 seconds", Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; break; } else if(update_err == VideoPlayer::Error::EXITED && video_player->exit_status == 0 && (!is_matrix || is_youtube)) { std::string new_video_url; if(video_tasks.valid()) { TaskResult task_result = run_task_with_loading_screen([&video_tasks, &related_videos]() { while(true) { if(program_is_dead_in_current_thread()) return false; if(video_tasks.ready()) { related_videos = video_tasks.get(); return true; } std::this_thread::sleep_for(std::chrono::milliseconds(50)); } }); if(task_result == TaskResult::CANCEL || task_result == TaskResult::FALSE) { current_page = previous_page; go_to_previous_page = true; break; } } // Find video that hasn't been played before in this video session auto find_next_video = [this, parent_body, move_in_parent, &related_videos, &video_page, &new_video_url]() { for(auto it = related_videos.begin(), end = related_videos.end(); it != end; ++it) { if(!(*it)->url.empty() && watched_videos.find((*it)->url) == watched_videos.end() && !video_page->video_should_be_skipped((*it)->url)) { if(parent_body && move_in_parent) { parent_body->filter_search_fuzzy(""); parent_body->set_selected_item(it->get()); } new_video_url = (*it)->url; related_videos.erase(it); break; } } }; find_next_video(); if(new_video_url.empty() && parent_page && parent_body_page && video_page->autoplay_next_item()) { BodyItems new_body_items; const int fetch_page = (*parent_body_page) + 1; TaskResult load_next_page_result = run_task_with_loading_screen([parent_page, parent_page_search, fetch_page, &new_body_items] { if(parent_page->get_page(parent_page_search, fetch_page, new_body_items) != PluginResult::OK) { fprintf(stderr, "Failed to get next page (page %d)\n", fetch_page); return false; } return true; }); fprintf(stderr, "Finished fetching page %d, num new items: %zu\n", fetch_page, new_body_items.size()); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { next_play_items.insert(next_play_items.end(), new_body_items.begin(), new_body_items.end()); if(parent_body) parent_body->items_set_dirty(); (*parent_body_page)++; related_videos = std::move(new_body_items); find_next_video(); } if(load_next_page_result == TaskResult::CANCEL) { current_page = previous_page; go_to_previous_page = true; break; } } // If there are no videos to play, then dont play any... if(new_video_url.empty()) { show_notification("QuickMedia", "No more related videos to play"); current_page = previous_page; go_to_previous_page = true; break; } TaskResult get_playable_url_result = run_task_with_loading_screen([video_page, &new_video_url]() { video_page->set_url(video_page->url_get_playable_url(new_video_url)); return true; }); if(get_playable_url_result == TaskResult::CANCEL) { current_page = previous_page; go_to_previous_page = true; break; } else if(get_playable_url_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to get playable url", Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; break; } load_video_error_check(); } else if(update_err != VideoPlayer::Error::OK) { ++load_try; if(load_try < num_load_tries_max) { fprintf(stderr, "Failed to play the media, retrying (try %d out of %d)\n", 1 + load_try, num_load_tries_max); load_video_error_check(prev_start_time); } else { show_notification("QuickMedia", "Failed to play the video (error code " + std::to_string((int)update_err) + ")", Urgency::CRITICAL); current_page = previous_page; go_to_previous_page = true; break; } } AsyncImageLoader::get_instance().update(); if(!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; } 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)); } video_player.reset(); 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.empty() && website_url_str.back() != '/') website_url_str.push_back('/'); extra_args = { CommandArg { "-H", "referer: " + std::move(website_url_str) }, }; } Path image_filepath_tmp(image_filepath.data + ".tmpz"); // TODO: Move to page 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); AsyncImageLoader::get_instance().update(); 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::max(0, 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); sf::Clock force_redraw_timer; window.setFramerateLimit(20); idle = true; // TODO: Show to user if a certain page is missing (by checking page name (number) and checking if some are skipped) while (current_page == PageType::IMAGES && window.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; // TODO: Track x11 window damage instead if(force_redraw_timer.getElapsedTime().asSeconds() >= 1.0f) { force_redraw_timer.restart(); redraw = true; } 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; image_index = std::max(0, std::min(image_index, num_manga_pages)); 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); } idle_active_handler(); 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; } AsyncImageLoader::get_instance().update(); 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; } 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; std::string attached_image_url; 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; } }); }; bool redraw = true; Entry comment_input("Press i to begin writing a comment...", &rounded_rectangle_shader); comment_input.draw_background = false; comment_input.set_editable(false); std::string selected_file_for_upload; auto post_comment = [&comment_input, &selected_file_for_upload, &navigation_stage, &thread_page, &captcha_post_id, &request_new_google_captcha_challenge](std::string comment_to_post, std::string file_to_upload) { comment_input.set_editable(false); navigation_stage = NavigationStage::POSTING_COMMENT; PostResult post_result = thread_page->post_comment(captcha_post_id, comment_to_post, file_to_upload); 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. selected_file_for_upload.clear(); // TODO: Remove from here, this is async } 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::FILE_TOO_LARGE) { // show_notification("QuickMedia", "Failed to post comment because the file you are trying to upload is larger than " + std::to_string((double)thread_page->get_max_upload_file_size() * 1024.0 * 1024.0) + " mb", Urgency::CRITICAL); // navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::NO_SUCH_FILE) { show_notification("QuickMedia", "Failed to post comment because the file you are trying to upload no longer exists", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::FILE_TYPE_NOT_ALLOWED) { show_notification("QuickMedia", "Failed to post comment because you are trying to upload a file of a type that is not allowed", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::UPLOAD_FAILED) { show_notification("QuickMedia", "Failed to post comment because file upload failed", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::ERR) { show_notification("QuickMedia", "Failed to post comment", 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, &selected_file_for_upload, &thread_page](std::string text) -> bool { if(text.empty() && selected_file_for_upload.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, comment_to_post, selected_file_for_upload]() -> bool { post_comment(comment_to_post, selected_file_for_upload); 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, comment_to_post, selected_file_for_upload]() -> bool { post_comment(comment_to_post, selected_file_for_upload); 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(std::floor(plugin_logo.getSize().x * logo_sprite.getScale().x), std::floor(plugin_logo.getSize().y * logo_sprite.getScale().y)); sf::Sprite file_to_upload_sprite; bool sprite_applied_texture = false; std::shared_ptr file_to_upload_thumbnail_data; const float logo_file_to_upload_spacing = std::floor(10.0f * get_ui_scale()); float prev_chat_height = comment_input.get_height(); float chat_input_height_full = 0.0f; const float logo_padding_x = std::floor(15.0f * get_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; sf::Event event; std::deque comment_navigation_stack; std::deque 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, navigation_stage == NavigationStage::VIEWING_COMMENTS)) 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::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->url.empty()) { if(is_url_video(selected_item->url)) { page_stack.push(PageType::IMAGE_BOARD_THREAD); current_page = PageType::VIDEO_CONTENT; watched_videos.clear(); thread_page->set_url(selected_item->url); BodyItems next_items; int prev_selected = thread_body->get_selected_item(); // TODO: Use real title video_content_page(thread_page, thread_page, "", true, thread_body, thread_body->items, thread_body->get_selected_item()); if(thread_body->get_selected_item() != prev_selected) { comment_navigation_stack.clear(); comment_page_scroll_stack.clear(); } redraw = true; idle_active_handler(); } else { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->url.empty()) { load_image_future.cancel(); downloading_image = true; navigation_stage = NavigationStage::VIEWING_ATTACHED_IMAGE; attached_image_url = selected_item->url; load_image_future = AsyncTask([attached_image_url]() { std::string image_data; if(download_to_string_cache(attached_image_url, image_data, {}) != DownloadResult::OK) { show_notification("QuickMedia", "Failed to download image: " + attached_image_url, Urgency::CRITICAL); image_data.clear(); } return image_data; }); } } } } else if(event.key.code == sf::Keyboard::U) { std::filesystem::path &fm_dir = file_manager_start_dir; auto file_manager_page = std::make_unique(this, (FileManagerMimeType)(FILE_MANAGER_MIME_TYPE_IMAGE|FILE_MANAGER_MIME_TYPE_VIDEO)); file_manager_page->set_current_directory(fm_dir.string()); auto file_manager_body = create_body(); 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)}); sf::Event event; while(window.pollEvent(event)) {} selected_files.clear(); page_loop(file_manager_tabs); if(selected_files.empty()) { fprintf(stderr, "No files selected!\n"); } else { selected_file_for_upload = selected_files[0]; } redraw = true; frame_skip_text_entry = true; } else if(event.key.code == sf::Keyboard::D && event.key.control) { selected_file_for_upload.clear(); } else if(event.key.code == sf::Keyboard::C && event.key.control) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item) { std::string title = selected_item->get_title(); std::string description = selected_item->get_description(); std::string clipboard = title; if(!clipboard.empty()) { clipboard += '\n'; clipboard += std::move(description); } if(!clipboard.empty()) sf::Clipboard::setString(sf::String::fromUtf8(clipboard.begin(), clipboard.end())); } } else if(event.key.code == sf::Keyboard::I && event.key.control) { BodyItem *selected_item = thread_body->get_selected(); if(show_info_page(selected_item, true)) { redraw = true; frame_skip_text_entry = true; } } else if(event.key.code == sf::Keyboard::S && event.key.control) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->url.empty()) download_async_gui(selected_item->url, file_manager_start_dir.string(), false, false); } 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.back()) && (!selected_item->replies_to.empty() || !selected_item->replies.empty())) { for(auto &body_item : thread_body->items) { body_item->visible = false; } selected_item->visible = true; for(size_t reply_to_index : selected_item->replies_to) { thread_body->items[reply_to_index]->visible = true; } for(size_t reply_index : selected_item->replies) { thread_body->items[reply_index]->visible = true; } comment_navigation_stack.push_back(thread_body->get_selected_item()); comment_page_scroll_stack.push_back(thread_body->get_page_scroll()); //thread_body->clamp_selection(); thread_body->set_page_scroll(0.0f); } else if(event.key.code == sf::Keyboard::BackSpace && !comment_navigation_stack.empty()) { size_t previous_selected = comment_navigation_stack.back(); float previous_page_scroll = comment_page_scroll_stack.back(); comment_navigation_stack.pop_back(); comment_page_scroll_stack.pop_back(); if(comment_navigation_stack.empty()) { 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.back()].get(); selected_item->visible = true; for(size_t reply_to_index : selected_item->replies_to) { thread_body->items[reply_to_index]->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 && !event.key.control) { frame_skip_text_entry = true; navigation_stage = NavigationStage::REPLYING; comment_input.set_editable(true); comment_input.move_caret_to_end(); } } if(event.type == 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, comment_to_post, selected_file_for_upload](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(comment_to_post, selected_file_for_upload); } 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()); } else if(event.key.code == sf::Keyboard::I && event.key.control && !attached_image_url.empty()) { std::vector saucenao_tabs; saucenao_tabs.push_back(Tab{create_body(), std::make_unique(this, attached_image_url, false), nullptr}); page_loop(saucenao_tabs); redraw = true; frame_skip_text_entry = true; } else if(event.key.code == sf::Keyboard::S && event.key.control) { download_async_gui(attached_image_url, file_manager_start_dir.string(), false, false); } } } frame_skip_text_entry = false; update_idle_state(); handle_window_close(); if(selected_file_for_upload.empty()) { if(file_to_upload_thumbnail_data) { file_to_upload_thumbnail_data.reset(); redraw = true; } } else { file_to_upload_thumbnail_data = AsyncImageLoader::get_instance().get_thumbnail(selected_file_for_upload, true, sf::Vector2i(logo_size.x, logo_size.y * 4)); } if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING && file_to_upload_thumbnail_data->image->getSize().x > 0 && file_to_upload_thumbnail_data->image->getSize().y > 0) { if(!file_to_upload_thumbnail_data->texture.loadFromImage(*file_to_upload_thumbnail_data->image)) fprintf(stderr, "Warning: failed to load texture for attached file\n"); file_to_upload_thumbnail_data->texture.setSmooth(true); //room_avatar_thumbnail_data->texture.generateMipmap(); file_to_upload_thumbnail_data->image.reset(); file_to_upload_thumbnail_data->loading_state = LoadingState::APPLIED_TO_TEXTURE; sprite_applied_texture = false; } if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE && !sprite_applied_texture) { file_to_upload_sprite.setTexture(file_to_upload_thumbnail_data->texture, true); sprite_applied_texture = true; sf::Vector2f texture_size_f(file_to_upload_thumbnail_data->texture.getSize().x, file_to_upload_thumbnail_data->texture.getSize().y); sf::Vector2f image_scale = get_ratio(texture_size_f, clamp_to_size_x(texture_size_f, logo_size)); file_to_upload_sprite.setScale(image_scale); redraw = true; } float chat_input_height_full_images = logo_size.y; if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE) { const float file_to_upload_height = std::floor(logo_file_to_upload_spacing + file_to_upload_sprite.getTexture()->getSize().y * file_to_upload_sprite.getScale().y); chat_input_height_full_images += file_to_upload_height; } chat_input_height_full = chat_input_padding_y + std::max(comment_input.get_height(), chat_input_height_full_images) + chat_input_padding_y; const float chat_height = comment_input.get_height(); if(std::abs(chat_height - prev_chat_height) > 1.0f) { prev_chat_height = chat_height; redraw = true; } if(redraw) { redraw = false; comment_input.set_max_width(window_size.x - (logo_padding_x + logo_size.x + chat_input_padding_x + logo_padding_x)); comment_input.set_position(sf::Vector2f(std::floor(logo_padding_x + logo_size.x + chat_input_padding_x), chat_input_padding_y)); const float body_width = window_size.x; 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(0.0f, comment_input_shade.getSize().y); body_size = sf::Vector2f(body_width, window_size.y - comment_input_shade.getSize().y); logo_sprite.setPosition(logo_padding_x, chat_input_padding_y); file_to_upload_sprite.setPosition(logo_sprite.getPosition() + sf::Vector2f(0.0f, logo_size.y + logo_file_to_upload_spacing)); } //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(std::floor(image_size.x * 0.5f), 0.0f) - sf::Vector2f(std::floor(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. if(downloading_image && load_image_future.ready()) { downloading_image = false; std::string 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->url; show_notification("QuickMedia", "Failed to load image downloaded from url: " + selected_item_attached_url, Urgency::CRITICAL); } } 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(52, 58, 70)); 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); 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); } } else if(navigation_stage == NavigationStage::REPLYING) { window.draw(comment_input_shade); window.draw(logo_sprite); if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE) window.draw(file_to_upload_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } else if(navigation_stage == NavigationStage::VIEWING_COMMENTS) { window.draw(comment_input_shade); window.draw(logo_sprite); if(file_to_upload_thumbnail_data && file_to_upload_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE) window.draw(file_to_upload_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } AsyncImageLoader::get_instance().update(); 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"); const int num_inputs = 3; SearchBar *inputs[num_inputs] = { &login_input, &password_input, &homeserver_input }; int focused_input = 0; RoundedRectangle background(sf::Vector2f(1.0f, 1.0f), 10.0f, sf::Color(33, 37, 44), &rounded_rectangle_shader); auto text_submit_callback = [this, inputs](const sf::String&) { for(int i = 0; i < num_inputs; ++i) { if(inputs[i]->get_text().empty()) { show_notification("QuickMedia", "All fields need to be filled in", Urgency::CRITICAL); return; } } run_task_with_loading_screen([this, inputs](){ std::string err_msg; if(matrix->login(inputs[0]->get_text(), inputs[1]->get_text(), inputs[2]->get_text(), err_msg) == PluginResult::OK) { current_page = PageType::CHAT; return true; } else { show_notification("QuickMedia", "Failed to login, error: " + err_msg, Urgency::CRITICAL); return false; } }); }; for(int i = 0; i < num_inputs; ++i) { inputs[i]->padding_top = 0.0f; inputs[i]->padding_bottom = 0.0f; inputs[i]->padding_x = 0.0f; inputs[i]->caret_visible = false; inputs[i]->onTextSubmitCallback = text_submit_callback; } inputs[focused_input]->caret_visible = true; const float padding_x = std::floor(20.0f * get_ui_scale()); const float padding_y = std::floor(20.0f * get_ui_scale()); const float spacing_y = std::floor(20.0f * get_ui_scale()); sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; while (current_page == PageType::CHAT_LOGIN && window.isOpen()) { while (window.pollEvent(event)) { 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; idle_active_handler(); } 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); background.set_size(sf::Vector2f( std::min(window_size.x, std::max(640.0f, window_size.x * 0.5f)), num_inputs * inputs[0]->getBottomWithoutShadow() + padding_y * 2.0f + spacing_y * std::max(0, num_inputs - 1))); background.set_position(window_size * 0.5f - background.get_size() * 0.5f); sf::Vector2f pos = background.get_position() + sf::Vector2f(padding_x, padding_y); for(int i = 0; i < num_inputs; ++i) { inputs[i]->set_position(pos); pos.y += inputs[i]->getBottomWithoutShadow() + spacing_y; } } window.clear(back_color); background.draw(window); for(int i = 0; i < num_inputs; ++i) { inputs[i]->update(); inputs[i]->draw(window, background.get_size() - sf::Vector2f(padding_x * 2.0f, 0.0f), false); } AsyncImageLoader::get_instance().update(); 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(message->user->user_id != my_user_id && ((related_body_item->userdata && static_cast(related_body_item->userdata)->user.get() == me) || message_contains_user_mention(body_item->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->user->user_id != my_user_id && (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); } static Message* get_original_message(Message *message) { if(!message) return nullptr; Message *replaces = message->replaces; while(replaces) { if(!replaces->replaces) return replaces; replaces = replaces->replaces; } return nullptr; } bool Program::chat_page(MatrixChatPage *matrix_chat_page, RoomData *current_room) { assert(current_room); assert(strcmp(plugin_name, "matrix") == 0); if(!current_room) { show_notification("QuickMedia", "Bug: current room empty", Urgency::CRITICAL); abort(); } window.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(true); pinned_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; pinned_tab.body->attach_side = AttachSide::BOTTOM; tabs.push_back(std::move(pinned_tab)); ChatTab messages_tab; messages_tab.body = create_body(true); messages_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; messages_tab.body->attach_side = AttachSide::BOTTOM; tabs.push_back(std::move(messages_tab)); ChatTab users_tab; users_tab.body = create_body(true); users_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; users_tab.body->attach_side = AttachSide::TOP; tabs.push_back(std::move(users_tab)); for(ChatTab &tab : tabs) { tab.body->show_drop_shadow = false; } Tabs ui_tabs(&rounded_rectangle_shader, is_touch_enabled() ? sf::Color::Transparent : back_color); const int PINNED_TAB_INDEX = ui_tabs.add_tab("Pinned messages (0)", tabs[0].body.get()); const int MESSAGES_TAB_INDEX = ui_tabs.add_tab("Messages", tabs[1].body.get()); const int USERS_TAB_INDEX = ui_tabs.add_tab("Users (0)", tabs[2].body.get()); ui_tabs.set_selected(MESSAGES_TAB_INDEX); matrix_chat_page->chat_body = tabs[MESSAGES_TAB_INDEX].body.get(); matrix_chat_page->messages_tab_visible = true; matrix_chat_page->set_current_room(current_room, tabs[USERS_TAB_INDEX].body.get()); size_t prev_num_users_in_room = 0; 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 = 5.0; bool typing = false; MessageQueue typing_state_queue; auto typing_state_handler = [this, ¤t_room, &typing_state_queue]() { while(true) { std::optional state_opt = typing_state_queue.pop_wait(); if(!state_opt) break; bool state = state_opt.value(); if(state) matrix->on_start_typing(current_room); else matrix->on_stop_typing(current_room); } }; std::thread typing_state_thread(typing_state_handler); ui_tabs.on_change_tab = [matrix_chat_page, &redraw, &typing, &typing_state_queue, &read_marker_timer, &tabs, MESSAGES_TAB_INDEX](int selected_tab) { tabs[selected_tab].body->clear_cache(); if(selected_tab == MESSAGES_TAB_INDEX) matrix_chat_page->messages_tab_visible = true; else matrix_chat_page->messages_tab_visible = false; read_marker_timer.restart(); redraw = true; if(typing) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } }; bool is_window_focused = window.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(matrix_chat_page->rooms_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 *reply_to_message = static_cast(body_item->userdata); Message *orig_message = get_original_message(reply_to_message); body_item->set_description(strip(message_get_body_remove_formatting(message.get()))); if(message->user != me && (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))) body_item->set_description_color(sf::Color(255, 100, 100)); else body_item->set_description_color(sf::Color::White); message->replaces = reply_to_message; reply_to_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 *reply_to_message = static_cast(body_item->userdata); Message *orig_message = get_original_message(reply_to_message); body_item->set_description(strip(message_get_body_remove_formatting(message.get()))); if(message->user != me && (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))) body_item->set_description_color(sf::Color(255, 100, 100)); else body_item->set_description_color(sf::Color::White); message->replaces = reply_to_message; reply_to_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(BODY_THEME_MINIMAL, loading_icon, &rounded_rectangle_shader, &rounded_rectangle_mask_shader); std::unordered_set fetched_messages_set; auto filter_existing_messages = [&fetched_messages_set](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { 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())); 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 *reply_to_message = static_cast(body_item->userdata); if(!reply_to_message) { show_notification("QuickMedia", "Unexpected error, failed to set replaced by message", Urgency::CRITICAL); return; } message->replaces = reply_to_message; reply_to_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, ¤t_room](const std::string &filepath) { 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; } }); }; struct Mention { sf::Clock filter_timer; bool visible = false; bool filter_updated = false; sf::String filter; Body *users_tab_body = nullptr; void show() { visible = true; } void hide() { visible = false; filter_updated = false; filter.clear(); users_tab_body->filter_search_fuzzy(""); users_tab_body->select_first_item(); users_tab_body->clear_cache(); } void handle_event(const sf::Event &event) { if(visible) { if(event.type == sf::Event::TextEntered) { filter_timer.restart(); if(event.text.unicode > 32) { filter += event.text.unicode; filter_updated = true; } else if(event.text.unicode == ' ' || event.text.unicode == '\t') { hide(); } } else if(event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { users_tab_body->select_previous_item(true); } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { users_tab_body->select_next_item(true); } else if(event.key.code == sf::Keyboard::Enter && event.key.shift) { hide(); } else if(event.key.code == sf::Keyboard::Backspace) { if(filter.getSize() == 0) { hide(); } else { filter.erase(filter.getSize() - 1, 1); filter_updated = true; } } } } if(event.type == sf::Event::TextEntered && event.text.unicode == '@' && !visible) show(); } void update() { if(visible && filter_updated && filter_timer.getElapsedTime().asMilliseconds() > 50) { filter_updated = false; // TODO: Use std::string instead of sf::String auto u8 = filter.toUtf8(); users_tab_body->filter_search_fuzzy(*(std::string*)&u8); users_tab_body->select_first_item(); } } }; Mention mention; mention.users_tab_body = tabs[USERS_TAB_INDEX].body.get(); const float user_mention_body_height = std::floor(300.0f * get_ui_scale()); bool frame_skip_text_entry = false; chat_input.on_submit_callback = [this, &frame_skip_text_entry, &mention, &tabs, &me, &chat_input, &ui_tabs, MESSAGES_TAB_INDEX, USERS_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(mention.visible) { BodyItem *selected_mention_item = tabs[USERS_TAB_INDEX].body->get_selected(); if(selected_mention_item) { std::string str_to_append = selected_mention_item->get_description(); if(!str_to_append.empty()) str_to_append.erase(0, 1); str_to_append += " "; chat_input.replace(chat_input.get_caret_index() - mention.filter.getSize(), mention.filter.getSize(), sf::String::fromUtf8(str_to_append.begin(), str_to_append.end())); mention.hide(); } 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; // Fetch replied to message if(event_data->status == FetchStatus::FINISHED_LOADING && event_data->message) { if(event_data->message->related_event_id.empty() || event_data->message->related_event_type != RelatedEventType::REPLY || (body_item->embedded_item_status != FetchStatus::NONE && body_item->embedded_item_status != FetchStatus::QUEUED_LOADING)) return; if(load_cached_related_embedded_item(body_item, 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)}; }); }; // TODO: How about instead fetching all messages we have, not only the visible ones? also fetch with multiple threads. tabs[MESSAGES_TAB_INDEX].body->body_item_render_callback = [this, ¤t_room, &me, &fetch_message_future, &tabs, &fetch_body_item, &fetch_message_tab, MESSAGES_TAB_INDEX](BodyItem *body_item) { Message *message = static_cast(body_item->userdata); if(!message) return; if(message->related_event_id.empty() || message->related_event_type != RelatedEventType::REPLY || (body_item->embedded_item_status != FetchStatus::NONE && body_item->embedded_item_status != FetchStatus::QUEUED_LOADING)) return; if(fetch_message_future.valid()) { body_item->embedded_item_status = FetchStatus::QUEUED_LOADING; return; } if(load_cached_related_embedded_item(body_item, 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; 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; }); } } }; 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()); bool avatar_applied = false; auto launch_url = [this, matrix_chat_page, &tabs, MESSAGES_TAB_INDEX, &redraw, &avatar_applied](const std::string &url) mutable { if(url.empty()) return; std::string video_id; if(youtube_url_extract_id(url, video_id)) { watched_videos.clear(); page_stack.push(PageType::CHAT); current_page = PageType::VIDEO_CONTENT; auto youtube_video_page = std::make_unique(this, url); // TODO: Use real title video_content_page(matrix_chat_page, youtube_video_page.get(), "", false, nullptr, tabs[MESSAGES_TAB_INDEX].body->items, tabs[MESSAGES_TAB_INDEX].body->get_selected_item()); redraw = true; avatar_applied = false; } else { const char *launch_program = "xdg-open"; if(!is_program_executable_by_name("xdg-open")) { launch_program = getenv("BROWSER"); if(!launch_program) { show_notification("QuickMedia", "xdg-utils which provides xdg-open needs to be installed to open urls. Alternatively set the $BROWSER environment variable to a browser", Urgency::CRITICAL); return; } } std::string url_modified = url; if(strncmp(url.c_str(), "http://", 7) != 0 && strncmp(url.c_str(), "https://", 8) != 0) url_modified = "https://" + url; const char *args[] = { launch_program, url_modified.c_str(), nullptr }; exec_program_async(args, nullptr); } }; auto filter_provisional_messages = [&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; } 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(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, &avatar_applied, PINNED_TAB_INDEX, MESSAGES_TAB_INDEX](BodyItem *selected) { if(!selected) return false; const int selected_tab = ui_tabs.get_selected(); Message *selected_item_message = nullptr; if(selected_tab == MESSAGES_TAB_INDEX) { selected_item_message = static_cast(selected->userdata); } else if(selected_tab == PINNED_TAB_INDEX && static_cast(selected->userdata)->status == FetchStatus::FINISHED_LOADING) { selected_item_message = static_cast(selected->userdata)->message; } if(selected_item_message) { MessageType message_type = selected_item_message->type; if(!selected->url.empty()) { 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->set_url(selected->url); BodyItems next_items; video_content_page(matrix_chat_page, video_page.get(), selected_item_message->body, message_type == MessageType::VIDEO || message_type == MessageType::AUDIO, nullptr, next_items, 0); no_video = prev_no_video; redraw = true; avatar_applied = false; return true; } else if(message_type == MessageType::FILE) { download_async_gui(selected->url, file_manager_start_dir.string(), false, no_video); 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 download_selected_item = [this, &ui_tabs, 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() && message_type >= MessageType::IMAGE && message_type <= MessageType::FILE) { download_async_gui(selected->url, file_manager_start_dir.string(), false, no_video); 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, MESSAGES_TAB_INDEX, PINNED_TAB_INDEX, USERS_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()) { tabs[MESSAGES_TAB_INDEX].body->clear_items(); for(auto &body_item : tabs[PINNED_TAB_INDEX].body->items) { delete (PinnedEventData*)body_item->userdata; } tabs[PINNED_TAB_INDEX].body->clear_items(); tabs[USERS_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; matrix_chat_page->rooms_page->body->body_item_select_callback = [&move_room](BodyItem *body_item) { move_room = true; }; std::function on_top_reached = [this, &previous_messages_future, &ui_tabs, &MESSAGES_TAB_INDEX, &gradient_inc, current_room] { const int selected_tab = ui_tabs.get_selected(); if(!previous_messages_future.valid() && selected_tab == MESSAGES_TAB_INDEX) { gradient_inc = 0; previous_messages_future = AsyncTask([this, current_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; }); } }; for(size_t i = 0; i < tabs.size(); ++i) { tabs[i].body->on_top_reached = on_top_reached; } 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, chat_state == ChatState::NAVIGATING)) 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) { if(!mention.visible || event.type != sf::Event::KeyPressed || (event.key.code != sf::Keyboard::Up && event.key.code != sf::Keyboard::Down && event.key.code != sf::Keyboard::Left && event.key.code != sf::Keyboard::Right)) chat_input.process_event(event); if(chat_input.is_editable()) mention.handle_event(event); } if(draw_room_list) { if(matrix_chat_page->rooms_page->body->on_event(window, event, false)) idle_active_handler(); } if(event.type == sf::Event::KeyPressed && 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)) { matrix_chat_page->rooms_page->body->select_previous_item(true); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { matrix_chat_page->rooms_page->body->select_next_item(true); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::PageUp) { matrix_chat_page->rooms_page->body->select_previous_page(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::PageDown) { matrix_chat_page->rooms_page->body->select_next_page(); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::Home) { matrix_chat_page->rooms_page->body->select_first_item(false); move_room = true; goto chat_page_end; } else if(event.key.code == sf::Keyboard::End) { matrix_chat_page->rooms_page->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::Escape) { goto chat_page_end; } else if(event.key.code == sf::Keyboard::I && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item && !selected_item->url.empty() && !selected_item->thumbnail_url.empty()) { Message *selected_item_message = nullptr; if(selected_tab == MESSAGES_TAB_INDEX) { selected_item_message = static_cast(selected_item->userdata); } else if(selected_tab == PINNED_TAB_INDEX && static_cast(selected_item->userdata)->status == FetchStatus::FINISHED_LOADING) { selected_item_message = static_cast(selected_item->userdata)->message; } if(selected_item_message && (selected_item_message->type == MessageType::IMAGE || selected_item_message->type == MessageType::VIDEO)) { std::string image_url = selected_item->url; if(selected_item_message->type == MessageType::VIDEO) image_url = selected_item->thumbnail_url; std::vector saucenao_tabs; saucenao_tabs.push_back(Tab{create_body(), std::make_unique(this, image_url, false), nullptr}); page_loop(saucenao_tabs); redraw = true; frame_skip_text_entry = true; } } } if((selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) && !frame_skip_text_entry) { if(event.key.code == sf::Keyboard::Enter) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!display_url_or_image(selected)) display_url_or_image(selected->embedded_item.get()); } } else if(event.key.code == sf::Keyboard::S && event.key.control) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!download_selected_item(selected)) download_selected_item(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 && !event.key.control) { frame_skip_text_entry = true; chat_input.set_editable(true); chat_state = ChatState::TYPING_MESSAGE; } if(event.key.control && event.key.code == 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 && !chat_input.is_editable()) { frame_skip_text_entry = true; BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!is_state_message_type(static_cast(selected->userdata))) { if(static_cast(selected->userdata)->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't delete a message that hasn't been sent yet"); } else { //set_body_as_deleted(static_cast(selected->userdata), selected); void *selected_message = selected->userdata; post_task_queue.push([this, ¤t_room, selected_message]() { ProvisionalMessage provisional_message; std::string err_msg; if(matrix->delete_message(current_room, selected_message, err_msg) != PluginResult::OK) { // TODO: Show inline notification fprintf(stderr, "Failed to delete message, reason: %s\n", err_msg.c_str()); } return provisional_message; }); } } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for deletion"); } } } } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::URL_SELECTION) { 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) { if(mention.visible) { mention.hide(); } else { chat_input.set_editable(false); chat_input.set_text(""); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; if(typing && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } } } } } frame_skip_text_entry = false; update_idle_state(); handle_window_close(); matrix->update(); mention.update(); const size_t num_users_in_room = matrix_chat_page->get_num_users_in_current_room(); if(num_users_in_room != prev_num_users_in_room) { prev_num_users_in_room = num_users_in_room; ui_tabs.set_text(USERS_TAB_INDEX, "Users (" + std::to_string(num_users_in_room) + ")"); } while((provisional_message = provisional_message_queue.pop_if_available()) != std::nullopt) { if(!provisional_message->body_item || !provisional_message->message) continue; if(!provisional_message->event_id.empty()) { provisional_message->message->event_id = std::move(provisional_message->event_id); provisional_message->body_item->set_description_color(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; } } switch(new_page) { case PageType::FILE_MANAGER: { new_page = PageType::CHAT; for(ChatTab &tab : tabs) { tab.body->clear_cache(); } std::filesystem::path &fm_dir = file_manager_start_dir; auto file_manager_page = std::make_unique(this); file_manager_page->set_current_directory(fm_dir.string()); auto file_manager_body = create_body(); 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; avatar_applied = false; break; } case PageType::CHAT_LOGIN: { matrix_chat_page->set_current_room(nullptr, nullptr); 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->body_item->thumbnail_url.empty()) room_avatar_thumbnail_data = AsyncImageLoader::get_instance().get_thumbnail(current_room->body_item->thumbnail_url, false, AVATAR_THUMBNAIL_SIZE); if(room_avatar_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING && room_avatar_thumbnail_data->image->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; avatar_applied = false; } if(room_avatar_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE && !avatar_applied) { avatar_applied = true; 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()); } redraw = true; } const int selected_tab = ui_tabs.get_selected(); float room_name_padding_y = 0.0f; if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX || selected_tab == USERS_TAB_INDEX) room_name_padding_y = room_name_total_height; chat_input_height_full = chat_input.get_height() + chat_input_padding_y * 2.0f; if(selected_tab != MESSAGES_TAB_INDEX) chat_input_height_full = 0.0f; const float chat_height = chat_input.get_height(); if(std::abs(chat_height - prev_chat_height) > 1.0f) { prev_chat_height = chat_height; redraw = true; } if(redraw) { redraw = false; if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX || selected_tab == USERS_TAB_INDEX) { tab_vertical_offset = std::floor(10.0f * get_ui_scale()); } tab_shade_height = std::floor(tab_vertical_offset) + Tabs::get_shade_height() + room_name_padding_y; const float body_width = window_size.x; this->body_pos = 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, tab_shade_height); body_size = sf::Vector2f(body_width - this->body_pos.x - this->body_size.x, window_size.y - chat_input_height_full - tab_shade_height); chat_input_shade.setSize(sf::Vector2f(window_size.x - body_pos.x, chat_input_height_full)); chat_input_shade.setPosition(body_pos.x, 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)); chat_input.set_position(sf::Vector2f(std::floor(body_pos.x + 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 + logo_padding_x, std::floor(window_size.y - chat_input_height_full * 0.5f - logo_size.y * 0.5f)); } 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; if(fetch_message_result.message->user == me) fetch_body_item->set_description_color(sf::Color(255, 100, 100)); } 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); if(selected_tab == MESSAGES_TAB_INDEX && mention.visible && chat_state == ChatState::TYPING_MESSAGE) { sf::RectangleShape user_mention_background(sf::Vector2f(body_size.x, user_mention_body_height)); user_mention_background.setPosition(sf::Vector2f(body_pos.x, body_pos.y + body_size.y - user_mention_body_height)); user_mention_background.setFillColor(sf::Color(33, 37, 44)); window.draw(user_mention_background); tabs[USERS_TAB_INDEX].body->draw(window, user_mention_background.getPosition(), user_mention_background.getSize()); } } //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 || selected_tab == USERS_TAB_INDEX) { float room_name_text_offset_x = 0.0f; if(room_avatar_thumbnail_data->loading_state == LoadingState::APPLIED_TO_TEXTURE && 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 + std::floor(10.0f * get_ui_scale()), 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 += std::floor(10.0f * get_ui_scale()) + 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 tab_y = std::floor(tab_vertical_offset) + room_name_padding_y; matrix_chat_page->rooms_page->body->draw(window, sf::Vector2f(0.0f, tab_y), sf::Vector2f(this->body_size.x, 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; float extra_user_mention_height = 0.0f; if(mention.visible) extra_user_mention_height = user_mention_body_height; sf::RectangleShape overlay(sf::Vector2f(window_size.x, window_size.y - chat_input_height_full - extra_user_mention_height)); 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); if(mention.visible) { sf::RectangleShape user_mention_background(sf::Vector2f(window_size.x, user_mention_body_height)); user_mention_background.setPosition(sf::Vector2f(0.0f, item_background.getPosition().y - user_mention_body_height)); user_mention_background.setFillColor(sf::Color(33, 37, 44)); window.draw(user_mention_background); tabs[USERS_TAB_INDEX].body->draw(window, sf::Vector2f(body_pos.x, item_background.getPosition().y - user_mention_body_height), sf::Vector2f(body_size.x, user_mention_body_height)); } 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(!tabs[selected_tab].body->is_bottom_cut_off() && is_window_focused && chat_state != ChatState::URL_SELECTION && !setting_read_marker && read_marker_timer.getElapsedTime().asMilliseconds() >= read_marker_timeout_ms) { auto &body_items = tabs[selected_tab].body->items; int last_timeline_message = (int)body_items.size() - 1; for(int i = last_timeline_message - 1; i >= 0; --i) { BodyItem *item = body_items[i].get(); Message *message = static_cast(item->userdata); if(item->visible && message && message_is_timeline(message)) break; } if(last_timeline_message != -1) { 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; matrix_chat_page->set_room_as_read(current_room); Message *read_message = static_cast(body_items[last_timeline_message]->userdata); 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_bottom_cut_off()) { 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)) { matrix_chat_page->set_current_room(nullptr, nullptr); previous_messages_future.cancel(); cleanup_tasks(); tabs.clear(); unreferenced_events.clear(); unresolved_reactions.clear(); all_messages.clear(); show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); matrix->logout(); current_page = PageType::CHAT_LOGIN; chat_login_page(); after_matrix_login_page(); window.close(); goto chat_page_end; } } AsyncImageLoader::get_instance().update(); window.display(); if(selected_tab == MESSAGES_TAB_INDEX) 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)) { matrix_chat_page->set_current_room(nullptr, nullptr); previous_messages_future.cancel(); cleanup_tasks(); tabs.clear(); unreferenced_events.clear(); unresolved_reactions.clear(); all_messages.clear(); show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); matrix->logout(); 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; 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: matrix_chat_page->set_current_room(nullptr, nullptr); 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_tags_body = create_body(); auto matrix_rooms_tag_page = std::make_unique(this, rooms_tags_body.get()); auto rooms_body = create_body(true); auto rooms_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_rooms_page = std::make_unique(this, rooms_body.get(), "All rooms", nullptr, rooms_page_search_bar.get()); auto notifications_body = create_body(); //notifications_body->attach_side = AttachSide::BOTTOM; auto matrix_notifications_page = std::make_unique(this, matrix, notifications_body.get(), matrix_rooms_page.get()); auto invites_body = create_body(); auto matrix_invites_page = std::make_unique(this, matrix, invites_body.get()); 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(), matrix_notifications_page.get()); bool sync_cached = false; if(!matrix->start_sync(&matrix_handler, sync_cached)) { show_notification("QuickMedia", "Failed to start sync", Urgency::CRITICAL); exit_code = 1; return; } is_login_sync = !sync_cached; std::vector tabs; tabs.push_back(Tab{std::move(notifications_body), std::move(matrix_notifications_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{std::move(rooms_tags_body), std::move(matrix_rooms_tag_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{std::move(rooms_body), std::move(matrix_rooms_page), std::move(rooms_page_search_bar)}); tabs.push_back(Tab{std::move(invites_body), std::move(matrix_invites_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); tabs.push_back(Tab{std::move(room_directory_body), std::move(matrix_room_directory_page), create_search_bar("Server to search on...", SEARCH_DELAY_FILTER)}); while(window.isOpen()) { page_loop(tabs, 2); } matrix->stop_sync(); } enum class DownloadUpdateStatus { DOWNLOADING, FINISHED, ERROR }; class Downloader { public: Downloader(const std::string &url, const std::string &output_filepath) : url(url), output_filepath(output_filepath) {} virtual ~Downloader() = default; virtual bool start() = 0; virtual void stop(bool download_completed) = 0; virtual DownloadUpdateStatus update() = 0; virtual float get_progress() = 0; virtual std::string get_progress_text() = 0; virtual std::string get_download_speed_text() = 0; protected: std::string url; std::string output_filepath; }; class CurlDownloader : public Downloader { public: CurlDownloader(const std::string &url, const std::string &output_filepath) : Downloader(url, output_filepath) { output_filepath_tmp = output_filepath; output_filepath_tmp.append(".tmp"); read_program.pid = -1; read_program.read_fd = -1; progress_text = "0 bytes/Unknown"; download_speed_text = "Unknown/s"; } bool start() override { remove(output_filepath_tmp.data.c_str()); const char *args[] = { "curl", "-H", "Accept-Language: en-US,en;q=0.5", "-H", "Connection: keep-alive", "--compressed", "-H", "user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", "-g", "-s", "-L", "-f", "-o", output_filepath_tmp.data.c_str(), "-D", "/dev/stdout", "--", url.c_str(), nullptr }; if(exec_program_pipe(args, &read_program) != 0) return false; header_reader = AsyncTask([this]{ char tmp_buf[1024]; while(true) { ssize_t bytes_available = read(read_program.read_fd, tmp_buf, sizeof(tmp_buf)); if(bytes_available == -1) { return false; } else if(bytes_available > 0 && content_length == (size_t)-1) { header.append(tmp_buf, bytes_available); if(header.find("\r\n\r\n") != std::string::npos) { std::string content_length_str = header_extract_value(header, "content-length"); if(!content_length_str.empty()) { errno = 0; char *endptr; const long content_length_tmp = strtol(content_length_str.c_str(), &endptr, 10); if(endptr != content_length_str.c_str() && errno == 0) { std::lock_guard lock(content_length_mutex); content_length = content_length_tmp; } } } } } return true; }); return true; } void stop(bool download_completed) override { if(read_program.pid != -1) close(read_program.pid); if(read_program.read_fd != -1) kill(read_program.read_fd, SIGTERM); if(!download_completed) remove(output_filepath_tmp.data.c_str()); //header_reader.cancel(); } DownloadUpdateStatus update() override { int status = 0; if(wait_program_non_blocking(read_program.pid, &status)) { read_program.pid = -1; if(status == 0 && rename_atomic(output_filepath_tmp.data.c_str(), output_filepath.c_str()) == 0) { return DownloadUpdateStatus::FINISHED; } else { return DownloadUpdateStatus::ERROR; } } if(header_reader.ready()) { if(!header_reader.get()) return DownloadUpdateStatus::ERROR; } std::lock_guard lock(content_length_mutex); size_t output_file_size = 0; file_get_size(output_filepath_tmp, &output_file_size); size_t downloaded_size = std::min(output_file_size, content_length); if(content_length == (size_t)-1) { progress_text = std::to_string(output_file_size / 1024) + "/Unknown"; } else { size_t percentage = 0; if(output_file_size > 0) percentage = (double)downloaded_size / (double)content_length * 100.0; progress = (double)percentage / 100.0; progress_text = file_size_to_human_readable_string(downloaded_size) + "/" + file_size_to_human_readable_string(content_length) + " (" + std::to_string(percentage) + "%)"; } // TODO: Take into consideration time overflow? size_t downloaded_diff = std::max(0lu, downloaded_size - downloaded_since_last_check); download_speed_text = file_size_to_human_readable_string(downloaded_diff) + "/s"; downloaded_since_last_check = downloaded_size; return DownloadUpdateStatus::DOWNLOADING; } float get_progress() override { return progress; } std::string get_progress_text() override { return progress_text; } std::string get_download_speed_text() override { return download_speed_text; } private: Path output_filepath_tmp; ReadProgram read_program; AsyncTask header_reader; std::string header; size_t content_length = (size_t)-1; size_t downloaded_since_last_check = 0; float progress = 0.0f; std::mutex content_length_mutex; std::string progress_text; std::string download_speed_text; }; class YoutubeDlDownloader : public Downloader { public: YoutubeDlDownloader(const std::string &url, const std::string &output_filepath, bool no_video) : Downloader(url, output_filepath), no_video(no_video) { // youtube-dl requires a file extension for the file if(this->output_filepath.find('.') == std::string::npos) this->output_filepath += ".mkv"; read_program.pid = -1; read_program.read_fd = -1; progress_text = "0.0% of Unknown"; download_speed_text = "Unknown/s"; } bool start() override { remove(output_filepath.c_str()); std::vector args = { "youtube-dl", "-f", "bestvideo+bestaudio/best", "--no-warnings", "--no-continue", "--output", output_filepath.c_str(), "--newline" }; if(no_video) args.push_back("-x"); args.insert(args.end(), { "--", url.c_str(), nullptr }); if(exec_program_pipe(args.data(), &read_program) != 0) return false; read_program_file = fdopen(read_program.read_fd, "rb"); if(!read_program_file) { wait_program(read_program.pid); return false; } youtube_dl_output_reader = AsyncTask([this]{ char line[128]; char progress_c[10]; char content_size_c[20]; char download_speed_c[20]; while(true) { if(fgets(line, sizeof(line), read_program_file)) { int len = strlen(line); if(len > 0 && line[len - 1] == '\n') { line[len - 1] = '\0'; --len; } if(sscanf(line, "[download] %10s of %20s at %20s", progress_c, content_size_c, download_speed_c) == 3) { std::lock_guard lock(progress_update_mutex); if(strcmp(progress_c, "Unknown") != 0 && strcmp(content_size_c, "Unknown") != 0) { std::string progress_str = progress_c; progress_text = progress_str + " of " + content_size_c; if(progress_str.back() == '%') { errno = 0; char *endptr; const double progress_tmp = strtod(progress_str.c_str(), &endptr); if(endptr != progress_str.c_str() && errno == 0) progress = progress_tmp / 100.0; } } if(strcmp(download_speed_c, "Unknown") == 0) download_speed_text = "Unknown/s"; else download_speed_text = download_speed_c; } } else { return false; } } return true; }); return true; } void stop(bool) override { if(read_program_file) fclose(read_program_file); if(read_program.read_fd != -1) kill(read_program.read_fd, SIGTERM); // TODO: Remove the temporary files created by youtube-dl (if !download_completed) //header_reader.cancel(); } DownloadUpdateStatus update() override { int status = 0; if(wait_program_non_blocking(read_program.pid, &status)) { read_program.pid = -1; if(status == 0) { return DownloadUpdateStatus::FINISHED; } else { return DownloadUpdateStatus::ERROR; } } if(youtube_dl_output_reader.ready()) { if(!youtube_dl_output_reader.get()) return DownloadUpdateStatus::ERROR; } return DownloadUpdateStatus::DOWNLOADING; } float get_progress() override { std::lock_guard lock(progress_update_mutex); return progress; } std::string get_progress_text() override { std::lock_guard lock(progress_update_mutex); return progress_text; } std::string get_download_speed_text() override { std::lock_guard lock(progress_update_mutex); return download_speed_text; } private: ReadProgram read_program; FILE *read_program_file = nullptr; AsyncTask youtube_dl_output_reader; std::mutex progress_update_mutex; float progress = 0.0f; std::string progress_text; std::string download_speed_text; bool no_video; }; static int accumulate_string(char *data, int size, void *userdata) { std::string *str = (std::string*)userdata; if(str->size() + size > 1024 * 1024 * 100) // 100mb sane limit, TODO: make configurable return 1; str->append(data, size); return 0; } void Program::download_page(const char *url, bool download_use_youtube_dl) { window.setTitle("QuickMedia - Select where you want to save " + std::string(url)); std::string filename; TaskResult task_result; if(download_use_youtube_dl) { task_result = run_task_with_loading_screen([this, url, &filename]{ std::string json_str; std::vector args = { "youtube-dl", "-f", "bestvideo+bestaudio/best", "--skip-download", "--print-json", "--no-warnings" }; if(no_video) args.push_back("-x"); args.insert(args.end(), { "--", url, nullptr }); if(exec_program(args.data(), accumulate_string, &json_str) != 0) return false; Json::Value result; Json::CharReaderBuilder json_builder; std::unique_ptr json_reader(json_builder.newCharReader()); std::string json_errors; if(!json_reader->parse(json_str.data(), json_str.data() + json_str.size(), &result, &json_errors)) { fprintf(stderr, "Failed to json response, error: %s\n", json_errors.c_str()); return false; } const Json::Value &title_json = result["title"]; const Json::Value &ext_json = result["ext"]; if(title_json.isString()) filename = title_json.asString(); if(ext_json.isString()) { if(ext_json.asCString()[0] != '.' && (filename.empty() || filename.back() != '.')) filename += "."; filename += ext_json.asString(); } return !filename.empty(); }); } else { task_result = run_task_with_loading_screen([url, &filename]{ return url_get_remote_name(url, filename, true) == DownloadResult::OK; }); } if(task_result == TaskResult::CANCEL) { exit_code = 1; return; } std::string output_filepath = file_save_page(filename); if(!window.isOpen() || output_filepath.empty()) { exit_code = 1; return; } sf::Vector2i monitor_size; sf::Vector2i focused_monitor_center = get_focused_monitor_center(disp, monitor_size); window_size.x = std::min(monitor_size.x, (int)(300.0f + 380.0f * get_ui_scale())); window_size.y = std::min(monitor_size.y, (int)(50.0f + 130.0f * get_ui_scale())); window.setSize(sf::Vector2u(window_size.x, window_size.y)); XSizeHints *size_hints = XAllocSizeHints(); if(size_hints) { size_hints->width = window_size.x; size_hints->min_width = window_size.x; size_hints->max_width = window_size.x; size_hints->height = window_size.y; size_hints->min_height = window_size.y; size_hints->max_height = window_size.y; size_hints->flags = PSize | PMinSize | PMaxSize; XSetWMNormalHints(disp, x11_window, size_hints); XFree(size_hints); XFlush(disp); } window.setPosition(sf::Vector2i(focused_monitor_center.x - window_size.x * 0.5f, focused_monitor_center.y - window_size.y * 0.5f)); std::string output_filepath_s = output_filepath; char *output_dir = dirname(output_filepath_s.data()); if(create_directory_recursive(output_dir) != 0) { show_notification("QuickMedia", std::string("Failed to download ") + url + " to " + output_filepath, Urgency::CRITICAL); exit_code = 1; return; } idle_active_handler(); window.clear(back_color); window.display(); const float loading_bar_padding_x = std::floor(4.0f * get_ui_scale()); const float loading_bar_padding_y = std::floor(4.0f * get_ui_scale()); RoundedRectangle loading_bar_background(sf::Vector2f(1.0f, 1.0f), std::floor(10.0f * get_ui_scale()), sf::Color(21, 25, 30), &rounded_rectangle_shader); RoundedRectangle loading_bar(sf::Vector2f(1.0f, 1.0f), std::floor(10.0f * get_ui_scale() - loading_bar_padding_y), sf::Color(31, 117, 255), &rounded_rectangle_shader); const float padding_x = std::floor(30.0f * get_ui_scale()); const float spacing_y = std::floor(15.0f * get_ui_scale()); const float loading_bar_height = std::floor(20.0f * get_ui_scale()); sf::Text progress_text("0kb/Unknown", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(20.0f * get_ui_scale())); sf::Text status_text("Downloading", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(20.0f * get_ui_scale())); sf::Text filename_text(filename.c_str(), *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(14.0f * get_ui_scale())); filename_text.setFillColor(sf::Color(179, 179, 179)); sf::Text download_speed_text("0 bytes/s", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(14.0f * get_ui_scale())); download_speed_text.setFillColor(sf::Color(179, 179, 179)); bool redraw = true; sf::Event event; std::unique_ptr downloader; if(download_use_youtube_dl) downloader = std::make_unique(url, output_filepath, no_video); else downloader = std::make_unique(url, output_filepath); if(!downloader->start()) { show_notification("QuickMedia", std::string("Failed to download ") + url + " to " + output_filepath, Urgency::CRITICAL); exit_code = 1; return; } sf::Clock frame_timer; sf::Clock progress_update_timer; bool download_completed = false; float progress = 0.0f; float ui_progress = 0.0f; 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)); redraw = true; } } if(handle_window_close()) { show_notification("QuickMedia", "Download cancelled!"); downloader->stop(false); exit_code = 1; exit(exit_code); } if(progress_update_timer.getElapsedTime().asSeconds() >= 1.0f) { progress_update_timer.restart(); DownloadUpdateStatus update_status = downloader->update(); switch(update_status) { case DownloadUpdateStatus::DOWNLOADING: break; case DownloadUpdateStatus::FINISHED: download_completed = true; goto cleanup; case DownloadUpdateStatus::ERROR: goto cleanup; } progress = downloader->get_progress(); progress = std::max(0.0f, std::min(1.0f, progress)); progress_text.setString(downloader->get_progress_text()); download_speed_text.setString(downloader->get_download_speed_text()); redraw = true; } if(redraw) { redraw = false; loading_bar_background.set_size(sf::Vector2f(window_size.x - padding_x * 2.0f, loading_bar_height)); loading_bar_background.set_position(window_size * 0.5f - loading_bar_background.get_size() * 0.5f + sf::Vector2f(0.0f, download_speed_text.getLocalBounds().height * 0.5f)); loading_bar_background.set_position(sf::Vector2f(std::floor(loading_bar_background.get_position().x), std::floor(loading_bar_background.get_position().y))); loading_bar.set_position(loading_bar_background.get_position() + sf::Vector2f(loading_bar_padding_x, loading_bar_padding_y)); filename_text.setPosition( loading_bar_background.get_position() + sf::Vector2f(0.0f, -(filename_text.getLocalBounds().height + spacing_y))); progress_text.setPosition( filename_text.getPosition() + sf::Vector2f(loading_bar_background.get_size().x - progress_text.getLocalBounds().width, -(progress_text.getLocalBounds().height + spacing_y))); status_text.setPosition( filename_text.getPosition() + sf::Vector2f(0.0f, -(status_text.getLocalBounds().height + spacing_y))); download_speed_text.setPosition( loading_bar_background.get_position() + sf::Vector2f(0.0f, loading_bar_height + spacing_y)); } const float progress_diff = progress - ui_progress; const float progress_move = frame_timer.getElapsedTime().asSeconds() * 500.0f * std::abs(progress_diff); if(std::abs(progress_diff) < progress_move) { ui_progress = progress; } else { if(progress_diff > 0.0f) ui_progress += progress_move; else ui_progress -= progress_move; } loading_bar.set_size(sf::Vector2f( std::floor((loading_bar_background.get_size().x - loading_bar_padding_x * 2.0f) * ui_progress), loading_bar_height - loading_bar_padding_y * 2.0f)); window.clear(sf::Color(33, 37, 44)); loading_bar_background.draw(window); loading_bar.draw(window); window.draw(progress_text); window.draw(status_text); window.draw(filename_text); window.draw(download_speed_text); AsyncImageLoader::get_instance().update(); window.display(); frame_timer.restart(); } cleanup: downloader->stop(download_completed); if(download_completed) { show_notification("QuickMedia", std::string("Download finished! Downloaded ") + Path(filename).filename() + " to " + output_filepath); exit_code = 0; } else { show_notification("QuickMedia", std::string("Failed to download ") + url + " to " + output_filepath, Urgency::CRITICAL); exit_code = 1; } exit(exit_code); } std::string Program::file_save_page(const std::string &filename) { sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; auto file_manager_page = std::make_unique(this); file_manager_page->set_current_directory(file_manager_start_dir); auto file_manager_body = create_body(); file_manager_page->get_files_in_directory(file_manager_body->items); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); Tabs ui_tabs(&rounded_rectangle_shader); const int tab_path_index = ui_tabs.add_tab(file_manager_start_dir, file_manager_body.get()); search_bar->onTextUpdateCallback = [&file_manager_body](const std::string &text) { file_manager_body->filter_search_fuzzy(text); file_manager_body->select_first_item(); }; search_bar->onTextSubmitCallback = [this, &search_bar, &file_manager_body, &file_manager_page, &ui_tabs, tab_path_index](const std::string&) { if(sf::Keyboard::isKeyPressed(sf::Keyboard::LControl) || sf::Keyboard::isKeyPressed(sf::Keyboard::RControl)) return; BodyItem *selected = file_manager_body->get_selected(); if(!selected) return; std::vector new_tabs; TaskResult task_result = run_task_with_loading_screen([selected, &file_manager_page, &new_tabs]() { return file_manager_page->submit(selected->get_title(), selected->url, new_tabs) == PluginResult::OK; }); if(task_result == TaskResult::TRUE) { if(!new_tabs.empty()) { file_manager_body->items = std::move(new_tabs[0].body->items); file_manager_body->select_first_item(); search_bar->clear(); } } else if(task_result == TaskResult::FALSE) { show_notification("QuickMedia", "Failed to change directory", Urgency::CRITICAL); } ui_tabs.set_text(tab_path_index, file_manager_page->get_current_directory().string()); idle_active_handler(); }; const float bottom_panel_padding = 10.0f; const float bottom_panel_spacing = 10.0f; Button cancel_button("Cancel", FontLoader::get_font(FontLoader::FontType::LATIN), 16, 100.0f, &rounded_rectangle_shader, get_ui_scale()); cancel_button.set_background_color(sf::Color(41, 45, 50)); Button save_button("Save", FontLoader::get_font(FontLoader::FontType::LATIN), 16, 100.0f, &rounded_rectangle_shader, get_ui_scale()); save_button.set_background_color(sf::Color(31, 117, 255)); sf::Text file_name_label("File name:", *FontLoader::get_font(FontLoader::FontType::LATIN), std::floor(16.0f * get_ui_scale())); Entry file_name_entry("", &rounded_rectangle_shader); file_name_entry.set_text(filename); file_name_entry.set_single_line(true); file_name_entry.set_editable(false); sf::RectangleShape bottom_panel_background; bottom_panel_background.setFillColor(sf::Color(33, 37, 44)); const sf::Color color(0, 0, 0, 50); const float gradient_height = 5.0f; sf::Vertex gradient_points[4]; auto save_file = [this, &file_name_entry, &file_manager_page]() -> std::string { auto u8 = file_name_entry.get_text().toUtf8(); std::string *filename = (std::string*)&u8; Path filename_full_path = file_manager_page->get_current_directory().string(); filename_full_path.join(*filename); if(filename->empty()) { show_notification("QuickMedia", "The file name can't be empty", Urgency::CRITICAL); } else if(*filename == "." || *filename == ".." || filename->find('/') != std::string::npos) { show_notification("QuickMedia", "Invalid file name. File can't be ., .. or contain /", Urgency::CRITICAL); } else if(filename->size() >= 255 || filename_full_path.data.size() >= 4096) { show_notification("QuickMedia", "The file name has to be less than 255 characters and the full path has to be less than 4096 characters", Urgency::CRITICAL); } else { if(std::filesystem::exists(filename_full_path.data)) { bool overwrite = false; auto body = create_body(); auto options_page = std::make_unique(this, "Are you sure you want to overwrite " + filename_full_path.data + "?"); options_page->add_option(body->items, "No", "", [&overwrite](){ overwrite = false; }); options_page->add_option(body->items, "Yes", "", [&overwrite](){ overwrite = true; }); std::vector tabs; tabs.push_back(Tab{ std::move(body), std::move(options_page), nullptr }); page_loop(tabs); if(overwrite) return std::move(filename_full_path.data); } else { return std::move(filename_full_path.data); } } return ""; }; float prev_entry_height = file_name_entry.get_height(); while (window.isOpen()) { while (window.pollEvent(event)) { if(file_manager_body->on_event(window, event, !file_name_entry.is_editable())) idle_active_handler(); else event_idle_handler(event); search_bar->on_event(event); if(cancel_button.on_event(event) & BUTTON_EVENT_CLICKED) return ""; if(save_button.on_event(event) & BUTTON_EVENT_CLICKED) { std::string save_path = save_file(); if(!save_path.empty()) return save_path; } file_name_entry.process_event(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) { file_name_entry.set_editable(!file_name_entry.is_editable()); search_bar->set_editable(!file_name_entry.is_editable()); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Enter && event.key.control) { std::string save_path = save_file(); if(!save_path.empty()) return save_path; } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { window.close(); } } update_idle_state(); handle_window_close(); search_bar->update(); if(std::abs(file_name_entry.get_height() - prev_entry_height) >= 1.0f) { prev_entry_height = file_name_entry.get_height(); redraw = true; } if(redraw) { redraw = false; get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); body_pos.y += Tabs::get_shade_height(); body_size.y -= Tabs::get_shade_height(); save_button.set_position(window_size - sf::Vector2f(save_button.get_width(), save_button.get_height()) - sf::Vector2f(bottom_panel_padding, bottom_panel_padding)); cancel_button.set_position(save_button.get_position() - sf::Vector2f(cancel_button.get_width() + bottom_panel_spacing, 0.0f)); file_name_label.setPosition(sf::Vector2f(bottom_panel_spacing, std::floor(window_size.y - bottom_panel_padding - file_name_entry.get_height() * 0.5f - file_name_label.getLocalBounds().height * 0.5f - 5.0f * get_ui_scale()))); file_name_entry.set_position(sf::Vector2f(file_name_label.getPosition().x + file_name_label.getLocalBounds().width + bottom_panel_spacing, window_size.y - file_name_entry.get_height() - bottom_panel_padding)); file_name_entry.set_max_width(std::floor(cancel_button.get_position().x - bottom_panel_spacing - file_name_label.getLocalBounds().width - bottom_panel_spacing - bottom_panel_spacing)); bottom_panel_background.setPosition(0.0f, window_size.y - std::floor(bottom_panel_padding * 2.0f + file_name_entry.get_height())); bottom_panel_background.setSize(sf::Vector2f(window_size.x, std::floor(bottom_panel_padding * 2.0f + file_name_entry.get_height()))); gradient_points[0] = sf::Vertex(bottom_panel_background.getPosition() + sf::Vector2f(0.0f, -gradient_height), sf::Color(color.r, color.g, color.b, 0)); gradient_points[1] = sf::Vertex(bottom_panel_background.getPosition() + sf::Vector2f(bottom_panel_background.getSize().x, -gradient_height), sf::Color(color.r, color.g, color.b, 0)); gradient_points[2] = sf::Vertex(bottom_panel_background.getPosition() + sf::Vector2f(bottom_panel_background.getSize().x, 0.0f), color); gradient_points[3] = sf::Vertex(bottom_panel_background.getPosition() + sf::Vector2f(0.0f, 0.0f), color); } window.clear(back_color); ui_tabs.draw(window, sf::Vector2f(0.0f, search_bar->getBottomWithoutShadow()), window_size.x); search_bar->draw(window, window_size, true); file_manager_body->draw(window, body_pos, body_size - sf::Vector2f(0.0f, bottom_panel_background.getSize().y)); window.draw(bottom_panel_background); window.draw(gradient_points, 4, sf::Quads); window.draw(file_name_label); cancel_button.draw(window); save_button.draw(window); file_name_entry.draw(window); AsyncImageLoader::get_instance().update(); window.display(); } return ""; } }