From 02e029ed40f801e0710b09062069e7083cd30b93 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Thu, 17 Feb 2022 19:18:19 +0100 Subject: Add local anime tracking. Check readme for more info about local_anime config --- README.md | 49 +++++- TODO | 5 +- example-config.json | 3 +- include/BodyItem.hpp | 15 ++ include/Config.hpp | 1 - include/Path.hpp | 2 +- include/VideoPlayer.hpp | 5 +- plugins/LocalAnime.hpp | 59 ++++++- plugins/Page.hpp | 6 + src/AsyncImageLoader.cpp | 3 +- src/Body.cpp | 31 +++- src/BodyItem.cpp | 7 +- src/Config.cpp | 16 +- src/Entry.cpp | 17 +- src/ImageViewer.cpp | 16 +- src/QuickMedia.cpp | 78 +++++++-- src/SearchBar.cpp | 29 ++-- src/Tabs.cpp | 31 ++-- src/Text.cpp | 27 ++-- src/VideoPlayer.cpp | 14 +- src/gui/Button.cpp | 13 +- src/plugins/LocalAnime.cpp | 392 +++++++++++++++++++++++++++++++++++++++++++-- src/plugins/LocalManga.cpp | 46 +----- src/plugins/Matrix.cpp | 1 - src/plugins/Page.cpp | 2 +- 25 files changed, 708 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 9533bc0..4baf18b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # QuickMedia A rofi inspired native client for web services. Currently supported web services: `youtube`, `peertube`, `lbry`, `soundcloud`, `nyaa.si`, `manganelo`, `manganelos`, `mangatown`, `mangakatana`, `mangadex`, `readm`, `onimanga`, `4chan`, `matrix`, `saucenao`, `hotexamples`, `anilist` and _others_.\ -QuickMedia also supports reading local manga (add "local_manga_directory" config to `~/.config/quickmedia/config.json`, see the [config](#config) section). +QuickMedia also supports reading local manga and watching local anime, see [local manga](#local-manga) and [local anime](#local-anime) ## Usage ``` usage: quickmedia [plugin] [--dir ] [-e ] [youtube-url] OPTIONS: - plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, local-manga, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager or stdin + plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, local-manga, local-anime, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager or stdin --no-video Only play audio when playing a video. Disabled by default --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default --upscale-images-always Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default @@ -76,6 +76,8 @@ Type text and then wait and QuickMedia will automatically search.\ `Ctrl+B`: Bookmark the selected manga. If the manga is already bookmarked then its removed from bookmarks. ### Local manga search page controls `Ctrl+R`: Mark the manga as read/unread. +### Local anime search page controls +`Ctrl+R`: Mark the anime as watched/unwatched. ### Manga page view controls `Arrow up`/`Ctrl+K`: Go to the next page (or chapter if the current page is the last one).\ `Arrow down`/`Ctrl+J`: Go to the previous page (or chapter if the current page is the first one).\ @@ -159,14 +161,53 @@ Type text and then wait and QuickMedia will automatically search.\ `/me [text]`: Send a message of type "m.emote".\ `/react [text]`: React to the selected message (also works if you are replying to a message).\ `/id`: Show the room id. -## Config +## Config Config is loaded from `~/.config/quickmedia/config.json` if it exists. See [example-config.json](https://git.dec05eba.com/QuickMedia/plain/example-config.json) for an example config. All fields in the config file are optional.\ If `use_system_mpv_config` is set to `true` then your systems mpv config in `~/.config/mpv/mpv.conf` and plugins will be used. ## Theme Theme is loaded from `~/.config/quickmedia/themes/.json` if it exists or from `/usr/share/quickmedia/themes`. Theme name is set in `~/.config/quickmedia/config.json` under the variable `theme`.\ Default themes available: `default, nord`.\ See [default.json](https://git.dec05eba.com/QuickMedia/plain/themes/default.json) for an example theme.\ -The `default` there is used by default. +The `default` is used by default. +## Local manga +`local_manga.directory` needs to be set in `~/.config/quickmedia/config.json` to a directory with all your manga.\ +The directory layout is expected to be like this: +``` +Manga 1 + Chapter 1 + 1.png + 2.png + Chapter 2 + 1.png + 2.png +Manga 2 + Chapter 1 + 1.png + 2.png + Chapter 2 + 1.png + 2.png +``` +Note that the manga name and chapter names can be anything you want, but the image names need to be a number (with `.jpg`, `.jpeg`, or `.png` extension). +## Local anime +`local_anime.directory` needs to be set in `~/.config/quickmedia/config.json` to a directory with all your anime.\ +The directory layout is expected to be like this: +``` +Anime 1 - Episode 1.mkv +Anime 1 - Episode 2.mkv +Anime 2 + Episode 1.mkv + Episode 2.mkv +Anime 3 + Season 1 + Episode 1.mkv + Episode 2.mkv + Season 2 + Episode 1.mkv + Episode 2.mkv +``` +Note that the episode names can be anything you want.\ +Local anime always uses your system mpv config. ## Environment variables If `xdg-open` is not installed then the `BROWSER` environment variable is used to open links in a browser.\ Set `QM_PHONE_FACTOR=1` to disable the room list side panel in matrix. diff --git a/TODO b/TODO index 795380c..d155689 100644 --- a/TODO +++ b/TODO @@ -216,4 +216,7 @@ Add "finished reading" to online manga as well, for the manga sites that publish Async load visible body item content. This is needed for local-manga if the manga is stored on NFS where recursively reading all manga directories is slow. We only want to read recursively for the manga that is visible on the screen. Allow using ~/.config/mpv/input.conf when using use_system_mpv_config by merging QuickMedia's input.conf and the users input.conf and creating a temporary file in /tmp and load that. But make sure configs dont clash. Allow asynchronously loading body items. This is needed in manga combined plugin. -Bold video subtitles. \ No newline at end of file +Bold video subtitles. +Add ctrl+o keybind to open the selected media in an external application (video, imageviewer, music player should be configured in the config.json file). Open other files or youtube links in a browser when using this keybind. +Local anime bookmark. +Local anime history. \ No newline at end of file diff --git a/example-config.json b/example-config.json index 321f331..2214571 100644 --- a/example-config.json +++ b/example-config.json @@ -28,8 +28,7 @@ }, "local_anime": { "directory": "", - "sort_by_name": false, - "sort_episodes_by_name": true + "sort_by_name": false }, "use_system_fonts": false, "use_system_mpv_config": false, diff --git a/include/BodyItem.hpp b/include/BodyItem.hpp index eef674e..2e7dad4 100644 --- a/include/BodyItem.hpp +++ b/include/BodyItem.hpp @@ -2,6 +2,7 @@ #include "Text.hpp" #include +#include #include namespace mgl { @@ -30,10 +31,24 @@ namespace QuickMedia { BODY_THEME_MODERN_SPACIOUS }; + struct ThumbnailWidget { + mgl::vec2f position; + mgl::vec2f size; + }; + + struct Widgets { + std::optional thumbnail; + }; + // TODO: Remove and create an Userdata class instead to replace the void* userdata in BodyItem class BodyItemExtra { public: virtual ~BodyItemExtra() = default; + + virtual void draw_overlay(mgl::Window &window, const Widgets &widgets) { + (void)window; + (void)widgets; + } }; struct Reaction { diff --git a/include/Config.hpp b/include/Config.hpp index ee9e190..b191c96 100644 --- a/include/Config.hpp +++ b/include/Config.hpp @@ -39,7 +39,6 @@ namespace QuickMedia { struct LocalAnimeConfig { std::string directory; bool sort_by_name = false; - bool sort_episodes_by_name = true; }; struct Config { diff --git a/include/Path.hpp b/include/Path.hpp index 67e6942..a881a53 100644 --- a/include/Path.hpp +++ b/include/Path.hpp @@ -39,7 +39,7 @@ namespace QuickMedia { return data.substr(name - data.data(), extension - name); } - // Returns empty string if no extension + // Returns extension with the dot. Returns empty string if no extension const char* ext() const { size_t slash_index = data.rfind('/'); size_t index = data.rfind('.'); diff --git a/include/VideoPlayer.hpp b/include/VideoPlayer.hpp index 989bfd2..feede37 100644 --- a/include/VideoPlayer.hpp +++ b/include/VideoPlayer.hpp @@ -39,8 +39,9 @@ namespace QuickMedia { mgl::WindowHandle parent_window; bool no_video = false; bool use_system_mpv_config = false; - bool use_system_input_config = false; // use_system_mpv_config has to be true for this + bool use_system_input_config = false; // |use_system_mpv_config| has to be true if this is set to true bool keep_open = false; + bool resume = false; std::string resource_root; int monitor_height = 1080; bool use_youtube_dl = false; @@ -50,7 +51,7 @@ namespace QuickMedia { std::string plugin_name; }; - // @event_callback is called from another thread + // Important: do not call |get_time_in_file| or |add_subtitle| from the |event_callback| callback VideoPlayer(StartupArgs startup_args, EventCallbackFunc event_callback, VideoPlayerWindowCreateCallback window_create_callback); ~VideoPlayer(); VideoPlayer(const VideoPlayer&) = delete; diff --git a/plugins/LocalAnime.hpp b/plugins/LocalAnime.hpp index cd240d3..e768da9 100644 --- a/plugins/LocalAnime.hpp +++ b/plugins/LocalAnime.hpp @@ -1,13 +1,50 @@ #pragma once #include "Page.hpp" - -// TODO: Progress > 90% = fully watched (because ending might have been skipped) +#include +#include namespace QuickMedia { + struct LocalAnimeWatchProgress { + double time = 0.0; + double duration = 0.0; + + double get_watch_ratio() const; + bool has_finished_watching() const; + }; + + struct LocalAnime; + struct LocalAnimeSeason; + struct LocalAnimeEpisode; + using LocalAnimeItem = std::variant; + + struct LocalAnimeEpisode { + Path path; + time_t modified_time_seconds; + }; + + struct LocalAnimeSeason { + Path path; + std::vector episodes; + time_t modified_time_seconds; + }; + + struct LocalAnime { + Path path; + std::vector items; + time_t modified_time_seconds; + }; + + enum class LocalAnimeSearchPageType { + DIRECTORY, + ANIME, + SEASON + }; + class LocalAnimeSearchPage : public LazyFetchPage { public: - LocalAnimeSearchPage(Program *program) : LazyFetchPage(program) {} + LocalAnimeSearchPage(Program *program, Path directory, LocalAnimeSearchPageType type) + : LazyFetchPage(program), directory(std::move(directory)), type(type) {} const char* get_title() const override { return "Search"; } bool search_is_filter() override { return true; } PluginResult submit(const SubmitArgs &args, std::vector &result_tabs) override; @@ -17,5 +54,21 @@ namespace QuickMedia { bool reseek_to_body_item_by_url() override { return true; } std::shared_ptr get_bookmark_body_item(BodyItem *selected_item) override; void toggle_read(BodyItem *selected_item) override; + private: + Path directory; + LocalAnimeSearchPageType type; + }; + + class LocalAnimeVideoPage : public VideoPage { + public: + LocalAnimeVideoPage(Program *program, std::string filepath, LocalAnimeWatchProgress watch_progress) + : VideoPage(program, std::move(filepath)), watch_progress(std::move(watch_progress)) {} + const char* get_title() const override { return ""; } + std::string get_video_url(int max_height, bool &has_embedded_audio, std::string &ext) override; + std::string get_url_timestamp() override; + bool is_local() const override { return true; } + void set_watch_progress(double time_pos_sec) override; + private: + LocalAnimeWatchProgress watch_progress; }; } \ No newline at end of file diff --git a/plugins/Page.hpp b/plugins/Page.hpp index 20d6000..2ab19b1 100644 --- a/plugins/Page.hpp +++ b/plugins/Page.hpp @@ -174,6 +174,10 @@ namespace QuickMedia { virtual void mark_watched() {}; // Should not do any network request to not slow down video loading virtual void get_subtitles(SubtitleData &subtitle_data) { (void)subtitle_data; } + + virtual bool is_local() const { return false; } + + virtual void set_watch_progress(double time_pos_sec) { (void)time_pos_sec; } protected: std::string url; }; @@ -187,6 +191,8 @@ namespace QuickMedia { bool reload_on_page_change() override { return true; } const char* get_bookmark_name() const override { return redirect_page->get_bookmark_name(); } bool is_bookmark_page() const override { return true; } + + mgl::vec2i thumbnail_size = {101, 141}; private: Page *redirect_page; bool local_thumbnail; diff --git a/src/AsyncImageLoader.cpp b/src/AsyncImageLoader.cpp index d7fbfbe..e6d0b52 100644 --- a/src/AsyncImageLoader.cpp +++ b/src/AsyncImageLoader.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #define STB_IMAGE_RESIZE_IMPLEMENTATION #include "../external/stb/stb_image_resize.h" @@ -258,6 +257,8 @@ namespace QuickMedia { while(image_thumbnail_create_queue.is_running()) { thumbnail_load_data_opt = image_thumbnail_create_queue.pop_if_available(); if(thumbnail_load_data_opt) { + // TODO: Do this multithreaded because creating thumbnails is pretty slow single-threaded, + // especially video thumbnails. process_thumbnail(thumbnail_load_data_opt.value()); if(thumbnail_load_data_opt.value().thumbnail_data->loading_state == LoadingState::READY_TO_LOAD) load_processed_thumbnail(thumbnail_load_data_opt.value()); diff --git a/src/Body.cpp b/src/Body.cpp index 4681410..6fdb285 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -13,8 +13,8 @@ #include #include #include -#include #include +#include struct BodySpacing { float spacing_y = 0.0f; @@ -954,7 +954,7 @@ namespace QuickMedia { if(item->thumbnail_size.x > 0 && item->thumbnail_size.y > 0) content_size = clamp_to_size(mgl::vec2i(std::floor(item->thumbnail_size.x * get_config().scale), std::floor(item->thumbnail_size.y * get_config().scale)), thumbnail_max_size_scaled); else - content_size = mgl::vec2i(250 * get_config().scale, 141 * get_config().scale); + content_size = clamp_to_size(mgl::vec2i(250 * get_config().scale, 141 * get_config().scale), thumbnail_max_size_scaled); return content_size; } @@ -1365,6 +1365,7 @@ namespace QuickMedia { const float padding_y = item_thumbnail ? body_spacing[body_theme].padding_y : body_spacing[body_theme].padding_y_text_only; + bool thumbnail_drawn = false; float text_offset_x = body_spacing[body_theme].padding_x; if(item_thumbnail && !merge_with_previous) { // TODO: Verify if this is safe. The thumbnail is being modified in another thread @@ -1390,6 +1391,7 @@ namespace QuickMedia { text_offset_x += body_spacing[body_theme].image_padding_x + new_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.set_size(mgl::vec2f(width_ratio * image_size.x, height_ratio * image_size.y)); + thumbnail_drawn = true; } else if(!item->thumbnail_url.empty()) { mgl::vec2f content_size = thumbnail_size.to_vec2f(); @@ -1506,6 +1508,17 @@ namespace QuickMedia { window.draw(*item->timestamp_text); } + if(item->extra) { + Widgets widgets; + if(thumbnail_drawn) { + ThumbnailWidget thumbnail; + thumbnail.position = image.get_position(); + thumbnail.size = image.get_texture()->get_size().to_vec2f() * image.get_scale(); + widgets.thumbnail = std::move(thumbnail); + } + item->extra->draw_overlay(window, widgets); + } + if(!content_progress.isObject()) return; @@ -1530,6 +1543,7 @@ namespace QuickMedia { mgl::View new_view = { mgl::vec2i(0, scissor_y), mgl::vec2i(window_size.x, body_size.y) }; window.set_view(new_view); + bool thumbnail_drawn = false; { float image_height = 0.0f; if(item_thumbnail && item_thumbnail->loading_state == LoadingState::APPLIED_TO_TEXTURE && item_thumbnail->texture.is_valid()) { @@ -1552,6 +1566,8 @@ namespace QuickMedia { } else { window.draw(image); } + + thumbnail_drawn = true; } else if(!item->thumbnail_url.empty()) { mgl::vec2f content_size = thumbnail_size.to_vec2f(); mgl::vec2f loading_icon_size(loading_icon.get_texture()->get_size().x, loading_icon.get_texture()->get_size().y); @@ -1563,6 +1579,17 @@ namespace QuickMedia { image_height = content_size.y; } + if(item->extra) { + Widgets widgets; + if(thumbnail_drawn) { + ThumbnailWidget thumbnail; + thumbnail.position = image.get_position(); + thumbnail.size = image.get_texture()->get_size().to_vec2f() * image.get_scale(); + widgets.thumbnail = std::move(thumbnail); + } + item->extra->draw_overlay(window, widgets); + } + const float text_padding = item_thumbnail ? card_image_text_padding : 0.0f; mgl::vec2f text_pos = mgl::vec2f(pos.x, scissor_y + body_spacing[body_theme].body_padding_vertical) + pos_offset + mgl::vec2f(card_padding_x, card_padding_y) + mgl::vec2f(0.0f, image_height + text_padding); diff --git a/src/BodyItem.cpp b/src/BodyItem.cpp index ebc3ead..0c6e1ce 100644 --- a/src/BodyItem.cpp +++ b/src/BodyItem.cpp @@ -1,9 +1,12 @@ #include "../include/BodyItem.hpp" #include "../include/Theme.hpp" #include "../include/Config.hpp" -#include namespace QuickMedia { + static float floor(float v) { + return (int)v; + } + // static std::shared_ptr BodyItem::create(std::string title, bool selectable) { return std::shared_ptr(new BodyItem(std::move(title), selectable)); @@ -78,7 +81,7 @@ namespace QuickMedia { void BodyItem::add_reaction(std::string text, void *userdata) { Reaction reaction; - reaction.text = std::make_unique(std::move(text), false, std::floor(get_config().body.reaction_font_size * get_config().scale * get_config().font_scale), 0.0f); + reaction.text = std::make_unique(std::move(text), false, floor(get_config().body.reaction_font_size * get_config().scale * get_config().font_scale), 0.0f); reaction.userdata = userdata; reactions.push_back(std::move(reaction)); } diff --git a/src/Config.cpp b/src/Config.cpp index e98f4c7..d6f826b 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -134,8 +134,12 @@ namespace QuickMedia { const Json::Value &local_manga_json = json_root["local_manga"]; if(local_manga_json.isObject()) { const Json::Value &directory_json = local_manga_json["directory"]; - if(directory_json.isString()) + if(directory_json.isString()) { config->local_manga.directory = directory_json.asString(); + while(config->local_manga.directory.size() > 1 && config->local_manga.directory.back() == '/') { + config->local_manga.directory.pop_back(); + } + } const Json::Value &sort_by_name_json = local_manga_json["sort_by_name"]; if(sort_by_name_json.isBool()) @@ -149,16 +153,16 @@ namespace QuickMedia { const Json::Value &local_anime_json = json_root["local_anime"]; if(local_anime_json.isObject()) { const Json::Value &directory_json = local_anime_json["directory"]; - if(directory_json.isString()) + if(directory_json.isString()) { config->local_anime.directory = directory_json.asString(); + while(config->local_anime.directory.size() > 1 && config->local_anime.directory.back() == '/') { + config->local_anime.directory.pop_back(); + } + } const Json::Value &sort_by_name_json = local_anime_json["sort_by_name"]; if(sort_by_name_json.isBool()) config->local_anime.sort_by_name = sort_by_name_json.asBool(); - - const Json::Value &sort_episodes_by_name_json = local_anime_json["sort_episodes_by_name"]; - if(sort_episodes_by_name_json.isBool()) - config->local_anime.sort_episodes_by_name = sort_episodes_by_name_json.asBool(); } const Json::Value &use_system_fonts_json = json_root["use_system_fonts"]; diff --git a/src/Entry.cpp b/src/Entry.cpp index b196009..c3ecb40 100644 --- a/src/Entry.cpp +++ b/src/Entry.cpp @@ -7,17 +7,20 @@ #include #include #include -#include namespace QuickMedia { - static const float background_margin_horizontal = std::floor(5.0f * get_config().scale * get_config().spacing_scale); - static const float padding_vertical = std::floor(5.0f * get_config().scale * get_config().spacing_scale); - static const float background_margin_vertical = std::floor(0.0f * get_config().scale * get_config().spacing_scale); + static float floor(float v) { + return (int)v; + } + + static const float background_margin_horizontal = floor(5.0f * get_config().scale * get_config().spacing_scale); + static const float padding_vertical = floor(5.0f * get_config().scale * get_config().spacing_scale); + static const float background_margin_vertical = floor(0.0f * get_config().scale * get_config().spacing_scale); Entry::Entry(const std::string &placeholder_text, mgl::Shader *rounded_rectangle_shader) : on_submit_callback(nullptr), draw_background(true), - text("", false, std::floor(get_config().input.font_size * get_config().scale * get_config().font_scale), 0.0f), + text("", false, floor(get_config().input.font_size * get_config().scale * get_config().font_scale), 0.0f), width(0.0f), background(mgl::vec2f(1.0f, 1.0f), 10.0f * get_config().scale, get_theme().selected_color, rounded_rectangle_shader), placeholder(placeholder_text, *FontLoader::get_font(FontLoader::FontType::LATIN, get_config().input.font_size * get_config().scale * get_config().font_scale)), @@ -107,7 +110,7 @@ namespace QuickMedia { void Entry::set_position(const mgl::vec2f &pos) { background.set_position(pos); text.set_position(pos + mgl::vec2f(background_margin_horizontal, background_margin_vertical)); - placeholder.set_position(pos + mgl::vec2f(background_margin_horizontal, background_margin_vertical + std::floor(-1.0f * get_config().scale))); + placeholder.set_position(pos + mgl::vec2f(background_margin_horizontal, background_margin_vertical + floor(-1.0f * get_config().scale))); } void Entry::set_max_width(float width) { @@ -121,7 +124,7 @@ namespace QuickMedia { float Entry::get_height() { text.updateGeometry(); - return std::floor(text.getHeight() + background_margin_vertical * 2.0f + padding_vertical * 2.0f); + return floor(text.getHeight() + background_margin_vertical * 2.0f + padding_vertical * 2.0f); } const std::string& Entry::get_text() const { diff --git a/src/ImageViewer.cpp b/src/ImageViewer.cpp index 2b40c1f..d00c375 100644 --- a/src/ImageViewer.cpp +++ b/src/ImageViewer.cpp @@ -5,12 +5,12 @@ #include "../include/Scale.hpp" #include "../include/Config.hpp" #include -#include #include #include #include #include #include +#include namespace QuickMedia { static const int page_text_character_size = 14 * get_config().scale * get_config().font_scale; @@ -88,7 +88,7 @@ namespace QuickMedia { std::shared_ptr &page_image_data = image_data[page]; const mgl::vec2d image_size = get_page_size(page); - mgl::vec2d render_pos(std::floor(window_size.x * 0.5 - image_size.x * 0.5), scroll + offset_y); + mgl::vec2d render_pos(floor(window_size.x * 0.5 - image_size.x * 0.5), scroll + offset_y); if(render_pos.y + image_size.y <= 0.0 || render_pos.y >= window_size.y) { if(page_image_data) page_image_data->visible_on_screen = false; @@ -97,7 +97,7 @@ namespace QuickMedia { bool scrolling = (std::abs(scroll_speed) > 0.01f); if(!scrolling) - render_pos.y = std::floor(render_pos.y); + render_pos.y = floor(render_pos.y); double top_dist = std::abs(0.0 - render_pos.y); if(top_dist < min_page_top_dist) { @@ -141,10 +141,10 @@ namespace QuickMedia { mgl::Text error_message(std::move(msg), *FontLoader::get_font(FontLoader::FontType::LATIN, 30 * get_config().scale * get_config().font_scale)); auto text_bounds = error_message.get_bounds(); error_message.set_color(mgl::Color(0, 0, 0, 255)); - mgl::vec2d render_pos_text(std::floor(window_size.x * 0.5 - text_bounds.size.x * 0.5), image_size.y * 0.5 - text_bounds.size.y * 0.5 + scroll + offset_y); + mgl::vec2d render_pos_text(floor(window_size.x * 0.5 - text_bounds.size.x * 0.5), image_size.y * 0.5 - text_bounds.size.y * 0.5 + scroll + offset_y); if(!scrolling) - render_pos_text.y = std::floor(render_pos_text.y); + render_pos_text.y = floor(render_pos_text.y); mgl::Rectangle background(mgl::vec2f(image_size.x, image_size.y)); background.set_color(mgl::Color(255, 255, 255, 255)); @@ -160,10 +160,10 @@ namespace QuickMedia { mgl::Text error_message("Downloading page " + page_str, *FontLoader::get_font(FontLoader::FontType::LATIN, 30 * get_config().scale * get_config().font_scale)); auto text_bounds = error_message.get_bounds(); error_message.set_color(mgl::Color(0, 0, 0, 255)); - mgl::vec2d render_pos_text(std::floor(window_size.x * 0.5 - text_bounds.size.x * 0.5), image_size.y * 0.5 - text_bounds.size.y * 0.5 + scroll + offset_y); + mgl::vec2d render_pos_text(floor(window_size.x * 0.5 - text_bounds.size.x * 0.5), image_size.y * 0.5 - text_bounds.size.y * 0.5 + scroll + offset_y); if(!scrolling) - render_pos_text.y = std::floor(render_pos_text.y); + render_pos_text.y = floor(render_pos_text.y); mgl::Rectangle background(mgl::vec2f(image_size.x, image_size.y)); background.set_color(mgl::Color(255, 255, 255, 255)); @@ -367,7 +367,7 @@ namespace QuickMedia { window->draw(page_text_background); auto page_text_bounds = page_text.get_bounds(); - page_text.set_position(mgl::vec2f(std::floor(window_size.x * 0.5f - page_text_bounds.size.x * 0.5f), std::floor(window_size.y - background_height * 0.5f - font_height * 0.7f))); + page_text.set_position(mgl::vec2f(floor(window_size.x * 0.5f - page_text_bounds.size.x * 0.5f), floor(window_size.y - background_height * 0.5f - font_height * 0.7f))); window->draw(page_text); // Free pages that are not visible on the screen diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 99e8f1b..33bab93 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -2,6 +2,7 @@ #include "../plugins/Manganelo.hpp" #include "../plugins/Mangadex.hpp" #include "../plugins/LocalManga.hpp" +#include "../plugins/LocalAnime.hpp" #include "../plugins/MangaGeneric.hpp" #include "../plugins/MangaCombined.hpp" #include "../plugins/MediaGeneric.hpp" @@ -44,7 +45,6 @@ #include "../external/hash-library/sha256.h" #include -#include #include #include #include @@ -52,6 +52,7 @@ #include #include #include +#include #include #include @@ -82,6 +83,7 @@ static const std::pair valid_plugins[] = { std::make_pair("onimanga", nullptr), std::make_pair("readm", "readm_logo.png"), std::make_pair("local-manga", nullptr), + std::make_pair("local-anime", nullptr), std::make_pair("manga", nullptr), std::make_pair("youtube", "yt_logo_rgb_dark_small.png"), std::make_pair("peertube", "peertube_logo.png"), @@ -283,7 +285,7 @@ namespace QuickMedia { static void usage() { fprintf(stderr, "usage: quickmedia [plugin] [--no-video] [--dir ] [-e ] [youtube-url]\n"); fprintf(stderr, "OPTIONS:\n"); - fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, local-manga, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager, stdin, pornhub, spankbang, xvideos or xhamster\n"); + fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, local-manga, local-anime, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager, stdin, pornhub, spankbang, xvideos or xhamster\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default\n"); fprintf(stderr, " --upscale-images-always Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default\n"); @@ -983,6 +985,7 @@ namespace QuickMedia { create_launcher_body_item("AniList", "anilist", resources_root + "images/anilist_logo.png"), create_launcher_body_item("Hot Examples", "hotexamples", ""), create_launcher_body_item("Lbry", "lbry", resources_root + "icons/lbry_launcher.png"), + create_launcher_body_item("Local anime", "local-anime", ""), create_launcher_body_item("Local manga", "local-manga", ""), create_launcher_body_item("Manga (all)", "manga", ""), create_launcher_body_item("Mangadex", "mangadex", resources_root + "icons/mangadex_launcher.png"), @@ -1079,13 +1082,28 @@ namespace QuickMedia { } else if(strcmp(plugin_name, "local-manga") == 0) { auto search_page = std::make_unique(this, true); - tabs.push_back(Tab{create_body(), std::make_unique(this, search_page.get(), true), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); - tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + tabs.push_back(Tab{create_body(false, true), std::make_unique(this, search_page.get(), true), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); auto history_page = std::make_unique(this, tabs.back().page.get(), HistoryType::MANGA, true); - tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + tabs.push_back(Tab{create_body(false, true), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; + } else if(strcmp(plugin_name, "local-anime") == 0) { + if(get_config().local_anime.directory.empty()) { + show_notification("QuickMedia", "local_anime.directory config is not set", Urgency::CRITICAL); + exit(1); + } + + if(get_file_type(get_config().local_anime.directory) != FileType::DIRECTORY) { + show_notification("QuickMedia", "local_anime.directory config is not set to a valid directory", Urgency::CRITICAL); + exit(1); + } + + auto search_page = std::make_unique(this, get_config().local_anime.directory, LocalAnimeSearchPageType::DIRECTORY); + tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + + start_tab_index = 0; } else if(strcmp(plugin_name, "manga") == 0) { auto mangadex = std::make_unique(this); @@ -2684,6 +2702,9 @@ namespace QuickMedia { PageType previous_page = pop_page_stack(); bool video_loaded = false; + double video_time_pos = 0.0; // Time in media in seconds. Updates every 5 seconds and when starting to watch the video and when seeking. + bool update_time_pos = false; + mgl::Clock video_time_pos_clock; std::string youtube_video_id_dummy; const bool is_youtube = youtube_url_extract_id(video_page->get_url(), youtube_video_id_dummy); @@ -2700,7 +2721,7 @@ namespace QuickMedia { } mgl::WindowHandle video_player_window = None; - auto on_window_create = [this, &video_player_window, &video_page](mgl::WindowHandle _video_player_window) mutable { + auto on_window_create = [&](mgl::WindowHandle _video_player_window) mutable { video_player_window = _video_player_window; XSelectInput(disp, video_player_window, KeyPressMask | PointerMotionMask); XSync(disp, False); @@ -2709,6 +2730,9 @@ namespace QuickMedia { video_page->get_subtitles(subtitle_data); if(!subtitle_data.url.empty()) video_player->add_subtitle(subtitle_data.url, subtitle_data.title, "eng"); + + if(video_page->is_local()) + update_time_pos = true; }; std::unique_ptr youtube_video_media_proxy; @@ -2897,12 +2921,13 @@ namespace QuickMedia { startup_args.audio_path = a; startup_args.parent_window = window.get_system_handle(); startup_args.no_video = is_audio_only; - startup_args.use_system_mpv_config = get_config().use_system_mpv_config; - startup_args.use_system_input_config = false; + startup_args.use_system_mpv_config = get_config().use_system_mpv_config || video_page->is_local(); + startup_args.use_system_input_config = video_page->is_local(); startup_args.keep_open = is_matrix && !is_youtube; + startup_args.resume = false; startup_args.resource_root = resources_root; startup_args.monitor_height = video_max_height; - startup_args.use_youtube_dl = use_youtube_dl; + startup_args.use_youtube_dl = use_youtube_dl && !video_page->is_local(); startup_args.title = video_title; startup_args.start_time = start_time; startup_args.chapters = std::move(media_chapters); @@ -2959,7 +2984,7 @@ namespace QuickMedia { } }; - video_event_callback = [&video_loaded](const char *event_name) mutable { + video_event_callback = [&](const char *event_name) mutable { 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) @@ -2968,10 +2993,15 @@ namespace QuickMedia { //video_player->set_paused(false); } else if(strcmp(event_name, "start-file") == 0) { video_loaded = true; + if(video_page->is_local()) + update_time_pos = true; } else if(strcmp(event_name, "file-loaded") == 0) { video_loaded = true; } else if(strcmp(event_name, "video-reconfig") == 0 || strcmp(event_name, "audio-reconfig") == 0) { video_loaded = true; + } else if(strcmp(event_name, "seek") == 0) { + if(video_page->is_local()) + update_time_pos = true; } //fprintf(stderr, "event name: %s\n", event_name); @@ -3018,9 +3048,9 @@ namespace QuickMedia { } } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::F && event.key.control) { window_set_fullscreen(disp, window.get_system_handle(), WindowFullscreenState::TOGGLE); - } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::C && event.key.control) { + } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::C && event.key.control && !video_page->is_local()) { save_video_url_to_clipboard(); - } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::F5) { + } else if(event.type == mgl::Event::KeyPressed && event.key.code == mgl::Keyboard::F5 && !video_page->is_local()) { load_video_error_check(); } } @@ -3044,13 +3074,13 @@ namespace QuickMedia { } } else if(pressed_keysym == XK_f && pressing_ctrl) { window_set_fullscreen(disp, window.get_system_handle(), WindowFullscreenState::TOGGLE); - } else if(pressed_keysym == XK_s && pressing_ctrl) { + } else if(pressed_keysym == XK_s && pressing_ctrl && !video_page->is_local()) { video_page_download_video(video_page->get_download_url(video_get_max_height()), video_player_window); - } else if(pressed_keysym == XK_F5) { + } else if(pressed_keysym == XK_F5 && !video_page->is_local()) { double resume_start_time = 0.0; video_player->get_time_in_file(&resume_start_time); load_video_error_check(std::to_string((int)resume_start_time)); - } else if(pressed_keysym == XK_r && pressing_ctrl) { + } else if(pressed_keysym == XK_r && pressing_ctrl && !video_page->is_local()) { bool cancelled = false; if(video_tasks.valid()) { XUnmapWindow(disp, video_player_window); @@ -3201,7 +3231,8 @@ namespace QuickMedia { // If there are no videos to play, then dont play any... if(new_video_url.empty()) { - show_notification("QuickMedia", "No more related videos to play"); + if(!video_page->is_local()) + show_notification("QuickMedia", "No more related videos to play"); current_page = previous_page; go_to_previous_page = true; break; @@ -3250,6 +3281,18 @@ namespace QuickMedia { continue; } + if(video_player) { + if(video_time_pos_clock.get_elapsed_time_seconds() >= 5.0) { + video_time_pos_clock.restart(); + update_time_pos = true; + } + + if(update_time_pos) { + update_time_pos = false; + video_player->get_time_in_file(&video_time_pos); + } + } + if(video_player_window) { if(!cursor_visible) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); @@ -3272,6 +3315,9 @@ namespace QuickMedia { auto window_size_u = window.get_size(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; + + if(video_page->is_local()) + video_page->set_watch_progress(video_time_pos); } void Program::select_episode(BodyItem *item, bool start_from_beginning) { diff --git a/src/SearchBar.cpp b/src/SearchBar.cpp index 6d790ad..71e662e 100644 --- a/src/SearchBar.cpp +++ b/src/SearchBar.cpp @@ -8,16 +8,19 @@ #include #include #include -#include #include // TODO: Use a seperate placeholder mgl::Text instead of switching the text to placeholder text.... namespace QuickMedia { - static const float background_margin_horizontal = std::floor(15.0f * get_config().scale * get_config().spacing_scale); - static const float padding_top_default = std::floor(10.0f * get_config().scale * get_config().spacing_scale); - static const float padding_bottom_default = std::floor(15.0f * get_config().scale * get_config().spacing_scale); - static const float background_margin_vertical = std::floor(4.0f * get_config().scale * get_config().spacing_scale); + static float floor(float v) { + return (int)v; + } + + static const float background_margin_horizontal = floor(15.0f * get_config().scale); + static const float padding_top_default = floor(10.0f * get_config().scale * get_config().spacing_scale); + static const float padding_bottom_default = floor(15.0f * get_config().scale * get_config().spacing_scale); + static const float background_margin_vertical = floor(4.0f * get_config().scale * get_config().spacing_scale); static const int character_size = get_config().search.font_size * get_config().scale * get_config().font_scale; SearchBar::SearchBar(mgl::Texture *plugin_logo, mgl::Shader *rounded_rectangle_shader, const std::string &placeholder, bool input_masked) : @@ -73,7 +76,7 @@ namespace QuickMedia { } else { window.draw(text); if(show_placeholder || text.get_string().empty()) - caret.set_position(text.get_position() - mgl::vec2f(2.0f, 0.0f) + mgl::vec2f(0.0f, character_size * 0.4f)); + caret.set_position(text.get_position() + mgl::vec2f(0.0f, character_size * 0.4f)); else caret.set_position(text.find_character_pos(text.get_string().size()) + mgl::vec2f(0.0f, character_size * 0.4f)); } @@ -156,30 +159,30 @@ namespace QuickMedia { draw_logo = false; float font_height = character_size + 7.0f; - float rect_height = std::floor(font_height + background_margin_vertical * 2.0f); + float rect_height = floor(font_height + background_margin_vertical * 2.0f); float offset_x; if(draw_logo) { - float one_line_height = std::floor(character_size + 8.0f + background_margin_vertical * 2.0f); + float one_line_height = floor(character_size + 8.0f + background_margin_vertical * 2.0f); auto texture_size = plugin_logo_sprite.get_texture()->get_size(); mgl::vec2f texture_size_f(texture_size.x, texture_size.y); mgl::vec2f new_size = wrap_to_size(texture_size_f, mgl::vec2f(200.0f, one_line_height)); plugin_logo_sprite.set_scale(get_ratio(texture_size_f, new_size)); plugin_logo_sprite.set_position(mgl::vec2f(pos.x + padding_x, pos.y + padding_top + rect_height * 0.5f - plugin_logo_sprite.get_texture()->get_size().y * plugin_logo_sprite.get_scale().y * 0.5f)); - offset_x = padding_x + new_size.x + std::floor(10.0f * get_config().spacing_scale); + offset_x = padding_x + new_size.x + floor(10.0f * get_config().spacing_scale); } else { offset_x = padding_x; } - const float width = std::floor(size.x - offset_x - padding_x); + const float width = floor(size.x - offset_x - padding_x); background.set_size(mgl::vec2f(width, rect_height)); shade.set_size(mgl::vec2f(size.x, padding_top + rect_height + padding_bottom)); - caret.set_size(vec2f_floor(2.0f * get_config().scale, character_size + std::floor(2.0f * get_config().scale))); + caret.set_size(vec2f_floor(2.0f * get_config().scale, character_size + floor(2.0f * get_config().scale))); background.set_position(mgl::vec2f(pos.x + offset_x, pos.y + padding_top)); shade.set_position(pos); - mgl::vec2f font_position(std::floor(pos.x + offset_x + background_margin_horizontal), std::floor(pos.y + padding_top + background_margin_vertical - character_size * 0.3f)); + mgl::vec2f font_position(floor(pos.x + offset_x + background_margin_horizontal), floor(pos.y + padding_top + background_margin_vertical - character_size * 0.3f)); text.set_position(font_position); } @@ -288,7 +291,7 @@ namespace QuickMedia { float SearchBar::getBottomWithoutShadow() const { float font_height = character_size + 7.0f; - return std::floor(font_height + background_margin_vertical * 2.0f + padding_top + padding_bottom); + return floor(font_height + background_margin_vertical * 2.0f + padding_top + padding_bottom); } std::string SearchBar::get_text() const { diff --git a/src/Tabs.cpp b/src/Tabs.cpp index 6b33df1..213e11e 100644 --- a/src/Tabs.cpp +++ b/src/Tabs.cpp @@ -8,13 +8,16 @@ #include #include #include -#include namespace QuickMedia { - static const float tab_text_size = std::floor(get_config().tab.font_size * get_config().scale * get_config().font_scale); - static const float tab_height = tab_text_size + std::floor(10.0f * get_config().scale); + static float floor(float v) { + return (int)v; + } + + static const float tab_text_size = floor(get_config().tab.font_size * get_config().scale * get_config().font_scale); + static const float tab_height = tab_text_size + floor(10.0f * get_config().scale); static const float tab_min_width = 250.0f; - static const float tab_margin_x = std::floor(10.0f * get_config().spacing_scale); + static const float tab_margin_x = floor(10.0f * get_config().spacing_scale); // static float Tabs::get_height() { @@ -23,7 +26,7 @@ namespace QuickMedia { // static float Tabs::get_shade_height() { - return tab_height + std::floor(10.0f * get_config().scale * get_config().spacing_scale); + return tab_height + floor(10.0f * get_config().scale * get_config().spacing_scale); } Tabs::Tabs(mgl::Shader *rounded_rectangle_shader, mgl::Color shade_color) : background(mgl::vec2f(1.0f, 1.0f), 10.0f * get_config().scale, get_theme().selected_color, rounded_rectangle_shader), shade_color(shade_color) { @@ -98,14 +101,14 @@ namespace QuickMedia { container_width = width; const int num_visible_tabs = std::min((int)tabs.size(), std::max(1, (int)(width / tab_min_width))); - width_per_tab = std::floor(width / num_visible_tabs); - const float tab_text_y = std::floor(pos.y + tab_height*0.5f - tab_text_size); - tab_background_width = std::floor(width_per_tab - tab_margin_x*2.0f); + width_per_tab = floor(width / num_visible_tabs); + const float tab_text_y = floor(pos.y + tab_height*0.5f - tab_text_size); + tab_background_width = floor(width_per_tab - tab_margin_x*2.0f); background.set_size(mgl::vec2f(tab_background_width, tab_height)); if(shade_color != mgl::Color(0, 0, 0, 0)) { shade.set_size(mgl::vec2f(width, get_shade_height())); - shade.set_position(mgl::vec2f(std::floor(pos.x), std::floor(pos.y))); + shade.set_position(mgl::vec2f(floor(pos.x), floor(pos.y))); window.draw(shade); } @@ -131,7 +134,7 @@ namespace QuickMedia { pos.x += scroll_fixed; for(size_t i = 0; i < tabs.size(); ++i) { const int index = i; - const float background_pos_x = std::floor(pos.x + tab_index_to_x_offset(i)); + const float background_pos_x = floor(pos.x + tab_index_to_x_offset(i)); if(background_pos_x - start_pos.x >= width - tab_margin_x) { tabs_cutoff_right = true; break; @@ -141,12 +144,12 @@ namespace QuickMedia { } if((int)index == selected_tab) { - background.set_position(mgl::vec2f(background_pos_x, std::floor(pos.y))); + background.set_position(mgl::vec2f(background_pos_x, floor(pos.y))); background.draw(window); } mgl::Text &tab_text = tabs[index].text; - float text_pos_x = std::floor(pos.x + i*width_per_tab + width_per_tab*0.5f - tab_text.get_bounds().size.x*0.5f); + float text_pos_x = floor(pos.x + i*width_per_tab + width_per_tab*0.5f - tab_text.get_bounds().size.x*0.5f); text_pos_x = std::max(text_pos_x, background_pos_x); window.set_view(create_scissor_view({ text_pos_x, tab_text_y }, { tab_background_width, tab_height })); @@ -154,7 +157,7 @@ namespace QuickMedia { window.set_view(prev_view); } - const float lw = std::floor(25.0f * get_config().scale); + const float lw = floor(25.0f * get_config().scale); const float lh = background.get_size().y; if(tabs_cutoff_left) { @@ -224,6 +227,6 @@ namespace QuickMedia { } float Tabs::tab_index_to_x_offset(int index) { - return std::floor(index*width_per_tab + tab_margin_x); + return floor(index*width_per_tab + tab_margin_x); } } \ No newline at end of file diff --git a/src/Text.cpp b/src/Text.cpp index 53118b8..383256a 100644 --- a/src/Text.cpp +++ b/src/Text.cpp @@ -9,10 +9,17 @@ #include #include #include -#include namespace QuickMedia { + static float floor(float v) { + return (int)v; + } + + static float fabs(float v) { + return v >= 0.0 ? v : -v; + } + static const float TAB_WIDTH = 4.0f; static const float WORD_WRAP_MIN_SIZE = 80.0f; @@ -381,7 +388,7 @@ namespace QuickMedia } float Text::font_get_real_height(mgl::Font *font) { - return font->get_glyph('|').size.y + std::floor(4.0f * ((float)characterSize / (float)14.0f)); + return font->get_glyph('|').size.y + floor(4.0f * ((float)characterSize / (float)14.0f)); } float Text::get_text_quad_left_side(const VertexRef &vertex_ref) const { @@ -510,11 +517,11 @@ namespace QuickMedia int vertexStart = vertices[vertices_index].size(); EmojiRectangle emoji_rec = emoji_get_extents(codepoint); - const float font_height_offset = std::floor(-vspace * 0.2f); - mgl::vec2f vertexTopLeft(glyphPos.x, glyphPos.y + font_height_offset - std::floor(emoji_rec.height * emoji_scale) * 0.5f); - mgl::vec2f vertexTopRight(glyphPos.x + std::floor(emoji_rec.width * emoji_scale), glyphPos.y + font_height_offset - std::floor(emoji_rec.height * emoji_scale) * 0.5f); + const float font_height_offset = floor(-vspace * 0.2f); + mgl::vec2f vertexTopLeft(glyphPos.x, glyphPos.y + font_height_offset - floor(emoji_rec.height * emoji_scale) * 0.5f); + mgl::vec2f vertexTopRight(glyphPos.x + floor(emoji_rec.width * emoji_scale), glyphPos.y + font_height_offset - floor(emoji_rec.height * emoji_scale) * 0.5f); mgl::vec2f vertexBottomLeft(glyphPos.x, glyphPos.y + font_height_offset + emoji_rec.height * emoji_scale * 0.5f); - mgl::vec2f vertexBottomRight(glyphPos.x + std::floor(emoji_rec.width * emoji_scale), glyphPos.y + font_height_offset + std::floor(emoji_rec.height * emoji_scale) * 0.5f); + mgl::vec2f vertexBottomRight(glyphPos.x + floor(emoji_rec.width * emoji_scale), glyphPos.y + font_height_offset + floor(emoji_rec.height * emoji_scale) * 0.5f); vertexTopLeft = vec2f_floor(vertexTopLeft); vertexTopRight = vec2f_floor(vertexTopRight); @@ -533,7 +540,7 @@ namespace QuickMedia vertices[vertices_index].emplace_back(vertexBottomRight, textureBottomRight, emoji_color); vertices[vertices_index].emplace_back(vertexTopRight, textureTopRight, emoji_color); - glyphPos.x += std::floor(emoji_rec.width * emoji_scale) + characterSpacing; + glyphPos.x += floor(emoji_rec.width * emoji_scale) + characterSpacing; vertices_linear.push_back({vertices_index, vertexStart, 0, codepoint}); i += clen; @@ -1133,12 +1140,12 @@ namespace QuickMedia if(!editable) return true; pos.y -= floor(vspace * 2.0f); - const float caret_margin = std::floor(2.0f * get_config().scale); + const float caret_margin = floor(2.0f * get_config().scale); - mgl::Rectangle caretRect(mgl::vec2f(0.0f, 0.0f), mgl::vec2f(std::floor(2.0f * get_config().scale), floor(vspace - caret_margin * 2.0f))); + mgl::Rectangle caretRect(mgl::vec2f(0.0f, 0.0f), mgl::vec2f(floor(2.0f * get_config().scale), floor(vspace - caret_margin * 2.0f))); caretRect.set_position(mgl::vec2f( floor(pos.x + caretPosition.x), - floor(pos.y + caretPosition.y + caret_margin + std::floor(4.0f * get_config().scale)) + floor(pos.y + caretPosition.y + caret_margin + floor(4.0f * get_config().scale)) )); target.draw(caretRect); return true; diff --git a/src/VideoPlayer.cpp b/src/VideoPlayer.cpp index 4be0671..8fdf9dc 100644 --- a/src/VideoPlayer.cpp +++ b/src/VideoPlayer.cpp @@ -175,9 +175,7 @@ namespace QuickMedia { args.insert(args.end(), { video_player_filepath.c_str(), "--cursor-autohide=no", - "--save-position-on-quit=no", "--profile=pseudo-gui", // For gui when playing audio, requires a version of mpv that isn't ancient - "--resume-playback=no", // TODO: Disable hr seek on low power devices? "--hr-seek=yes", "--force-seekable=yes", @@ -199,6 +197,14 @@ namespace QuickMedia { ipc_fd.c_str() }); + if(startup_args.resume) { + args.push_back("--save-position-on-quit=yes"); + args.push_back("--resume-playback=yes"); + } else { + args.push_back("--save-position-on-quit=no"); + args.push_back("--resume-playback=no"); + } + if(!startup_args.use_system_input_config) args.push_back(input_conf.c_str()); @@ -226,7 +232,9 @@ namespace QuickMedia { if(get_file_type(mpris_path) == FileType::REGULAR) mpris_arg = "--scripts=" + mpris_path.data; - if(!startup_args.use_system_mpv_config) { + if(startup_args.use_system_mpv_config) { + args.push_back("--config=yes"); + } else { args.insert(args.end(), { "--config=no", "--profile=gpu-hq", diff --git a/src/gui/Button.cpp b/src/gui/Button.cpp index 13bc8c6..bf9c174 100644 --- a/src/gui/Button.cpp +++ b/src/gui/Button.cpp @@ -4,9 +4,12 @@ #include #include #include -#include namespace QuickMedia { + static float floor(float v) { + return (int)v; + } + static const float PADDING_Y = 5.0f; Button::Button(const std::string &label, mgl::Font *font, float width, mgl::Shader *rounded_rectangle_shader, float scale) : @@ -14,7 +17,7 @@ namespace QuickMedia { background(mgl::vec2f(1.0f, 1.0f), 10.0f * get_config().scale, get_theme().shade_color, rounded_rectangle_shader), scale(scale) { - background.set_size(mgl::vec2f(std::floor(width * scale), get_height())); + background.set_size(mgl::vec2f(floor(width * scale), get_height())); set_position(mgl::vec2f(0.0f, 0.0f)); } @@ -60,8 +63,8 @@ namespace QuickMedia { const auto label_bounds = label.get_bounds(); mgl::vec2f label_pos(pos + background.get_size() * 0.5f - label_bounds.size * 0.5f - mgl::vec2f(0.0f, 5.0f * scale)); - label_pos.x = std::floor(label_pos.x); - label_pos.y = std::floor(label_pos.y); + label_pos.x = floor(label_pos.x); + label_pos.y = floor(label_pos.y); label.set_position(label_pos); } @@ -74,6 +77,6 @@ namespace QuickMedia { } float Button::get_height() { - return std::floor((PADDING_Y * 2.0f) * scale + label.get_bounds().size.y); + return floor((PADDING_Y * 2.0f) * scale + label.get_bounds().size.y); } } \ No newline at end of file diff --git a/src/plugins/LocalAnime.cpp b/src/plugins/LocalAnime.cpp index 4bc296a..9b1205a 100644 --- a/src/plugins/LocalAnime.cpp +++ b/src/plugins/LocalAnime.cpp @@ -1,42 +1,408 @@ #include "../../plugins/LocalAnime.hpp" #include "../../include/Config.hpp" +#include "../../include/Theme.hpp" #include "../../include/Storage.hpp" #include "../../include/Notification.hpp" +#include "../../include/FileAnalyzer.hpp" +#include "../../include/ResourceLoader.hpp" +#include "../../include/StringUtils.hpp" +#include +#include +#include +#include namespace QuickMedia { - static bool validate_local_anime_dir_config_is_set() { - if(get_config().local_anime.directory.empty()) { - show_notification("QuickMedia", "local_anime.directory config is not set", Urgency::CRITICAL); - return false; + static const mgl::Color finished_watching_color = mgl::Color(43, 255, 47); + + static float floor(float v) { + return (int)v; + } + + class LocalAnimeBodyItemData : public BodyItemExtra { + public: + void draw_overlay(mgl::Window &render_target, const Widgets &widgets) override { + if(!std::holds_alternative(anime_item) || !widgets.thumbnail) + return; + + const int rect_height = 5; + const double watch_ratio = watch_progress.get_watch_ratio(); + + mgl::Rectangle watch_rect; + watch_rect.set_position({ widgets.thumbnail->position.x, widgets.thumbnail->position.y + widgets.thumbnail->size.y - rect_height }); + watch_rect.set_size({ floor(widgets.thumbnail->size.x * watch_ratio), rect_height }); + watch_rect.set_color(mgl::Color(255, 0, 0, 255)); + render_target.draw(watch_rect); + + mgl::Rectangle unwatch_rect; + unwatch_rect.set_position({ floor(widgets.thumbnail->position.x + widgets.thumbnail->size.x * watch_ratio), widgets.thumbnail->position.y + widgets.thumbnail->size.y - rect_height }); + unwatch_rect.set_size({ floor(widgets.thumbnail->size.x * (1.0 - watch_ratio)), rect_height }); + unwatch_rect.set_color(mgl::Color(255, 255, 255, 255)); + render_target.draw(unwatch_rect); + } + + LocalAnimeItem anime_item; + LocalAnimeWatchProgress watch_progress; + }; + + static std::vector get_episodes_in_directory(const Path &directory) { + std::vector episodes; + for_files_in_dir_sort_name(directory, [&episodes](const Path &filepath, FileType file_type) -> bool { + if(file_type != FileType::REGULAR || !is_video_ext(filepath.ext())) + return true; + + time_t modified_time_seconds; + if(!file_get_last_modified_time_seconds(filepath.data.c_str(), &modified_time_seconds)) + return true; + + episodes.push_back(LocalAnimeEpisode{ filepath, modified_time_seconds }); + return true; + }, FileSortDirection::DESC); + return episodes; + } + + static std::vector get_episodes_or_seasons_in_directory(const Path &directory) { + std::vector anime_items; + for_files_in_dir_sort_name(directory, [&](const Path &filepath, FileType file_type) -> bool { + time_t modified_time_seconds; + if(!file_get_last_modified_time_seconds(filepath.data.c_str(), &modified_time_seconds)) + return true; + + if(file_type == FileType::REGULAR) { + if(is_video_ext(filepath.ext())) + anime_items.push_back(LocalAnimeEpisode{ filepath, modified_time_seconds }); + return true; + } + + LocalAnimeSeason season; + season.path = filepath; + season.episodes = get_episodes_in_directory(filepath); + season.modified_time_seconds = modified_time_seconds; + if(season.episodes.empty()) + return true; + + anime_items.push_back(std::move(season)); + return true; + }, FileSortDirection::DESC); + return anime_items; + } + + static std::vector get_anime_in_directory(const Path &directory) { + std::vector anime_items; + auto callback = [&anime_items](const Path &filepath, FileType file_type) -> bool { + time_t modified_time_seconds; + if(!file_get_last_modified_time_seconds(filepath.data.c_str(), &modified_time_seconds)) + return true; + + if(file_type == FileType::REGULAR) { + if(is_video_ext(filepath.ext())) + anime_items.push_back(LocalAnimeEpisode{ filepath, modified_time_seconds }); + return true; + } + + LocalAnime anime; + anime.path = filepath; + anime.items = get_episodes_or_seasons_in_directory(filepath); + anime.modified_time_seconds = modified_time_seconds; + if(anime.items.empty()) + return true; + + anime_items.push_back(std::move(anime)); + return true; + }; + + if(get_config().local_anime.sort_by_name) + for_files_in_dir_sort_name(directory, std::move(callback), FileSortDirection::ASC); + else + for_files_in_dir_sort_last_modified(directory, std::move(callback)); + + return anime_items; + } + + static const LocalAnimeEpisode* get_latest_anime_item(const LocalAnimeItem &item) { + if(std::holds_alternative(item)) { + const LocalAnime &anime = std::get(item); + return get_latest_anime_item(anime.items.front()); + } else if(std::holds_alternative(item)) { + const LocalAnimeSeason &season = std::get(item); + return get_latest_anime_item(season.episodes.front()); + } else if(std::holds_alternative(item)) { + const LocalAnimeEpisode &episode = std::get(item); + return &episode; + } else { + return nullptr; + } + } + + static LocalAnimeWatchProgress get_watch_progress(const Json::Value &watched_json, const LocalAnimeItem &item) { + LocalAnimeWatchProgress progress; + Path latest_anime_path = get_latest_anime_item(item)->path; + + std::string filename_relative_to_anime_dir = latest_anime_path.data.substr(get_config().local_anime.directory.size() + 1); + const Json::Value *found_watched_item = watched_json.find( + filename_relative_to_anime_dir.data(), + filename_relative_to_anime_dir.data() + filename_relative_to_anime_dir.size()); + if(!found_watched_item || !found_watched_item->isObject()) + return progress; + + const Json::Value &time_json = (*found_watched_item)["time"]; + const Json::Value &duration_json = (*found_watched_item)["duration"]; + if(!time_json.isInt64() || !duration_json.isInt64() || duration_json.asInt64() == 0) + return progress; + + // We consider having watched the anime if the user stopped watching 90% in, because they might skip the ending theme/credits + progress.time = (double)time_json.asInt64(); + progress.duration = (double)duration_json.asInt64(); + return progress; + } + + enum class WatchedStatus { + WATCHED, + NOT_WATCHED + }; + + static bool toggle_watched_save_to_file(const Path &filepath, WatchedStatus &watched_status) { + Path local_anime_progress_path = get_storage_dir().join("watch-progress").join("local-anime"); + + Json::Value json_root; + if(!read_file_as_json(local_anime_progress_path, json_root) || !json_root.isObject()) + json_root = Json::Value(Json::objectValue); + + bool watched = false; + std::string filename_relative_to_anime_dir = filepath.data.substr(get_config().local_anime.directory.size() + 1); + Json::Value &watched_item = json_root[filename_relative_to_anime_dir]; + if(watched_item.isObject()) { + watched = true; + } else { + watched_item = Json::Value(Json::objectValue); + watched = false; + } + + if(watched) { + json_root.removeMember(filename_relative_to_anime_dir.c_str()); + } else { + FileAnalyzer file_analyzer; + if(!file_analyzer.load_file(filepath.data.c_str(), true)) { + show_notification("QuickMedia", "Failed to mark " + filename_relative_to_anime_dir + " as watched", Urgency::CRITICAL); + return false; + } + + if(!file_analyzer.get_duration_seconds() || *file_analyzer.get_duration_seconds() == 0) { + show_notification("QuickMedia", "Failed to mark " + filename_relative_to_anime_dir + " as watched", Urgency::CRITICAL); + return false; + } + + watched_item["time"] = (int64_t)*file_analyzer.get_duration_seconds(); + watched_item["duration"] = (int64_t)*file_analyzer.get_duration_seconds(); + watched_item["thumbnail_url"] = filename_relative_to_anime_dir; + watched_item["timestamp"] = (int64_t)time(nullptr); } - if(get_file_type(get_config().local_anime.directory) != FileType::DIRECTORY) { - show_notification("QuickMedia", "local_anime.directory config is not set to a valid directory", Urgency::CRITICAL); + if(!save_json_to_file_atomic(local_anime_progress_path, json_root)) { + show_notification("QuickMedia", "Failed to mark " + filename_relative_to_anime_dir + " as " + (watched ? "not watched" : "watched"), Urgency::CRITICAL); return false; } + watched_status = watched ? WatchedStatus::NOT_WATCHED : WatchedStatus::WATCHED; return true; } + double LocalAnimeWatchProgress::get_watch_ratio() const { + if(duration == 0.0) + return 0.0; + return (double)time / (double)duration; + } + + // We consider having watched the anime if the user stopped watching 90% in, because they might skip the ending theme/credits + bool LocalAnimeWatchProgress::has_finished_watching() const { + return get_watch_ratio() >= 0.9; + } + PluginResult LocalAnimeSearchPage::submit(const SubmitArgs &args, std::vector &result_tabs) { - if(!validate_local_anime_dir_config_is_set()) + LocalAnimeBodyItemData *item_data = static_cast(args.extra.get()); + if(std::holds_alternative(item_data->anime_item)) { + const LocalAnime &anime = std::get(item_data->anime_item); + result_tabs.push_back(Tab{ create_body(), std::make_unique(program, anime.path.data, LocalAnimeSearchPageType::ANIME), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); return PluginResult::OK; - + } else if(std::holds_alternative(item_data->anime_item)) { + const LocalAnimeSeason &season = std::get(item_data->anime_item); + result_tabs.push_back(Tab{ create_body(), std::make_unique(program, season.path.data, LocalAnimeSearchPageType::SEASON), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); + return PluginResult::OK; + } else if(std::holds_alternative(item_data->anime_item)) { + const LocalAnimeEpisode &episode = std::get(item_data->anime_item); + result_tabs.push_back(Tab{ nullptr, std::make_unique(program, episode.path.data, item_data->watch_progress), nullptr }); + return PluginResult::OK; + } return PluginResult::ERR; } PluginResult LocalAnimeSearchPage::lazy_fetch(BodyItems &result_items) { - if(!validate_local_anime_dir_config_is_set()) - return PluginResult::OK; + Json::Value json_root; + if(!read_file_as_json(get_storage_dir().join("watch-progress").join("local-anime"), json_root) || !json_root.isObject()) + json_root = Json::Value(Json::objectValue); - return PluginResult::ERR; + std::vector anime_items; + switch(type) { + case LocalAnimeSearchPageType::DIRECTORY: + anime_items = get_anime_in_directory(directory); + break; + case LocalAnimeSearchPageType::ANIME: + anime_items = get_episodes_or_seasons_in_directory(directory); + break; + case LocalAnimeSearchPageType::SEASON: + anime_items = get_episodes_in_directory(directory); + break; + } + + const time_t time_now = time(nullptr); + for(LocalAnimeItem &anime_item : anime_items) { + auto body_item_data = std::make_shared(); + body_item_data->watch_progress = get_watch_progress(json_root, anime_item); + const bool has_finished_watching = body_item_data->watch_progress.has_finished_watching(); + + if(std::holds_alternative(anime_item)) { + const LocalAnime &anime = std::get(anime_item); + + std::string title; + if(has_finished_watching) + title = "[Finished watching] "; + title += anime.path.filename(); + + auto body_item = BodyItem::create(std::move(title)); + if(has_finished_watching) + body_item->set_title_color(finished_watching_color); + + body_item->set_description("Updated " + seconds_to_relative_time_str(time_now - anime.modified_time_seconds)); + body_item->set_description_color(get_theme().faded_text_color); + + body_item->url = anime.path.data; + + body_item_data->anime_item = std::move(anime_item); + body_item->extra = std::move(body_item_data); + result_items.push_back(std::move(body_item)); + } else if(std::holds_alternative(anime_item)) { + const LocalAnimeSeason &season = std::get(anime_item); + + std::string title; + if(has_finished_watching) + title = "[Finished watching] "; + title += season.path.filename(); + + auto body_item = BodyItem::create(std::move(title)); + if(has_finished_watching) + body_item->set_title_color(finished_watching_color); + + body_item->set_description("Updated " + seconds_to_relative_time_str(time_now - season.modified_time_seconds)); + body_item->set_description_color(get_theme().faded_text_color); + + body_item->url = season.path.data; + + body_item_data->anime_item = std::move(anime_item); + body_item->extra = std::move(body_item_data); + result_items.push_back(std::move(body_item)); + } else if(std::holds_alternative(anime_item)) { + const LocalAnimeEpisode &episode = std::get(anime_item); + + std::string title; + if(has_finished_watching) + title = "[Finished watching] "; + title += episode.path.filename(); + + auto body_item = BodyItem::create(std::move(title)); + if(has_finished_watching) + body_item->set_title_color(finished_watching_color); + + body_item->set_description("Updated " + seconds_to_relative_time_str(time_now - episode.modified_time_seconds)); + body_item->set_description_color(get_theme().faded_text_color); + + body_item->url = episode.path.data; + body_item->thumbnail_is_local = true; + body_item->thumbnail_url = episode.path.data; + + body_item_data->anime_item = std::move(anime_item); + body_item->extra = std::move(body_item_data); + result_items.push_back(std::move(body_item)); + } + } + + return PluginResult::OK; } std::shared_ptr LocalAnimeSearchPage::get_bookmark_body_item(BodyItem *selected_item) { - return nullptr; + if(!selected_item) + return nullptr; + + std::string filename_relative_to_anime_dir = selected_item->url.substr(get_config().local_anime.directory.size() + 1); + auto body_item = BodyItem::create(filename_relative_to_anime_dir); + body_item->url = filename_relative_to_anime_dir; + body_item->thumbnail_url = selected_item->thumbnail_url; + return body_item; } void LocalAnimeSearchPage::toggle_read(BodyItem *selected_item) { - // TODO: + if(!selected_item) + return; + + LocalAnimeBodyItemData *item_data = static_cast(selected_item->extra.get()); + WatchedStatus watch_status; + if(!toggle_watched_save_to_file(get_latest_anime_item(item_data->anime_item)->path, watch_status)) + return; + + mgl::Color color = get_theme().text_color; + std::string title; + if(watch_status == WatchedStatus::WATCHED) { + title = "[Finished watching] "; + color = finished_watching_color; + } + title += Path(selected_item->url).filename(); + + selected_item->set_title(std::move(title)); + selected_item->set_title_color(color); + } + + std::string LocalAnimeVideoPage::get_video_url(int, bool &has_embedded_audio, std::string &ext) { + ext = Path(url).ext(); + has_embedded_audio = true; + return url; + } + + std::string LocalAnimeVideoPage::get_url_timestamp() { + // If we are very close to the end then start from the beginning. + // This is the same behavior as mpv. + // This is better because we dont want the video player to stop immediately after we start playing and we dont get any chance to seek. + if(watch_progress.time + 10.0 >= watch_progress.duration) + return "0"; + else + return std::to_string(watch_progress.time); + } + + void LocalAnimeVideoPage::set_watch_progress(double time_pos_sec) { + std::string filename_relative_to_anime_dir = url.substr(get_config().local_anime.directory.size() + 1); + + Path watch_progress_dir = get_storage_dir().join("watch-progress"); + if(create_directory_recursive(watch_progress_dir) != 0) { + show_notification("QuickMedia", "Failed to create " + watch_progress_dir.data + " to set watch progress for " + filename_relative_to_anime_dir, Urgency::CRITICAL); + return; + } + + Path local_anime_progress_path = watch_progress_dir; + local_anime_progress_path.join("local-anime"); + + Json::Value json_root; + if(!read_file_as_json(local_anime_progress_path, json_root) || !json_root.isObject()) + json_root = Json::Value(Json::objectValue); + + Json::Value watch_progress_json(Json::objectValue); + watch_progress_json["time"] = (int64_t)time_pos_sec; + watch_progress_json["duration"] = (int64_t)watch_progress.duration; + watch_progress_json["thumbnail_url"] = filename_relative_to_anime_dir; + watch_progress_json["timestamp"] = (int64_t)time(nullptr); + json_root[filename_relative_to_anime_dir] = std::move(watch_progress_json); + + if(!save_json_to_file_atomic(local_anime_progress_path, json_root)) { + show_notification("QuickMedia", "Failed to set watch progress for " + filename_relative_to_anime_dir, Urgency::CRITICAL); + return; + } + + fprintf(stderr, "Set watch progress for \"%s\" to %d/%d\n", filename_relative_to_anime_dir.c_str(), (int)time_pos_sec, (int)watch_progress.duration); } } \ No newline at end of file diff --git a/src/plugins/LocalManga.cpp b/src/plugins/LocalManga.cpp index 4367401..3fc7269 100644 --- a/src/plugins/LocalManga.cpp +++ b/src/plugins/LocalManga.cpp @@ -9,6 +9,8 @@ #include #include +// TODO: Make thumbnail paths in history and thumbnail-link relative to local_manga.directory + namespace QuickMedia { // This is needed because the manga may be stored on NFS. // TODO: Remove once body items can async load when visible on screen @@ -418,47 +420,6 @@ namespace QuickMedia { selected_item->set_title_color(color); } - static std::unordered_set get_lines_in_file(const Path &filepath) { - std::unordered_set lines; - - std::string file_content; - if(file_get_content(filepath, file_content) != 0) - return lines; - - string_split(file_content, '\n', [&lines](const char *str_part, size_t size) { - lines.insert(std::string(str_part, size)); - return true; - }); - - return lines; - } - - static bool append_seen_manga_to_automedia_seen(const std::string &manga_chapter_name) { - Path automedia_config_dir = get_home_dir().join(".config").join("automedia"); - if(create_directory_recursive(automedia_config_dir) != 0) { - fprintf(stderr, "Warning: failed to create directory: %s\n", automedia_config_dir.data.c_str()); - return false; - } - - Path automedia_seen_filepath = automedia_config_dir; - automedia_seen_filepath.join("seen"); - - std::unordered_set lines = get_lines_in_file(automedia_seen_filepath); - if(lines.find(manga_chapter_name) != lines.end()) - return true; // Already seen - - FILE *file = fopen(automedia_seen_filepath.data.c_str(), "ab"); - if(!file) { - fprintf(stderr, "Warning: failed to open automedia seen file %s\n", automedia_seen_filepath.data.c_str()); - return false; - } - - std::string new_line_data = manga_chapter_name + "\n"; - fwrite(new_line_data.data(), 1, new_line_data.size(), file); - fclose(file); - return true; - } - PluginResult LocalMangaChaptersPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(!validate_local_manga_dir_config_is_set()) return PluginResult::OK; @@ -484,9 +445,6 @@ namespace QuickMedia { chapter_image_urls.push_back(local_manga_page.path.data); } - if(is_program_executable_by_name("automedia")) - append_seen_manga_to_automedia_seen(manga_name + "/" + url); - num_images = chapter_image_urls.size(); return ImageResult::OK; } diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp index 367c777..144bbd8 100644 --- a/src/plugins/Matrix.cpp +++ b/src/plugins/Matrix.cpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include diff --git a/src/plugins/Page.cpp b/src/plugins/Page.cpp index c2e8060..654c983 100644 --- a/src/plugins/Page.cpp +++ b/src/plugins/Page.cpp @@ -123,7 +123,7 @@ namespace QuickMedia { if(thumbnail_url_json.isString()) { body_item->thumbnail_url = thumbnail_url_json.asString(); - body_item->thumbnail_size = {101, 141}; + body_item->thumbnail_size = thumbnail_size; body_item->thumbnail_is_local = local_thumbnail; } -- cgit v1.2.3