#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 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(!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) { 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) { 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); 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) { 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) { 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); } }