#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/Dmenu.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 #include #include #include #include #include #include #include #include #include #include #include #include static const sf::Color back_color(21, 25, 30); static const int DOUBLE_CLICK_TIME = 500; 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 sf::Color tab_unselected_color(32, 36, 42); // 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; } namespace QuickMedia { Program::Program() : disp(nullptr), window(sf::VideoMode(1280, 720), "QuickMedia", sf::Style::Default, sf::ContextSettings(0, 0, 0, 3, 3)), window_size(1280, 720), body(nullptr), current_plugin(nullptr), current_page(Page::SEARCH_SUGGESTION), 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 = "../../../"; } if(!font.loadFromFile(resources_root + "fonts/Lato-Regular.ttf")) { fprintf(stderr, "Failed to load font: Lato-Regular.ttf\n"); abort(); } if(!bold_font.loadFromFile(resources_root + "fonts/Lato-Bold.ttf")) { fprintf(stderr, "Failed to load font: Lato-Bold.ttf\n"); abort(); } body = new Body(this, &font, &bold_font); related_media_body = new Body(this, &font, &bold_font); related_media_body->draw_thumbnails = true; 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_images && running) { running = false; { std::unique_lock lock(image_upscale_mutex); image_upscale_cv.notify_one(); } image_upscale_thead.join(); } else { running = false; } if(related_media_body) delete related_media_body; if(body) delete body; if(file_manager) delete file_manager; if(current_plugin) delete current_plugin; if(disp) XCloseDisplay(disp); } static SearchResult search_selected_suggestion(Body *input_body, Body *output_body, Plugin *plugin, std::string &selected_title, std::string &selected_url, bool skip_search) { BodyItem *selected_item = input_body->get_selected(); if(!selected_item) return SearchResult::ERR; selected_title = selected_item->get_title(); selected_url = selected_item->url; if(!skip_search) { output_body->clear_items(); SearchResult search_result = plugin->search(!selected_url.empty() ? selected_url : selected_title, output_body->items); output_body->reset_selected(); return search_result; } else { return SearchResult::OK; } } static void usage() { fprintf(stderr, "usage: QuickMedia [--tor] [--use-system-mpv-config] [--dir ] [-p ]\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 or dmenu\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, " --dir Set the start directory when using file-manager\n"); fprintf(stderr, " -p Change the placeholder text for dmenu\n"); fprintf(stderr, "EXAMPLES:\n"); fprintf(stderr, "QuickMedia manganelo\n"); fprintf(stderr, "QuickMedia youtube --tor\n"); fprintf(stderr, "echo \"hello\\nworld\" | QuickMedia dmenu\n"); } static bool is_program_executable_by_name(const char *name) { // TODO: Implement for Windows. Windows also uses semicolon instead of colon as a separator char *env = getenv("PATH"); std::unordered_set paths; string_split(env, ':', [&paths](const char *str, size_t size) { paths.insert(std::string(str, size)); return true; }); for(const std::string &path_str : paths) { Path path(path_str); path.join(name); if(get_file_type(path) == FileType::REGULAR) return true; } return false; } int Program::run(int argc, char **argv) { if(argc < 2) { usage(); return -1; } current_plugin = nullptr; std::string plugin_logo_path; std::string search_placeholder; const char *start_dir = nullptr; for(int i = 1; i < argc; ++i) { if(!current_plugin) { if(strcmp(argv[i], "manganelo") == 0) { current_plugin = new Manganelo(); plugin_logo_path = resources_root + "images/manganelo_logo.png"; } else if(strcmp(argv[i], "mangatown") == 0) { current_plugin = new Mangatown(); plugin_logo_path = resources_root + "images/mangatown_logo.png"; } else if(strcmp(argv[i], "mangadex") == 0) { current_plugin = new Mangadex(); plugin_logo_path = resources_root + "images/mangadex_logo.png"; } else if(strcmp(argv[i], "youtube") == 0) { current_plugin = new Youtube(); plugin_logo_path = resources_root + "images/yt_logo_rgb_dark_small.png"; } else if(strcmp(argv[i], "pornhub") == 0) { current_plugin = new Pornhub(); plugin_logo_path = resources_root + "images/pornhub_logo.png"; } else if(strcmp(argv[i], "4chan") == 0) { current_plugin = new Fourchan(resources_root); plugin_logo_path = resources_root + "images/4chan_logo.png"; } else if(strcmp(argv[i], "nyaa.si") == 0) { current_plugin = new NyaaSi(); plugin_logo_path = resources_root + "images/nyaa_si_logo.png"; } else if(strcmp(argv[i], "matrix") == 0) { current_plugin = new Matrix(); //plugin_logo_path = resources_root + "images/matrix_logo.png"; } else if(strcmp(argv[i], "file-manager") == 0) { current_plugin = new FileManager(); } else if(strcmp(argv[i], "dmenu") == 0) { current_plugin = new Dmenu(); } else { fprintf(stderr, "Invalid plugin %s\n", argv[i]); usage(); return -1; } } if(strcmp(argv[i], "--tor") == 0) { use_tor = 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_images = true; } else if(strcmp(argv[i], "--dir") == 0) { if(i < argc - 1) { start_dir = argv[i + 1]; ++i; } } else if(strcmp(argv[i], "-p") == 0) { if(i < argc - 1) { search_placeholder = argv[i + 1]; ++i; } } else if(argv[i][0] == '-') { fprintf(stderr, "Invalid option %s\n", argv[i]); usage(); return -1; } } if(!current_plugin) { fprintf(stderr, "Missing plugin argument\n"); usage(); return -1; } if(!search_placeholder.empty() && current_plugin->name == "dmenu") { fprintf(stderr, "Option -p is only valid with dmenu\n"); usage(); return -1; } if(current_plugin->name == "file-manager") { current_page = Page::FILE_MANAGER; file_manager = static_cast(current_plugin); } else { if(start_dir) { fprintf(stderr, "Option --dir is only valid with file-manager\n"); usage(); return -1; } } if(start_dir) { if(!static_cast(current_plugin)->set_current_directory(start_dir)) { fprintf(stderr, "Invalid directory provided with --dir: %s\n", start_dir); return -3; } } 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_images) { if(!current_plugin->is_manga()) { fprintf(stderr, "Option --upscale-images 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 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()); } }); } else { running = true; } current_plugin->use_tor = use_tor; window.setTitle("QuickMedia - " + current_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(current_plugin->name == "matrix") { Matrix *matrix = static_cast(current_plugin); if(matrix->load_and_verify_cached_session() == PluginResult::OK) { current_page = Page::CHAT; } else { fprintf(stderr, "Failed to load session cache, redirecting to login page\n"); current_page = Page::CHAT_LOGIN; } } if(search_placeholder.empty()) search_placeholder = "Search..."; search_bar = std::make_unique(font, &plugin_logo, search_placeholder); search_bar->text_autosearch_delay = current_plugin->get_search_delay(); while(window.isOpen()) { switch(current_page) { case Page::EXIT: window.close(); break; case Page::SEARCH_SUGGESTION: body->draw_thumbnails = current_plugin->search_suggestions_has_thumbnails(); search_suggestion_page(); body->clear_thumbnails(); break; #if 0 case Page::SEARCH_RESULT: body->draw_thumbnails = current_plugin->search_results_has_thumbnails(); search_result_page(); break; #endif case Page::VIDEO_CONTENT: body->draw_thumbnails = false; video_content_page(); break; case Page::EPISODE_LIST: body->draw_thumbnails = false; episode_list_page(); break; case Page::IMAGES: { body->draw_thumbnails = false; window.setKeyRepeatEnabled(false); window.setFramerateLimit(4); image_page(); body->filter_search_fuzzy(""); if(vsync_set) window.setFramerateLimit(0); else window.setFramerateLimit(monitor_hz); window.setKeyRepeatEnabled(true); break; } case Page::IMAGES_CONTINUOUS: { body->draw_thumbnails = false; window.setKeyRepeatEnabled(false); image_continuous_page(); body->filter_search_fuzzy(""); window.setKeyRepeatEnabled(true); break; } case Page::CONTENT_LIST: { body->draw_thumbnails = true; content_list_page(); break; } case Page::CONTENT_DETAILS: { body->draw_thumbnails = true; content_details_page(); break; } case Page::IMAGE_BOARD_THREAD_LIST: { body->draw_thumbnails = true; image_board_thread_list_page(); body->clear_thumbnails(); break; } case Page::IMAGE_BOARD_THREAD: { body->draw_thumbnails = true; image_board_thread_page(); body->clear_thumbnails(); break; } case Page::CHAT_LOGIN: { chat_login_page(); break; } case Page::CHAT: { body->draw_thumbnails = true; chat_page(); break; } case Page::FILE_MANAGER: { body->draw_thumbnails = true; file_manager_page(); break; } } } return exit_code; } void Program::base_event_handler(sf::Event &event, Page previous_page, bool handle_keypress, bool clear_on_escape, bool handle_searchbar) { if (event.type == sf::Event::Closed) { current_page = Page::EXIT; } 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::Escape) { current_page = previous_page; if(clear_on_escape) { body->clear_items(); body->reset_selected(); search_bar->clear(); } } } else if(handle_searchbar) { 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"; } 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 = std::make_unique(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 void fill_recommended_items_from_json(const Json::Value &recommended_json, BodyItems &body_items) { assert(recommended_json.isObject()); 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()) recommended_items.push_back(std::make_pair(member_name, std::move(recommended_item))); } /* 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 = std::make_unique(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()); } static Path get_video_history_filepath(Plugin *plugin) { 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"); } static Path get_recommended_filepath(Plugin *plugin) { 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"); } // 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(Plugin *plugin) { Path video_history_filepath = get_video_history_filepath(plugin); 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(Plugin *plugin) { Path recommended_filepath = get_recommended_filepath(plugin); 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::plugin_get_watch_history(Plugin *plugin, BodyItems &history_items) { // TOOD: Make generic, instead of checking for plugin if(plugin->is_manga()) { 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](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)) { 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 = std::make_unique(manga_name.asString()); if(plugin->name == "manganelo") body_item->url = "https://manganelo.com/manga/" + base64_decode(filename.string()); else if(plugin->name == "mangadex") body_item->url = "https://mangadex.org/title/" + base64_decode(filename.string()); else if(plugin->name == "mangatown") 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; }); return; } if(plugin->name != "youtube") return; fill_history_items_from_json(load_video_history_json(plugin), 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->getBottomWithoutShadow(); 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; }; struct Tab { Body *body; std::unique_ptr login_tab; SearchSuggestionTab tab; sf::Text *text; }; bool Program::on_search_suggestion_submit_text(Body *input_body, Body *output_body) { if(input_body->no_items_visible()) return false; Page next_page = current_plugin->get_page_after_search(); bool skip_search = (next_page == Page::VIDEO_CONTENT || next_page == Page::CONTENT_LIST); // TODO: This shouldn't be done if search_selected_suggestion fails if(search_selected_suggestion(input_body, output_body, current_plugin, content_title, content_url, skip_search) != SearchResult::OK) { show_notification("Search", "Search failed!", Urgency::CRITICAL); return false; } if(next_page == Page::EPISODE_LIST && current_plugin->is_manga()) { Manga *manga_plugin = static_cast(current_plugin); if(content_url.empty()) { show_notification("Manga", "Url is missing for manga!", Urgency::CRITICAL); return false; } Path content_storage_dir = get_storage_dir().join(current_plugin->name); std::string manga_id; if(!manga_plugin->extract_id_from_url(content_url, manga_id)) return false; 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"] = content_title; FileType file_type = get_file_type(content_storage_file); if(file_type == FileType::REGULAR) read_file_as_json(content_storage_file, content_storage_json); } else if(next_page == Page::VIDEO_CONTENT) { watched_videos.clear(); if(content_url.empty()) //next_page = Page::SEARCH_RESULT; next_page = Page::SEARCH_SUGGESTION; else { page_stack.push(Page::SEARCH_SUGGESTION); } current_page = next_page; return false; } else if(next_page == Page::CONTENT_LIST) { content_list_url = content_url; } else if(next_page == Page::IMAGE_BOARD_THREAD_LIST) { image_board_thread_list_url = content_url; } current_page = next_page; return true; } void Program::search_suggestion_page() { std::string update_search_text; bool search_running = false; bool typing = false; bool is_fourchan = current_plugin->name == "4chan"; std::string autocomplete_text; bool autocomplete_running = false; Body history_body(this, &font, &bold_font); std::unique_ptr recommended_body; sf::Text all_tab_text("All", font, tab_text_size); sf::Text history_tab_text("History", font, tab_text_size); sf::Text recommended_tab_text("Recommended", font, tab_text_size); sf::Text login_tab_text("Login", font, tab_text_size); SearchBar *focused_login_input = nullptr; if(current_plugin->name == "youtube") { recommended_body = std::make_unique(this, &font, &bold_font); recommended_body->draw_thumbnails = true; fill_recommended_items_from_json(load_recommended_json(current_plugin), recommended_body->items); } std::vector tabs; int selected_tab = 0; auto login_submit_callback = [this, &tabs, &selected_tab](const std::string&) -> bool { if(!tabs[selected_tab].body) { std::string username = tabs[selected_tab].login_tab->username->get_text(); std::string password = tabs[selected_tab].login_tab->password->get_text(); if(current_plugin->name == "4chan") { std::string response_msg; PluginResult result = static_cast(current_plugin)->login(username, password, response_msg); if(result == PluginResult::NET_ERR) { show_notification("4chan", "Login failed!", Urgency::CRITICAL); } else if(result == PluginResult::ERR) { std::string desc = "Login failed, reason: "; if(response_msg.empty()) desc += "Unknown"; else desc += response_msg; show_notification("4chan", desc, Urgency::CRITICAL); } else if(result == PluginResult::OK) { show_notification("4chan", "Successfully logged in!", Urgency::LOW); selected_tab = 0; } } } return false; }; tabs.push_back(Tab{body, nullptr, SearchSuggestionTab::ALL, &all_tab_text}); tabs.push_back(Tab{&history_body, nullptr, SearchSuggestionTab::HISTORY, &history_tab_text}); if(recommended_body) tabs.push_back(Tab{recommended_body.get(), nullptr, SearchSuggestionTab::RECOMMENDED, &recommended_tab_text}); if(is_fourchan) { tabs.push_back(Tab{nullptr, std::make_unique(font), SearchSuggestionTab::LOGIN, &login_tab_text}); focused_login_input = tabs.back().login_tab->username.get(); tabs.back().login_tab->username->caret_visible = true; tabs.back().login_tab->password->caret_visible = false; tabs.back().login_tab->username->onTextSubmitCallback = login_submit_callback; tabs.back().login_tab->password->onTextSubmitCallback = login_submit_callback; } plugin_get_watch_history(current_plugin, history_body.items); if(current_plugin->name == "youtube") history_body.draw_thumbnails = true; search_bar->onTextBeginTypingCallback = [&typing]() { typing = true; }; search_bar->autocomplete_search_delay = current_plugin->get_autocomplete_delay(); search_bar->onAutocompleteRequestCallback = [this, &tabs, &selected_tab, &autocomplete_text](const sf::String &text) { if(tabs[selected_tab].body == body && !current_plugin->search_is_filter()) autocomplete_text = text; }; std::string recommended_filter; search_bar->onTextUpdateCallback = [&update_search_text, this, &tabs, &selected_tab, &typing, &recommended_body, &recommended_filter](const std::string &text) { if(tabs[selected_tab].body == body && !current_plugin->search_is_filter()) update_search_text = text; else { tabs[selected_tab].body->filter_search_fuzzy(text); tabs[selected_tab].body->clamp_selection(); } if(tabs[selected_tab].body == recommended_body.get()) recommended_filter = text; typing = false; }; search_bar->onTextSubmitCallback = [this, &tabs, &selected_tab, &typing](const std::string&) -> bool { if(current_plugin->name != "dmenu") { if(typing || tabs[selected_tab].body->no_items_visible()) return false; } return on_search_suggestion_submit_text(tabs[selected_tab].body, body); }; std::future recommended_future; /* if(recommended_body) { recommended_future = std::async(std::launch::async, [this]() { BodyItems body_items; PluginResult front_page_result = current_plugin->get_front_page(body_items); return body_items; }); } else { */ if(current_plugin->get_front_page(body->items) != PluginResult::OK) { show_notification("QuickMedia", "Failed to get front page", Urgency::CRITICAL); current_page = Page::EXIT; return; } body->clamp_selection(); /*}*/ sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; sf::RectangleShape tab_spacing_rect(sf::Vector2f(0.0f, 0.0f)); tab_spacing_rect.setFillColor(tab_unselected_color); const float tab_spacer_height = 0.0f; sf::RectangleShape tab_drop_shadow; tab_drop_shadow.setFillColor(sf::Color(23, 25, 27)); //sf::Clock tt; //int fps = 0; while (current_page == Page::SEARCH_SUGGESTION) { while (window.pollEvent(event)) { base_event_handler(event, Page::EXIT, false, true, tabs[selected_tab].body != nullptr); 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::Up) { if(tabs[selected_tab].body) tabs[selected_tab].body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { if(tabs[selected_tab].body) tabs[selected_tab].body->select_next_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = Page::EXIT; exit_code = 1; } else if(event.key.code == sf::Keyboard::Left) { if(tabs[selected_tab].body) { tabs[selected_tab].body->filter_search_fuzzy(""); tabs[selected_tab].body->clamp_selection(); } selected_tab = std::max(0, selected_tab - 1); search_bar->clear(); } else if(event.key.code == sf::Keyboard::Right) { if(tabs[selected_tab].body) { tabs[selected_tab].body->filter_search_fuzzy(""); tabs[selected_tab].body->clamp_selection(); } selected_tab = std::min((int)tabs.size() - 1, selected_tab + 1); search_bar->clear(); } else if(event.key.code == sf::Keyboard::Tab) { if(tabs[selected_tab].body) search_bar->set_to_autocomplete(); } } if(!tabs[selected_tab].body) { if(event.type == sf::Event::TextEntered) focused_login_input->onTextEntered(event.text.unicode); focused_login_input->on_event(event); if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Tab) { focused_login_input->caret_visible = false; if(focused_login_input == tabs[selected_tab].login_tab->username.get()) focused_login_input = tabs[selected_tab].login_tab->password.get(); else focused_login_input = tabs[selected_tab].login_tab->username.get(); focused_login_input->caret_visible = true; } } } if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size, true); } if(tabs[selected_tab].body) search_bar->update(); if(!update_search_text.empty() && !search_running) { search_suggestion_future = std::async(std::launch::async, [this, update_search_text]() { BodyItems result; if(current_plugin->update_search_suggestions(update_search_text, result) != SuggestionResult::OK) { show_notification("Search", "Search failed!", Urgency::CRITICAL); } return result; }); update_search_text.clear(); search_running = true; } if(search_running && search_suggestion_future.valid() && search_suggestion_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { if(update_search_text.empty()) { body->items = search_suggestion_future.get(); body->clamp_selection(); } else { search_suggestion_future.get(); } 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) { search_bar->set_autocomplete_text(autocomplete_future.get()); autocomplete_running = false; } /* if(recommended_future.valid() && recommended_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { recommended_body->items = recommended_future.get(); recommended_body->filter_search_fuzzy(recommended_filter); recommended_body->clamp_selection(); } */ window.clear(back_color); { //tab_spacing_rect.setPosition(0.0f, search_bar->getBottomWithoutShadow()); //tab_spacing_rect.setSize(sf::Vector2f(window_size.x, tab_spacer_height)); //window.draw(tab_spacing_rect); const float width_per_tab = window_size.x / tabs.size(); sf::RectangleShape tab_background(sf::Vector2f(std::floor(width_per_tab), tab_height)); float tab_vertical_offset = search_bar->getBottomWithoutShadow(); if(tabs[selected_tab].body) { tabs[selected_tab].body->draw(window, body_pos, body_size); } else { tabs[selected_tab].login_tab->username->draw(window, false); tabs[selected_tab].login_tab->password->draw(window, false); tabs[selected_tab].login_tab->password->set_vertical_position(tabs[selected_tab].login_tab->username->getBottomWithoutShadow()); tab_vertical_offset = tabs[selected_tab].login_tab->username->getBottomWithoutShadow() + tabs[selected_tab].login_tab->password->getBottomWithoutShadow(); } const float tab_y = tab_spacer_height + std::floor(tab_vertical_offset + tab_height * 0.5f - (tab_text_size + 5.0f) * 0.5f); int i = 0; for(Tab &tab : tabs) { if(i == selected_tab) { tab_background.setFillColor(tab_selected_color); } else { tab_background.setFillColor(tab_unselected_color); } tab_background.setPosition(std::floor(i * width_per_tab), tab_spacer_height + std::floor(tab_vertical_offset)); 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; } tab_drop_shadow.setSize(sf::Vector2f(window_size.x, 5.0f)); tab_drop_shadow.setPosition(0.0f, std::floor(tab_vertical_offset + tab_height)); window.draw(tab_drop_shadow); } if(tabs[selected_tab].body) search_bar->draw(window, false); // fps++; // if(tt.getElapsedTime().asMilliseconds() >= 1000) { // fprintf(stderr, "fps: %d\n", fps); // fps = 0; // tt.restart(); // } window.display(); } search_bar->onTextBeginTypingCallback = nullptr; search_bar->onAutocompleteRequestCallback = nullptr; } void Program::search_result_page() { assert(false); #if 0 search_bar->onTextUpdateCallback = [this](const std::string &text) { body->filter_search_fuzzy(text); body->clamp_selection(); }; search_bar->onTextSubmitCallback = [this](const std::string &text) { BodyItem *selected_item = body->get_selected(); if(!selected_item) return; video_url = selected_item->url; current_page = Page::VIDEO_CONTENT; }; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; while (current_page == Page::SEARCH_RESULT) { while (window.pollEvent(event)) { base_event_handler(event, Page::SEARCH_SUGGESTION); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; } if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); } search_bar->update(); window.clear(back_color); body->draw(window, body_pos, body_size); search_bar->draw(window); window.display(); faefeaf } #endif } 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() { std::string video_id; if(!youtube_url_extract_id(content_url, video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += content_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(current_plugin); 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"] = content_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", content_url.c_str()); } } save_json_to_file_atomic(get_recommended_filepath(current_plugin), recommended_json); } #define CLEANMASK(mask) ((mask) & (ShiftMask|ControlMask|Mod1Mask|Mod4Mask|Mod5Mask)) void Program::video_content_page() { search_bar->onTextUpdateCallback = nullptr; search_bar->onTextSubmitCallback = nullptr; sf::Clock time_watched_timer; bool added_recommendations = false; bool video_loaded = false; Page 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(); sf::WindowHandle video_player_window = None; auto on_window_create = [this, &video_player_window, &related_media_window, &related_media_window_size](sf::WindowHandle _video_player_window) mutable { video_player_window = _video_player_window; if(!current_plugin->is_image_board()) { 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); } XSelectInput(disp, video_player_window, KeyPressMask | PointerMotionMask); XSync(disp, False); }; auto load_video_error_check = [this, &video_player, previous_page, &time_watched_timer, &added_recommendations]() mutable { time_watched_timer.restart(); added_recommendations = false; watched_videos.insert(content_url); VideoPlayer::Error err = video_player->load_video(content_url.c_str(), window.getSystemHandle(), current_plugin->name); if(err != VideoPlayer::Error::OK) { std::string err_msg = "Failed to play url: "; err_msg += content_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 = current_plugin->get_related_media(content_url); // TODO: Make this also work for other video plugins if(current_plugin->name != "youtube") return; std::string video_id; if(!youtube_url_extract_id(content_url, video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += content_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(current_plugin); 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"] = content_title; new_content_object["timestamp"] = time_now; video_history_json.append(std::move(new_content_object)); Path video_history_filepath = get_video_history_filepath(current_plugin); save_json_to_file_atomic(video_history_filepath, video_history_json); } }; bool has_video_started = true; auto video_event_callback = [this, &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; } content_url = std::move(new_video_url); content_title = std::move(new_video_title); load_video_error_check(); } }; video_player = std::make_unique(current_plugin->use_tor, use_system_mpv_config, video_event_callback, on_window_create); 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 = current_plugin->name == "youtube"; while (current_page == Page::VIDEO_CONTENT) { while (window.pollEvent(event)) { base_event_handler(event, previous_page, true, false, 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::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_thumbnails(); } 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; // Make window black while the next video is loading, otherwise it will be stuck at the last view related_media_window->clear(); related_media_window->display(); related_media_window.reset(); related_media_window_visible = false; video_player_window = None; has_video_started = true; video_player.reset(); video_player = std::make_unique(current_plugin->use_tor, use_system_mpv_config, video_event_callback, on_window_create); content_url = selected_item->url; content_title = selected_item->get_title(); load_video_error_check(); } } } if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, KeyPress, &xev)/* && xev.xkey.subwindow == video_player_window*/) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" KeySym pressed_keysym = XKeycodeToKeysym(disp, xev.xkey.keycode, 0); #pragma GCC diagnostic pop bool pressing_ctrl = (CLEANMASK(xev.xkey.state) == ControlMask); if(pressed_keysym == XK_Escape) { 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 && related_media_window && !related_media_window_visible) { related_media_window_visible = true; related_media_window->setVisible(related_media_window_visible); if(!cursor_visible) window.setMouseCursorVisible(true); cursor_visible = true; } } 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", Urgency::CRITICAL); current_page = previous_page; break; } // TODO: Show loading video animation. load_video needs to be made asynchronous first //window.clear(); //window.display(); /* 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(); } 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; } enum class TrackMediaType { RSS, HTML }; const char* track_media_type_string(TrackMediaType media_type) { switch(media_type) { case TrackMediaType::RSS: return "rss"; case TrackMediaType::HTML: return "html"; } assert(false); return ""; } static int track_media(TrackMediaType media_type, const std::string &manga_title, const std::string &chapter_title, const std::string &url) { const char *args[] = { "automedia", "add", track_media_type_string(media_type), url.data(), "--start-after", chapter_title.data(), "--name", manga_title.data(), nullptr }; return exec_program(args, nullptr, nullptr); } void Program::select_episode(BodyItem *item, bool start_from_beginning) { images_url = item->url; chapter_title = item->get_title(); image_index = 0; switch(image_view_mode) { case ImageViewMode::SINGLE: current_page = Page::IMAGES; break; case ImageViewMode::SCROLL: current_page = Page::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[chapter_title]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) image_index = current.asInt() - 1; } } } Page Program::pop_page_stack() { if(!page_stack.empty()) { Page previous_page = page_stack.top(); page_stack.pop(); return previous_page; } return Page::EXIT; } enum class EpisodeListTabType { CHAPTERS, CREATOR }; struct EpisodeListTab { EpisodeListTabType type; Body *body; const Creator *creator; std::future creator_page_download_future; sf::Text text; }; void Program::episode_list_page() { assert(current_plugin->is_manga()); Manga *manga = static_cast(current_plugin); Json::Value *json_chapters = &content_storage_json["chapters"]; std::vector tabs; int selected_tab = 0; search_bar->onTextUpdateCallback = [&tabs, &selected_tab](const std::string &text) { tabs[selected_tab].body->filter_search_fuzzy(text); tabs[selected_tab].body->clamp_selection(); }; search_bar->onTextSubmitCallback = [this, &tabs, &selected_tab, &json_chapters](const std::string&) -> bool { if(tabs[selected_tab].type == EpisodeListTabType::CHAPTERS) { BodyItem *selected_item = body->get_selected(); if(!selected_item) return false; select_episode(selected_item, false); return true; } else { if(on_search_suggestion_submit_text(tabs[selected_tab].body, body)) { selected_tab = 0; json_chapters = &content_storage_json["chapters"]; return true; } else { return false; } } }; auto download_creator_page = [manga](std::string url) { BodyItems body_items; if(manga->get_creators_manga_list(url, body_items) != PluginResult::OK) show_notification("Manga", "Failed to download authors page", Urgency::CRITICAL); return body_items; }; EpisodeListTab chapters_tab; chapters_tab.type = EpisodeListTabType::CHAPTERS; chapters_tab.body = body; chapters_tab.creator = nullptr; chapters_tab.text = sf::Text("Chapters", font, tab_text_size); tabs.push_back(std::move(chapters_tab)); const std::vector& creators = manga->get_creators(); for(const Creator &creator : creators) { EpisodeListTab tab; tab.type = EpisodeListTabType::CREATOR; tab.body = new Body(this, &font, &bold_font); tab.body->draw_thumbnails = true; tab.creator = &creator; tab.creator_page_download_future = std::async(std::launch::async, download_creator_page, creator.url); tab.text = sf::Text(creator.name, font, tab_text_size); tabs.push_back(std::move(tab)); } const float tab_spacer_height = 0.0f; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; sf::RectangleShape tab_drop_shadow; tab_drop_shadow.setFillColor(sf::Color(23, 25, 27)); while (current_page == Page::EPISODE_LIST) { while (window.pollEvent(event)) { base_event_handler(event, Page::SEARCH_SUGGESTION, false, true); 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::T && event.key.control && tabs[selected_tab].type == EpisodeListTabType::CHAPTERS) { BodyItem *selected_item = body->get_selected(); if(selected_item) { if(track_media(TrackMediaType::HTML, content_title, selected_item->get_title(), content_url) == 0) { show_notification("Media tracker", "You are now tracking \"" + content_title + "\" after \"" + selected_item->get_title() + "\"", Urgency::LOW); } else { show_notification("Media tracker", "Failed to track media \"" + content_title + "\", chapter: \"" + selected_item->get_title() + "\"", Urgency::CRITICAL); } } } else if(event.key.code == sf::Keyboard::Up) { tabs[selected_tab].body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { tabs[selected_tab].body->select_next_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = Page::SEARCH_SUGGESTION; body->clear_items(); body->reset_selected(); search_bar->clear(); } else if(event.key.code == sf::Keyboard::Left) { tabs[selected_tab].body->filter_search_fuzzy(""); tabs[selected_tab].body->clamp_selection(); selected_tab = std::max(0, selected_tab - 1); search_bar->clear(); } else if(event.key.code == sf::Keyboard::Right) { tabs[selected_tab].body->filter_search_fuzzy(""); tabs[selected_tab].body->clamp_selection(); selected_tab = std::min((int)tabs.size() - 1, selected_tab + 1); search_bar->clear(); } } } // TODO: This code is duplicated in many places. Handle it in one place. if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size, true); } search_bar->update(); window.clear(back_color); const float width_per_tab = window_size.x / tabs.size(); sf::RectangleShape tab_background(sf::Vector2f(std::floor(width_per_tab), tab_height)); float tab_vertical_offset = search_bar->getBottomWithoutShadow(); if(tabs[selected_tab].type == EpisodeListTabType::CHAPTERS) tabs[selected_tab].body->draw(window, body_pos, body_size, *json_chapters); 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); int i = 0; for(EpisodeListTab &tab : tabs) { if(tab.type == EpisodeListTabType::CREATOR && tab.creator_page_download_future.valid() && tab.creator_page_download_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { tab.body->items = tab.creator_page_download_future.get(); tab.body->filter_search_fuzzy(search_bar->get_text()); tab.body->clamp_selection(); } if(i == selected_tab) tab_background.setFillColor(tab_selected_color); else tab_background.setFillColor(tab_unselected_color); tab_background.setPosition(std::floor(i * width_per_tab), tab_spacer_height + std::floor(tab_vertical_offset)); 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; } tab_drop_shadow.setSize(sf::Vector2f(window_size.x, 5.0f)); tab_drop_shadow.setPosition(0.0f, std::floor(tab_vertical_offset + tab_height)); window.draw(tab_drop_shadow); search_bar->draw(window, false); window.display(); } for(EpisodeListTab &tab : tabs) { if(tab.type == EpisodeListTabType::CREATOR) delete tab.body; } } // 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)); if(get_file_type(image_path) == FileType::REGULAR) { std::string image_data; if(file_get_content(image_path, image_data) == 0) { if(image_texture.loadFromMemory(image_data.data(), image_data.size())) { 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; } } // TODO: Cancel download when navigating to another non-manga page void Program::download_chapter_images_if_needed(Manga *image_plugin) { if(downloading_chapter_url == images_url) return; downloading_chapter_url = images_url; if(image_download_future.valid()) { image_download_cancel = true; image_download_future.get(); image_download_cancel = false; } std::string chapter_url = images_url; Path content_cache_dir_ = content_cache_dir; image_download_future = std::async(std::launch::async, [chapter_url, image_plugin, content_cache_dir_, this]() { // TODO: Download images in parallel int page = 1; image_plugin->for_each_page_in_chapter(chapter_url, [content_cache_dir_, &page, this](const std::string &url) { if(image_download_cancel) return false; #if 0 size_t last_index = url.find_last_of('/'); if(last_index == std::string::npos || (int)url.size() - (int)last_index + 1 <= 0) { show_notification("Manganelo", "Image url is in incorrect format, missing '/': " + url, Urgency::CRITICAL); return false; } std::string image_filename = url.substr(last_index + 1); Path image_filepath = content_cache_dir_; image_filepath.join(image_filename); #endif // 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++)); if(get_file_type(image_filepath) != FileType::FILE_NOT_FOUND) return true; std::vector extra_args; if(current_plugin->name == "manganelo") { 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/" } }; } std::string image_content; if(download_to_string(url, image_content, extra_args, current_plugin->use_tor, true) != DownloadResult::OK || image_content.size() <= 255) { if(current_plugin->name == "manganelo") { 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, current_plugin->use_tor, 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_images) { 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); } } 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()); } } 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; }); }); } void Program::image_page() { image_download_cancel = false; search_bar->onTextUpdateCallback = nullptr; search_bar->onTextSubmitCallback = nullptr; sf::Texture image_texture; sf::Sprite image; sf::Text error_message("", font, 30); error_message.setFillColor(sf::Color::White); assert(current_plugin->is_manga()); Manga *image_plugin = static_cast(current_plugin); std::string image_data; bool download_in_progress = false; content_cache_dir = get_cache_dir().join(image_plugin->name).join(manga_id_base64).join(base64_encode(chapter_title)); if(create_directory_recursive(content_cache_dir) != 0) { show_notification("Storage", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = Page::EPISODE_LIST; return; } download_chapter_images_if_needed(image_plugin); int num_images = 0; if(image_plugin->get_number_of_images(images_url, num_images) != ImageResult::OK) { show_notification("Plugin", "Failed to get number of images", Urgency::CRITICAL); current_page = Page::EPISODE_LIST; return; } image_index = std::min(image_index, num_images); 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 " + chapter_title); } 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[chapter_title]; 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[chapter_title] = 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::Event event; sf::Text chapter_text(content_title + " | " + chapter_title + " | Page " + std::to_string(image_index + 1) + "/" + std::to_string(num_images), font, 14); if(image_index == num_images) chapter_text.setString(content_title + " | " + chapter_title + " | 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; // TODO: Show to user if a certain page is missing (by checking page name (number) and checking if some are skipped) while (current_page == Page::IMAGES) { while(window.pollEvent(event)) { if (event.type == sf::Event::Closed) { current_page = Page::EXIT; } 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 && body->get_selected_item() < (int)body->items.size() - 1) { // TODO: Make this work if the list is sorted differently than from newest to oldest. body->filter_search_fuzzy(""); body->select_next_item(); select_episode(body->items[body->get_selected_item()].get(), true); image_index = 99999; // Start at the page that shows we are at the end of the chapter 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 && body->get_selected_item() > 0) { // TODO: Make this work if the list is sorted differently than from newest to oldest. body->filter_search_fuzzy(""); body->select_previous_item(); select_episode(body->items[body->get_selected_item()].get(), true); goto end_of_images_page; } } else if(event.key.code == sf::Keyboard::Escape) { current_page = Page::EPISODE_LIST; } else if(event.key.code == sf::Keyboard::I) { current_page = Page::IMAGES_CONTINUOUS; image_view_mode = ImageViewMode::SCROLL; } } } 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 { auto 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 != Page::IMAGES && current_page != Page::IMAGES_CONTINUOUS) { image_download_cancel = true; std::unique_lock lock(image_upscale_mutex); images_to_upscale.clear(); } } void Program::image_continuous_page() { image_download_cancel = false; search_bar->onTextUpdateCallback = nullptr; search_bar->onTextSubmitCallback = nullptr; assert(current_plugin->is_manga()); Manga *image_plugin = static_cast(current_plugin); content_cache_dir = get_cache_dir().join(image_plugin->name).join(manga_id_base64).join(base64_encode(chapter_title)); if(create_directory_recursive(content_cache_dir) != 0) { show_notification("Storage", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = Page::EPISODE_LIST; return; } download_chapter_images_if_needed(image_plugin); 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[chapter_title]; 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(image_plugin, images_url, content_title, chapter_title, image_index, content_cache_dir, &font); json_chapter["current"] = std::min(latest_read, image_viewer.get_num_pages()); json_chapter["total"] = image_viewer.get_num_pages(); json_chapters[chapter_title] = 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 == Page::IMAGES_CONTINUOUS) { window.clear(back_color); ImageViewerAction action = image_viewer.draw(window); switch(action) { case ImageViewerAction::NONE: break; case ImageViewerAction::RETURN: current_page = Page::EPISODE_LIST; break; case ImageViewerAction::SWITCH_TO_SINGLE_IMAGE_MODE: image_view_mode = ImageViewMode::SINGLE; current_page = Page::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[chapter_title] = 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 != Page::IMAGES && current_page != Page::IMAGES_CONTINUOUS) { image_download_cancel = true; std::unique_lock lock(image_upscale_mutex); images_to_upscale.clear(); } } void Program::content_list_page() { std::string update_search_text; bool search_running = false; std::future search_future; if(!current_plugin->content_list_search_is_filter()) search_bar->text_autosearch_delay = current_plugin->get_content_list_search_delay(); body->clear_items(); body->clear_thumbnails(); if(current_plugin->get_content_list(content_list_url, body->items) != PluginResult::OK) { show_notification("Content list", "Failed to get content list for url: " + content_list_url, Urgency::CRITICAL); current_page = Page::SEARCH_SUGGESTION; return; } search_bar->onTextUpdateCallback = [this, &update_search_text](const std::string &text) { if(current_plugin->content_list_search_is_filter()) { body->filter_search_fuzzy(text); body->select_first_item(); } else { update_search_text = text; } }; search_bar->onTextSubmitCallback = [this](const std::string&) -> bool { BodyItem *selected_item = body->get_selected(); if(!selected_item) return false; content_episode = selected_item->get_title(); content_url = selected_item->url; current_page = Page::CONTENT_DETAILS; body->clear_items(); return true; }; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; while (current_page == Page::CONTENT_LIST) { while (window.pollEvent(event)) { base_event_handler(event, Page::SEARCH_SUGGESTION); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; } // TODO: This code is duplicated in many places. Handle it in one place. if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); } search_bar->update(); if(!update_search_text.empty() && !search_running) { search_future = std::async(std::launch::async, [this, update_search_text]() { BodyItems result; if(current_plugin->content_list_search(content_list_url, update_search_text, result) != SearchResult::OK) { show_notification("Search", "Search failed!", Urgency::CRITICAL); } return result; }); update_search_text.clear(); search_running = true; } if(search_running && search_future.valid() && search_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { if(update_search_text.empty()) { body->items = search_future.get(); body->select_first_item(); } else { search_future.get(); } search_running = false; } window.clear(back_color); body->draw(window, body_pos, body_size); search_bar->draw(window); window.display(); } search_bar->text_autosearch_delay = current_plugin->get_search_delay(); } void Program::content_details_page() { if(current_plugin->get_content_details(content_list_url, content_url, body->items) != PluginResult::OK) { show_notification("Content details", "Failed to get content details for url: " + content_url, Urgency::CRITICAL); // TODO: This will return to an empty content list. // Each page should have its own @Body so we can return to the last page and still have the data loaded // however the cached images should be cleared. current_page = Page::CONTENT_LIST; return; } // TODO: Have an option for the search bar to be multi-line. search_bar->onTextUpdateCallback = nullptr; search_bar->onTextSubmitCallback = [this](const std::string&) -> bool { if(current_plugin->name == "nyaa.si") { BodyItem *selected_item = body->get_selected(); if(selected_item && strncmp(selected_item->url.c_str(), "magnet:?", 8) == 0) { 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 false; } const char *args[] = { "xdg-open", selected_item->url.c_str(), nullptr }; exec_program_async(args, nullptr); } } return false; }; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; while (current_page == Page::CONTENT_DETAILS) { while (window.pollEvent(event)) { base_event_handler(event, Page::CONTENT_LIST); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; } if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); } search_bar->update(); window.clear(back_color); body->draw(window, body_pos, body_size); search_bar->draw(window); window.display(); } } void Program::file_manager_page() { selected_files.clear(); int prev_autosearch_delay = search_bar->text_autosearch_delay; search_bar->text_autosearch_delay = file_manager->get_search_delay(); Page previous_page = pop_page_stack(); sf::Text current_dir_text(file_manager->get_current_dir().string(), bold_font, 18); // TODO: Make asynchronous. // TODO: Automatically go to the parent if this fails (recursively). if(file_manager->get_files_in_directory(body->items) != PluginResult::OK) { show_notification("QuickMedia", "File manager failed to get files in directory: " + file_manager->get_current_dir().string(), Urgency::CRITICAL); } // TODO: Have an option for the search bar to be multi-line. search_bar->onTextUpdateCallback = [this](const sf::String &text) { body->filter_search_fuzzy(text); body->reset_selected(); }; search_bar->onTextSubmitCallback = [this, previous_page, ¤t_dir_text](const std::string&) -> bool { BodyItem *selected_item = body->get_selected(); if(!selected_item) return false; if(file_manager->set_child_directory(selected_item->get_title())) { std::string current_dir_str = file_manager->get_current_dir().string(); current_dir_text.setString(current_dir_str); // TODO: Make asynchronous. // TODO: Automatically go to the parent if this fails (recursively). body->items.clear(); if(file_manager->get_files_in_directory(body->items) != PluginResult::OK) { show_notification("QuickMedia", "File manager failed to get files in directory: " + current_dir_str, Urgency::CRITICAL); } body->reset_selected(); return true; } else { std::filesystem::path full_path = file_manager->get_current_dir() / selected_item->get_title(); selected_files.push_back(full_path.string()); printf("%s\n", selected_files.back().c_str()); current_page = previous_page; return false; } }; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; while (current_page == Page::FILE_MANAGER) { while (window.pollEvent(event)) { base_event_handler(event, previous_page); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; } if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); const float dir_text_height = std::floor(current_dir_text.getLocalBounds().height + 12.0f); body_pos.y += dir_text_height; body_size.y -= dir_text_height; current_dir_text.setPosition(body_pos.x, body_pos.y - dir_text_height); } search_bar->update(); window.clear(back_color); body->draw(window, body_pos, body_size); window.draw(current_dir_text); search_bar->draw(window); window.display(); } search_bar->text_autosearch_delay = prev_autosearch_delay; // We want exit code 1 if the file manager was launched and no files were selected, to know when the user didn't select any file(s) if(selected_files.empty() && current_page == Page::EXIT) exit(1); } void Program::image_board_thread_list_page() { assert(current_plugin->is_image_board()); ImageBoard *image_board = static_cast(current_plugin); if(image_board->get_threads(image_board_thread_list_url, body->items) != PluginResult::OK) { show_notification("Content list", "Failed to get threads for url: " + image_board_thread_list_url, Urgency::CRITICAL); current_page = Page::SEARCH_SUGGESTION; return; } search_bar->onTextUpdateCallback = [this](const std::string &text) { body->filter_search_fuzzy(text); body->select_first_item(); }; search_bar->onTextSubmitCallback = [this](const std::string&) -> bool { BodyItem *selected_item = body->get_selected(); if(!selected_item) return false; content_episode = selected_item->get_title(); content_url = selected_item->url; current_page = Page::IMAGE_BOARD_THREAD; body->clear_items(); return true; }; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; while (current_page == Page::IMAGE_BOARD_THREAD_LIST) { while (window.pollEvent(event)) { base_event_handler(event, Page::SEARCH_SUGGESTION); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; } if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); } search_bar->update(); window.clear(back_color); body->draw(window, body_pos, body_size); search_bar->draw(window); window.display(); } } 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() { assert(current_plugin->is_image_board()); // TODO: Support image board other than 4chan. To make this work, the captcha code needs to be changed // to work with other captcha than google captcha assert(current_plugin->name == "4chan"); ImageBoard *image_board = static_cast(current_plugin); if(image_board->get_thread_comments(image_board_thread_list_url, content_url, body->items) != PluginResult::OK) { show_notification("Content details", "Failed to get content details for url: " + content_url, Urgency::CRITICAL); // TODO: This will return to an empty content list. // Each page should have its own @Body so we can return to the last page and still have the data loaded // however the cached images should be cleared. current_page = Page::IMAGE_BOARD_THREAD_LIST; return; } const std::string &board = image_board_thread_list_url; const std::string &thread = content_url; // 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; 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; std::mutex attachment_load_mutex; 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(55, 60, 68)); // 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 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, {}, current_plugin->use_tor); 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; } }, current_plugin->use_tor); }; auto post_comment = [this, &navigation_stage, &image_board, &board, &thread, &captcha_post_id, &comment_to_post, &request_new_google_captcha_challenge]() { navigation_stage = NavigationStage::POSTING_COMMENT; PostResult post_result = image_board->post_comment(board, thread, captcha_post_id, comment_to_post); if(post_result == PostResult::OK) { show_notification(current_plugin->name, "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(current_plugin->name, "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(current_plugin->name, "Failed to post comment because you are banned", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::ERR) { show_notification(current_plugin->name, "Failed to post comment. Is " + current_plugin->name + " down or is your internet down?", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else { assert(false && "Unhandled post result"); show_notification(current_plugin->name, "Failed to post comment. Unknown error", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }; // Instead of using search bar to searching, use it for commenting. // TODO: Have an option for the search bar to be multi-line. search_bar->onTextUpdateCallback = nullptr; search_bar->onTextSubmitCallback = [&post_comment_future, &navigation_stage, &request_new_google_captcha_challenge, &comment_to_post, &captcha_post_id, &captcha_solved_time, &post_comment, &image_board](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(image_board->get_pass_id().empty()) { request_new_google_captcha_challenge(); } else if(!image_board->get_pass_id().empty()) { post_comment_future = std::async(std::launch::async, [&post_comment]() -> bool { post_comment(); return true; }); } return true; }; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; std::stack comment_navigation_stack; while (current_page == Page::IMAGE_BOARD_THREAD) { while (window.pollEvent(event)) { search_bar->on_event(event); if (event.type == sf::Event::Closed) { current_page = Page::EXIT; } 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) { body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { body->select_next_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = Page::IMAGE_BOARD_THREAD_LIST; body->clear_items(); body->reset_selected(); search_bar->clear(); } else if(event.key.code == sf::Keyboard::P) { BodyItem *selected_item = body->get_selected(); if(selected_item && !selected_item->attached_content_url.empty()) { if(is_url_video(selected_item->attached_content_url)) { page_stack.push(Page::IMAGE_BOARD_THREAD); current_page = Page::VIDEO_CONTENT; std::string prev_content_url = content_url; content_url = selected_item->attached_content_url; watched_videos.clear(); video_content_page(); content_url = std::move(prev_content_url); } else { navigation_stage = NavigationStage::VIEWING_ATTACHED_IMAGE; load_image_future = std::async(std::launch::async, [this, &image_board, &attached_image_texture, &attached_image_sprite, &attachment_load_mutex]() -> bool { BodyItem *selected_item = body->get_selected(); if(!selected_item || selected_item->attached_content_url.empty()) { return false; } std::string image_data; if(download_to_string(selected_item->attached_content_url, image_data, {}, image_board->use_tor) != DownloadResult::OK) { show_notification(image_board->name, "Failed to download image: " + selected_item->attached_content_url, Urgency::CRITICAL); return false; } std::lock_guard lock(attachment_load_mutex); if(!attached_image_texture->loadFromMemory(image_data.data(), image_data.size())) { show_notification(image_board->name, "Failed to load image downloaded from url: " + selected_item->attached_content_url, Urgency::CRITICAL); return false; } attached_image_texture->setSmooth(true); attached_image_sprite.setTexture(*attached_image_texture, true); return true; }); } } } BodyItem *selected_item = body->get_selected(); if(event.key.code == sf::Keyboard::Enter && selected_item && (comment_navigation_stack.empty() || body->get_selected_item() != comment_navigation_stack.top()) && !selected_item->replies.empty()) { for(auto &body_item : body->items) { body_item->visible = false; } selected_item->visible = true; for(size_t reply_index : selected_item->replies) { body->items[reply_index]->visible = true; } comment_navigation_stack.push(body->get_selected_item()); } else if(event.key.code == sf::Keyboard::BackSpace && !comment_navigation_stack.empty()) { size_t previous_selected = 0; if(!comment_navigation_stack.empty()) { previous_selected = comment_navigation_stack.top(); } comment_navigation_stack.pop(); if(comment_navigation_stack.empty()) { for(auto &body_item : body->items) { body_item->visible = true; } body->set_selected_item(previous_selected); } else { for(auto &body_item : body->items) { body_item->visible = false; } body->set_selected_item(previous_selected); selected_item = body->items[comment_navigation_stack.top()].get(); selected_item->visible = true; for(size_t reply_index : selected_item->replies) { body->items[reply_index]->visible = true; } } } else if(event.key.code == sf::Keyboard::C && event.key.control && selected_item) { navigation_stage = NavigationStage::REPLYING; } else if(event.key.code == sf::Keyboard::R && selected_item) { std::string text_to_add = ">>" + selected_item->post_number; if(search_bar->is_cursor_at_start_of_line()) text_to_add += '\n'; search_bar->append_text(text_to_add); } } else if(event.type == sf::Event::TextEntered && navigation_stage == NavigationStage::REPLYING) { search_bar->onTextEntered(event.text.unicode); } if(event.type == sf::Event::KeyPressed && navigation_stage == NavigationStage::REPLYING) { if(event.key.code == sf::Keyboard::Escape) { //search_bar->clear(); navigation_stage = NavigationStage::VIEWING_COMMENTS; } } 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); } }, current_plugin->use_tor); } } 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()); } } } if(redraw) { redraw = false; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), body_pos, body_size); } //search_bar->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: Show a white image with the text "Downloading..." while the image is downloading and loading std::lock_guard lock(attachment_load_mutex); 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) { body->draw(window, body_pos, body_size); search_bar->draw(window); } else if(navigation_stage == NavigationStage::VIEWING_COMMENTS) { body->draw(window, body_pos, body_size); search_bar->draw(window); } 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(); // Clear post that is still being written. // TODO: A multiline text edit widget should be cleared instead of the search bar. // TODO: This post should be saved for the thread. Each thread should have its own text edit widget, // so you dont have to retype a post that was in the middle of being posted when returning. search_bar->clear(); } // TODO: Provide a way to logout void Program::chat_login_page() { assert(current_plugin->name == "matrix"); 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&) -> bool { Matrix *matrix = static_cast(current_plugin); 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 false; } } 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 = Page::CHAT; } else { status_text.setString("Failed to login, error: " + err_msg); } return false; }; 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; while (current_page == Page::CHAT_LOGIN) { while (window.pollEvent(event)) { base_event_handler(event, Page::EXIT, false, 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; search_bar->onWindowResize(window_size); get_body_dimensions(window_size, search_bar.get(), 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; }; void Program::chat_page() { assert(current_plugin->name == "matrix"); Matrix *matrix = static_cast(current_plugin); std::vector tabs; int selected_tab = 0; size_t room_message_index = 0; ChatTab messages_tab; messages_tab.type = ChatTabType::MESSAGES; messages_tab.body = std::make_unique(this, &font, &bold_font); messages_tab.body->draw_thumbnails = true; 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, &bold_font); rooms_tab.body->draw_thumbnails = true; rooms_tab.body->line_seperator_color = sf::Color::Transparent; 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; tabs[MESSAGES_TAB_INDEX].body->clear_items(); /* if(matrix->get_cached_sync(tabs[MESSAGES_TAB_INDEX].body->items) != PluginResult::OK) { fprintf(stderr, "Failed to get matrix cached sync\n"); } else { fprintf(stderr, "Loaded matrix sync from cache, num items: %zu\n", tabs[MESSAGES_TAB_INDEX].body->items.size()); } */ if(matrix->sync() != PluginResult::OK) { show_notification("QuickMedia", "Intial matrix sync failed", Urgency::CRITICAL); current_page = Page::EXIT; return; } if(matrix->get_joined_rooms(tabs[ROOMS_TAB_INDEX].body->items) != PluginResult::OK) { show_notification("QuickMedia", "Failed to get a list of joined rooms", Urgency::CRITICAL); current_page = Page::EXIT; return; } // TODO: the initial room to view should be the last viewed room when closing QuickMedia. // The room id should be saved in a file when changing viewed room. std::string current_room_id; if(!tabs[ROOMS_TAB_INDEX].body->items.empty()) current_room_id = tabs[ROOMS_TAB_INDEX].body->items[0]->url; // TODO: Allow empty initial room (if the user hasn't joined any room yet) assert(!current_room_id.empty()); { std::string plugin_logo_path = resources_root + "images/matrix_logo.png"; if(!plugin_logo.loadFromFile(plugin_logo_path)) { show_notification("QuickMedia", "Failed to load plugin logo, path: " + plugin_logo_path, Urgency::CRITICAL); exit(1); } plugin_logo.generateMipmap(); plugin_logo.setSmooth(true); } SearchBar chat_input(font, &plugin_logo, "Send a message..."); chat_input.set_background_color(sf::Color::Transparent); // TODO: Filer for rooms and settings chat_input.onTextUpdateCallback = nullptr; // TODO: Show post message immediately, instead of waiting for sync. Otherwise it can take a while until we receive the message, // which happens when uploading an image. chat_input.onTextSubmitCallback = [this, matrix, &tabs, &selected_tab, &room_message_index, ¤t_room_id](const std::string &text) -> bool { if(tabs[selected_tab].type == ChatTabType::MESSAGES) { if(text.empty()) return false; if(text[0] == '/') { std::string command = text; strip(command); if(command == "/upload") { if(!file_manager) file_manager = new FileManager(); page_stack.push(Page::CHAT); current_page = Page::FILE_MANAGER; file_manager_page(); if(selected_files.empty()) { fprintf(stderr, "No files selected!\n"); return true; } else { // TODO: Make asynchronous. // TODO: Upload multiple files. if(matrix->post_file(current_room_id, selected_files[0]) != PluginResult::OK) { show_notification("QuickMedia", "Failed to upload image to room", Urgency::CRITICAL); return false; } else { return true; } } } else { fprintf(stderr, "Error: invalid command: %s, expected /upload\n", command.c_str()); return false; } } // TODO: Make asynchronous if(matrix->post_message(current_room_id, text) != PluginResult::OK) { show_notification("QuickMedia", "Failed to post matrix message", Urgency::CRITICAL); return false; } return true; } else if(tabs[selected_tab].type == ChatTabType::ROOMS) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item) { current_room_id = selected_item->url; selected_tab = MESSAGES_TAB_INDEX; room_message_index = 0; tabs[MESSAGES_TAB_INDEX].body->clear_items(); size_t num_new_messages = 0; BodyItems new_items; // TODO: Make asynchronous if(matrix->get_room_messages(current_room_id, 0, new_items, num_new_messages) == PluginResult::OK) { room_message_index += num_new_messages; tabs[MESSAGES_TAB_INDEX].body->append_items(std::move(new_items)); if(!tabs[MESSAGES_TAB_INDEX].body->items.empty() && num_new_messages > 0) tabs[MESSAGES_TAB_INDEX].body->set_selected_item(tabs[MESSAGES_TAB_INDEX].body->items.size() - 1); } else { std::string err_msg = "Failed to get messages in room: " + current_room_id; show_notification("QuickMedia", err_msg, Urgency::CRITICAL); } return true; } } return false; }; struct SyncFutureResult { BodyItems body_items; size_t num_new_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 const float tab_spacer_height = 0.0f; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; sf::Event event; sf::RectangleShape tab_drop_shadow; tab_drop_shadow.setFillColor(sf::Color(23, 25, 27)); while (current_page == Page::CHAT) { while (window.pollEvent(event)) { base_event_handler(event, Page::EXIT, false, false, false); 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::Up) { tabs[selected_tab].body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down) { tabs[selected_tab].body->select_next_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = Page::EXIT; body->clear_items(); body->reset_selected(); } else if(event.key.code == sf::Keyboard::Left) { tabs[selected_tab].body->filter_search_fuzzy(""); tabs[selected_tab].body->clamp_selection(); selected_tab = std::max(0, selected_tab - 1); chat_input.clear(); } else if(event.key.code == sf::Keyboard::Right) { tabs[selected_tab].body->filter_search_fuzzy(""); tabs[selected_tab].body->clamp_selection(); selected_tab = std::min((int)tabs.size() - 1, selected_tab + 1); chat_input.clear(); } if(tabs[selected_tab].type == ChatTabType::MESSAGES && event.key.control && event.key.code == sf::Keyboard::I) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(!selected_item) continue; if(selected_item->url.empty()) continue; page_stack.push(Page::CHAT); watched_videos.clear(); content_url = selected_item->url; current_page = Page::VIDEO_CONTENT; video_content_page(); } } if(event.type == sf::Event::TextEntered) chat_input.onTextEntered(event.text.unicode); chat_input.on_event(event); } if(redraw) { redraw = false; chat_input.onWindowResize(window_size); chat_input.set_vertical_position(window_size.y - chat_input.getBottomWithoutShadow()); 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 search_bottom = chat_input.getBottomWithoutShadow(); body_pos = sf::Vector2f(body_padding_horizontal, body_padding_vertical + tab_height); body_size = sf::Vector2f(body_width, window_size.y - search_bottom - body_padding_vertical - tab_height); //get_body_dimensions(window_size, &chat_input, body_pos, body_size, true); } 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: Ignore matrix->sync() call the first time, its already called above for the first time sync_min_time_ms = 3000; 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, room_message_index]() { Matrix *matrix = static_cast(current_plugin); SyncFutureResult result; result.num_new_messages = 0; if(matrix->sync() == PluginResult::OK) { fprintf(stderr, "Synced matrix\n"); if(matrix->get_room_messages(sync_future_room_id, room_message_index, result.body_items, result.num_new_messages) != 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_future_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) { room_message_index += sync_future_result.num_new_messages; tabs[MESSAGES_TAB_INDEX].body->append_items(std::move(sync_future_result.body_items)); if(!tabs[MESSAGES_TAB_INDEX].body->items.empty() && sync_future_result.num_new_messages > 0) tabs[MESSAGES_TAB_INDEX].body->set_selected_item(tabs[MESSAGES_TAB_INDEX].body->items.size() - 1); } sync_running = false; } chat_input.update(); window.clear(back_color); const float width_per_tab = window_size.x / tabs.size(); sf::RectangleShape tab_background(sf::Vector2f(std::floor(width_per_tab), tab_height)); float tab_vertical_offset = 0.0f; 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); int i = 0; for(ChatTab &tab : tabs) { if(i == selected_tab) tab_background.setFillColor(tab_selected_color); else tab_background.setFillColor(tab_unselected_color); tab_background.setPosition(std::floor(i * width_per_tab), tab_spacer_height + std::floor(tab_vertical_offset)); 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; } tab_drop_shadow.setSize(sf::Vector2f(window_size.x, 5.0f)); tab_drop_shadow.setPosition(0.0f, std::floor(tab_vertical_offset + tab_height)); window.draw(tab_drop_shadow); chat_input.draw(window, false); window.display(); } exit(0); // Ignore futures and quit immediately } }