diff --git a/fastanime/cli/interactive/menu/media/media_actions.py b/fastanime/cli/interactive/menu/media/media_actions.py index 3e9e1f3..923748f 100644 --- a/fastanime/cli/interactive/menu/media/media_actions.py +++ b/fastanime/cli/interactive/menu/media/media_actions.py @@ -54,12 +54,12 @@ def media_actions(ctx: Context, state: State) -> State | InternalDirective: ctx, state ) options[f"{'💿 ' if icons else ''}Episodes (Downloads)"] = _stream_downloads( - ctx, state + ctx, state, force_episodes_menu=True ) options.update( { - f"{'📥 ' if icons else ''}Download": _queue_downloads(ctx, state), + f"{'📥 ' if icons else ''}Download": _download_episodes(ctx, state), f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer(ctx, state), f"{'🔗 ' if icons else ''}Recommendations": _view_recommendations( ctx, state @@ -144,14 +144,18 @@ def _stream(ctx: Context, state: State, force_episodes_menu=False) -> MenuAction return action -def _stream_downloads(ctx: Context, state: State) -> MenuAction: +def _stream_downloads( + ctx: Context, state: State, force_episodes_menu=False +) -> MenuAction: def action(): + if force_episodes_menu: + ctx.switch.force_episodes_menu() return State(menu_name=MenuName.PLAY_DOWNLOADS, media_api=state.media_api) return action -def _queue_downloads(ctx: Context, state: State) -> MenuAction: +def _download_episodes(ctx: Context, state: State) -> MenuAction: def action(): return State(menu_name=MenuName.DOWNLOAD_EPISODES, media_api=state.media_api) diff --git a/fastanime/cli/interactive/menu/media/play_downloads.py b/fastanime/cli/interactive/menu/media/play_downloads.py index d3b15ef..e2021f4 100644 --- a/fastanime/cli/interactive/menu/media/play_downloads.py +++ b/fastanime/cli/interactive/menu/media/play_downloads.py @@ -1,7 +1,11 @@ +from typing import Callable, Dict, Literal, Union + from .....libs.player.params import PlayerParams from ....service.registry.models import DownloadStatus from ...session import Context, session -from ...state import InternalDirective, State +from ...state import InternalDirective, MenuName, State + +MenuAction = Callable[[], Union[State, InternalDirective]] @session.menu @@ -9,6 +13,7 @@ def play_downloads(ctx: Context, state: State) -> State | InternalDirective: """Menu to select and play locally downloaded episodes.""" feedback = ctx.feedback media_item = state.media_api.media_item + current_episode_num = state.provider.episode if not media_item: feedback.error("No media item selected.") return InternalDirective.BACK @@ -30,27 +35,293 @@ def play_downloads(ctx: Context, state: State) -> State | InternalDirective: feedback.warning("No complete downloaded episodes found.") return InternalDirective.BACK - choices = list(downloaded_episodes.keys()) + ["Back"] - chosen_episode = ctx.selector.choose("Select a downloaded episode to play", choices) + chosen_episode: str | None = current_episode_num + start_time: str | None = None + + if not chosen_episode and ctx.config.stream.continue_from_watch_history: + _chosen_episode, _start_time = ctx.watch_history.get_episode(media_item) + if _chosen_episode in downloaded_episodes: + chosen_episode = _chosen_episode + start_time = _start_time + + if not chosen_episode or ctx.switch.show_episodes_menu: + choices = [*list(sorted(downloaded_episodes.keys(), key=float)), "Back"] + + preview_command = None + if ctx.config.general.preview != "none": + from ....utils.preview import create_preview_context + + with create_preview_context() as preview_ctx: + preview_command = preview_ctx.get_episode_preview( + list(downloaded_episodes.keys()), media_item, ctx.config + ) + + chosen_episode_str = ctx.selector.choose( + prompt="Select Episode", choices=choices, preview=preview_command + ) + + if not chosen_episode_str or chosen_episode_str == "Back": + return InternalDirective.BACK + + chosen_episode = chosen_episode_str + # Workers are automatically cleaned up when exiting the context + else: + # No preview mode + chosen_episode_str = ctx.selector.choose( + prompt="Select Episode", choices=choices, preview=None + ) + + if not chosen_episode_str or chosen_episode_str == "Back": + return InternalDirective.BACK + + chosen_episode = chosen_episode_str if not chosen_episode or chosen_episode == "Back": return InternalDirective.BACK - file_path = downloaded_episodes[chosen_episode] - - # Use the player service to play the local file - title = f"{media_item.title.english or media_item.title.romaji} - Episode {chosen_episode}" - player_result = ctx.player.play( - PlayerParams( - url=str(file_path), - title=title, - query=media_item.title.english or media_item.title.romaji or "", - episode=chosen_episode, - ) + return State( + menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, + media_api=state.media_api, + provider=state.provider.model_copy( + update={"episode": chosen_episode, "start_time": start_time} + ), ) - # Track watch history after playing - ctx.watch_history.track(media_item, player_result) - # Stay on this menu to allow playing another downloaded episode - return InternalDirective.RELOAD +# TODO: figure out the best way to implement this logic for next episode ... +@session.menu +def downloads_player_controls( + ctx: Context, state: State +) -> Union[State, InternalDirective]: + feedback = ctx.feedback + feedback.clear_console() + + config = ctx.config + selector = ctx.selector + + media_item = state.media_api.media_item + current_episode_num = state.provider.episode + current_start_time = state.provider.start_time + + if not media_item or not current_episode_num: + feedback.error("Player state is incomplete. Returning.") + return InternalDirective.BACK + record = ctx.media_registry.get_media_record(media_item.id) + if not record or not record.media_episodes: + feedback.warning("No downloaded episodes found for this anime.") + return InternalDirective.BACK + + downloaded_episodes = { + ep.episode_number: ep.file_path + for ep in record.media_episodes + if ep.download_status == DownloadStatus.COMPLETED + and ep.file_path + and ep.file_path.exists() + } + available_episodes = list(sorted(downloaded_episodes.keys(), key=float)) + current_index = available_episodes.index(current_episode_num) + + if not ctx.switch.dont_play: + file_path = downloaded_episodes[current_episode_num] + + # Use the player service to play the local file + title = f"{media_item.title.english or media_item.title.romaji}; Episode {current_episode_num}" + if media_item.streaming_episodes: + streaming_episode = media_item.streaming_episodes.get(current_episode_num) + title = streaming_episode.title if streaming_episode else title + player_result = ctx.player.play( + PlayerParams( + url=str(file_path), + title=title, + query=media_item.title.english or media_item.title.romaji or "", + episode=current_episode_num, + start_time=current_start_time, + ) + ) + + # Track watch history after playing + ctx.watch_history.track(media_item, player_result) + + if config.stream.auto_next and current_index < len(available_episodes) - 1: + feedback.info("Auto-playing next episode...") + next_episode_num = available_episodes[current_index + 1] + + return State( + menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, + media_api=state.media_api, + provider=state.provider.model_copy( + update={"episode": next_episode_num, "start_time": None} + ), + ) + + # --- Menu Options --- + icons = config.general.icons + options: Dict[str, Callable[[], Union[State, InternalDirective]]] = {} + + if current_index < len(available_episodes) - 1: + options[f"{'⏭️ ' if icons else ''}Next Episode"] = _next_episode(ctx, state) + if current_index: + options[f"{'⏪ ' if icons else ''}Previous Episode"] = _previous_episode( + ctx, state + ) + + options.update( + { + f"{'🔂 ' if icons else ''}Replay": _replay(ctx, state), + f"{'🎞️ ' if icons else ''}Episode List": _episodes_list(ctx, state), + f"{'🔘 ' if icons else ''}Toggle Auto Next Episode (Current: {ctx.config.stream.auto_next})": _toggle_config_state( + ctx, state, "AUTO_EPISODE" + ), + f"{'🎥 ' if icons else ''}Media Actions Menu": lambda: InternalDirective.BACKX2, + f"{'🏠 ' if icons else ''}Main Menu": lambda: InternalDirective.MAIN, + f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT, + } + ) + + choice = selector.choose(prompt="What's next?", choices=list(options.keys())) + + if choice and choice in options: + return options[choice]() + else: + return InternalDirective.RELOAD + + +def _next_episode(ctx: Context, state: State) -> MenuAction: + def action(): + feedback = ctx.feedback + + config = ctx.config + + media_item = state.media_api.media_item + current_episode_num = state.provider.episode + + if not media_item or not current_episode_num: + feedback.error( + "Player state is incomplete. not going to next episode. Returning." + ) + ctx.switch.force_dont_play() + return InternalDirective.RELOAD + + record = ctx.media_registry.get_media_record(media_item.id) + if not record or not record.media_episodes: + feedback.warning("No downloaded episodes found for this anime.") + ctx.switch.force_dont_play() + return InternalDirective.RELOAD + + downloaded_episodes = { + ep.episode_number: ep.file_path + for ep in record.media_episodes + if ep.download_status == DownloadStatus.COMPLETED + and ep.file_path + and ep.file_path.exists() + } + available_episodes = list(sorted(downloaded_episodes.keys(), key=float)) + current_index = available_episodes.index(current_episode_num) + + if current_index < len(available_episodes) - 1: + next_episode_num = available_episodes[current_index + 1] + + return State( + menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, + media_api=state.media_api, + provider=state.provider.model_copy( + update={"episode": next_episode_num, "start_time": None} + ), + ) + feedback.warning("This is the last available episode.") + ctx.switch.force_dont_play() + return InternalDirective.RELOAD + + return action + + +def _previous_episode(ctx: Context, state: State) -> MenuAction: + def action(): + feedback = ctx.feedback + + config = ctx.config + + media_item = state.media_api.media_item + current_episode_num = state.provider.episode + + if not media_item or not current_episode_num: + feedback.error( + "Player state is incomplete not going to previous episode. Returning." + ) + ctx.switch.force_dont_play() + return InternalDirective.RELOAD + + record = ctx.media_registry.get_media_record(media_item.id) + if not record or not record.media_episodes: + feedback.warning("No downloaded episodes found for this anime.") + ctx.switch.force_dont_play() + return InternalDirective.RELOAD + + downloaded_episodes = { + ep.episode_number: ep.file_path + for ep in record.media_episodes + if ep.download_status == DownloadStatus.COMPLETED + and ep.file_path + and ep.file_path.exists() + } + available_episodes = list(sorted(downloaded_episodes.keys(), key=float)) + current_index = available_episodes.index(current_episode_num) + + if current_index: + prev_episode_num = available_episodes[current_index - 1] + + return State( + menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, + media_api=state.media_api, + provider=state.provider.model_copy( + update={"episode": prev_episode_num, "start_time": None} + ), + ) + feedback.warning("This is the last available episode.") + ctx.switch.force_dont_play() + return InternalDirective.RELOAD + + return action + + +def _replay(ctx: Context, state: State) -> MenuAction: + def action(): + return InternalDirective.RELOAD + + return action + + +def _toggle_config_state( + ctx: Context, + state: State, + config_state: Literal[ + "AUTO_ANIME", "AUTO_EPISODE", "CONTINUE_FROM_HISTORY", "TRANSLATION_TYPE" + ], +) -> MenuAction: + def action(): + match config_state: + case "AUTO_ANIME": + ctx.config.general.auto_select_anime_result = ( + not ctx.config.general.auto_select_anime_result + ) + case "AUTO_EPISODE": + ctx.config.stream.auto_next = not ctx.config.stream.auto_next + case "CONTINUE_FROM_HISTORY": + ctx.config.stream.continue_from_watch_history = ( + not ctx.config.stream.continue_from_watch_history + ) + case "TRANSLATION_TYPE": + ctx.config.stream.translation_type = ( + "sub" if ctx.config.stream.translation_type == "dub" else "dub" + ) + return InternalDirective.RELOAD + + return action + + +def _episodes_list(ctx: Context, state: State) -> MenuAction: + def action(): + ctx.switch.force_episodes_menu() + return InternalDirective.BACK + + return action diff --git a/fastanime/cli/interactive/menu/media/player_controls.py b/fastanime/cli/interactive/menu/media/player_controls.py index 124cafc..0744396 100644 --- a/fastanime/cli/interactive/menu/media/player_controls.py +++ b/fastanime/cli/interactive/menu/media/player_controls.py @@ -78,6 +78,8 @@ def player_controls(ctx: Context, state: State) -> Union[State, InternalDirectiv if choice and choice in options: return options[choice]() + else: + return InternalDirective.RELOAD def _next_episode(ctx: Context, state: State) -> MenuAction: diff --git a/fastanime/cli/interactive/session.py b/fastanime/cli/interactive/session.py index 293703b..fd7c8fd 100644 --- a/fastanime/cli/interactive/session.py +++ b/fastanime/cli/interactive/session.py @@ -34,6 +34,7 @@ class Switch: _provider_results: bool = False _episodes: bool = False _servers: bool = False + _dont_play: bool = False @property def show_provider_results_menu(self): @@ -45,6 +46,16 @@ class Switch: def force_provider_results_menu(self): self._provider_results = True + @property + def dont_play(self): + if self._dont_play: + self._dont_play = False + return True + return False + + def force_dont_play(self): + self._dont_play = True + @property def show_episodes_menu(self): if self._episodes: diff --git a/fastanime/cli/interactive/state.py b/fastanime/cli/interactive/state.py index 78ec004..1701dc1 100644 --- a/fastanime/cli/interactive/state.py +++ b/fastanime/cli/interactive/state.py @@ -45,6 +45,7 @@ class MenuName(Enum): MEDIA_CHARACTERS = "MEDIA_CHARACTERS" MEDIA_AIRING_SCHEDULE = "MEDIA_AIRING_SCHEDULE" PLAY_DOWNLOADS = "PLAY_DOWNLOADS" + DOWNLOADS_PLAYER_CONTROLS = "DOWNLOADS_PLAYER_CONTROLS" DOWNLOAD_EPISODES = "DOWNLOAD_EPISODES"