#include "../include/Body.hpp" #include "../include/QuickMedia.hpp" #include "../include/Scale.hpp" #include "../include/base64_url.hpp" #include "../include/ImageUtils.hpp" #include "../plugins/Plugin.hpp" #include #include #include const sf::Color front_color(32, 36, 42); const sf::Color back_color(33, 35, 37); float image_max_height = 100.0f; const float spacing_y = 15.0f; const float padding_x = 10.0f; const float image_padding_x = 5.0f; const float padding_y = 5.0f; namespace QuickMedia { BodyItem::BodyItem(std::string _title) : visible(true), dirty(false), dirty_description(false), dirty_author(false), thumbnail_is_local(false), title_color(sf::Color::White), author_color(sf::Color::White) { if(!_title.empty()) set_title(std::move(_title)); } BodyItem::BodyItem(const BodyItem &other) { title = other.title; description = other.description; url = other.url; thumbnail_url = other.thumbnail_url; attached_content_url = other.attached_content_url; author = other.author; visible = other.visible; dirty = other.dirty; dirty_description = other.dirty_description; dirty_author = other.dirty_author; thumbnail_is_local = other.thumbnail_is_local; if(other.title_text) title_text = std::make_unique(*other.title_text); else title_text = nullptr; if(other.description_text) description_text = std::make_unique(*other.description_text); else description_text = nullptr; if(other.author_text) author_text = std::make_unique(*other.author_text); else author_text = nullptr; replies = other.replies; post_number = other.post_number; title_color = other.title_color; author_color = other.author_color; } Body::Body(Program *program, sf::Font *font, sf::Font *bold_font, sf::Font *cjk_font) : font(font), bold_font(bold_font), cjk_font(cjk_font), progress_text("", *font, 14), replies_text("", *font, 14), draw_thumbnails(false), wrap_around(false), line_seperator_color(sf::Color(32, 37, 43, 255)), program(program), loading_thumbnail(false), selected_item(0), prev_selected_item(0), page_scroll(0.0f), item_background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10), num_visible_items(0), last_item_fully_visible(true) { progress_text.setFillColor(sf::Color::White); replies_text.setFillColor(sf::Color(129, 162, 190)); thumbnail_resize_target_size.x = 200; thumbnail_resize_target_size.y = 119; thumbnail_fallback_size.x = 50.0f; thumbnail_fallback_size.y = 100.0f; image_fallback.setFillColor(sf::Color::White); item_background.setFillColor(sf::Color(55, 60, 68)); } Body::~Body() { if(load_thumbnail_future.valid()) load_thumbnail_future.get(); } // TODO: Make this work with wraparound enabled? // TODO: For plugins with different sized body items this can be weird, because after scrolling down thumbnails could load and they could move items up/down until we see items we haven't seen bool Body::select_previous_page() { for(int i = 0; i < num_visible_items - 1; ++i) { if(!select_previous_item()) return false; } return true; } // TODO: Make this work with wraparound enabled? // TODO: For plugins with different sized body items this can be weird, because after scrolling down thumbnails could load and they could move items up/down until we see items we haven't seen bool Body::select_next_page() { for(int i = 0; i < num_visible_items - 1; ++i) { if(!select_next_item()) return false; } return true; } bool Body::select_previous_item() { if(items.empty()) return false; int new_selected_item = selected_item; int num_items = (int)items.size(); for(int i = 0; i < num_items; ++i) { if(new_selected_item - 1 < 0) { if(wrap_around) new_selected_item = num_items - 1; else { new_selected_item = selected_item; break; } } else { --new_selected_item; } if(items[new_selected_item]->visible) break; } if(selected_item == new_selected_item) return false; selected_item = new_selected_item; return true; } bool Body::select_next_item() { if(items.empty()) return false; int new_selected_item = selected_item; int num_items = (int)items.size(); for(int i = 0; i < num_items; ++i) { if(new_selected_item + 1 == num_items) { if(wrap_around) { new_selected_item = 0; } else { new_selected_item = selected_item; break; } } else { ++new_selected_item; } if(items[new_selected_item]->visible) break; } if(selected_item == new_selected_item) return false; selected_item = new_selected_item; return true; } void Body::set_selected_item(int item) { assert(item >= 0 && item < (int)items.size()); selected_item = item; prev_selected_item = selected_item; //page_scroll = 0.0f; } void Body::select_first_item() { selected_item = 0; prev_selected_item = selected_item; page_scroll = 0.0f; clamp_selection(); } void Body::select_last_item() { selected_item = std::max(0, (int)items.size() - 1); //prev_selected_item = selected_item; //page_scroll = 0.0f; clamp_selection(); } void Body::reset_selected() { for(size_t i = 0; i < items.size(); ++i) { if(items[i]->visible) { selected_item = i; return; } } selected_item = 0; prev_selected_item = selected_item; page_scroll = 0.0f; } void Body::clear_items() { items.clear(); selected_item = 0; prev_selected_item = selected_item; page_scroll = 0.0f; } // TODO: Optimize with memcpy and changing capacity before loop void Body::prepend_items(BodyItems new_items) { for(auto &body_item : new_items) { items.insert(items.begin(), std::move(body_item)); } } // TODO: Optimize with memcpy and changing capacity before loop void Body::append_items(BodyItems new_items) { for(auto &body_item : new_items) { items.push_back(std::move(body_item)); } } void Body::clear_thumbnails() { item_thumbnail_textures.clear(); } BodyItem* Body::get_selected() const { if(selected_item < 0 || selected_item >= (int)items.size() || !items[selected_item]->visible) return nullptr; return items[selected_item].get(); } void Body::clamp_selection() { int num_items = (int)items.size(); if(items.empty()) return; if(selected_item < 0) selected_item = 0; else if(selected_item >= num_items) selected_item = num_items - 1; for(int i = selected_item; i >= 0; --i) { if(items[i]->visible) { selected_item = i; goto reset_scroll; } } for(int i = selected_item; i < num_items; ++i) { if(items[i]->visible) { selected_item = i; goto reset_scroll; } } reset_scroll: {} //prev_selected_item = selected_item; //page_scroll = 0.0f; } static sf::Vector2f to_vec2f(const sf::Vector2u &vec) { return sf::Vector2f(vec.x, vec.y); } static sf::Vector2f to_vec2f(const sf::Vector2i &vec) { return sf::Vector2f(vec.x, vec.y); } static sf::Vector2u to_vec2u(const sf::Vector2f &vec) { return sf::Vector2u(vec.x, vec.y); } static void copy_resize(const sf::Image &source, sf::Image &destination, sf::Vector2u destination_size) { const sf::Vector2u source_size = source.getSize(); if(source_size.x == 0 || source_size.y == 0 || destination_size.x == 0 || destination_size.y == 0) return; //float width_ratio = (float)source_size.x / (float)destination_size.x; //float height_ratio = (float)source_size.y / (float)destination_size.y; const sf::Uint8 *source_pixels = source.getPixelsPtr(); // TODO: Remove this somehow. Right now we need to allocate this and also allocate the same array in the destination image sf::Uint32 *destination_pixels = new sf::Uint32[destination_size.x * destination_size.y]; sf::Uint32 *destination_pixel = destination_pixels; for(unsigned int y = 0; y < destination_size.y; ++y) { for(unsigned int x = 0; x < destination_size.x; ++x) { int scaled_x = ((float)x / (float)destination_size.x) * source_size.x; int scaled_y = ((float)y / (float)destination_size.y) * source_size.y; //float scaled_x = x * width_ratio; //float scaled_y = y * height_ratio; //sf::Uint32 *source_pixel = (sf::Uint32*)(source_pixels + (int)(scaled_x + scaled_y * source_size.x) * 4); sf::Uint32 *source_pixel = (sf::Uint32*)(source_pixels + (scaled_x + scaled_y * source_size.x) * 4); *destination_pixel = *source_pixel; ++destination_pixel; } } destination.create(destination_size.x, destination_size.y, (sf::Uint8*)destination_pixels); delete []destination_pixels; } static bool save_image_as_thumbnail_atomic(const sf::Image &image, const Path &thumbnail_path, const char *ext) { Path tmp_path = thumbnail_path; tmp_path.append(".tmp"); const char *thumbnail_path_ext = thumbnail_path.ext(); if(is_image_ext(ext)) tmp_path.append(ext); else if(is_image_ext(thumbnail_path_ext)) tmp_path.append(thumbnail_path_ext); else tmp_path.append(".png"); return image.saveToFile(tmp_path.data) && (rename(tmp_path.data.c_str(), thumbnail_path.data.c_str()) == 0); } // Returns empty string if no extension static const char* get_ext(const std::string &path) { size_t index = path.rfind('.'); if(index == std::string::npos) return ""; return path.c_str() + index; } // TODO: Do not load thumbnails for images larger than 30mb. // TODO: Load the thumbnail embedded in the file instead. void Body::load_thumbnail_from_url(const std::string &url, bool local, sf::Vector2i thumbnail_resize_target_size, std::shared_ptr thumbnail_data) { assert(!loading_thumbnail); loading_thumbnail = true; load_thumbnail_future = std::async(std::launch::async, [this, url, local, thumbnail_resize_target_size, thumbnail_data]() { // TODO: Use sha256 instead of base64_url encoding Path thumbnail_path = get_cache_dir().join("thumbnails").join(base64_url::encode(url)); thumbnail_data->image = std::make_unique(); if(thumbnail_data->image->loadFromFile(thumbnail_path.data)) { fprintf(stderr, "Loaded %s from thumbnail cache\n", url.c_str()); thumbnail_data->loading_state = LoadingState::FINISHED_LOADING; return; } else { if(local) { if(!thumbnail_data->image->loadFromFile(url)) { thumbnail_data->loading_state = LoadingState::FINISHED_LOADING; return; } } else { std::string texture_data; if(download_to_string_cache(url, texture_data, {}, program->get_current_plugin()->use_tor, true) != DownloadResult::OK || !thumbnail_data->image->loadFromMemory(texture_data.data(), texture_data.size())) { thumbnail_data->loading_state = LoadingState::FINISHED_LOADING; return; } } } if(thumbnail_resize_target_size.x != 0 && thumbnail_resize_target_size.y != 0) { sf::Vector2u new_image_size = to_vec2u(clamp_to_size(to_vec2f(thumbnail_data->image->getSize()), to_vec2f(thumbnail_resize_target_size))); if(new_image_size.x < thumbnail_data->image->getSize().x || new_image_size.y < thumbnail_data->image->getSize().y) { auto destination_image = std::make_unique(); copy_resize(*thumbnail_data->image, *destination_image, new_image_size); thumbnail_data->image = std::move(destination_image); save_image_as_thumbnail_atomic(*thumbnail_data->image, thumbnail_path, get_ext(url)); thumbnail_data->loading_state = LoadingState::FINISHED_LOADING; return; } } thumbnail_data->loading_state = LoadingState::FINISHED_LOADING; return; }); } void Body::draw(sf::RenderWindow &window, sf::Vector2f pos, sf::Vector2f size) { Json::Value empty_object(Json::objectValue); draw(window, pos, size, empty_object); } // TODO: Use a render target for the whole body so all images can be put into one. // TODO: Load thumbnails with more than one thread. // TODO: Show chapters (rows) that have been read differently to make it easier to see what hasn't been read yet. void Body::draw(sf::RenderWindow &window, sf::Vector2f pos, sf::Vector2f size, const Json::Value &content_progress) { sf::Vector2f scissor_pos = pos; sf::Vector2f scissor_size = size; const float start_y = pos.y; if(thumbnail_resize_target_size.x != 0 && thumbnail_resize_target_size.y != 0) { image_max_height = thumbnail_resize_target_size.y; } //item_background.setFillColor(front_color); //item_background.setOutlineThickness(1.0f); //item_background.setOutlineColor(sf::Color(13, 15, 17)); image_fallback.setSize(thumbnail_fallback_size); item_background_shadow.setFillColor(line_seperator_color); num_visible_items = 0; last_item_fully_visible = true; if(loading_thumbnail && load_thumbnail_future.valid() && load_thumbnail_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { load_thumbnail_future.get(); loading_thumbnail = false; } int num_items = items.size(); if(num_items == 0 || size.y <= 0.0f) { for(auto it = item_thumbnail_textures.begin(); it != item_thumbnail_textures.end();) { if(!it->second->referenced) it = item_thumbnail_textures.erase(it); else ++it; } return; } for(auto &thumbnail_it : item_thumbnail_textures) { thumbnail_it.second->referenced = false; } for(auto &body_item : items) { if(body_item->dirty) { body_item->dirty = false; // TODO: Find a way to optimize fromUtf8 sf::String str = sf::String::fromUtf8(body_item->get_title().data(), body_item->get_title().data() + body_item->get_title().size()); if(body_item->title_text) body_item->title_text->setString(std::move(str)); else body_item->title_text = std::make_unique(std::move(str), font, cjk_font, 16, size.x - 50 - image_padding_x * 2.0f); body_item->title_text->setFillColor(body_item->title_color); body_item->title_text->updateGeometry(); } if(body_item->dirty_description) { body_item->dirty_description = false; sf::String str = sf::String::fromUtf8(body_item->get_description().data(), body_item->get_description().data() + body_item->get_description().size()); if(body_item->description_text) body_item->description_text->setString(std::move(str)); else body_item->description_text = std::make_unique(std::move(str), font, cjk_font, 14, size.x - 50 - image_padding_x * 2.0f); body_item->description_text->updateGeometry(); } if(body_item->dirty_author) { body_item->dirty_author = false; sf::String str = sf::String::fromUtf8(body_item->get_author().data(), body_item->get_author().data() + body_item->get_author().size()); if(body_item->author_text) body_item->author_text->setString(std::move(str)); else body_item->author_text = std::make_unique(std::move(str), bold_font, cjk_font, 14, size.x - 50 - image_padding_x * 2.0f); body_item->author_text->setFillColor(body_item->author_color); body_item->author_text->updateGeometry(); } } // TODO: Optimize this, especially when scrolling to top/bottom. // TODO: Test when wrapping is enabled int selected_item_diff = selected_item - prev_selected_item; int selected_int_diff_abs = std::abs(selected_item_diff); if(selected_item_diff > 0) { int num_items_scrolled = 0; int i = prev_selected_item; while(num_items_scrolled < selected_int_diff_abs && i < num_items) { if(items[i]->visible) { page_scroll += (get_item_height(items[i].get()) + spacing_y); } ++num_items_scrolled; ++i; } prev_selected_item = selected_item; } else if(selected_item_diff < 0) { int num_items_scrolled = 0; int i = prev_selected_item - 1; while(num_items_scrolled < selected_int_diff_abs && i >= 0) { if(items[i]->visible) { page_scroll -= (get_item_height(items[i].get()) + spacing_y); } ++num_items_scrolled; --i; } prev_selected_item = selected_item; } float selected_item_height = get_item_height(items[selected_item].get()) + spacing_y; if(page_scroll > size.y - selected_item_height) { page_scroll = size.y - selected_item_height; } else if(page_scroll < 0.0f) { page_scroll = 0.0f; } pos.y += page_scroll; sf::Vector2u window_size = window.getSize(); glEnable(GL_SCISSOR_TEST); glScissor(scissor_pos.x, (int)window_size.y - (int)scissor_pos.y - (int)scissor_size.y, scissor_size.x, scissor_size.y); sf::Vector2f prev_pos = pos; for(int i = selected_item - 1; i >= 0; --i) { auto &item = items[i]; // TODO: Find a better solution? if(!item->visible) continue; float item_height = get_item_height(item.get()); prev_pos.y -= item_height + spacing_y; if(prev_pos.y + item_height <= 0.0f) break; draw_item(window, item.get(), prev_pos, size, item_height, i, content_progress); ++num_visible_items; } sf::Vector2f after_pos = pos; for(int i = selected_item; i < num_items; ++i) { auto &item = items[i]; // TODO: Find a better solution? if(!item->visible) continue; float item_height = get_item_height(item.get()); if(after_pos.y + item_height > start_y + size.y) last_item_fully_visible = false; if(after_pos.y >= start_y + size.y) break; draw_item(window, item.get(), after_pos, size, item_height, i, content_progress); after_pos.y += item_height + spacing_y; ++num_visible_items; } glDisable(GL_SCISSOR_TEST); for(auto it = item_thumbnail_textures.begin(); it != item_thumbnail_textures.end();) { if(!it->second->referenced) it = item_thumbnail_textures.erase(it); else ++it; } } void Body::draw_item(sf::RenderWindow &window, BodyItem *item, const sf::Vector2f &pos, const sf::Vector2f &size, const float item_height, const int item_index, const Json::Value &content_progress) { // TODO: Instead of generating a new hash everytime to access textures, cache the hash of the thumbnail url std::shared_ptr item_thumbnail; auto item_thumbnail_it = item_thumbnail_textures.find(item->thumbnail_url); if(item_thumbnail_it == item_thumbnail_textures.end()) { item_thumbnail = std::make_shared(); item_thumbnail_textures.insert(std::make_pair(item->thumbnail_url, item_thumbnail)); } else { item_thumbnail = item_thumbnail_it->second; } item_thumbnail->referenced = true; if(draw_thumbnails) { if(!loading_thumbnail && !item->thumbnail_url.empty() && item_thumbnail->loading_state == LoadingState::NOT_LOADED) { item_thumbnail->loading_state = LoadingState::LOADING; load_thumbnail_from_url(item->thumbnail_url, item->thumbnail_is_local, thumbnail_resize_target_size, item_thumbnail); } if(item_thumbnail->loading_state == LoadingState::FINISHED_LOADING) { if(!item_thumbnail->texture.loadFromImage(*item_thumbnail->image)) fprintf(stderr, "Warning: failed to load texture from image: %s\n", item->thumbnail_url.c_str()); item_thumbnail->image.reset(); item_thumbnail->loading_state = LoadingState::APPLIED_TO_TEXTURE; } } sf::Vector2f item_pos; item_pos.x = std::floor(pos.x); item_pos.y = std::floor(pos.y); item_background_shadow.setSize(sf::Vector2f(std::max(0.0f, size.x - 20.0f), 1.0f)); item_background_shadow.setPosition(item_pos + sf::Vector2f(10.0f, std::floor(item_height + spacing_y * 0.5f))); window.draw(item_background_shadow); if(item_index == selected_item) { item_background.setPosition(item_pos); item_background.setSize(sf::Vector2f(size.x, item_height)); window.draw(item_background); } float text_offset_x = padding_x; if(draw_thumbnails) { // TODO: Verify if this is safe. The thumbnail is being modified in another thread // and it might not be fully finished before the native handle is set? if(item_thumbnail->loading_state == LoadingState::APPLIED_TO_TEXTURE && item_thumbnail->texture.getNativeHandle() != 0) { image.setTexture(item_thumbnail->texture, true); auto image_size = image.getTexture()->getSize(); auto height_ratio = std::min(image_max_height, (float)image_size.y) / image_size.y; auto scale = image.getScale(); auto image_scale_ratio = scale.x / scale.y; const float width_ratio = height_ratio * image_scale_ratio; image.setScale(width_ratio, height_ratio); image.setPosition(item_pos + sf::Vector2f(image_padding_x, padding_y)); window.draw(image); text_offset_x += image_padding_x + width_ratio * image_size.x; // We want the next image fallback to have the same size as the successful image rendering, because its likely the image fallback will have the same size (for example thumbnails on youtube) //image_fallback.setSize(sf::Vector2f(width_ratio * image_size.x, height_ratio * image_size.y)); } else if(!item->thumbnail_url.empty()) { image_fallback.setPosition(item_pos + sf::Vector2f(image_padding_x, padding_y)); window.draw(image_fallback); text_offset_x += image_padding_x + image_fallback.getSize().x; } } if(!item->get_author().empty()) { item->author_text->setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y - 6.0f)); item->author_text->setMaxWidth(size.x - text_offset_x - image_padding_x * 2.0f); item->author_text->draw(window); sf::Vector2f replies_text_pos = item->author_text->getPosition() + sf::Vector2f(0.0f, 5.0f); replies_text_pos.x += item->author_text->getWidth() + 5.0f; replies_text.setPosition(replies_text_pos); sf::String replies_text_str; for(size_t reply_index : item->replies) { BodyItem *reply_item = items[reply_index].get(); replies_text_str += ">>"; replies_text_str += reply_item->post_number; } replies_text.setString(std::move(replies_text_str)); window.draw(replies_text); item_pos.y += item->author_text->getHeight() - 2.0f; } //title_text.setString(item->title); //title_text.setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y)); //window.draw(title_text); if(!item->get_title().empty()) { item->title_text->setFillColor(item->title_color); item->title_text->setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y - 8.0f)); item->title_text->setMaxWidth(size.x - text_offset_x - image_padding_x * 2.0f); item->title_text->draw(window); } if(!item->get_description().empty()) { float height_offset = 0.0f; if(!item->get_title().empty()) { height_offset = item->title_text->getHeight(); } item->description_text->setPosition(std::floor(item_pos.x + text_offset_x), std::floor(item_pos.y + padding_y - 8.0f + height_offset)); item->description_text->setMaxWidth(size.x - text_offset_x - image_padding_x * 2.0f); item->description_text->draw(window); } // TODO: Do the same for non-manga content. // TODO: Cache this instead of hash access every item every frame. const Json::Value &item_progress = content_progress[item->get_title()]; if(item_progress.isObject()) { const Json::Value ¤t_json = item_progress["current"]; const Json::Value &total_json = item_progress["total"]; if(current_json.isNumeric() && total_json.isNumeric()) { progress_text.setString(std::string("Page: ") + std::to_string(current_json.asInt()) + "/" + std::to_string(total_json.asInt())); auto bounds = progress_text.getLocalBounds(); progress_text.setPosition(std::floor(item_pos.x + size.x - bounds.width - padding_x), std::floor(item_pos.y + padding_y)); window.draw(progress_text); } } } float Body::get_item_height(BodyItem *item) { float item_height = 0.0f; if(!item->get_title().empty()) { item_height += item->title_text->getHeight() - 2.0f; } if(!item->get_author().empty()) { item_height += item->author_text->getHeight() - 2.0f; } if(item->description_text) { item_height += item->description_text->getHeight() - 2.0f; } if(draw_thumbnails && !item->thumbnail_url.empty()) { std::shared_ptr item_thumbnail; auto item_thumbnail_it = item_thumbnail_textures.find(item->thumbnail_url); if(item_thumbnail_it == item_thumbnail_textures.end()) { item_thumbnail = std::make_shared(); item_thumbnail_textures.insert(std::make_pair(item->thumbnail_url, item_thumbnail)); } else { item_thumbnail = item_thumbnail_it->second; } float image_height = image_fallback.getSize().y; if(item_thumbnail->loading_state == LoadingState::APPLIED_TO_TEXTURE && item_thumbnail->texture.getNativeHandle() != 0) { auto image_size = item_thumbnail->texture.getSize(); image_height = std::min(image_max_height, (float)image_size.y); } item_height = std::max(item_height, image_height); } return item_height + padding_y * 2.0f; } //static bool Body::string_find_case_insensitive(const std::string &str, const std::string &substr) { auto it = std::search(str.begin(), str.end(), substr.begin(), substr.end(), [](char c1, char c2) { return std::toupper(c1) == std::toupper(c2); }); return it != str.end(); } void Body::filter_search_fuzzy(const std::string &text) { if(text.empty()) { for(auto &item : items) { item->visible = true; } return; } for(auto &item : items) { item->visible = string_find_case_insensitive(item->get_title(), text); if(!item->visible && !item->get_description().empty()) item->visible = string_find_case_insensitive(item->get_description(), text); } } bool Body::no_items_visible() const { for(auto &item : items) { if(item->visible) return false; } return true; } }