From 58481b46a2c64fda4f506e15ee94dd97f527d552 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Tue, 6 Aug 2019 03:12:16 +0200 Subject: Save and show progress in manga and return to last page" --- README.md | 2 +- include/Body.hpp | 3 ++ include/Path.hpp | 26 +++++++++++ include/QuickMedia.hpp | 6 +++ include/Storage.hpp | 18 ++++++++ include/env.hpp | 59 +++++++++++++++++++++++++ plugins/Plugin.hpp | 1 + project.conf | 3 +- src/Body.cpp | 38 +++++++++++++--- src/QuickMedia.cpp | 101 ++++++++++++++++++++++++++++++++++++++---- src/SearchBar.cpp | 2 + src/Storage.cpp | 109 ++++++++++++++++++++++++++++++++++++++++++++++ src/plugins/Manganelo.cpp | 4 +- src/plugins/Plugin.cpp | 23 ++++++++++ src/plugins/Youtube.cpp | 2 +- 15 files changed, 378 insertions(+), 19 deletions(-) create mode 100644 include/Path.hpp create mode 100644 include/Storage.hpp create mode 100644 include/env.hpp create mode 100644 src/Storage.cpp diff --git a/README.md b/README.md index 9937445..0ff3427 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ youtube-dl needs to be installed to play videos from youtube. # TODO Fix x11 freeze that sometimes happens when playing video. If a search returns no results, then "No results found for ..." should be shown and navigation should go back to searching with suggestions. -Keep track of content that has been viewed so the user can return to where they were last. +Give user the option to start where they left off or from the start. For manga, view the next chapter when reaching the end of a chapter. Make network requests asynchronous to not freeze gui when navigating. Also have loading animation. Retain search text when navigating back. \ No newline at end of file diff --git a/include/Body.hpp b/include/Body.hpp index e09017d..e854e76 100644 --- a/include/Body.hpp +++ b/include/Body.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace QuickMedia { @@ -35,6 +36,7 @@ namespace QuickMedia { void clamp_selection(); void draw(sf::RenderWindow &window, sf::Vector2f pos, sf::Vector2f size); + void draw(sf::RenderWindow &window, sf::Vector2f pos, sf::Vector2f size, const Json::Value &content_progress); static bool string_find_case_insensitive(const std::string &str, const std::string &substr); // TODO: Make this actually fuzzy... Right now it's just a case insensitive string find. @@ -42,6 +44,7 @@ namespace QuickMedia { void filter_search_fuzzy(const std::string &text); sf::Text title_text; + sf::Text progress_text; int selected_item; std::vector> items; std::vector> item_thumbnail_textures; diff --git a/include/Path.hpp b/include/Path.hpp new file mode 100644 index 0000000..351fd5d --- /dev/null +++ b/include/Path.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace QuickMedia { + class Path { + public: + Path() = default; + ~Path() = default; + Path(const Path &other) = default; + Path& operator=(const Path &other) = default; + Path(const char *path) : data(path) {} + Path(const std::string &path) : data(path) {} + Path(Path &&other) { + data = std::move(other.data); + } + + Path& join(const Path &other) { + data += "/"; + data += other.data; + return *this; + } + + std::string data; + }; +} \ No newline at end of file diff --git a/include/QuickMedia.hpp b/include/QuickMedia.hpp index e5f4f2d..48a2d8a 100644 --- a/include/QuickMedia.hpp +++ b/include/QuickMedia.hpp @@ -2,10 +2,12 @@ #include "SearchBar.hpp" #include "Page.hpp" +#include "Storage.hpp" #include #include #include #include +#include namespace QuickMedia { class Body; @@ -34,6 +36,10 @@ namespace QuickMedia { // TODO: Combine these std::string video_url; std::string images_url; + std::string content_title; + std::string chapter_title; int image_index; + Path content_storage_file; + Json::Value content_storage_json; }; } \ No newline at end of file diff --git a/include/Storage.hpp b/include/Storage.hpp new file mode 100644 index 0000000..bd4283c --- /dev/null +++ b/include/Storage.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "Path.hpp" + +namespace QuickMedia { + enum class FileType { + FILE_NOT_FOUND, + REGULAR, + DIRECTORY + }; + + Path get_home_dir(); + Path get_storage_dir(); + int create_directory_recursive(const Path &path); + FileType get_file_type(const Path &path); + int file_get_content(const Path &path, std::string &result); + int file_overwrite(const Path &path, const std::string &data); +} \ No newline at end of file diff --git a/include/env.hpp b/include/env.hpp new file mode 100644 index 0000000..b842ff3 --- /dev/null +++ b/include/env.hpp @@ -0,0 +1,59 @@ +#pragma once + +#define OS_FAMILY_WINDOWS 0 +#define OS_FAMILY_POSIX 1 + +#define OS_TYPE_WINDOWS 0 +#define OS_TYPE_LINUX 1 + +#if defined(_WIN32) || defined(_WIN64) + #if defined(_WIN64) + #define SYS_ENV_64BIT + #else + #define SYS_ENV_32BIT + #endif + #define OS_FAMILY OS_FAMILY_WINDOWS + #define OS_TYPE OS_TYPE_WINDOWS + + #ifndef UNICODE + #define UNICODE + #endif + + #ifndef _UNICODE + #define _UNICODE + #endif + + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + + #include +#endif + +#if defined(__linux__) || defined(__unix__) || defined(__APPLE__) || defined(_POSIX_VERSION) + #define OS_FAMILY OS_FAMILY_POSIX +#endif + +#if defined(__linux__) || defined(__CYGWIN__) + #define OS_TYPE OS_TYPE_LINUX +#endif + +#if defined(__GNUC__) + #if defined(__x86_64__) || defined(__pc64__) + #define SYS_ENV_64BIT + #else + #define SYS_ENV_32BIT + #endif +#endif + +#if !defined(SYS_ENV_32BIT) && !defined(SYS_ENV_64BIT) + #error "System is not detected as either 32-bit or 64-bit" +#endif + +#if !defined(OS_FAMILY) + #error "System not supported. Only Windows and Posix systems supported right now" +#endif + +#if !defined(OS_TYPE) + #error "System not supported. Only Windows and linux systems supported right now" +#endif diff --git a/plugins/Plugin.hpp b/plugins/Plugin.hpp index 2d6005e..6d5a986 100644 --- a/plugins/Plugin.hpp +++ b/plugins/Plugin.hpp @@ -38,6 +38,7 @@ namespace QuickMedia { }; DownloadResult download_to_string(const std::string &url, std::string &result, const std::vector &additional_args = {}); + std::string strip(const std::string &str); class Plugin { public: diff --git a/project.conf b/project.conf index d54e94b..7e4e124 100644 --- a/project.conf +++ b/project.conf @@ -9,4 +9,5 @@ sfml-graphics = "2" mpv = "1.25.0" gl = ">=17.3" x11 = "1.6.5" -jsoncpp = "1.5" \ No newline at end of file +jsoncpp = "1.5" +cppcodec-1 = "0.1" \ No newline at end of file diff --git a/src/Body.cpp b/src/Body.cpp index d391e87..68acaad 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -9,8 +9,14 @@ const sf::Color front_color(43, 45, 47); const sf::Color back_color(33, 35, 37); namespace QuickMedia { - Body::Body(sf::Font &font) : title_text("", font, 14), selected_item(0), loading_thumbnail(false) { + Body::Body(sf::Font &font) : + title_text("", font, 14), + progress_text("", font, 14), + selected_item(0), + loading_thumbnail(false) + { title_text.setFillColor(sf::Color::White); + progress_text.setFillColor(sf::Color::White); } void Body::select_previous_item() { @@ -98,11 +104,19 @@ namespace QuickMedia { return result; } + 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: Skip drawing the rows that are outside the window. // TODO: Use a render target for the whole body so all images can be put into one. // TODO: Only load images once they are visible on the screen. // TODO: Load thumbnails with more than one thread. - void Body::draw(sf::RenderWindow &window, sf::Vector2f pos, sf::Vector2f size) { + // TODO: Show chapters (rows) that have been read differently to make it easier to see what + // needs hasn't been read yet. + void Body::draw(sf::RenderWindow &window, sf::Vector2f pos, sf::Vector2f size, const Json::Value &content_progress) { + assert(content_progress.isObject()); const float font_height = title_text.getCharacterSize() + 8.0f; const float image_height = 100.0f; @@ -113,16 +127,19 @@ namespace QuickMedia { sf::RectangleShape item_background; item_background.setFillColor(front_color); - item_background.setOutlineThickness(1.0f); - item_background.setOutlineColor(sf::Color(63, 65, 67)); + //item_background.setOutlineThickness(1.0f); + //item_background.setOutlineColor(sf::Color(63, 65, 67)); sf::RectangleShape selected_border; selected_border.setFillColor(sf::Color::Red); const float selected_border_width = 5.0f; int num_items = items.size(); - if((int)item_thumbnail_textures.size() != num_items) + if((int)item_thumbnail_textures.size() != num_items) { + // First unload all textures, then prepare to load new textures + item_thumbnail_textures.resize(0); item_thumbnail_textures.resize(num_items); + } for(int i = 0; i < num_items; ++i) { const auto &item = items[i]; @@ -182,6 +199,17 @@ namespace QuickMedia { title_text.setPosition(std::floor(item_pos.x + text_offset_x + 10.0f), std::floor(item_pos.y)); window.draw(title_text); + // TODO: Do the same for non-manga content + const Json::Value &item_progress = content_progress[item->title]; + 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("Progress: ") + 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 - 10.0f), std::floor(item_pos.y)); + window.draw(progress_text); + } + pos.y += row_height + 10.0f; } } diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 3285eb9..3cfbc08 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -2,10 +2,13 @@ #include "../plugins/Manganelo.hpp" #include "../plugins/Youtube.hpp" #include "../include/VideoPlayer.hpp" +#include #include #include #include +#include +#include #include #include @@ -37,15 +40,15 @@ namespace QuickMedia { delete current_plugin; } - static SearchResult search_selected_suggestion(Body *body, Plugin *plugin, Page &next_page) { + static SearchResult search_selected_suggestion(Body *body, Plugin *plugin, Page &next_page, std::string &selected_title) { BodyItem *selected_item = body->get_selected(); if(!selected_item) return SearchResult::ERR; - std::string selected_item_title = selected_item->title; + selected_title = selected_item->title; std::string selected_item_url = selected_item->url; body->clear_items(); - SearchResult search_result = plugin->search(!selected_item_url.empty() ? selected_item_url : selected_item_title, body->items, next_page); + SearchResult search_result = plugin->search(!selected_item_url.empty() ? selected_item_url : selected_title, body->items, next_page); body->reset_selected(); return search_result; } @@ -77,9 +80,12 @@ namespace QuickMedia { case Page::EPISODE_LIST: episode_list_page(); break; - case Page::IMAGES: + case Page::IMAGES: { + window.setKeyRepeatEnabled(false); image_page(); + window.setKeyRepeatEnabled(true); break; + } default: return; } @@ -110,6 +116,31 @@ namespace QuickMedia { } } + static std::string base64_encode(const std::string &data) { + return cppcodec::base64_rfc4648::encode(data); + } + + static bool get_manga_storage_json(const Path &storage_path, Json::Value &result) { + std::string file_content; + if(file_get_content(storage_path, file_content) != 0) + return -1; + + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(json_reader->parse(&file_content.front(), &file_content.back(), &result, &json_errors)) { + fprintf(stderr, "Failed to read json, error: %s\n", json_errors.c_str()); + return -1; + } + + return 0; + } + + static bool save_manga_progress_json(const Path &path, const Json::Value &json) { + Json::StreamWriterBuilder json_builder; + return file_overwrite(path, Json::writeString(json_builder, json)) == 0; + } + void Program::search_suggestion_page() { search_bar->onTextUpdateCallback = [this](const std::string &text) { update_search_suggestions(text, body, current_plugin); @@ -117,8 +148,24 @@ namespace QuickMedia { search_bar->onTextSubmitCallback = [this](const std::string &text) { Page next_page; - if(search_selected_suggestion(body, current_plugin, next_page) == SearchResult::OK) + if(search_selected_suggestion(body, current_plugin, next_page, content_title) == SearchResult::OK) { + if(next_page == Page::EPISODE_LIST) { + Path content_storage_dir = get_storage_dir().join("manga"); + if(create_directory_recursive(content_storage_dir) != 0) { + // TODO: Show this to the user + fprintf(stderr, "Failed to create directory: %s\n", content_storage_dir.data.c_str()); + return; + } + + content_storage_file = content_storage_dir.join(base64_encode(content_title)); + content_storage_json.clear(); + content_storage_json["name"] = content_title; + FileType file_type = get_file_type(content_storage_file); + if(file_type == FileType::REGULAR) + get_manga_storage_json(content_storage_file, content_storage_json); + } current_page = next_page; + } }; sf::Vector2f body_pos; @@ -263,9 +310,23 @@ namespace QuickMedia { images_url = selected_item->url; image_index = 0; + chapter_title = selected_item->title; current_page = Page::IMAGES; + + 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; + } + } + }; + const Json::Value &json_chapters = content_storage_json["chapters"]; + sf::Vector2f body_pos; sf::Vector2f body_size; bool resized = true; @@ -297,7 +358,7 @@ namespace QuickMedia { search_bar->update(); window.clear(back_color); - body->draw(window, body_pos, body_size); + body->draw(window, body_pos, body_size, json_chapters); search_bar->draw(window); window.display(); } @@ -361,13 +422,35 @@ namespace QuickMedia { } image_data.resize(0); + int num_images = 0; + image_plugin->get_number_of_images(images_url, num_images); + + 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_chapters = Json::Value(Json::objectValue); + json_chapter = Json::Value(Json::objectValue); + } + json_chapter["current"] = latest_read; + json_chapter["total"] = num_images; + json_chapters[chapter_title] = json_chapter; + if(!save_manga_progress_json(content_storage_file, content_storage_json)) { + // TODO: Show this to the user + fprintf(stderr, "Failed to save manga progress!\n"); + } + bool error = !error_message.getString().isEmpty(); bool resized = true; sf::Event event; - int num_images = 0; - image_plugin->get_number_of_images(images_url, num_images); - sf::Text chapter_text(std::string("Page ") + std::to_string(image_index + 1) + "/" + std::to_string(num_images), font, 14); if(image_index == num_images) chapter_text.setString("End"); diff --git a/src/SearchBar.cpp b/src/SearchBar.cpp index 15777e5..82ade2f 100644 --- a/src/SearchBar.cpp +++ b/src/SearchBar.cpp @@ -20,6 +20,8 @@ namespace QuickMedia { text.setFillColor(text_placeholder_color); background.setFillColor(front_color); background.setPosition(padding_horizontal, padding_vertical); + //background.setOutlineThickness(1.0f); + //background.setOutlineColor(sf::Color(63, 65, 67)); } void SearchBar::draw(sf::RenderWindow &window) { diff --git a/src/Storage.cpp b/src/Storage.cpp new file mode 100644 index 0000000..80d5d70 --- /dev/null +++ b/src/Storage.cpp @@ -0,0 +1,109 @@ +#include "../include/Storage.hpp" +#include "../include/env.hpp" +#include + +#if OS_FAMILY == OS_FAMILY_POSIX +#include +#include +#include +#endif + +static int makedir(const char *path) { + return mkdir(path, S_IRWXU); +} + +namespace QuickMedia { + Path get_home_dir() + { + #if OS_FAMILY == OS_FAMILY_POSIX + const char *homeDir = getenv("HOME"); + if(!homeDir) + { + passwd *pw = getpwuid(getuid()); + homeDir = pw->pw_dir; + } + return homeDir; + #elif OS_FAMILY == OS_FAMILY_WINDOWS + BOOL ret; + HANDLE hToken; + std::wstring homeDir; + DWORD homeDirLen = MAX_PATH; + homeDir.resize(homeDirLen); + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_READ, &hToken)) + throw std::runtime_error("Failed to open process token"); + + if (!GetUserProfileDirectory(hToken, &homeDir[0], &homeDirLen)) + { + CloseHandle(hToken); + throw std::runtime_error("Failed to get home directory"); + } + + CloseHandle(hToken); + homeDir.resize(wcslen(homeDir.c_str())); + return boost::filesystem::path(homeDir); + #endif + } + + Path get_storage_dir() { + return get_home_dir().join(".local").join("share").join("quickmedia"); + } + + int create_directory_recursive(const Path &path) { + size_t index = 0; + while(true) { + index = path.data.find('/', index); + + // Skips first '/' on unix-like systems + if(index == 0) { + ++index; + continue; + } + + std::string path_component = path.data.substr(0, index); + int err = makedir(path_component.c_str()); + + if(err == -1 && errno != EEXIST) + return err; + + if(index == std::string::npos) + break; + else + ++index; + } + return 0; + } + + FileType get_file_type(const Path &path) { + struct stat file_stat; + if(stat(path.data.c_str(), &file_stat) == 0) + return S_ISREG(file_stat.st_mode) ? FileType::REGULAR : FileType::DIRECTORY; + return FileType::FILE_NOT_FOUND; + } + + int file_get_content(const Path &path, std::string &result) { + FILE *file = fopen(path.data.c_str(), "rb"); + if(!file) + return -errno; + + fseek(file, 0, SEEK_END); + size_t file_size = ftell(file); + fseek(file, 0, SEEK_SET); + + result.resize(file_size); + fread(&result[0], 1, file_size, file); + + fclose(file); + return 0; + } + + int file_overwrite(const Path &path, const std::string &data) { + FILE *file = fopen(path.data.c_str(), "wb"); + if(!file) + return -errno; + + fwrite(data.data(), 1, data.size(), file); + fclose(file); + return 0; + } +} \ No newline at end of file diff --git a/src/plugins/Manganelo.cpp b/src/plugins/Manganelo.cpp index e8d24dc..1989a37 100644 --- a/src/plugins/Manganelo.cpp +++ b/src/plugins/Manganelo.cpp @@ -21,7 +21,7 @@ namespace QuickMedia { const char *href = quickmedia_html_node_get_attribute_value(node, "href"); const char *text = quickmedia_html_node_get_text(node); if(href && text) { - auto item = std::make_unique(text); + auto item = std::make_unique(strip(text)); item->url = href; item_data->push_back(std::move(item)); } @@ -83,7 +83,7 @@ namespace QuickMedia { std::string name_str = name.asString(); while(remove_html_span(name_str)) {} if(name_str != text) { - auto item = std::make_unique(name_str); + auto item = std::make_unique(strip(name_str)); item->url = "https://manganelo.com/manga/" + url_param_encode(nameunsigned.asString()); Json::Value image = child.get("image", ""); if(image.isString() && image.asCString()[0] != '\0') diff --git a/src/plugins/Plugin.cpp b/src/plugins/Plugin.cpp index d87ac34..2367cb3 100644 --- a/src/plugins/Plugin.cpp +++ b/src/plugins/Plugin.cpp @@ -33,6 +33,29 @@ namespace QuickMedia { return DownloadResult::OK; } + static bool is_whitespace(char c) { + return c == ' ' || c == '\n' || c == '\t' || c == '\v'; + } + + std::string strip(const std::string &str) { + if(str.empty()) + return str; + + int start = 0; + for(; start < (int)str.size(); ++start) { + if(!is_whitespace(str[start])) + break; + } + + int end = str.size() - 1; + for(; end >= start; --end) { + if(!is_whitespace(str[end])) + break; + } + + return str.substr(start, end - start + 1); + } + std::string Plugin::url_param_encode(const std::string ¶m) const { std::ostringstream result; result.fill('0'); diff --git a/src/plugins/Youtube.cpp b/src/plugins/Youtube.cpp index 6cc4ac6..4ee3933 100644 --- a/src/plugins/Youtube.cpp +++ b/src/plugins/Youtube.cpp @@ -29,7 +29,7 @@ namespace QuickMedia { const char *title = quickmedia_html_node_get_attribute_value(node, "title"); // Checking for watch?v helps skipping ads if(href && title && begins_with(href, "/watch?v=")) { - auto item = std::make_unique(title); + auto item = std::make_unique(strip(title)); item->url = std::string("https://www.youtube.com") + href; result_items->push_back(std::move(item)); } -- cgit v1.2.3