#include "../include/QuickMedia.hpp" #include "../plugins/Manganelo.hpp" #include "../plugins/Mangatown.hpp" #include "../plugins/Mangadex.hpp" #include "../plugins/Youtube.hpp" #include "../plugins/Pornhub.hpp" #include "../plugins/Fourchan.hpp" #include "../plugins/NyaaSi.hpp" #include "../plugins/Matrix.hpp" #include "../plugins/FileManager.hpp" #include "../include/Scale.hpp" #include "../include/Program.h" #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 "../include/base64_url.hpp" #include "../include/Entry.hpp" #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 const float tab_text_size = 18.0f; static const float tab_height = tab_text_size + 10.0f; static const sf::Color tab_selected_color(55, 60, 68); static const float tab_margin_x = 10.0f; // 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 int get_monitor_max_hz(Display *display) { XRRScreenResources *screen_res = XRRGetScreenResources(display, DefaultRootWindow(display)); if(screen_res) { unsigned long max_hz = 0; for(int i = 0; i < screen_res->nmode; ++i) { unsigned long total = screen_res->modes[i].hTotal*screen_res->modes[i].vTotal; if(total > 0) max_hz = std::max(max_hz, screen_res->modes[i].dotClock/total); } XRRFreeScreenResources(screen_res); if(max_hz == 0) max_hz = 60; return std::min(max_hz, 144UL); } return 60; } 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); printf("The swap interval is %u\n", swap); return swap == 1; } static bool enable_vsync(Display *disp, Window window) { if(vsync_loaded) { if(glXSwapIntervalMESA) return glXSwapIntervalMESA(1) == 0; if(glXSwapIntervalSGI) return glXSwapIntervalSGI(1) == 0; if(glXSwapIntervalEXT) { glXSwapIntervalEXT(disp, window, 1); return true; } return false; } vsync_loaded = true; if(has_gl_ext(disp, "GLX_MESA_swap_control")) { fprintf(stderr, "vsync method: GLX_MESA_swap_control\n"); glXSwapIntervalMESA = (PFNGLXSWAPINTERVALMESAPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalMESA"); if(glXSwapIntervalMESA && glXSwapIntervalMESA(1) == 0 && test_vsync(disp, window)) return true; } if(has_gl_ext(disp, "GLX_SGI_swap_control")) { fprintf(stderr, "vsync method: GLX_SGI_swap_control\n"); glXSwapIntervalSGI = (PFNGLXSWAPINTERVALSGIPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalSGI"); if(glXSwapIntervalSGI && glXSwapIntervalSGI(1) == 0 && test_vsync(disp, window)) return true; } if(has_gl_ext(disp, "GLX_EXT_swap_control")) { fprintf(stderr, "vsync method: GLX_EXT_swap_control\n"); glXSwapIntervalEXT = (PFNGLXSWAPINTERVALEXTPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalEXT"); if(glXSwapIntervalEXT) { glXSwapIntervalEXT(disp, window, 1); return test_vsync(disp, window); } } fprintf(stderr, "vsync method: none\n"); return false; } static sf::Color interpolate_colors(sf::Color source, sf::Color target, double progress) { int diff_r = (int)target.r - (int)source.r; int diff_g = (int)target.g - (int)source.g; int diff_b = (int)target.b - (int)source.b; int diff_a = (int)target.a - (int)source.a; return sf::Color( source.r + diff_r * progress, source.g + diff_g * progress, source.b + diff_b * progress, source.a + diff_a * progress); } namespace QuickMedia { class HistoryPage : public Page { public: HistoryPage(Program *program, Page *search_page) : Page(program), search_page(search_page) {} 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); } private: Page *search_page; }; class RecommendedPage : public Page { public: RecommendedPage(Program *program, Page *search_page) : Page(program), search_page(search_page) {} const char* get_title() const override { return "Recommended"; } PluginResult submit(const std::string &title, const std::string &url, std::vector &result_tabs) override { return search_page->submit(title, url, result_tabs); } private: Page *search_page; }; static Path get_recommended_filepath(const char *plugin_name) { Path video_history_dir = get_storage_dir().join("recommended"); if(create_directory_recursive(video_history_dir) != 0) { std::string err_msg = "Failed to create recommended directory "; err_msg += video_history_dir.data; show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); exit(1); } Path video_history_filepath = video_history_dir; return video_history_filepath.join(plugin_name).append(".json"); } // TODO: Make asynchronous static void fill_recommended_items_from_json(const char *plugin_name, const Json::Value &recommended_json, BodyItems &body_items) { assert(recommended_json.isObject()); const int64_t recommendations_autodelete_period = 60*60*24*20; // 20 days time_t time_now = time(NULL); int num_items_deleted = 0; std::vector> recommended_items(recommended_json.size()); /* TODO: Optimize member access */ for(auto &member_name : recommended_json.getMemberNames()) { Json::Value recommended_item = recommended_json[member_name]; if(recommended_item.isObject()) { Json::Value recommended_timestamp_json = recommended_item.get("recommended_timestamp", Json::Value::nullSingleton()); Json::Value watched_timestamp_json = recommended_item.get("watched_timestamp", Json::Value::nullSingleton()); if(watched_timestamp_json.isNumeric() && time_now - watched_timestamp_json.asInt64() >= recommendations_autodelete_period) { ++num_items_deleted; } else if(recommended_timestamp_json.isNumeric() && time_now - recommended_timestamp_json.asInt64() >= recommendations_autodelete_period) { ++num_items_deleted; } else if(recommended_timestamp_json.isNull() && watched_timestamp_json.isNull()) { ++num_items_deleted; } else { recommended_items.push_back(std::make_pair(member_name, std::move(recommended_item))); } } } if(num_items_deleted > 0) { // TODO: Is there a better way? Json::Value new_recommendations(Json::objectValue); for(auto &recommended : recommended_items) { new_recommendations[recommended.first] = recommended.second; } fprintf(stderr, "Number of old recommendations to delete: %d\n", num_items_deleted); save_json_to_file_atomic(get_recommended_filepath(plugin_name), new_recommendations); } /* TODO: Better algorithm for recommendations */ std::sort(recommended_items.begin(), recommended_items.end(), [](std::pair &a, std::pair &b) { Json::Value &a_timestamp_json = a.second["recommended_timestamp"]; Json::Value &b_timestamp_json = b.second["recommended_timestamp"]; int64_t a_timestamp = 0; int64_t b_timestamp = 0; if(a_timestamp_json.isNumeric()) a_timestamp = a_timestamp_json.asInt64(); if(b_timestamp_json.isNumeric()) b_timestamp = b_timestamp_json.asInt64(); Json::Value &a_recommended_count_json = a.second["recommended_count"]; Json::Value &b_recommended_count_json = b.second["recommended_count"]; int64_t a_recommended_count = 0; int64_t b_recommended_count = 0; if(a_recommended_count_json.isNumeric()) a_recommended_count = a_recommended_count_json.asInt64(); if(b_recommended_count_json.isNumeric()) b_recommended_count = b_recommended_count_json.asInt64(); /* Put frequently recommended videos on top of recommendations. Each recommendation count is worth 5 minutes */ a_timestamp += (300 * a_recommended_count); b_timestamp += (300 * b_recommended_count); return a_timestamp > b_timestamp; }); for(auto it = recommended_items.begin(); it != recommended_items.end(); ++it) { const std::string &recommended_item_id = it->first; Json::Value &recommended_item = it->second; int64_t watched_count = 0; const Json::Value &watched_count_json = recommended_item["watched_count"]; if(watched_count_json.isNumeric()) watched_count = watched_count_json.asInt64(); /* TODO: Improve recommendations with some kind of algorithm. Videos we have seen should be recommended in some cases */ if(watched_count != 0) continue; const Json::Value &recommended_title_json = recommended_item["title"]; if(!recommended_title_json.isString()) continue; auto body_item = BodyItem::create(recommended_title_json.asString()); body_item->url = "https://www.youtube.com/watch?v=" + recommended_item_id; body_item->thumbnail_url = "https://img.youtube.com/vi/" + recommended_item_id + "/hqdefault.jpg"; body_items.push_back(std::move(body_item)); // We dont want more than 150 recommendations if(body_items.size() == 150) break; } std::random_shuffle(body_items.begin(), body_items.end()); } Program::Program() : disp(nullptr), window(sf::VideoMode(1280, 720), "QuickMedia", sf::Style::Default, sf::ContextSettings(0, 0, 0, 3, 3)), window_size(1280, 720), current_page(PageType::EXIT), image_index(0) { disp = XOpenDisplay(NULL); if (!disp) throw std::runtime_error("Failed to open display to X11 server"); resources_root = "/usr/share/quickmedia/"; if(get_file_type("../../../images/manganelo_logo.png") == FileType::REGULAR) { resources_root = "../../../"; } const std::string noto_sans_directories[] = { "/usr/share/fonts/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/noto-cjk", "/usr/share/fonts/truetype/noto-cjk" }; for(const std::string ¬o_sans_dir : noto_sans_directories) { if(!font) { auto new_font = std::make_unique(); if(new_font->loadFromFile(noto_sans_dir + "/NotoSans-Regular.ttf")) font = std::move(new_font); } if(!bold_font) { auto new_font = std::make_unique(); if(new_font->loadFromFile(noto_sans_dir + "/NotoSans-Bold.ttf")) bold_font = std::move(new_font); } if(!cjk_font) { auto new_font = std::make_unique(); if(new_font->loadFromFile(noto_sans_dir + "/NotoSansCJK-Regular.ttc")) cjk_font = std::move(new_font); } } if(!font) { fprintf(stderr, "Failed to find NotoSans-Regular.ttf in /usr/share/fonts/noto and /usr/share/fonts/truetype/noto\n"); abort(); } if(!bold_font) { fprintf(stderr, "Failed to find NotoSans-Bold.ttf in /usr/share/fonts/noto and /usr/share/fonts/truetype/noto\n"); abort(); } if(!cjk_font) { fprintf(stderr, "Failed to find NotoSansCJK-Regular.ttc in /usr/share/fonts/noto and /usr/share/fonts/truetype/noto\n"); abort(); } 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.setFramerateLimit(0); monitor_hz = get_monitor_max_hz(disp); if(enable_vsync(disp, window.getSystemHandle())) { vsync_set = true; } else { fprintf(stderr, "Failed to enable vsync, fallback to frame limiting\n"); window.setFramerateLimit(monitor_hz); } fprintf(stderr, "Monitor hz: %d\n", monitor_hz); if(create_directory_recursive(get_cache_dir().join("thumbnails")) != 0) { fprintf(stderr, "Failed to create thumbnails directory\n"); } } Program::~Program() { if(upscale_image_action != UpscaleImageAction::NO && running) { running = false; { std::unique_lock lock(image_upscale_mutex); image_upscale_cv.notify_one(); } image_upscale_thead.join(); } else { running = false; } if(matrix) delete matrix; if(disp) XCloseDisplay(disp); } static void usage() { fprintf(stderr, "usage: QuickMedia [--tor] [--no-video] [--use-system-mpv-config] [--dir ]\n"); fprintf(stderr, "OPTIONS:\n"); fprintf(stderr, " plugin The plugin to use. Should be either 4chan, manganelo, mangatown, mangadex, pornhub, youtube, nyaa.si, matrix, file-manager\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --tor Use tor. 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-force Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default\n"); fprintf(stderr, " --dir Set the start directory when using file-manager\n"); fprintf(stderr, "EXAMPLES:\n"); fprintf(stderr, "QuickMedia manganelo\n"); fprintf(stderr, "QuickMedia youtube --tor\n"); } static bool is_manga_plugin(const char *plugin_name) { return strcmp(plugin_name, "manganelo") == 0 || strcmp(plugin_name, "mangatown") == 0 || strcmp(plugin_name, "mangadex") == 0; } int Program::run(int argc, char **argv) { if(argc < 2) { usage(); return -1; } std::string plugin_logo_path; const char *start_dir = nullptr; std::vector tabs; for(int i = 1; i < argc; ++i) { if(!plugin_name) { if(strcmp(argv[i], "manganelo") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/manganelo_logo.png"; } else if(strcmp(argv[i], "mangatown") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/mangatown_logo.png"; } else if(strcmp(argv[i], "mangadex") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/mangadex_logo.png"; } else if(strcmp(argv[i], "youtube") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/yt_logo_rgb_dark_small.png"; } else if(strcmp(argv[i], "pornhub") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/pornhub_logo.png"; plugin_name = argv[i]; } else if(strcmp(argv[i], "4chan") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/4chan_logo.png"; } else if(strcmp(argv[i], "nyaa.si") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/nyaa_si_logo.png"; } else if(strcmp(argv[i], "matrix") == 0) { plugin_name = argv[i]; matrix = new Matrix(); plugin_logo_path = resources_root + "images/matrix_logo.png"; } else if(strcmp(argv[i], "file-manager") == 0) { plugin_name = argv[i]; } } if(strcmp(argv[i], "--tor") == 0) { use_tor = true; } else if(strcmp(argv[i], "--no-video") == 0) { 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) { upscale_image_action = UpscaleImageAction::FORCE; } else if(strcmp(argv[i], "--dir") == 0) { if(i < argc - 1) { start_dir = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing directory after --dir argument\n"); usage(); return -1; } } else if(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(use_tor && !is_program_executable_by_name("torsocks")) { fprintf(stderr, "torsocks needs to be installed (and accessible from PATH environment variable) when using the --tor option\n"); return -2; } if(upscale_image_action != UpscaleImageAction::NO) { if(!is_manga_plugin(plugin_name)) { fprintf(stderr, "Option --upscale-images/-upscale-images-force is only valid for manganelo, mangatown and mangadex\n"); return -2; } if(!is_program_executable_by_name("waifu2x-ncnn-vulkan")) { fprintf(stderr, "waifu2x-ncnn-vulkan needs to be installed (and accessible from PATH environment variable) when using the --upscale-images/--upscale-images-force option\n"); return -2; } running = true; image_upscale_thead = std::thread([this]{ CopyOp copy_op; while(running) { { std::unique_lock lock(image_upscale_mutex); while(images_to_upscale.empty() && running) image_upscale_cv.wait(lock); if(!running) break; copy_op = images_to_upscale.front(); images_to_upscale.pop_front(); } 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", "-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(copy_op.source.data.c_str(), copy_op.destination.data.c_str()) != 0) perror(tmp_file.data.c_str()); continue; } if(rename(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"); } }); } else { running = true; } if(strcmp(plugin_name, "file-manager") != 0 && start_dir) { fprintf(stderr, "Option --dir is only valid with file-manager\n"); usage(); return -1; } window.setTitle("QuickMedia - " + std::string(plugin_name)); 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()); return -2; } plugin_logo.generateMipmap(); plugin_logo.setSmooth(true); } if(strcmp(plugin_name, "manganelo") == 0) { auto search_body = create_body(); search_body->draw_thumbnails = true; tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 200)}); auto history_body = create_body(); manga_get_watch_history(plugin_name, history_body->items); tabs.push_back(Tab{std::move(history_body), std::make_unique(this, tabs.front().page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "mangatown") == 0) { auto search_body = create_body(); search_body->draw_thumbnails = true; tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 200)}); auto history_body = create_body(); manga_get_watch_history(plugin_name, history_body->items); tabs.push_back(Tab{std::move(history_body), std::make_unique(this, tabs.front().page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "mangadex") == 0) { auto search_body = create_body(); search_body->draw_thumbnails = true; tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 300)}); auto history_body = create_body(); manga_get_watch_history(plugin_name, history_body->items); tabs.push_back(Tab{std::move(history_body), std::make_unique(this, tabs.front().page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "nyaa.si") == 0) { auto category_page = std::make_unique(this); auto categories_body = create_body(); category_page->get_categories(categories_body->items); tabs.push_back(Tab{std::move(categories_body), std::move(category_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "4chan") == 0) { auto boards_page = std::make_unique(this, resources_root); auto boards_body = create_body(); boards_page->get_boards(boards_body->items); tabs.push_back(Tab{std::move(boards_body), std::move(boards_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "file-manager") == 0) { auto file_manager_page = std::make_unique(this); if(start_dir && !file_manager_page->set_current_directory(start_dir)) { fprintf(stderr, "Invalid directory provided with --dir: %s\n", start_dir); return -3; } 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, "youtube") == 0) { auto search_body = create_body(); search_body->draw_thumbnails = true; tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 350)}); auto history_body = create_body(); history_body->draw_thumbnails = true; youtube_get_watch_history(history_body->items); tabs.push_back(Tab{std::move(history_body), std::make_unique(this, tabs.front().page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); auto recommended_body = create_body(); recommended_body->draw_thumbnails = true; fill_recommended_items_from_json(plugin_name, load_recommended_json(), recommended_body->items); tabs.push_back(Tab{std::move(recommended_body), std::make_unique(this, tabs.front().page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "pornhub") == 0) { auto search_body = create_body(); search_body->draw_thumbnails = true; tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 500)}); } if(!tabs.empty()) { page_loop(std::move(tabs)); return exit_code; } if(matrix) { matrix->use_tor = use_tor; if(matrix->load_and_verify_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; } while(window.isOpen()) { switch(current_page) { case PageType::CHAT_LOGIN: chat_login_page(); break; case PageType::CHAT: chat_page(); break; default: window.close(); break; } } } return exit_code; } 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::Closed) { current_page = PageType::EXIT; window.close(); } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(handle_keypress && event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up) { body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { body->select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { body->select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { body->select_first_item(); } else if(event.key.code == sf::Keyboard::End) { body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = previous_page; } } else if(handle_searchbar) { assert(search_bar); if(event.type == sf::Event::TextEntered) search_bar->onTextEntered(event.text.unicode); search_bar->on_event(event); } } static std::string base64_encode(const std::string &data) { return base64_url::encode(data); } static std::string base64_decode(const std::string &data) { return base64_url::decode(data); } enum class SearchSuggestionTab { ALL, HISTORY, RECOMMENDED, LOGIN }; // Returns relative time as a string (approximation) static std::string timestamp_to_relative_time_str(time_t seconds) { time_t minutes = seconds / 60; time_t hours = minutes / 60; time_t days = hours / 24; time_t months = days / 30; time_t years = days / 365; if(years >= 1) return std::to_string(years) + " year" + (years == 1 ? "" : "s") + " ago"; else if(months >= 1) return std::to_string(months) + " month" + (months == 1 ? "" : "s") + " ago"; else if(days >= 1) return std::to_string(days) + " day" + (days == 1 ? "" : "s") + " ago"; else if(hours >= 1) return std::to_string(hours) + " hour" + (hours == 1 ? "" : "s") + " ago"; else if(minutes >= 1) return std::to_string(minutes) + " minute" + (minutes == 1 ? "" : "s") + " ago"; else return std::to_string(seconds) + " second" + (seconds == 1 ? "" : "s") + " ago"; } // TODO: Make asynchronous static void fill_history_items_from_json(const Json::Value &history_json, BodyItems &history_items) { assert(history_json.isArray()); BodyItems body_items; time_t time_now = time(NULL); for(const Json::Value &item : history_json) { if(!item.isObject()) continue; const Json::Value &video_id = item["id"]; if(!video_id.isString()) continue; std::string video_id_str = video_id.asString(); const Json::Value &title = item["title"]; if(!title.isString()) continue; std::string title_str = title.asString(); const Json::Value ×tamp = item["timestamp"]; if(!timestamp.isNumeric()) continue; auto body_item = BodyItem::create(std::move(title_str)); body_item->url = "https://www.youtube.com/watch?v=" + video_id_str; body_item->thumbnail_url = "https://img.youtube.com/vi/" + video_id_str + "/hqdefault.jpg"; body_item->set_description(timestamp_to_relative_time_str(std::max(0l, time_now - timestamp.asInt64()))); 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("Video player", err_msg.c_str(), Urgency::CRITICAL); exit(1); } Path video_history_filepath = video_history_dir; return video_history_filepath.join(plugin_name).append(".json"); } // This is not cached because we could have multiple instances of QuickMedia running the same plugin! // TODO: Find a way to optimize this Json::Value Program::load_video_history_json() { Path video_history_filepath = get_video_history_filepath(plugin_name); Json::Value json_result; if(!read_file_as_json(video_history_filepath, json_result) || !json_result.isArray()) json_result = Json::Value(Json::arrayValue); return json_result; } // This is not cached because we could have multiple instances of QuickMedia running the same plugin! // TODO: Find a way to optimize this Json::Value Program::load_recommended_json() { Path recommended_filepath = get_recommended_filepath(plugin_name); Json::Value json_result; if(!read_file_as_json(recommended_filepath, json_result) || !json_result.isObject()) json_result = Json::Value(Json::objectValue); return json_result; } void Program::manga_get_watch_history(const char *plugin_name, BodyItems &history_items) { // TOOD: Make generic, instead of checking for plugin Path content_storage_dir = get_storage_dir().join(plugin_name); if(create_directory_recursive(content_storage_dir) != 0) { show_notification("Storage", "Failed to create directory: " + content_storage_dir.data, Urgency::CRITICAL); exit(1); } Path credentials_storage_dir = get_storage_dir().join("credentials"); if(create_directory_recursive(credentials_storage_dir) != 0) { show_notification("Storage", "Failed to create directory: " + credentials_storage_dir.data, Urgency::CRITICAL); exit(1); } // TODO: Make asynchronous for_files_in_dir_sort_last_modified(content_storage_dir, [&history_items, plugin_name](const std::filesystem::path &filepath) { // This can happen when QuickMedia crashes/is killed while writing to storage. // In that case, the storage wont be corrupt but there will be .tmp files. // TODO: Remove these .tmp files if they exist during startup if(filepath.extension() == ".tmp") return true; Path fullpath(filepath.c_str()); Json::Value body; if(!read_file_as_json(fullpath, body) || !body.isObject()) { fprintf(stderr, "Failed to read json file: %s\n", fullpath.data.c_str()); return true; } auto filename = filepath.filename(); const Json::Value &manga_name = body["name"]; if(!filename.empty() && manga_name.isString()) { // TODO: Add thumbnail auto body_item = BodyItem::create(manga_name.asString()); if(strcmp(plugin_name, "manganelo") == 0) body_item->url = "https://manganelo.com/manga/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "mangadex") == 0) body_item->url = "https://mangadex.org/title/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "mangatown") == 0) body_item->url = "https://mangatown.com/manga/" + base64_decode(filename.string()); else fprintf(stderr, "Error: Not implemented: filename to manga chapter list\n"); history_items.push_back(std::move(body_item)); } return true; }); } void Program::youtube_get_watch_history(BodyItems &history_items) { fill_history_items_from_json(load_video_history_json(), history_items); } static void get_body_dimensions(const sf::Vector2f &window_size, SearchBar *search_bar, sf::Vector2f &body_pos, sf::Vector2f &body_size, bool has_tabs = false) { float body_padding_horizontal = 25.0f; float body_padding_vertical = 25.0f; float body_width = window_size.x - body_padding_horizontal * 2.0f; if(body_width <= 480.0f) { body_width = window_size.x; body_padding_horizontal = 0.0f; body_padding_vertical = 10.0f; } float tab_h = tab_height; if(!has_tabs) tab_h = 0.0f; float search_bottom = search_bar ? search_bar->getBottomWithoutShadow() : 0.0f; body_pos = sf::Vector2f(body_padding_horizontal, search_bottom + body_padding_vertical + tab_h); body_size = sf::Vector2f(body_width, window_size.y - search_bottom - body_padding_vertical - tab_h); } static const float RELATED_MEDIA_WINDOW_WIDTH = 1.0f; static void get_related_media_body_dimensions(const sf::Vector2f &window_size, sf::Vector2f &body_pos, sf::Vector2f &body_size, float related_videos_text_height) { float body_padding_horizontal = 25.0f; float body_padding_vertical = 25.0f; float body_width = (window_size.x * RELATED_MEDIA_WINDOW_WIDTH) - body_padding_horizontal * 2.0f; if(body_width <= 480.0f) { body_width = (window_size.x * RELATED_MEDIA_WINDOW_WIDTH); body_padding_horizontal = 0.0f; body_padding_vertical = 10.0f; } body_pos = sf::Vector2f(body_padding_horizontal, body_padding_vertical + related_videos_text_height); body_size = sf::Vector2f(body_width, window_size.y - body_padding_vertical - related_videos_text_height); } class LoginTab { public: LoginTab(sf::Font &font) : username(std::make_unique(font, nullptr, "Token...")), password(std::make_unique(font, nullptr, "PIN...", true)) { } std::unique_ptr username; std::unique_ptr password; }; bool Program::is_tor_enabled() { return use_tor; } std::unique_ptr Program::create_body() { return std::make_unique(this, font.get(), bold_font.get(), cjk_font.get()); } std::unique_ptr Program::create_search_bar(const std::string &placeholder, int search_delay) { auto search_bar = std::make_unique(*font, &plugin_logo, 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_json["name"] = manga_title; FileType file_type = get_file_type(content_storage_file); if(file_type == FileType::REGULAR) { if(read_file_as_json(content_storage_file, content_storage_json) && content_storage_json.isObject()) return true; return false; } else { return true; } } void Program::select_file(const std::string &filepath) { puts(filepath.c_str()); selected_files.push_back(filepath); } void Program::page_loop(std::vector tabs) { if(tabs.empty()) { show_notification("QuickMedia", "No tabs provided!", Urgency::CRITICAL); return; } const Json::Value *json_chapters = &Json::Value::nullSingleton(); if(content_storage_json.isObject()) { const Json::Value &chapters_json = content_storage_json["chapters"]; if(chapters_json.isObject()) json_chapters = &chapters_json; } struct TabAssociatedData { std::string update_search_text; bool search_text_updated = false; bool search_running = false; bool typing = false; bool fetching_next_page_running = false; int fetched_page = 0; sf::Text search_result_text; std::future search_future; std::future next_page_future; }; std::vector tab_associated_data; for(size_t i = 0; i < tabs.size(); ++i) { TabAssociatedData data; data.search_result_text = sf::Text("", *font, 30); 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; sf::Vertex gradient_points[4]; sf::Text tab_text("", *font, tab_text_size); int selected_tab = 0; bool loop_running = true; bool redraw = true; auto submit_handler = [this, &tabs, &selected_tab, &loop_running, &redraw]() { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(!selected_item) return; std::vector new_tabs; PluginResult submit_result = tabs[selected_tab].page->submit(selected_item->get_title(), selected_item->url, new_tabs); if(submit_result == PluginResult::OK) { if(tabs[selected_tab].page->is_single_page()) { tabs[selected_tab].search_bar->clear(); if(new_tabs.size() == 1) tabs[selected_tab].body = std::move(new_tabs[0].body); else loop_running = false; return; } if(new_tabs.empty()) return; for(Tab &tab : tabs) { tab.body->clear_cache(); } if(new_tabs.size() == 1 && new_tabs[0].page->is_manga_images_page()) { select_episode(selected_item, false); Body *chapters_body = tabs[selected_tab].body.get(); chapters_body->filter_search_fuzzy(""); // Needed (or not really) to go to the next chapter when reaching the last page of a chapter MangaImagesPage *manga_images_page = static_cast(new_tabs[0].page.get()); window.setKeyRepeatEnabled(false); while(true) { if(current_page == PageType::IMAGES) { window.setFramerateLimit(20); while(current_page == PageType::IMAGES) { int page_navigation = image_page(manga_images_page, chapters_body); if(page_navigation == -1) { // TODO: Make this work if the list is sorted differently than from newest to oldest. chapters_body->select_next_item(); select_episode(chapters_body->get_selected(), true); image_index = 99999; // Start at the page that shows we are at the end of the chapter manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } else if(page_navigation == 1) { // TODO: Make this work if the list is sorted differently than from newest to oldest. chapters_body->select_previous_item(); select_episode(chapters_body->get_selected(), true); manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } } if(vsync_set) window.setFramerateLimit(0); else window.setFramerateLimit(monitor_hz); } else if(current_page == PageType::IMAGES_CONTINUOUS) { image_continuous_page(manga_images_page); } else { break; } } window.setKeyRepeatEnabled(true); redraw = true; } else if(new_tabs.size() == 1 && new_tabs[0].page->is_image_board_thread_page()) { current_page = PageType::IMAGE_BOARD_THREAD; image_board_thread_page(static_cast(new_tabs[0].page.get()), new_tabs[0].body.get()); redraw = true; } else if(new_tabs.size() == 1 && new_tabs[0].page->is_video_page()) { current_page = PageType::VIDEO_CONTENT; video_content_page(new_tabs[0].page.get(), selected_item->url, selected_item->get_title()); redraw = true; } else { page_loop(std::move(new_tabs)); } } else { // TODO: Show the exact cause of error (get error message from curl). // TODO: Make asynchronous show_notification("QuickMedia", std::string("Submit failed for page ") + tabs[selected_tab].page->get_title(), Urgency::CRITICAL); } }; for(size_t i = 0; i < tabs.size(); ++i) { Tab &tab = tabs[i]; TabAssociatedData &associated_data = tab_associated_data[i]; if(!tab.search_bar) continue; // tab.search_bar->autocomplete_search_delay = current_plugin->get_autocomplete_delay(); // tab.search_bar->onAutocompleteRequestCallback = [this, &tabs, &selected_tab, &autocomplete_text](const std::string &text) { // if(tabs[selected_tab].body == body && !current_plugin->search_is_filter()) // autocomplete_text = text; // }; tab.search_bar->onTextUpdateCallback = [&associated_data, &tabs, i](const std::string &text) { if(!tabs[i].page->search_is_filter()) { associated_data.update_search_text = text; associated_data.search_text_updated = true; } else { tabs[i].body->filter_search_fuzzy(text); tabs[i].body->select_first_item(); } associated_data.typing = false; }; tab.search_bar->onTextSubmitCallback = [&submit_handler, &associated_data](const std::string&) { if(associated_data.typing) return; submit_handler(); }; } sf::Vector2f body_pos; sf::Vector2f body_size; sf::Event event; const float tab_spacer_height = 0.0f; sf::RectangleShape tab_shade; tab_shade.setFillColor(sf::Color(33, 38, 44)); sf::RoundedRectangleShape tab_background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10); tab_background.setFillColor(tab_selected_color); sf::Clock frame_timer; while (window.isOpen() && loop_running) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(event)) { if (event.type == sf::Event::Closed) { window.close(); } else 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)); } if(tabs[selected_tab].search_bar) { if(event.type == sf::Event::TextEntered) tabs[selected_tab].search_bar->onTextEntered(event.text.unicode); tabs[selected_tab].search_bar->on_event(event); } if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; else if(event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Down || event.key.code == sf::Keyboard::PageDown || event.key.code == sf::Keyboard::End) { bool hit_bottom = false; switch(event.key.code) { case sf::Keyboard::Down: hit_bottom = !tabs[selected_tab].body->select_next_item(); break; case sf::Keyboard::PageDown: hit_bottom = !tabs[selected_tab].body->select_next_page(); break; case sf::Keyboard::End: tabs[selected_tab].body->select_last_item(); hit_bottom = true; break; default: hit_bottom = false; break; } if(hit_bottom && !tab_associated_data[selected_tab].search_running && !tab_associated_data[selected_tab].fetching_next_page_running && tabs[selected_tab].page) { 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 = std::async(std::launch::async, [update_search_text, next_page, page]() { BodyItems result_items; if(page->get_page(update_search_text, next_page, result_items) != PluginResult::OK) fprintf(stderr, "Failed to get next page (page %d)\n", next_page); return result_items; }); } } else if(event.key.code == sf::Keyboard::Up) { tabs[selected_tab].body->select_previous_item(); } else if(event.key.code == sf::Keyboard::PageUp) { tabs[selected_tab].body->select_previous_page(); } else if(event.key.code == sf::Keyboard::Home) { tabs[selected_tab].body->select_first_item(); } else if(event.key.code == sf::Keyboard::Escape) { goto page_end; } else if(event.key.code == sf::Keyboard::Left) { if(selected_tab > 0) { tabs[selected_tab].body->clear_cache(); --selected_tab; redraw = true; } } else if(event.key.code == sf::Keyboard::Right) { if(selected_tab < (int)tabs.size() - 1) { tabs[selected_tab].body->clear_cache(); ++selected_tab; redraw = true; } } 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) submit_handler(); } else if(event.key.code == sf::Keyboard::T && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item && tabs[selected_tab].page && tabs[selected_tab].page->is_trackable()) { TrackablePage *trackable_page = static_cast(tabs[selected_tab].page.get()); TrackResult track_result = trackable_page->track(selected_item->get_title()); // TODO: Show proper error message when this fails. For example if we are already tracking the manga if(track_result == TrackResult::OK) { show_notification("Media tracker", "You are now tracking \"" + trackable_page->content_title + "\" after \"" + selected_item->get_title() + "\"", Urgency::LOW); } else { show_notification("Media tracker", "Failed to track media \"" + trackable_page->content_title + "\", chapter: \"" + selected_item->get_title() + "\"", Urgency::CRITICAL); } } } } } if(redraw) { redraw = false; if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->onWindowResize(window_size); // TODO: Dont show tabs if there is only one tab get_body_dimensions(window_size, tabs[selected_tab].search_bar.get(), body_pos, body_size, true); gradient_points[0].position.x = 0.0f; gradient_points[0].position.y = window_size.y - gradient_height; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = window_size.y - gradient_height; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = window_size.y; gradient_points[3].position.x = 0.0f; gradient_points[3].position.y = window_size.y; } if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->update(); for(size_t i = 0; i < tabs.size(); ++i) { TabAssociatedData &associated_data = tab_associated_data[i]; if(associated_data.fetching_next_page_running && associated_data.next_page_future.valid() && associated_data.next_page_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { BodyItems new_body_items = associated_data.next_page_future.get(); fprintf(stderr, "Finished fetching page %d, num new messages: %zu\n", associated_data.fetched_page + 1, new_body_items.size()); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { tabs[i].body->append_items(std::move(new_body_items)); associated_data.fetched_page++; } associated_data.fetching_next_page_running = false; } if(associated_data.search_text_updated && !associated_data.search_running && !associated_data.fetching_next_page_running) { Page *page = tabs[i].page.get(); std::string update_search_text = associated_data.update_search_text; associated_data.search_future = std::async(std::launch::async, [update_search_text, page]() { BodyItems result_items; if(page->search(update_search_text, result_items) != SearchResult::OK) { show_notification("QuickMedia", "Search failed!", Urgency::CRITICAL); } return result_items; }); update_search_text.clear(); associated_data.search_text_updated = false; associated_data.search_running = true; associated_data.search_result_text.setString("Searching..."); } if(associated_data.search_running && associated_data.search_future.valid() && associated_data.search_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { if(!associated_data.search_text_updated) { BodyItems result_items = associated_data.search_future.get(); tabs[i].body->items = std::move(result_items); tabs[i].body->select_first_item(); associated_data.fetched_page = 0; if(tabs[i].body->items.empty()) associated_data.search_result_text.setString("No results found"); else associated_data.search_result_text.setString(""); } else { associated_data.search_future.get(); } associated_data.search_running = false; } } // if(!autocomplete_text.empty() && !autocomplete_running) { // autocomplete_future = std::async(std::launch::async, [this, autocomplete_text]() { // return current_plugin->autocomplete_search(autocomplete_text); // }); // autocomplete_text.clear(); // autocomplete_running = true; // } // if(autocomplete_running && autocomplete_future.valid() && autocomplete_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { // if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->set_autocomplete_text(autocomplete_future.get()); // autocomplete_running = false; // } window.clear(back_color); if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->draw(window, false); { float shade_extra_height = 0.0f; if(!tabs[selected_tab].search_bar) shade_extra_height = 10.0f; const float width_per_tab = window_size.x / tabs.size(); tab_background.setSize(sf::Vector2f(std::floor(width_per_tab - tab_margin_x * 2.0f), tab_height)); float tab_vertical_offset = tabs[selected_tab].search_bar ? tabs[selected_tab].search_bar->getBottomWithoutShadow() : 0.0f; tabs[selected_tab].body->draw(window, body_pos, body_size, *json_chapters); const float tab_y = tab_spacer_height + std::floor(tab_vertical_offset + tab_height * 0.5f - (tab_text_size + 5.0f) * 0.5f) + shade_extra_height; tab_shade.setPosition(0.0f, tab_spacer_height + std::floor(tab_vertical_offset)); tab_shade.setSize(sf::Vector2f(window_size.x, shade_extra_height + tab_height + 10.0f)); window.draw(tab_shade); int i = 0; // TODO: Dont show tabs if there is only one tab for(Tab &tab : tabs) { if(i == selected_tab) { tab_background.setPosition(std::floor(i * width_per_tab + tab_margin_x), tab_spacer_height + std::floor(tab_vertical_offset) + shade_extra_height); window.draw(tab_background); } const float center = (i * width_per_tab) + (width_per_tab * 0.5f); // TODO: Optimize. Only set once for each tab! tab_text.setString(tab.page->get_title()); tab_text.setPosition(std::floor(center - tab_text.getLocalBounds().width * 0.5f), tab_y); window.draw(tab_text); ++i; } } if(tab_associated_data[selected_tab].fetching_next_page_running) { double progress = 0.5 + std::sin(std::fmod(gradient_inc, 360.0) * 0.017453292519943295 - 1.5707963267948966*0.5) * 0.5; gradient_inc += (frame_time_ms * 0.5); sf::Color bottom_color = interpolate_colors(back_color, sf::Color(175, 180, 188), progress); gradient_points[0].color = back_color; gradient_points[1].color = back_color; gradient_points[2].color = bottom_color; gradient_points[3].color = bottom_color; window.draw(gradient_points, 4, sf::Quads); // Note: sf::Quads doesn't work with egl } if(!tab_associated_data[selected_tab].search_result_text.getString().isEmpty()) { auto search_result_text_bounds = tab_associated_data[selected_tab].search_result_text.getLocalBounds(); tab_associated_data[selected_tab].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[selected_tab].search_result_text); } window.display(); } page_end: // TODO: This is needed, because you cant terminate futures without causing an exception to be thrown and its not safe anyways. // Need a way to solve this, we dont want to wait for a search to finish when navigating backwards for(TabAssociatedData &associated_data : tab_associated_data) { if(associated_data.next_page_future.valid()) associated_data.next_page_future.get(); if(associated_data.search_future.valid()) associated_data.search_future.get(); } } static bool youtube_url_extract_id(const std::string &youtube_url, std::string &youtube_video_id) { size_t index = youtube_url.find("youtube.com/watch?v="); if(index != std::string::npos) { index += 20; size_t end_index = youtube_url.find("&", index); if(end_index == std::string::npos) end_index = youtube_url.size(); youtube_video_id = youtube_url.substr(index, end_index - index); return true; } index = youtube_url.find("youtu.be/"); if(index != std::string::npos) { index += 9; size_t end_index = youtube_url.find("?", index); if(end_index == std::string::npos) end_index = youtube_url.size(); youtube_video_id = youtube_url.substr(index, end_index - index); return true; } return false; } static int watch_history_get_item_by_id(const Json::Value &video_history_json, const char *id) { assert(video_history_json.isArray()); int index = -1; for(const Json::Value &item : video_history_json) { ++index; if(!item.isObject()) continue; const Json::Value &id_json = item["id"]; if(!id_json.isString()) continue; if(strcmp(id, id_json.asCString()) == 0) return index; } return -1; } enum class WindowFullscreenState { UNSET, SET, TOGGLE }; static bool window_set_fullscreen(Display *display, Window window, WindowFullscreenState state) { Atom wm_state_atom = XInternAtom(display, "_NET_WM_STATE", False); Atom wm_state_fullscreen_atom = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); if(wm_state_atom == False || wm_state_fullscreen_atom == False) { fprintf(stderr, "Failed to fullscreen window. The window manager doesn't support fullscreening windows.\n"); return false; } XEvent xev; xev.type = ClientMessage; xev.xclient.window = window; xev.xclient.message_type = wm_state_atom; xev.xclient.format = 32; xev.xclient.data.l[0] = (int)state; xev.xclient.data.l[1] = wm_state_fullscreen_atom; xev.xclient.data.l[2] = 0; xev.xclient.data.l[3] = 1; xev.xclient.data.l[4] = 0; if(!XSendEvent(display, XDefaultRootWindow(display), 0, SubstructureRedirectMask | SubstructureNotifyMask, &xev)) { fprintf(stderr, "Failed to fullscreen window\n"); return false; } XFlush(display); return true; } void Program::save_recommendations_from_related_videos(const std::string &video_url, const std::string &video_title, const Body *related_media_body) { std::string video_id; if(!youtube_url_extract_id(video_url, video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += video_url; err_msg + ", video wont be saved in recommendations"; show_notification("Video player", err_msg.c_str(), Urgency::LOW); return; } Json::Value recommended_json = load_recommended_json(); time_t time_now = time(NULL); Json::Value &existing_recommended_json = recommended_json[video_id]; if(existing_recommended_json.isObject()) { int64_t watched_count = 0; Json::Value &watched_count_json = existing_recommended_json["watched_count"]; if(watched_count_json.isNumeric()) watched_count = watched_count_json.asInt64(); existing_recommended_json["watched_count"] = watched_count + 1; existing_recommended_json["watched_timestamp"] = time_now; } else { Json::Value new_content_object(Json::objectValue); new_content_object["title"] = video_title; new_content_object["recommended_timestamp"] = time_now; new_content_object["recommended_count"] = 1; new_content_object["watched_count"] = 1; new_content_object["watched_timestamp"] = time_now; recommended_json[video_id] = std::move(new_content_object); } int saved_recommendation_count = 0; for(const auto &body_item : related_media_body->items) { std::string recommended_video_id; if(youtube_url_extract_id(body_item->url, recommended_video_id)) { Json::Value &existing_recommendation = recommended_json[recommended_video_id]; if(existing_recommendation.isObject()) { int64_t recommended_count = 0; Json::Value &count_json = existing_recommendation["recommended_count"]; if(count_json.isNumeric()) recommended_count = count_json.asInt64(); existing_recommendation["recommended_count"] = recommended_count + 1; existing_recommendation["recommended_timestamp"] = time_now; } else { Json::Value new_content_object(Json::objectValue); new_content_object["title"] = body_item->get_title(); new_content_object["recommended_timestamp"] = time_now; new_content_object["recommended_count"] = 1; recommended_json[recommended_video_id] = std::move(new_content_object); saved_recommendation_count++; /* TODO: Save more than the first 3 video that hasn't been watched yet? */ if(saved_recommendation_count == 3) break; } } else { fprintf(stderr, "Failed to extract id of youtube url %s, video wont be saved in recommendations\n", video_url.c_str()); } } save_json_to_file_atomic(get_recommended_filepath(plugin_name), recommended_json); } #define CLEANMASK(mask) ((mask) & (ShiftMask|ControlMask|Mod1Mask|Mod4Mask|Mod5Mask)) void Program::video_content_page(Page *page, std::string video_url, std::string video_title) { sf::Clock time_watched_timer; bool added_recommendations = false; bool video_loaded = false; PageType previous_page = pop_page_stack(); std::unique_ptr video_player; std::unique_ptr related_media_window; sf::Vector2f related_media_window_size; bool related_media_window_visible = false; sf::Text related_videos_text("Related videos", *bold_font, 20); const float related_videos_text_height = related_videos_text.getCharacterSize(); auto related_media_body = create_body(); related_media_body->draw_thumbnails = 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); }; auto load_video_error_check = [this, &related_media_body, &video_url, &video_title, &video_player, previous_page, &time_watched_timer, &added_recommendations, page]() mutable { time_watched_timer.restart(); added_recommendations = false; watched_videos.insert(video_url); VideoPlayer::Error err = video_player->load_video(video_url.c_str(), window.getSystemHandle(), plugin_name); if(err != VideoPlayer::Error::OK) { std::string err_msg = "Failed to play url: "; err_msg += video_url; show_notification("Video player", err_msg.c_str(), Urgency::CRITICAL); current_page = previous_page; } else { related_media_body->clear_items(); related_media_body->clear_thumbnails(); related_media_body->items = page->get_related_media(video_url); // TODO: Make this also work for other video plugins if(strcmp(plugin_name, "youtube") != 0) return; std::string video_id; if(!youtube_url_extract_id(video_url, video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += video_url; err_msg + ", video wont be saved in history"; show_notification("Video player", 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); } }; bool has_video_started = true; auto video_event_callback = [this, &related_media_body, &video_url, &video_title, &video_player, &load_video_error_check, previous_page, &has_video_started, &time_watched_timer, &video_loaded](const char *event_name) mutable { bool end_of_file = false; if(strcmp(event_name, "pause") == 0) { double time_remaining = 9999.0; if(video_player->get_time_remaining(&time_remaining) == VideoPlayer::Error::OK && time_remaining <= 1.0) end_of_file = true; } else if(strcmp(event_name, "playback-restart") == 0) { video_player->set_paused(false); } else if(strcmp(event_name, "file-loaded") == 0) { has_video_started = true; time_watched_timer.restart(); video_loaded = true; } else if(strcmp(event_name, "end-file") == 0) { video_loaded = false; } if(end_of_file && has_video_started) { has_video_started = false; std::string new_video_url; std::string new_video_title; // Find video that hasn't been played before in this video session // TODO: Remove duplicates for(auto it = related_media_body->items.begin(), end = related_media_body->items.end(); it != end; ++it) { if(watched_videos.find((*it)->url) == watched_videos.end()) { new_video_url = (*it)->url; new_video_title = (*it)->get_title(); break; } } // If there are no videos to play, then dont play any... if(new_video_url.empty()) { show_notification("Video player", "No more related videos to play"); current_page = previous_page; return; } video_url = std::move(new_video_url); video_title = std::move(new_video_title); load_video_error_check(); } }; video_player = std::make_unique(use_tor, no_video, use_system_mpv_config, video_event_callback, on_window_create, resources_root); load_video_error_check(); sf::Event event; sf::RectangleShape rect; rect.setFillColor(sf::Color::Red); // Clear screen before playing video, to show a black screen instead of being frozen // at the previous UI for a moment window.clear(); window.display(); XEvent xev; bool cursor_visible = true; sf::Clock cursor_hide_timer; bool is_youtube = strcmp(plugin_name, "youtube") == 0; bool is_pornhub = strcmp(plugin_name, "pornhub") == 0; bool supports_url_timestamp = is_youtube || is_pornhub; auto save_video_url_to_clipboard = [&video_url, &video_player_window, &video_player, &supports_url_timestamp]() { if(!video_player_window) return; if(supports_url_timestamp) { // TODO: Remove timestamp (&t= or ?t=) from video_url double time_in_file; if(video_player->get_time_in_file(&time_in_file) != VideoPlayer::Error::OK) time_in_file = 0.0; sf::Clipboard::setString(video_url + "&t=" + std::to_string((int)time_in_file)); } else { sf::Clipboard::setString(video_url); } }; while (current_page == PageType::VIDEO_CONTENT) { while (window.pollEvent(event)) { base_event_handler(event, previous_page, related_media_body.get(), nullptr, true, false); if(event.type == sf::Event::Resized && related_media_window) { related_media_window_size.x = window_size.x * RELATED_MEDIA_WINDOW_WIDTH; related_media_window_size.y = window_size.y; related_media_window->setSize(sf::Vector2u(related_media_window_size.x, related_media_window_size.y)); related_media_window->setPosition(sf::Vector2i(window_size.x - related_media_window_size.x, 0)); sf::FloatRect visible_area(0, 0, related_media_window_size.x, related_media_window_size.y); related_media_window->setView(sf::View(visible_area)); } } while(related_media_window && related_media_window->pollEvent(event)) { if(!related_media_window_visible) continue; if(event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up) { related_media_body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { related_media_body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { related_media_body->select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { related_media_body->select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { related_media_body->select_first_item(); } else if(event.key.code == sf::Keyboard::End) { related_media_body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { related_media_window_visible = false; related_media_window->setVisible(false); } else if(event.key.code == sf::Keyboard::R && event.key.control) { related_media_window_visible = false; related_media_window->setVisible(related_media_window_visible); related_media_body->clear_cache(); } else if(event.key.code == sf::Keyboard::F && event.key.control) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::TOGGLE); } else if(event.key.code == sf::Keyboard::Enter) { BodyItem *selected_item = related_media_body->get_selected(); if(!selected_item) continue; related_media_window_visible = false; related_media_window->setVisible(false); has_video_started = false; video_url = selected_item->url; video_title = selected_item->get_title(); load_video_error_check(); } else if(event.key.code == sf::Keyboard::C && event.key.control) { save_video_url_to_clipboard(); } } } if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, KeyPress, &xev)/* && xev.xkey.subwindow == video_player_window*/ && !related_media_window_visible) { #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) { current_page = previous_page; } else if(pressed_keysym == XK_f && pressing_ctrl) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::TOGGLE); } else if(pressed_keysym == XK_r && pressing_ctrl && strcmp(plugin_name, "4chan") != 0) { if(!related_media_window) { related_media_window_size.x = window_size.x * RELATED_MEDIA_WINDOW_WIDTH; related_media_window_size.y = window_size.y; related_media_window = std::make_unique(sf::VideoMode(related_media_window_size.x, related_media_window_size.y), "", 0, sf::ContextSettings(0, 0, 0, 3, 3)); related_media_window->setFramerateLimit(0); if(!enable_vsync(disp, related_media_window->getSystemHandle())) { fprintf(stderr, "Failed to enable vsync, fallback to frame limiting\n"); related_media_window->setFramerateLimit(monitor_hz); } related_media_window->setVisible(false); XReparentWindow(disp, related_media_window->getSystemHandle(), video_player_window, window_size.x - related_media_window_size.x, 0); XSync(disp, False); } related_media_window_visible = true; related_media_window->setVisible(related_media_window_visible); if(!cursor_visible) window.setMouseCursorVisible(true); cursor_visible = true; } else if(pressed_keysym == XK_c && pressing_ctrl) { save_video_url_to_clipboard(); } } if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, MotionNotify, &xev)) { while(XCheckTypedWindowEvent(disp, video_player_window, MotionNotify, &xev)); cursor_hide_timer.restart(); if(!cursor_visible) window.setMouseCursorVisible(true); cursor_visible = true; } VideoPlayer::Error update_err = video_player->update(); if(update_err == VideoPlayer::Error::FAIL_TO_CONNECT_TIMEOUT) { show_notification("Video player", "Failed to connect to mpv ipc after 10 seconds", Urgency::CRITICAL); current_page = previous_page; break; } else if(update_err == VideoPlayer::Error::EXITED && video_player->exit_status == 0) { fprintf(stderr, "mpv exited with status 0, the user most likely closed mpv with 'q'\n"); current_page = previous_page; break; } else if(update_err != VideoPlayer::Error::OK) { show_notification("Video player", "The video player failed to play the video (error code " + std::to_string((int)update_err) + ")", Urgency::CRITICAL); current_page = previous_page; break; } // TODO: Show loading video animation. load_video needs to be made asynchronous first /* Only save recommendations for the video if we have been watching it for 15 seconds */ if(is_youtube && video_loaded && !added_recommendations && time_watched_timer.getElapsedTime().asSeconds() >= 15) { added_recommendations = true; save_recommendations_from_related_videos(video_url, video_title, related_media_body.get()); } if(video_player_window) { if(related_media_window && related_media_window_visible) { sf::Vector2f body_pos, body_size; get_related_media_body_dimensions(window_size, body_pos, body_size, related_videos_text_height); related_media_window->clear(back_color); related_videos_text.setPosition(body_pos.x, 10.0f); related_media_window->draw(related_videos_text); related_media_body->draw(*related_media_window, body_pos, body_size); related_media_window->display(); continue; } if(!cursor_visible) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } const int UI_HIDE_TIMEOUT = 2500; if(cursor_hide_timer.getElapsedTime().asMilliseconds() > UI_HIDE_TIMEOUT) { cursor_visible = false; window.setMouseCursorVisible(false); } } std::this_thread::sleep_for(std::chrono::milliseconds(50)); } window.setMouseCursorVisible(true); window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::UNSET); auto window_size_u = window.getSize(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; } void Program::select_episode(BodyItem *item, bool start_from_beginning) { image_index = 0; switch(image_view_mode) { case ImageViewMode::SINGLE: current_page = PageType::IMAGES; break; case ImageViewMode::SCROLL: current_page = PageType::IMAGES_CONTINUOUS; break; } if(start_from_beginning) return; const Json::Value &json_chapters = content_storage_json["chapters"]; if(json_chapters.isObject()) { const Json::Value &json_chapter = json_chapters[item->get_title()]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) image_index = current.asInt() - 1; } } } // TODO: Remove PageType Program::pop_page_stack() { if(!page_stack.empty()) { PageType previous_page = page_stack.top(); page_stack.pop(); return previous_page; } return PageType::EXIT; } // TODO: Optimize this somehow. One image alone uses more than 20mb ram! Total ram usage for viewing one image // becomes 40mb (private memory, almost 100mb in total!) Unacceptable! Program::LoadImageResult Program::load_image_by_index(int image_index, sf::Texture &image_texture, sf::String &error_message) { Path image_path = content_cache_dir; image_path.join(std::to_string(image_index + 1)); bool upscaled_ok = true; if(upscale_image_action != UpscaleImageAction::NO) { Path image_filepath_upcaled = image_path; image_filepath_upcaled.append(".upscaled"); if(get_file_type(image_filepath_upcaled) == FileType::FILE_NOT_FOUND && image_upscale_status[image_index] == 0) upscaled_ok = false; } if(get_file_type(image_path) == FileType::REGULAR && upscaled_ok) { sf::Image image; if(image.loadFromFile(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("Manga", "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(); if(image_download_future.valid()) { // TODO: Cancel download instead of waiting for the last page to finish image_download_cancel = true; image_download_future.get(); image_download_cancel = false; } Path content_cache_dir_ = content_cache_dir; image_download_future = std::async(std::launch::async, [images_page, content_cache_dir_, this]() { // TODO: Download images in parallel int page = 1; images_page->for_each_page_in_chapter([content_cache_dir_, &page, images_page, this](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; if(strcmp(images_page->get_service_name(), "manganelo") == 0) { extra_args = { CommandArg { "-H", "accept: image/webp,image/apng,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/" } }; } // TODO: Download directly to file instead. TODO: Move to page std::string image_content; if(download_to_string(url, image_content, extra_args, is_tor_enabled(), true) != DownloadResult::OK || image_content.size() <= 255) { if(strcmp(images_page->get_service_name(), "manganelo") == 0) { bool try_backup_url = false; std::string new_url = url; if(string_replace_all(new_url, "s3.mkklcdnv3.com", "bu.mkklcdnbuv1.com") > 0) { try_backup_url = true; } else { try_backup_url = (string_replace_all(new_url, "s41.mkklcdnv41.com", "bu.mkklcdnbuv1.com") > 0); } if(try_backup_url) { image_content.clear(); if(download_to_string(new_url, image_content, extra_args, is_tor_enabled(), true) != DownloadResult::OK || image_content.size() <= 255) { show_notification("Manganelo", "Failed to download image: " + new_url, Urgency::CRITICAL); return false; } } else { show_notification("Manganelo", "Failed to download image: " + url, Urgency::CRITICAL); return false; } } else { show_notification("Manga", "Failed to download image: " + url, Urgency::CRITICAL); return false; } } Path image_filepath_tmp(image_filepath.data + ".tmp"); if(file_overwrite(image_filepath_tmp, image_content) != 0) { show_notification("Storage", "Failed to save image to file: " + image_filepath_tmp.data, Urgency::CRITICAL); return false; } 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; std::unique_lock lock(image_upscale_mutex); images_to_upscale.push_back(std::move(copy_op)); image_upscale_cv.notify_one(); } 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; std::unique_lock lock(image_upscale_mutex); images_to_upscale.push_back(std::move(copy_op)); image_upscale_cv.notify_one(); } if(rename_immediately) { if(rename(image_filepath_tmp.data.c_str(), image_filepath.data.c_str()) != 0) { perror(image_filepath_tmp.data.c_str()); show_notification("Storage", "Failed to save image to file: " + image_filepath.data, Urgency::CRITICAL); return false; } } return true; }); }); } int Program::image_page(MangaImagesPage *images_page, Body *chapters_body) { int page_navigation = 0; image_download_cancel = false; sf::Texture image_texture; sf::Sprite image; sf::Text error_message("", *font, 30); error_message.setFillColor(sf::Color::White); bool download_in_progress = 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("Storage", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = pop_page_stack(); return 0; } int num_images = 0; if(images_page->get_number_of_images(num_images) != ImageResult::OK) { show_notification("Plugin", "Failed to get number of images", Urgency::CRITICAL); current_page = pop_page_stack(); return 0; } image_index = std::min(image_index, num_images); if(num_images != (int)image_upscale_status.size()) image_upscale_status.resize(num_images); download_chapter_images_if_needed(images_page); if(image_index < num_images) { 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_images) { 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_images); json_chapter["total"] = num_images; json_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("Manga progress", "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_images), *font, 14); if(image_index == num_images) 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; sf::Event event; // Consume events sent during above call to get_number_of_images which sends a request to server which may take a while. We dont want pages to be skipped when pressing arrow up/down while(window.pollEvent(event)) {} // 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) { while(window.pollEvent(event)) { if (event.type == sf::Event::Closed) { current_page = PageType::EXIT; window.close(); } else 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) { 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) { if(image_index < num_images) { ++image_index; goto end_of_images_page; } else if(image_index == num_images && 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; } } } 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 background_height = font_height + 6.0f; sf::Vector2f content_size; content_size.x = window_size.x; content_size.y = window_size.y - background_height; if(redraw) { redraw = false; if(error) { auto bounds = error_message.getLocalBounds(); error_message.setPosition(std::floor(content_size.x * 0.5f - bounds.width * 0.5f), std::floor(content_size.y * 0.5f - bounds.height)); } else { sf::Vector2f image_scale; if(fit_image_to_window) image_scale = get_ratio(texture_size_f, wrap_to_size(texture_size_f, content_size)); else image_scale = get_ratio(texture_size_f, clamp_to_size(texture_size_f, content_size)); image.setScale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; image.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(content_size.y * 0.5f - image_size.y * 0.5f)); } window.clear(back_color); if(error) { window.draw(error_message); } else { window.draw(image); } chapter_text_background.setSize(sf::Vector2f(window_size.x, background_height)); chapter_text_background.setPosition(0.0f, std::floor(window_size.y - background_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 - background_height * 0.5f - font_height * 0.5f)); window.draw(chapter_text); } window.display(); } end_of_images_page: if(current_page != PageType::IMAGES && current_page != PageType::IMAGES_CONTINUOUS) { image_download_cancel = true; if(image_download_future.valid()) { // TODO: Cancel download instead of waiting for the last page to finish image_download_future.get(); image_download_cancel = false; } std::unique_lock lock(image_upscale_mutex); images_to_upscale.clear(); image_upscale_status.clear(); } 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("Storage", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = pop_page_stack(); return; } int num_images = 0; if(images_page->get_number_of_images(num_images) != ImageResult::OK) { show_notification("Plugin", "Failed to get number of images", Urgency::CRITICAL); current_page = pop_page_stack(); return; } if(num_images != (int)image_upscale_status.size()) image_upscale_status.resize(num_images); download_chapter_images_if_needed(images_page); 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(images_page, images_page->manga_name, images_page->get_chapter_name(), image_index, content_cache_dir, font.get()); 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("Manga progress", "Failed to save manga progress", Urgency::CRITICAL); } while(current_page == PageType::IMAGES_CONTINUOUS) { window.clear(back_color); ImageViewerAction action = image_viewer.draw(window); switch(action) { case ImageViewerAction::NONE: break; case ImageViewerAction::RETURN: current_page = pop_page_stack(); break; case ImageViewerAction::SWITCH_TO_SINGLE_IMAGE_MODE: image_view_mode = ImageViewMode::SINGLE; current_page = PageType::IMAGES; break; } window.display(); int focused_page = image_viewer.get_focused_page(); image_index = focused_page - 1; if(focused_page > latest_read) { latest_read = focused_page; json_chapter["current"] = latest_read; json_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("Manga progress", "Failed to save manga progress", Urgency::CRITICAL); } } } if(current_page != PageType::IMAGES && current_page != PageType::IMAGES_CONTINUOUS) { image_download_cancel = true; if(image_download_future.valid()) { // TODO: Cancel download instead of waiting for the last page to finish image_download_future.get(); image_download_cancel = false; } std::unique_lock lock(image_upscale_mutex); images_to_upscale.clear(); image_upscale_status.clear(); } } static bool is_url_video(const std::string &url) { return string_ends_with(url, ".webm") || string_ends_with(url, ".mp4") || string_ends_with(url, ".gif"); } void Program::image_board_thread_page(ImageBoardThreadPage *thread_page, Body *thread_body) { // TODO: Instead of using stage here, use different pages for each stage enum class NavigationStage { VIEWING_COMMENTS, REPLYING, SOLVING_POST_CAPTCHA, POSTING_SOLUTION, POSTING_COMMENT, VIEWING_ATTACHED_IMAGE }; NavigationStage navigation_stage = NavigationStage::VIEWING_COMMENTS; std::future captcha_request_future; std::future captcha_post_solution_future; std::future post_comment_future; std::future load_image_future; bool downloading_image = false; sf::Texture captcha_texture; sf::Sprite captcha_sprite; std::mutex captcha_image_mutex; auto attached_image_texture = std::make_unique(); sf::Sprite attached_image_sprite; GoogleCaptchaChallengeInfo challenge_info; sf::Text challenge_description_text("", *font, 24); 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 = [this, &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, {}, is_tor_enabled()); if(download_image_result == DownloadResult::OK) { std::lock_guard lock(captcha_image_mutex); if(captcha_texture.loadFromMemory(payload_image_data.data(), payload_image_data.size())) { captcha_texture.setSmooth(true); captcha_sprite.setTexture(captcha_texture, true); challenge_description_text.setString(challenge_info.description); } else { show_notification("Google captcha", "Failed to load downloaded captcha image", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } } else { show_notification("Google captcha", "Failed to download captcha image", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }; auto request_new_google_captcha_challenge = [this, &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("Google captcha", "Failed to get captcha challenge", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }, is_tor_enabled()); }; Entry comment_input("Press ctrl+m to begin writing a comment...", font.get(), cjk_font.get()); comment_input.draw_background = false; comment_input.set_editable(false); auto post_comment = [this, &comment_input, &navigation_stage, &thread_page, &captcha_post_id, &comment_to_post, &request_new_google_captcha_challenge]() { comment_input.set_editable(false); navigation_stage = NavigationStage::POSTING_COMMENT; PostResult post_result = thread_page->post_comment(captcha_post_id, comment_to_post); if(post_result == PostResult::OK) { show_notification("QuickMedia", "Comment posted!"); navigation_stage = NavigationStage::VIEWING_COMMENTS; // TODO: Append posted comment to the thread so the user can see their posted comment. // TODO: Asynchronously update the thread periodically to show new comments. } else if(post_result == PostResult::TRY_AGAIN) { show_notification("QuickMedia", "Error while posting, did the captcha expire? Please try again"); // TODO: Check if the response contains a new captcha instead of requesting a new one manually request_new_google_captcha_challenge(); } else if(post_result == PostResult::BANNED) { show_notification("QuickMedia", "Failed to post comment because you are banned", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::ERR) { show_notification("QuickMedia", "Failed to post comment. Is " + std::string(plugin_name) + " down or is your internet down?", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else { assert(false && "Unhandled post result"); show_notification("QuickMedia", "Failed to post comment. Unknown error", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }; comment_input.on_submit_callback = [&post_comment_future, &navigation_stage, &request_new_google_captcha_challenge, &comment_to_post, &captcha_post_id, &captcha_solved_time, &post_comment, &thread_page](const std::string &text) -> bool { if(text.empty()) return false; assert(navigation_stage == NavigationStage::REPLYING); comment_to_post = text; if(!captcha_post_id.empty() && captcha_solved_time.getElapsedTime().asSeconds() < 120) { post_comment_future = std::async(std::launch::async, [&post_comment]() -> bool { post_comment(); return true; }); } else if(thread_page->get_pass_id().empty()) { request_new_google_captcha_challenge(); } else if(!thread_page->get_pass_id().empty()) { post_comment_future = std::async(std::launch::async, [&post_comment]() -> bool { post_comment(); return true; }); } return true; }; sf::RectangleShape comment_input_shade; comment_input_shade.setFillColor(sf::Color(33, 38, 44)); sf::Sprite logo_sprite(plugin_logo); float prev_chat_height = comment_input.get_height(); float chat_input_height_full = 0.0f; const float logo_padding_x = 15.0f; const float chat_input_padding_x = 15.0f; const float chat_input_padding_y = 15.0f; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; std::stack comment_navigation_stack; std::stack comment_page_scroll_stack; while (current_page == PageType::IMAGE_BOARD_THREAD) { while (window.pollEvent(event)) { if(navigation_stage == NavigationStage::REPLYING) { comment_input.process_event(event); // To prevent pressing enter in comment_input text submit from also immediately sending captcha solution.. is there no better solution? if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Enter) break; 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::Closed) { current_page = PageType::EXIT; window.close(); } else 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)); } if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; else if(event.type == sf::Event::KeyPressed && navigation_stage == NavigationStage::VIEWING_COMMENTS) { if(event.key.code == sf::Keyboard::Up) { thread_body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { thread_body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { thread_body->select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { thread_body->select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { thread_body->select_first_item(); } else if(event.key.code == sf::Keyboard::End) { thread_body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = pop_page_stack(); } else if(event.key.code == sf::Keyboard::P) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->attached_content_url.empty()) { if(is_url_video(selected_item->attached_content_url)) { page_stack.push(PageType::IMAGE_BOARD_THREAD); current_page = PageType::VIDEO_CONTENT; watched_videos.clear(); // TODO: Use real title video_content_page(thread_page, selected_item->attached_content_url, "No title.webm"); redraw = true; } else { if(downloading_image && load_image_future.valid()) load_image_future.get(); downloading_image = true; navigation_stage = NavigationStage::VIEWING_ATTACHED_IMAGE; load_image_future = std::async(std::launch::async, [this, &thread_body]() { std::string image_data; BodyItem *selected_item = thread_body->get_selected(); if(!selected_item || selected_item->attached_content_url.empty()) { return image_data; } if(download_to_string_cache(selected_item->attached_content_url, image_data, {}, is_tor_enabled()) != DownloadResult::OK) { show_notification("QuickMedia", "Failed to download image: " + selected_item->attached_content_url, Urgency::CRITICAL); image_data.clear(); } return image_data; }); } } } BodyItem *selected_item = thread_body->get_selected(); if(event.key.code == sf::Keyboard::Enter && selected_item && (comment_navigation_stack.empty() || thread_body->get_selected_item() != comment_navigation_stack.top()) && !selected_item->replies.empty()) { for(auto &body_item : thread_body->items) { body_item->visible = false; } selected_item->visible = true; for(size_t reply_index : selected_item->replies) { thread_body->items[reply_index]->visible = true; } comment_navigation_stack.push(thread_body->get_selected_item()); comment_page_scroll_stack.push(thread_body->get_page_scroll()); thread_body->clamp_selection(); thread_body->set_page_scroll(0.0f); } else if(event.key.code == sf::Keyboard::BackSpace && !comment_navigation_stack.empty()) { size_t previous_selected = comment_navigation_stack.top(); float previous_page_scroll = comment_page_scroll_stack.top(); comment_navigation_stack.pop(); comment_page_scroll_stack.pop(); if(comment_navigation_stack.empty()) { for(auto &body_item : thread_body->items) { body_item->visible = true; } thread_body->set_selected_item(previous_selected); thread_body->clamp_selection(); } else { for(auto &body_item : thread_body->items) { body_item->visible = false; } thread_body->set_selected_item(previous_selected); selected_item = thread_body->items[comment_navigation_stack.top()].get(); selected_item->visible = true; for(size_t reply_index : selected_item->replies) { thread_body->items[reply_index]->visible = true; } thread_body->clamp_selection(); } thread_body->set_page_scroll(previous_page_scroll); } else if(event.key.code == sf::Keyboard::M && event.key.control && selected_item) { navigation_stage = NavigationStage::REPLYING; comment_input.set_editable(true); comment_input.move_caret_to_end(); } 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.type == sf::Event::KeyPressed && navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA) { int num = -1; if(event.key.code >= sf::Keyboard::Num1 && event.key.code <= sf::Keyboard::Num9) { num = event.key.code - sf::Keyboard::Num1; } else if(event.key.code >= sf::Keyboard::Numpad1 && event.key.code <= sf::Keyboard::Numpad9) { num = event.key.code - sf::Keyboard::Numpad1; } constexpr int select_map[9] = { 6, 7, 8, 3, 4, 5, 0, 1, 2 }; if(num != -1) { int index = select_map[num]; selected_captcha_images[index] = !selected_captcha_images[index]; } if(event.key.code == sf::Keyboard::Escape) { navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(event.key.code == sf::Keyboard::Enter) { navigation_stage = NavigationStage::POSTING_SOLUTION; captcha_post_solution_future = google_captcha_post_solution(fourchan_google_captcha_api_key, challenge_info.id, selected_captcha_images, [&navigation_stage, &captcha_post_id, &captcha_solved_time, &selected_captcha_images, &challenge_info, &request_google_captcha_image, &post_comment](std::optional new_captcha_post_id, std::optional new_challenge_info) { if(navigation_stage != NavigationStage::POSTING_SOLUTION) return; if(new_captcha_post_id) { captcha_post_id = new_captcha_post_id.value(); captcha_solved_time.restart(); post_comment(); } else if(new_challenge_info) { show_notification("Google captcha", "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); } }, is_tor_enabled()); } } 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()); } } } chat_input_height_full = comment_input.get_height() + chat_input_padding_y * 2.0f; const float chat_height = comment_input.get_height(); if(std::abs(chat_height - prev_chat_height) > 1.0f) { prev_chat_height = chat_height; redraw = true; } if(redraw) { redraw = false; comment_input.set_max_width(window_size.x); comment_input.set_max_width(window_size.x - (logo_padding_x + plugin_logo.getSize().x + chat_input_padding_x * 2.0f)); comment_input.set_position(sf::Vector2f(logo_padding_x + plugin_logo.getSize().x + chat_input_padding_x, chat_input_padding_y)); float body_padding_horizontal = 25.0f; float body_padding_vertical = 5.0f; float body_width = window_size.x - body_padding_horizontal * 2.0f; if(body_width <= 480.0f) { body_width = window_size.x; body_padding_horizontal = 0.0f; } comment_input_shade.setSize(sf::Vector2f(window_size.x, chat_input_height_full)); comment_input_shade.setPosition(0.0f, 0.0f); body_pos = sf::Vector2f(body_padding_horizontal, comment_input_shade.getSize().y + body_padding_vertical); body_size = sf::Vector2f(body_width, window_size.y - comment_input_shade.getSize().y - body_padding_vertical); logo_sprite.setPosition(logo_padding_x, comment_input_shade.getSize().y * 0.5f - plugin_logo.getSize().y * 0.5f); } //comment_input.update(); window.clear(back_color); if(navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA) { std::lock_guard lock(captcha_image_mutex); if(captcha_texture.getNativeHandle() != 0) { const float challenge_description_height = challenge_description_text.getCharacterSize() + 10.0f; sf::Vector2f content_size = window_size; content_size.y -= challenge_description_height; sf::Vector2u captcha_texture_size = captcha_texture.getSize(); sf::Vector2f captcha_texture_size_f(captcha_texture_size.x, captcha_texture_size.y); auto image_scale = get_ratio(captcha_texture_size_f, clamp_to_size(captcha_texture_size_f, content_size)); captcha_sprite.setScale(image_scale); auto image_size = captcha_texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; captcha_sprite.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(challenge_description_height + content_size.y * 0.5f - image_size.y * 0.5f)); window.draw(captcha_sprite); challenge_description_text.setPosition(captcha_sprite.getPosition() + sf::Vector2f(image_size.x * 0.5f, 0.0f) - sf::Vector2f(challenge_description_text.getLocalBounds().width * 0.5f, challenge_description_height)); window.draw(challenge_description_text); for(size_t column = 0; column < captcha_num_columns; ++column) { for(size_t row = 0; row < captcha_num_rows; ++row) { if(selected_captcha_images[column + captcha_num_columns * row]) { captcha_selection_rect.setPosition(captcha_sprite.getPosition() + sf::Vector2f(image_size.x / captcha_num_columns * column, image_size.y / captcha_num_rows * row)); captcha_selection_rect.setSize(sf::Vector2f(image_size.x / captcha_num_columns, image_size.y / captcha_num_rows)); window.draw(captcha_selection_rect); } } } } } else if(navigation_stage == NavigationStage::POSTING_SOLUTION) { // TODO: Show "Posting..." when posting solution } else if(navigation_stage == NavigationStage::POSTING_COMMENT) { // TODO: Show "Posting..." when posting comment } else if(navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { // TODO: Use image instead of data with string. texture->loadFromMemory creates a temporary image anyways that parses the string. std::string image_data; if(downloading_image && load_image_future.valid() && load_image_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { downloading_image = false; image_data = load_image_future.get(); if(attached_image_texture->loadFromMemory(image_data.data(), image_data.size())) { attached_image_texture->setSmooth(true); //attached_image_texture->generateMipmap(); attached_image_sprite.setTexture(*attached_image_texture, true); } else { BodyItem *selected_item = thread_body->get_selected(); std::string selected_item_attached_url; if(selected_item) selected_item_attached_url = selected_item->attached_content_url; show_notification("QuickMedia", "Failed to load image downloaded from url: " + selected_item->attached_content_url, Urgency::CRITICAL); } } // TODO: Show a white image with the text "Downloading..." while the image is downloading and loading if(attached_image_texture->getNativeHandle() != 0) { auto content_size = window_size; sf::Vector2u texture_size = attached_image_texture->getSize(); sf::Vector2f texture_size_f(texture_size.x, texture_size.y); auto image_scale = get_ratio(texture_size_f, clamp_to_size(texture_size_f, content_size)); attached_image_sprite.setScale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; attached_image_sprite.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(content_size.y * 0.5f - image_size.y * 0.5f)); window.draw(attached_image_sprite); } else { sf::RectangleShape rect(sf::Vector2f(640.0f, 480.0f)); rect.setFillColor(sf::Color::White); auto content_size = window_size; auto rect_size = clamp_to_size(rect.getSize(), content_size); rect.setSize(rect_size); rect.setPosition(std::floor(content_size.x * 0.5f - rect_size.x * 0.5f), std::floor(content_size.y * 0.5f - rect_size.y * 0.5f)); window.draw(rect); } } else if(navigation_stage == NavigationStage::REPLYING) { window.draw(comment_input_shade); window.draw(logo_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } else if(navigation_stage == NavigationStage::VIEWING_COMMENTS) { window.draw(comment_input_shade); window.draw(logo_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } window.display(); } // TODO: Instead of waiting for them, kill them somehow if(captcha_request_future.valid()) captcha_request_future.get(); if(captcha_post_solution_future.valid()) captcha_post_solution_future.get(); if(post_comment_future.valid()) post_comment_future.get(); if(load_image_future.valid()) load_image_future.get(); } void Program::chat_login_page() { assert(strcmp(plugin_name, "matrix") == 0); SearchBar login_input(*font, nullptr, "Username"); SearchBar password_input(*font, nullptr, "Password", true); SearchBar homeserver_input(*font, nullptr, "Homeserver"); sf::Text status_text("", *font, 18); const int num_inputs = 3; SearchBar *inputs[num_inputs] = { &login_input, &password_input, &homeserver_input }; int focused_input = 0; auto text_submit_callback = [this, inputs, &status_text](const sf::String&) { for(int i = 0; i < num_inputs; ++i) { if(inputs[i]->get_text().empty()) { status_text.setString("All fields need to be filled in"); return; } } std::string err_msg; // TODO: Make asynchronous if(matrix->login(inputs[0]->get_text(), inputs[1]->get_text(), inputs[2]->get_text(), err_msg) == PluginResult::OK) { current_page = PageType::CHAT; } else { status_text.setString("Failed to login, error: " + err_msg); } return; }; for(int i = 0; i < num_inputs; ++i) { inputs[i]->caret_visible = false; inputs[i]->onTextSubmitCallback = text_submit_callback; } inputs[focused_input]->caret_visible = true; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; auto body = create_body(); while (current_page == PageType::CHAT_LOGIN) { while (window.pollEvent(event)) { base_event_handler(event, PageType::EXIT, body.get(), nullptr, false, false); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::TextEntered) { inputs[focused_input]->onTextEntered(event.text.unicode); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Tab) { for(int i = 0; i < num_inputs; ++i) { inputs[i]->caret_visible = false; } focused_input = (focused_input + 1) % num_inputs; inputs[focused_input]->caret_visible = true; } inputs[focused_input]->on_event(event); } if(redraw) { redraw = false; get_body_dimensions(window_size, nullptr, body_pos, body_size); } window.clear(back_color); body->draw(window, body_pos, body_size); float y = 0.0f; for(int i = 0; i < num_inputs; ++i) { inputs[i]->set_vertical_position(y); inputs[i]->update(); inputs[i]->draw(window); y += inputs[i]->getBottomWithoutShadow(); } status_text.setPosition(0.0f, y + 10.0f); window.draw(status_text); window.display(); } } enum class ChatTabType { MESSAGES, ROOMS }; struct ChatTab { ChatTabType type; std::unique_ptr body; std::future future; sf::Text text; }; static std::string extract_first_line(const std::string &str, size_t max_length) { size_t index = str.find('\n'); if(index == std::string::npos) { if(str.size() > max_length) return str.substr(0, max_length) + " (...)"; return str; } else if(index == 0) { return ""; } else { return str.substr(0, std::min(index, max_length)) + " (...)"; } } void Program::chat_page() { assert(strcmp(plugin_name, "matrix") == 0); auto video_page = std::make_unique(this); std::vector tabs; int selected_tab = 0; ChatTab messages_tab; messages_tab.type = ChatTabType::MESSAGES; messages_tab.body = std::make_unique(this, font.get(), bold_font.get(), cjk_font.get()); messages_tab.body->draw_thumbnails = true; messages_tab.body->thumbnail_resize_target_size.x = 600; messages_tab.body->thumbnail_resize_target_size.y = 337; messages_tab.body->thumbnail_fallback_size.x = 32; messages_tab.body->thumbnail_fallback_size.y = 32; //messages_tab.body->line_seperator_color = sf::Color::Transparent; messages_tab.text = sf::Text("Messages", *font, tab_text_size); tabs.push_back(std::move(messages_tab)); ChatTab rooms_tab; rooms_tab.type = ChatTabType::ROOMS; rooms_tab.body = std::make_unique(this, font.get(), bold_font.get(), cjk_font.get()); rooms_tab.body->draw_thumbnails = true; //rooms_tab.body->line_seperator_color = sf::Color::Transparent; rooms_tab.body->thumbnail_fallback_size.x = 32; rooms_tab.body->thumbnail_fallback_size.y = 32; rooms_tab.text = sf::Text("Rooms", *font, tab_text_size); tabs.push_back(std::move(rooms_tab)); const int MESSAGES_TAB_INDEX = 0; const int ROOMS_TAB_INDEX = 1; // This is needed to get initial data, with joined rooms etc. TODO: Remove this once its cached // and allow asynchronous update of rooms bool synced = false; struct RoomBodyData { std::shared_ptr body_item; bool last_message_read; time_t last_read_message_timestamp; }; std::unordered_map body_items_by_room_id; std::string current_room_id; RoomBodyData *current_room_body_data = nullptr; bool is_window_focused = window.hasFocus(); auto process_new_room_messages = [this, &body_items_by_room_id, ¤t_room_id, &is_window_focused](RoomSyncMessages &room_sync_messages, bool only_show_mentions) mutable { for(auto &[room, messages] : room_sync_messages) { bool was_mentioned = false; for(auto &message : messages) { if(message->mentions_me) { was_mentioned = true; message->mentions_me = false; // TODO: What if the message or username begins with "-"? also make the notification image be the avatar of the user if(!is_window_focused || room->id != current_room_id) show_notification("QuickMedia matrix - " + matrix->message_get_author_displayname(message.get()) + " (" + room->name + ")", message->body); } } auto room_body_item_it = body_items_by_room_id.find(room->id); if(room_body_item_it == body_items_by_room_id.end()) continue; // TODO: this wont always because we dont display all types of messages from server, such as "joined", "left", "kicked", "banned", "changed avatar", "changed display name", etc. // TODO: Update local marker when another client with our user sets read marker, in that case our read marker (room->get_user_read_marker) will be updated. bool unread_messages_previous_session = false; if(!messages.empty()) { std::shared_ptr me = matrix->get_me(room->id); if(me && room->get_user_read_marker(me) != messages.back()->event_id) unread_messages_previous_session = true; } if(only_show_mentions && !unread_messages_previous_session) { std::string room_desc; if(!messages.empty()) room_desc = matrix->message_get_author_displayname(messages.back().get()) + ": " + extract_first_line(messages.back()->body, 150); if(was_mentioned) { room_desc += "\n** You were mentioned **"; // TODO: Better notification? room_body_item_it->second.body_item->title_color = sf::Color(255, 100, 100); room_body_item_it->second.last_message_read = false; } room_body_item_it->second.body_item->set_description(std::move(room_desc)); } else if(!messages.empty()) { std::string room_desc = "Unread: " + matrix->message_get_author_displayname(messages.back().get()) + ": " + extract_first_line(messages.back()->body, 150); if(was_mentioned) room_desc += "\n** You were mentioned **"; // TODO: Better notification? room_body_item_it->second.body_item->set_description(std::move(room_desc)); room_body_item_it->second.body_item->title_color = sf::Color(255, 100, 100); room_body_item_it->second.last_message_read = false; } } }; 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:", *font, 18); sf::Sprite logo_sprite(plugin_logo); Entry chat_input("Press m or i to begin writing a message...", font.get(), cjk_font.get()); chat_input.draw_background = false; chat_input.set_editable(false); chat_input.on_submit_callback = [this, &chat_input, &tabs, &selected_tab, ¤t_room_id, &new_page, &chat_state, ¤tly_operating_on_item](const std::string &text) mutable { if(tabs[selected_tab].type == ChatTabType::MESSAGES) { if(text.empty()) return false; if(chat_state == ChatState::TYPING_MESSAGE && text[0] == '/') { std::string command = strip(text); if(command == "/upload") { new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(command == "/logout") { new_page = PageType::CHAT_LOGIN; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else { fprintf(stderr, "Error: invalid command: %s, expected /upload\n", command.c_str()); return false; } } tabs[selected_tab].body->select_last_item(); if(chat_state == ChatState::TYPING_MESSAGE) { // TODO: Make asynchronous if(matrix->post_message(current_room_id, text, std::nullopt, std::nullopt) == PluginResult::OK) { chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else { show_notification("QuickMedia", "Failed to post matrix message", Urgency::CRITICAL); return false; } } else if(chat_state == ChatState::REPLYING) { // TODO: Make asynchronous if(matrix->post_reply(current_room_id, text, currently_operating_on_item->userdata) == PluginResult::OK) { chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; return true; } else { show_notification("QuickMedia", "Failed to post matrix reply", Urgency::CRITICAL); return false; } } else if(chat_state == ChatState::EDITING) { // TODO: Make asynchronous if(matrix->post_edit(current_room_id, text, currently_operating_on_item->userdata) == PluginResult::OK) { chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; return true; } else { show_notification("QuickMedia", "Failed to post matrix edit", Urgency::CRITICAL); return false; } } } return false; }; struct SyncFutureResult { BodyItems body_items; BodyItems rooms_body_items; RoomSyncMessages room_sync_messages; }; std::future sync_future; bool sync_running = false; std::string sync_future_room_id; sf::Clock sync_timer; sf::Int32 sync_min_time_ms = 0; // Sync immediately the first time std::future previous_messages_future; bool fetching_previous_messages_running = false; std::string previous_messages_future_room_id; const float tab_spacer_height = 0.0f; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; sf::RectangleShape tab_shade; tab_shade.setFillColor(sf::Color(33, 38, 44)); sf::RoundedRectangleShape tab_background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10); tab_background.setFillColor(tab_selected_color); const float gradient_height = 5.0f; sf::Vertex gradient_points[4]; double gradient_inc = 0; 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, 38, 44)); sf::Clock start_typing_timer; const double typing_timeout_seconds = 3.0; bool typing = false; const float tab_vertical_offset = 10.0f; sf::Text room_name_text("", *font, 18); const float room_name_text_height = 20.0f; const float room_name_text_padding_y = 10.0f; const float room_name_total_height = room_name_text_height + room_name_text_padding_y * 2.0f; const float room_avatar_height = 32.0f; sf::Sprite room_avatar_sprite; auto room_avatar_thumbnail_data = std::make_shared(); AsyncImageLoader async_image_loader; auto typing_async_func = [this](bool new_state, std::string room_id) { if(new_state) { matrix->on_start_typing(room_id); } else { matrix->on_stop_typing(room_id); } }; std::vector> typing_futures; sf::Clock frame_timer; float prev_chat_height = chat_input.get_height(); float chat_input_height_full = 0.0f; const float logo_padding_x = 15.0f; const float chat_input_padding_x = 15.0f; const float chat_input_padding_y = 15.0f; std::regex url_extract_regex("(http(s?):\\/\\/)?([a-zA-Z0-9\\-_]+\\.)+[a-zA-Z]+[^\\s.,]+"); Body url_selection_body(this, font.get(), bold_font.get(), cjk_font.get()); sf::Clock read_marker_timer; const sf::Int32 read_marker_timeout_ms_default = 3000; sf::Int32 read_marker_timeout_ms = 0; std::future set_read_marker_future; bool setting_read_marker = false; auto launch_url = [this, &video_page, &redraw](const std::string &url) mutable { if(url.empty()) return; std::string video_id; if(youtube_url_extract_id(url, video_id)) { page_stack.push(PageType::CHAT); watched_videos.clear(); current_page = PageType::VIDEO_CONTENT; // TODO: Add title video_content_page(video_page.get(), url, "No title"); redraw = true; } else { if(!is_program_executable_by_name("xdg-open")) { show_notification("Nyaa.si", "xdg-utils which provides xdg-open needs to be installed to download torrents", 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[] = { "xdg-open", url_modified.c_str(), nullptr }; exec_program_async(args, nullptr); } }; // TODO: Remove this? it adds additional delay to launch. // TODO: Make async auto link_get_content_type = [this](const std::string &url) -> const char* { std::vector additional_args = { { "-I", "" } // HEAD request, to get content-type }; std::string program_result; if(download_to_string(url, program_result, std::move(additional_args), use_tor, true) != DownloadResult::OK) { fprintf(stderr, "HEAD request to %s failed\n", url.c_str()); return nullptr; } const char *result = nullptr; string_split(program_result, '\n', [&result](const char *str, size_t size) mutable -> bool { if(size > 13 && strncasecmp(str, "content-type:", 13) == 0) { std::string content_type_value(str + 13, size - 13); content_type_value = strip(content_type_value); if(strncasecmp(content_type_value.c_str(), "audio", 5) == 0) { result = "audio"; return false; } else if(strncasecmp(content_type_value.c_str(), "video", 5) == 0) { result = "video"; return false; } else if(strncasecmp(content_type_value.c_str(), "image", 5) == 0) { result = "image"; return false; } } return true; }); return result; }; while (current_page == PageType::CHAT) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(event)) { base_event_handler(event, PageType::EXIT, tabs[selected_tab].body.get(), nullptr, false, false); 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 || event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::NAVIGATING) { if(event.key.code == sf::Keyboard::Up || event.key.code == sf::Keyboard::PageUp || event.key.code == sf::Keyboard::Home || event.key.code == sf::Keyboard::K) { bool hit_top = false; switch(event.key.code) { case sf::Keyboard::Up: case sf::Keyboard::K: hit_top = !tabs[selected_tab].body->select_previous_item(); break; case sf::Keyboard::PageUp: hit_top = !tabs[selected_tab].body->select_previous_page(); break; case sf::Keyboard::Home: tabs[selected_tab].body->select_first_item(); hit_top = true; break; default: hit_top = false; break; } if(hit_top && !fetching_previous_messages_running && tabs[selected_tab].type == ChatTabType::MESSAGES) { gradient_inc = 0; fetching_previous_messages_running = true; previous_messages_future_room_id = current_room_id; previous_messages_future = std::async(std::launch::async, [this, &previous_messages_future_room_id]() { BodyItems result_items; if(matrix->get_previous_room_messages(previous_messages_future_room_id, result_items) != PluginResult::OK) fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", previous_messages_future_room_id.c_str()); return result_items; }); } } else if(event.key.code == sf::Keyboard::Down || event.key.code == sf::Keyboard::J) { tabs[selected_tab].body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageDown) { tabs[selected_tab].body->select_next_page(); } else if(event.key.code == sf::Keyboard::End) { tabs[selected_tab].body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = PageType::EXIT; } else if((event.key.code == sf::Keyboard::Left || event.key.code == sf::Keyboard::H) && synced) { tabs[selected_tab].body->clear_cache(); selected_tab = std::max(0, selected_tab - 1); read_marker_timer.restart(); redraw = true; if(typing) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_futures.push_back(std::async(typing_async_func, false, current_room_id)); } } else if((event.key.code == sf::Keyboard::Right || event.key.code == sf::Keyboard::L) && synced) { tabs[selected_tab].body->clear_cache(); selected_tab = std::min((int)tabs.size() - 1, selected_tab + 1); read_marker_timer.restart(); redraw = true; if(typing) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_futures.push_back(std::async(typing_async_func, false, current_room_id)); } } if(tabs[selected_tab].type == ChatTabType::MESSAGES && event.key.code == sf::Keyboard::Enter) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!selected->url.empty()) { const char *content_type = link_get_content_type(selected->url); if(content_type && (strcmp(content_type, "audio") == 0 || strcmp(content_type, "video") == 0 || strcmp(content_type, "image") == 0)) { page_stack.push(PageType::CHAT); watched_videos.clear(); current_page = PageType::VIDEO_CONTENT; // TODO: Add title video_content_page(video_page.get(), selected->url, "No title"); redraw = true; continue; } launch_url(selected->url.c_str()); continue; } // TODO: If content type is a file, show file-manager prompt where it should be saved and asynchronously save it instead // TODO: Change this when messages are not stored in the description const std::string &message_str = selected->get_description(); auto urls_begin = std::sregex_iterator(message_str.begin(), message_str.end(), url_extract_regex); auto urls_end = std::sregex_iterator(); size_t num_urls = std::distance(urls_begin, urls_end); if(num_urls == 1) { launch_url(urls_begin->str()); } else if(num_urls > 1) { chat_state = ChatState::URL_SELECTION; url_selection_body.clear_items(); for(auto it = urls_begin; it != urls_end; ++it) { auto body_item = BodyItem::create(it->str()); url_selection_body.items.push_back(std::move(body_item)); } } } } } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::URL_SELECTION) { if(event.key.code == sf::Keyboard::Up) { url_selection_body.select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { url_selection_body.select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { url_selection_body.select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { url_selection_body.select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { url_selection_body.select_first_item(); } else if(event.key.code == sf::Keyboard::End) { url_selection_body.select_last_item(); } else if(event.key.code == sf::Keyboard::Left) { // TODO: Clear url_selection_body? selected_tab = std::max(0, selected_tab - 1); chat_state = ChatState::NAVIGATING; } else if(event.key.code == sf::Keyboard::Right) { selected_tab = std::min((int)tabs.size() - 1, selected_tab + 1); chat_state = ChatState::NAVIGATING; } else if(event.key.code == sf::Keyboard::Escape) { url_selection_body.clear_items(); chat_state = ChatState::NAVIGATING; } else if(event.key.code == sf::Keyboard::Enter) { BodyItem *selected_item = url_selection_body.get_selected(); if(!selected_item) continue; launch_url(selected_item->get_title()); } } else if(event.type == sf::Event::KeyReleased && chat_state == ChatState::NAVIGATING && tabs[selected_tab].type == ChatTabType::MESSAGES) { if(event.key.code == sf::Keyboard::U) { new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); } if(event.key.code == sf::Keyboard::M || event.key.code == sf::Keyboard::I) { chat_input.set_editable(true); chat_state = ChatState::TYPING_MESSAGE; } if(event.key.control && event.key.code == sf::Keyboard::V) { // TODO: Make asynchronous. // TODO: Upload multiple files. std::string err_msg; if(matrix->post_file(current_room_id, sf::Clipboard::getString(), err_msg) != PluginResult::OK) { std::string desc = "Failed to upload media to room, error: " + err_msg; show_notification("QuickMedia", desc.c_str(), Urgency::CRITICAL); } } if(event.key.code == sf::Keyboard::R) { std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); if(selected) { 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) { std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); if(selected) { if(!selected->url.empty()) { // cant edit messages that are image/video posts // TODO: Show inline notification show_notification("QuickMedia", "You can only edit messages with no file attached to it"); } 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.code == sf::Keyboard::D) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { // TODO: Make asynchronous std::string err_msg; if(matrix->delete_message(current_room_id, selected->userdata, err_msg) != PluginResult::OK) { // TODO: Show inline notification show_notification("QuickMedia", "Failed to delete message, reason: " + err_msg, Urgency::CRITICAL); } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for deletion"); } } } if((chat_state == ChatState::TYPING_MESSAGE || chat_state == ChatState::REPLYING || chat_state == ChatState::EDITING) && tabs[selected_tab].type == ChatTabType::MESSAGES) { if(event.type == sf::Event::TextEntered) { //chat_input.onTextEntered(event.text.unicode); // TODO: Also show typing event when ctrl+v pasting? if(event.text.unicode != 13) { // Return key start_typing_timer.restart(); if(!typing) { fprintf(stderr, "Started typing\n"); typing_futures.push_back(std::async(typing_async_func, true, current_room_id)); } typing = true; } } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { chat_input.set_editable(false); chat_input.set_text(""); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; if(typing) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_futures.push_back(std::async(typing_async_func, false, current_room_id)); } } //chat_input.on_event(event); chat_input.process_event(event); } else if(tabs[selected_tab].type == ChatTabType::ROOMS && event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Enter) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item) { tabs[selected_tab].body->clear_cache(); current_room_id = selected_item->url; selected_tab = MESSAGES_TAB_INDEX; tabs[MESSAGES_TAB_INDEX].body->clear_items(); BodyItems new_items; if(matrix->get_all_synced_room_messages(current_room_id, new_items) == PluginResult::OK) { tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(std::move(new_items)); tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } else { std::string err_msg = "Failed to get messages in room: " + current_room_id; show_notification("QuickMedia", err_msg, Urgency::CRITICAL); } auto room_body_item_it = body_items_by_room_id.find(current_room_id); if(room_body_item_it != body_items_by_room_id.end()) { current_room_body_data = &room_body_item_it->second; room_name_text.setString(current_room_body_data->body_item->get_title()); room_avatar_thumbnail_data = std::make_shared(); } read_marker_timeout_ms = 0; redraw = true; } } } switch(new_page) { case PageType::FILE_MANAGER: { new_page = PageType::CHAT; auto file_manager_page = std::make_unique(this); file_manager_page->set_current_directory(get_home_dir().data); auto file_manager_body = create_body(); file_manager_page->get_files_in_directory(file_manager_body->items); std::vector tabs; 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(std::move(tabs)); if(selected_files.empty()) { fprintf(stderr, "No files selected!\n"); } else { // TODO: Make asynchronous. // TODO: Upload multiple files. std::string err_msg; if(matrix->post_file(current_room_id, selected_files[0], err_msg) != PluginResult::OK) { std::string desc = "Failed to upload media to room, error: " + err_msg; show_notification("QuickMedia", desc.c_str(), Urgency::CRITICAL); } } redraw = true; break; } case PageType::CHAT_LOGIN: { new_page = PageType::CHAT; matrix->logout(); tabs[MESSAGES_TAB_INDEX].body->clear_cache(); // TODO: Instead of doing this, exit this current function and navigate to chat login page instead. // This doesn't currently work because at the end of this function there are futures that need to wait // and one of them is /sync, which has a timeout of 30 seconds. That timeout has to be killed somehow. //delete current_plugin; //current_plugin = new Matrix(); current_page = PageType::CHAT_LOGIN; chat_login_page(); if(current_page == PageType::CHAT) chat_page(); exit(0); break; } default: break; } if(typing && start_typing_timer.getElapsedTime().asSeconds() >= typing_timeout_seconds) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_futures.push_back(std::async(typing_async_func, false, current_room_id)); } for(auto it = typing_futures.begin(); it != typing_futures.end(); ) { if(!it->valid()) { it = typing_futures.erase(it); continue; } if(it->wait_for(std::chrono::seconds(0)) == std::future_status::ready) { it->get(); it = typing_futures.erase(it); continue; } ++it; } async_image_loader.update(); if(current_room_body_data && room_avatar_thumbnail_data->loading_state == LoadingState::NOT_LOADED) async_image_loader.load_thumbnail(current_room_body_data->body_item->thumbnail_url, false, sf::Vector2i(), use_tor, room_avatar_thumbnail_data); if(room_avatar_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING && room_avatar_thumbnail_data->image->getSize().x > 0 && room_avatar_thumbnail_data->image->getSize().y > 0) { if(!room_avatar_thumbnail_data->texture.loadFromImage(*room_avatar_thumbnail_data->image)) fprintf(stderr, "Warning: failed to load texture for room avatar\n"); room_avatar_thumbnail_data->texture.setSmooth(true); //room_avatar_thumbnail_data->texture.generateMipmap(); room_avatar_thumbnail_data->image.reset(); room_avatar_thumbnail_data->loading_state = LoadingState::APPLIED_TO_TEXTURE; room_avatar_sprite.setTexture(room_avatar_thumbnail_data->texture, true); auto texture_size = room_avatar_sprite.getTexture()->getSize(); if(texture_size.x > 0 && texture_size.y > 0) { float width_ratio = (float)texture_size.x / (float)texture_size.y; float height_scale = room_avatar_height / (float)texture_size.y; float width_scale = height_scale * width_ratio; room_avatar_sprite.setScale(width_scale, height_scale); } } const float room_name_padding_y = (selected_tab == MESSAGES_TAB_INDEX ? room_name_total_height : 0.0f); const float tab_shade_height = tab_spacer_height + std::floor(tab_vertical_offset) + tab_height + 10.0f + room_name_padding_y; 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; chat_input.set_max_width(window_size.x - (logo_padding_x + plugin_logo.getSize().x + chat_input_padding_x * 2.0f)); chat_input.set_position(sf::Vector2f(logo_padding_x + plugin_logo.getSize().x + chat_input_padding_x, window_size.y - chat_height - chat_input_padding_y)); float body_padding_horizontal = 25.0f; float body_padding_vertical = 5.0f; float body_width = window_size.x - body_padding_horizontal * 2.0f; if(body_width <= 480.0f) { body_width = window_size.x; body_padding_horizontal = 0.0f; } chat_input_shade.setSize(sf::Vector2f(window_size.x, chat_input_height_full)); chat_input_shade.setPosition(0.0f, window_size.y - chat_input_shade.getSize().y); body_pos = sf::Vector2f(body_padding_horizontal, body_padding_vertical + tab_shade_height); body_size = sf::Vector2f(body_width, window_size.y - chat_input_shade.getSize().y - body_padding_vertical - tab_shade_height); more_messages_below_rect.setSize(sf::Vector2f(window_size.x, gradient_height)); more_messages_below_rect.setPosition(0.0f, std::floor(window_size.y - chat_input_shade.getSize().y - gradient_height)); logo_sprite.setPosition(logo_padding_x, window_size.y - chat_input_shade.getSize().y * 0.5f - plugin_logo.getSize().y * 0.5f); } if(!sync_running && sync_timer.getElapsedTime().asMilliseconds() >= sync_min_time_ms) { fprintf(stderr, "Time since last sync: %d ms\n", sync_timer.getElapsedTime().asMilliseconds()); // TODO: What if the server just always responds immediately? sync_min_time_ms = 50; sync_running = true; sync_timer.restart(); sync_future_room_id = current_room_id; sync_future = std::async(std::launch::async, [this, &sync_future_room_id, synced]() { SyncFutureResult result; if(matrix->sync(result.room_sync_messages) == PluginResult::OK) { fprintf(stderr, "Synced matrix\n"); if(!synced) { if(matrix->get_joined_rooms(result.rooms_body_items) != PluginResult::OK) { show_notification("QuickMedia", "Failed to get a list of joined rooms", Urgency::CRITICAL); current_page = PageType::EXIT; return result; } } if(sync_future_room_id.empty() && !result.rooms_body_items.empty()) sync_future_room_id = result.rooms_body_items[0]->url; if(matrix->get_new_room_messages(sync_future_room_id, result.body_items) != PluginResult::OK) { fprintf(stderr, "Failed to get new matrix messages in room: %s\n", sync_future_room_id.c_str()); } } else { fprintf(stderr, "Failed to sync matrix\n"); } return result; }); } if(sync_future.valid() && sync_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { SyncFutureResult sync_result = sync_future.get(); // Ignore finished sync if it happened in another room. When we navigate back to the room we will get the messages again if(sync_future_room_id == current_room_id || !synced) { int num_items = tabs[MESSAGES_TAB_INDEX].body->items.size(); bool scroll_to_end = (num_items == 0 || (num_items > 0 && tabs[MESSAGES_TAB_INDEX].body->get_selected_item() == num_items - 1)); BodyItem *selected_item = tabs[MESSAGES_TAB_INDEX].body->get_selected(); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(std::move(sync_result.body_items)); if(selected_item && !scroll_to_end) { int selected_item_index = tabs[MESSAGES_TAB_INDEX].body->get_index_by_body_item(selected_item); if(selected_item_index != -1) tabs[MESSAGES_TAB_INDEX].body->set_selected_item(selected_item_index); } else if(scroll_to_end) { tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } } // Initial sync if(!synced) { tabs[ROOMS_TAB_INDEX].body->items = std::move(sync_result.rooms_body_items); for(auto body_item : tabs[ROOMS_TAB_INDEX].body->items) { body_items_by_room_id[body_item->url] = { body_item, true, 0 }; } // The room id should be saved in a file when changing viewed room. if(!tabs[ROOMS_TAB_INDEX].body->items.empty()) current_room_id = tabs[ROOMS_TAB_INDEX].body->items[0]->url; auto room_body_item_it = body_items_by_room_id.find(current_room_id); if(room_body_item_it != body_items_by_room_id.end()) { current_room_body_data = &room_body_item_it->second; room_name_text.setString(current_room_body_data->body_item->get_title()); } } process_new_room_messages(sync_result.room_sync_messages, !synced); sync_running = false; synced = true; } if(set_read_marker_future.valid() && set_read_marker_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { set_read_marker_future.get(); read_marker_timer.restart(); setting_read_marker = false; } if(fetching_previous_messages_running && previous_messages_future.valid() && previous_messages_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { BodyItems new_body_items = previous_messages_future.get(); fprintf(stderr, "Finished fetching older messages, num new messages: %zu\n", new_body_items.size()); // Ignore finished fetch of messages if it happened in another room. When we navigate back to the room we will get the messages again size_t num_new_messages = new_body_items.size(); if(previous_messages_future_room_id == current_room_id && num_new_messages > 0) { BodyItem *selected_item = tabs[MESSAGES_TAB_INDEX].body->get_selected(); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(std::move(new_body_items)); if(selected_item) { int selected_item_index = tabs[MESSAGES_TAB_INDEX].body->get_index_by_body_item(selected_item); if(selected_item_index != -1) tabs[MESSAGES_TAB_INDEX].body->set_selected_item(selected_item_index); } } fetching_previous_messages_running = false; } //chat_input.update(); window.clear(back_color); const float width_per_tab = window_size.x / tabs.size(); tab_background.setSize(sf::Vector2f(std::floor(width_per_tab - tab_margin_x * 2.0f), tab_height)); 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); const float tab_y = tab_spacer_height + std::floor(tab_vertical_offset + tab_height * 0.5f - (tab_text_size + 5.0f) * 0.5f) + room_name_padding_y; tab_shade.setSize(sf::Vector2f(window_size.x, tab_shade_height)); window.draw(tab_shade); if(tabs[selected_tab].type == ChatTabType::MESSAGES) { float room_name_text_offset_x = 0.0f; if(room_avatar_sprite.getTexture() && room_avatar_sprite.getTexture()->getNativeHandle() != 0) { auto room_avatar_texture_size = room_avatar_sprite.getTexture()->getSize(); room_avatar_texture_size.x *= room_avatar_sprite.getScale().x; room_avatar_texture_size.y *= room_avatar_sprite.getScale().y; room_avatar_sprite.setPosition(body_pos.x, room_name_total_height * 0.5f - room_avatar_texture_size.y * 0.5f + 5.0f); window.draw(room_avatar_sprite); room_name_text_offset_x += room_avatar_texture_size.x + 10.0f; } room_name_text.setPosition(body_pos.x + room_name_text_offset_x, room_name_text_padding_y); window.draw(room_name_text); } gradient_points[0].position.x = 0.0f; 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 = 0.0f; gradient_points[3].position.y = tab_shade_height + gradient_height; int i = 0; for(ChatTab &tab : tabs) { if(i == selected_tab) { tab_background.setPosition(std::floor(i * width_per_tab + tab_margin_x), tab_spacer_height + std::floor(tab_vertical_offset) + room_name_padding_y); window.draw(tab_background); } const float center = (i * width_per_tab) + (width_per_tab * 0.5f); tab.text.setPosition(std::floor(center - tab.text.getLocalBounds().width * 0.5f), tab_y); window.draw(tab.text); ++i; } // TODO: Have one for each room. Also add bottom one? for fetching new messages (currently not implemented, is it needed?) if(fetching_previous_messages_running && tabs[selected_tab].type == ChatTabType::MESSAGES) { 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].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; const 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()) + margin); sf::RectangleShape overlay(sf::Vector2f(window_size.x, window_size.y - tab_shade_height - chat_input_height_full)); overlay.setPosition(0.0f, tab_shade_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 + replying_to_text_height + margin)); item_background.setPosition(sf::Vector2f(0.0f, body_item_pos.y - replying_to_text_height - margin)); item_background.setFillColor(back_color); window.draw(item_background); replying_to_text.setPosition(body_item_pos.x, body_item_pos.y - replying_to_text_height); window.draw(replying_to_text); tabs[MESSAGES_TAB_INDEX].body->draw_item(window, currently_operating_on_item.get(), body_item_pos, body_item_size); } if(tabs[selected_tab].type == ChatTabType::MESSAGES && current_room_body_data) { if(tabs[selected_tab].body->is_last_item_fully_visible()) { if(!current_room_body_data->last_message_read) { std::string room_desc = current_room_body_data->body_item->get_description(); if(strncmp(room_desc.c_str(), "Unread: ", 8) == 0) room_desc = room_desc.substr(8); if(room_desc.size() >= 25 && strncmp(room_desc.c_str() + room_desc.size() - 25, "\n** You were mentioned **", 25) == 0) room_desc = room_desc.substr(0, room_desc.size() - 25); current_room_body_data->body_item->set_description(std::move(room_desc)); // TODO: Show a line like nheko instead for unread messages, or something else current_room_body_data->body_item->title_color = sf::Color::White; current_room_body_data->last_message_read = true; } } else if(!current_room_body_data->last_message_read) { window.draw(more_messages_below_rect); } } // TODO: Cache /sync, then we wont only see loading text if(!synced) { sf::Text loading_text("Loading...", *font, 24); loading_text.setPosition(body_pos.x + body_size.x * 0.5f - loading_text.getLocalBounds().width * 0.5f, body_pos.y + body_size.y * 0.5f - loading_text.getLocalBounds().height * 0.5f); window.draw(loading_text); } if(tabs[selected_tab].type == ChatTabType::MESSAGES) { BodyItem *last_visible_item = tabs[selected_tab].body->get_last_fully_visible_item(); if(is_window_focused && chat_state != ChatState::URL_SELECTION && current_room_body_data && last_visible_item && !setting_read_marker && read_marker_timer.getElapsedTime().asMilliseconds() >= read_marker_timeout_ms) { Message *message = (Message*)last_visible_item->userdata; // TODO: What if two messages have the same timestamp? if(message->timestamp > current_room_body_data->last_read_message_timestamp) { read_marker_timeout_ms = read_marker_timeout_ms_default; current_room_body_data->last_read_message_timestamp = message->timestamp; // TODO: What if the message is no longer valid? setting_read_marker = true; set_read_marker_future = std::async(std::launch::async, [this, current_room_id, message]() mutable { if(matrix->set_read_marker(current_room_id, message) != PluginResult::OK) { fprintf(stderr, "Warning: failed to set read marker to %s\n", message->event_id.c_str()); } }); } } window.draw(chat_input_shade); chat_input.draw(window); //chat_input.draw(window, false); window.draw(logo_sprite); } window.display(); } exit(0); // Ignore futures and quit immediately } }