From 6bf6547826bd96c6dcc4d590c34ddf6dcd6b6317 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:01:16 +0000 Subject: [PATCH 01/14] refactor: move the video block's free-standing helpers out of it The guards on its commands, the floating stacked layout, the delay texts, the reopen checks, SyncedView and the SponsorBlock seek mark each go where the rest of their kind already is, leaving video_block.py to the block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/dialogs/audio_delay.py | 11 + gridplayer/dialogs/subtitle_delay.py | 11 + gridplayer/player/managers/video_blocks.py | 3 +- gridplayer/utils/aspect_calc.py | 9 + gridplayer/utils/libvlc_options_parser.py | 34 +++ gridplayer/utils/sponsorblock.py | 22 ++ gridplayer/widgets/stacked_layout_floating.py | 20 ++ gridplayer/widgets/video_block.py | 220 +++--------------- gridplayer/widgets/video_block_guards.py | 75 ++++++ tests/test_deinterlace.py | 12 +- tests/test_subtitle_encoding.py | 10 +- tests/test_sync.py | 4 +- tests/test_video_block_audio_delay.py | 4 +- 13 files changed, 229 insertions(+), 206 deletions(-) create mode 100644 gridplayer/widgets/stacked_layout_floating.py create mode 100644 gridplayer/widgets/video_block_guards.py diff --git a/gridplayer/dialogs/audio_delay.py b/gridplayer/dialogs/audio_delay.py index 81d47b37..08991d4b 100644 --- a/gridplayer/dialogs/audio_delay.py +++ b/gridplayer/dialogs/audio_delay.py @@ -17,6 +17,17 @@ from gridplayer.utils.qt import translate +def audio_delay_txt(delay_ms: int) -> str: + """A delay as it reads, signed, since which way it goes is the point.""" + + milliseconds = translate("Audio Delay", "ms") + + if not delay_ms: + return f"0 {milliseconds}" + + return f"{delay_ms:+d} {milliseconds}" + + class SetAudioDelayDialog(QDialog): """Moves a video's sound against its picture. diff --git a/gridplayer/dialogs/subtitle_delay.py b/gridplayer/dialogs/subtitle_delay.py index 7d17c583..77fdb092 100644 --- a/gridplayer/dialogs/subtitle_delay.py +++ b/gridplayer/dialogs/subtitle_delay.py @@ -17,6 +17,17 @@ from gridplayer.utils.qt import translate +def subtitle_delay_txt(delay_ms: int) -> str: + """A delay as it reads, signed, since which way it goes is the point.""" + + milliseconds = translate("Subtitle Delay", "ms") + + if not delay_ms: + return f"0 {milliseconds}" + + return f"{delay_ms:+d} {milliseconds}" + + class SetSubtitleDelayDialog(QDialog): """Moves a video's subtitles against its picture. diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 142c6b09..244bd020 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -21,9 +21,10 @@ from gridplayer.player.managers.base import ManagerBase from gridplayer.playlist_settings import PlaylistSettings from gridplayer.settings import Settings +from gridplayer.utils.aspect_calc import SyncedView from gridplayer.utils.qt import MILLISECONDS, qt_connect, translate from gridplayer.utils.step_sizes import seek_step_label, seek_step_ms -from gridplayer.widgets.video_block import SyncedView, VideoBlock +from gridplayer.widgets.video_block import VideoBlock # what [ALL] does to the views, which leaves the one synced to out of date _VIEW_COMMANDS = frozenset( diff --git a/gridplayer/utils/aspect_calc.py b/gridplayer/utils/aspect_calc.py index 6548879c..2e78f442 100644 --- a/gridplayer/utils/aspect_calc.py +++ b/gridplayer/utils/aspect_calc.py @@ -581,6 +581,15 @@ class SharedView: place_y: AxisPlace +class SyncedView(NamedTuple): + """A video's view, for the others to follow: see VideoBlock.synced_view.""" + + view: SharedView + # where the picture stands is a zoom to a selection's doing, which a + # reset of the zoom then undoes in the others too + is_zoomed_to_selection: bool + + def calc_shared_view( frame_size: tuple[int, int], pane_size: tuple[int, int], diff --git a/gridplayer/utils/libvlc_options_parser.py b/gridplayer/utils/libvlc_options_parser.py index b7119b56..39611d45 100644 --- a/gridplayer/utils/libvlc_options_parser.py +++ b/gridplayer/utils/libvlc_options_parser.py @@ -51,3 +51,37 @@ def get_vlc_options(video_params: Video | None): vlc_options.append(f"--deinterlace-mode={option_str}") return vlc_options + + +def is_reopen_needed(old: Video | None, new: Video) -> bool: + """Whether a change needs the video opened again rather than adjusted. + + Instance options decide which VLC process a video is played in, so a + change to one only takes hold in a new process. The subtitle encoding + is a media option rather than an instance one -- as an instance option + it would fork the process pool one way per encoding -- but it is settled + when the input opens just the same, so it needs reopening too. + """ + + if get_vlc_options(old) != get_vlc_options(new): + return True + + return old is not None and old.subtitle_encoding != new.subtitle_encoding + + +def is_deinterlace_reload_needed( + current: Video, deinterlace: VideoDeinterlace, mode: VideoDeinterlaceMode +) -> bool: + """Whether a deinterlace setting needs the video opened again. + + libVLC 3 switches a player's deinterlacing on or off, mode and all, while + it plays; but it has no call to put a player back on auto, which is only + where a player starts, and on auto the mode comes from the VLC instance. + """ + + if deinterlace != VideoDeinterlace.AUTO: + return False + + return ( + current.deinterlace != VideoDeinterlace.AUTO or current.deinterlace_mode != mode + ) diff --git a/gridplayer/utils/sponsorblock.py b/gridplayer/utils/sponsorblock.py index e5e7db35..cad5d991 100644 --- a/gridplayer/utils/sponsorblock.py +++ b/gridplayer/utils/sponsorblock.py @@ -20,11 +20,13 @@ from PyQt5.QtCore import QObject, pyqtSignal +from gridplayer.models.seek_mark import SeekMark, SeekMarkKind from gridplayer.models.sponsor_segment import SponsorSegment from gridplayer.params.sponsorblock import ( CATEGORY_COLORS, ENABLED_SETTING, HIGHLIGHT_CATEGORY, + category_name, category_setting, ) from gridplayer.params.static import SponsorBlockMode @@ -189,6 +191,26 @@ def skip_span_at(spans, time_ms: int) -> SkipSpan | None: return None +def segment_mark(segment: SponsorSegment) -> SeekMark: + """A SponsorBlock segment as the seek bar marks it, by its colour.""" + + if segment.category == HIGHLIGHT_CATEGORY: + return SeekMark( + time_ms=segment.start_ms, + label=category_name(segment.category), + kind=SeekMarkKind.HIGHLIGHT, + color=CATEGORY_COLORS[segment.category], + ) + + return SeekMark( + time_ms=segment.start_ms, + label=category_name(segment.category), + kind=SeekMarkKind.SEGMENT, + end_ms=segment.end_ms, + color=CATEGORY_COLORS[segment.category], + ) + + class SponsorBlockFetcher(QObject): """Looks videos up on SponsorBlock, off the interface thread. diff --git a/gridplayer/widgets/stacked_layout_floating.py b/gridplayer/widgets/stacked_layout_floating.py new file mode 100644 index 00000000..bd320bf0 --- /dev/null +++ b/gridplayer/widgets/stacked_layout_floating.py @@ -0,0 +1,20 @@ +from PyQt5.QtWidgets import QStackedLayout + + +class QStackedLayoutFloating(QStackedLayout): + """overridden setGeometry for children is not honored due to type casting inside Qt, + need to override setGeometry in Layout to make floating windows resize with parent + + https://code.qt.io/cgit/qt/qtbase.git/tree/src/widgets/kernel/qstackedlayout.cpp#n474 + """ + + def setGeometry(self, rect): + if self.stackingMode() == QStackedLayout.StackOne: + widget = self.currentWidget() + if widget: + widget.setGeometry(rect) + if self.stackingMode() == QStackedLayout.StackAll: + for i in range(self.count()): + widget = self.itemAt(i).widget() + if widget: + widget.setGeometry(rect) diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index 1b6190ae..dad0b934 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -7,14 +7,13 @@ from datetime import datetime, timezone from functools import partial from pathlib import Path -from typing import NamedTuple from pydantic_extra_types.color import Color from PyQt5.QtCore import QRect, QRectF, QSize, Qt, QTimer, pyqtSignal from PyQt5.QtGui import QCursor, QIcon from PyQt5.QtWidgets import QFileDialog, QStackedLayout, QWidget -from gridplayer.dialogs.audio_delay import SetAudioDelayDialog +from gridplayer.dialogs.audio_delay import SetAudioDelayDialog, audio_delay_txt from gridplayer.dialogs.bookmark_rename_dialog import QBookmarkRenameDialog from gridplayer.dialogs.bookmarks import BookmarksDialog, held_end_action_txt from gridplayer.dialogs.error_details import ( @@ -28,7 +27,10 @@ QCustomTextInput, ) from gridplayer.dialogs.rename_dialog import QVideoRenameDialog -from gridplayer.dialogs.subtitle_delay import SetSubtitleDelayDialog +from gridplayer.dialogs.subtitle_delay import ( + SetSubtitleDelayDialog, + subtitle_delay_txt, +) from gridplayer.dialogs.subtitle_encoding import SetSubtitleEncodingDialog from gridplayer.exceptions import PlayerException from gridplayer.models.audio_selection import ( @@ -79,11 +81,7 @@ ) from gridplayer.params import env from gridplayer.params.extensions import SUPPORTED_AUDIO_EXT, SUPPORTED_SUBTITLE_EXT -from gridplayer.params.sponsorblock import ( - CATEGORY_COLORS, - HIGHLIGHT_CATEGORY, - category_name, -) +from gridplayer.params.sponsorblock import category_name from gridplayer.params.static import ( AUDIO_DELAY_STEP_MS, CHROME_MIN_SIZE, @@ -114,7 +112,7 @@ ViewParams, ) from gridplayer.settings import Settings -from gridplayer.utils.aspect_calc import SharedView +from gridplayer.utils.aspect_calc import SyncedView from gridplayer.utils.bookmarks import ( bookmark_at, default_bookmark_name, @@ -147,7 +145,11 @@ subtitle_file_label, ) from gridplayer.utils.folder_navigator import FolderNavigator, FolderStep -from gridplayer.utils.libvlc_options_parser import get_vlc_options +from gridplayer.utils.libvlc_options_parser import ( + get_vlc_options, + is_deinterlace_reload_needed, + is_reopen_needed, +) from gridplayer.utils.list_errors import ReadErrorKind from gridplayer.utils.list_navigator import ListNavigator from gridplayer.utils.qt import MILLISECONDS, count_txt, qt_connect, translate @@ -163,6 +165,7 @@ SkipSpan, category_modes, is_sponsorblock_enabled, + segment_mark, skip_span_at, skip_spans, sponsorblock_fetcher, @@ -202,6 +205,16 @@ paint_solid_outline, ) from gridplayer.widgets.custom_menu import CustomMenu +from gridplayer.widgets.stacked_layout_floating import QStackedLayoutFloating +from gridplayer.widgets.video_block_guards import ( + only_initialized, + only_live, + only_local_file, + only_navigable, + only_seekable, + only_streamable, + only_with_video_tacks, +) from gridplayer.widgets.video_frame_vlc_base import VideoFrameVLC from gridplayer.widgets.video_overlay import ( OverlayBlock, @@ -262,113 +275,10 @@ ) -class QStackedLayoutFloating(QStackedLayout): - """overridden setGeometry for children is not honored due to type casting inside Qt, - need to override setGeometry in Layout to make floating windows resize with parent - - https://code.qt.io/cgit/qt/qtbase.git/tree/src/widgets/kernel/qstackedlayout.cpp#n474 - """ - - def setGeometry(self, rect): - if self.stackingMode() == QStackedLayout.StackOne: - widget = self.currentWidget() - if widget: - widget.setGeometry(rect) - if self.stackingMode() == QStackedLayout.StackAll: - for i in range(self.count()): - widget = self.itemAt(i).widget() - if widget: - widget.setGeometry(rect) - - -def only_initialized(func): - def wrapper(*args, **kwargs): - self = args[0] - if not self.is_video_initialized: - return None - return func(*args, **kwargs) - - return wrapper - - -def only_seekable(func): - def wrapper(*args, **kwargs): - self = args[0] - if self.is_live: - return None - return func(*args, **kwargs) - - return wrapper - - -def only_with_video_tacks(func): - def wrapper(*args, **kwargs): - self = args[0] - if not self.video_tracks: - return None - return func(*args, **kwargs) - - return wrapper - - -def only_live(func): - def wrapper(*args, **kwargs): - self = args[0] - if not self.is_live: - return None - return func(*args, **kwargs) - - return wrapper - - -def only_local_file(func): - def wrapper(*args, **kwargs): - self = args[0] - if not self.is_local_file: - return None - return func(*args, **kwargs) - - return wrapper - - -def only_navigable(func): - """Only for a video with others to go on to: the files of its folder, or - the items of its list, links among them.""" - - def wrapper(*args, **kwargs): - self = args[0] - if not self.is_navigable: - return None - return func(*args, **kwargs) - - return wrapper - - -def only_streamable(func): - def wrapper(*args, **kwargs): - self = args[0] - if not self.streams: - return None - return func(*args, **kwargs) - - return wrapper - - def _file_names(file_paths) -> str: return ", ".join(file_path.name for file_path in file_paths) -def audio_delay_txt(delay_ms: int) -> str: - """A delay as it reads, signed, since which way it goes is the point.""" - - milliseconds = translate("Audio Delay", "ms") - - if not delay_ms: - return f"0 {milliseconds}" - - return f"{delay_ms:+d} {milliseconds}" - - def _audio_files_filter() -> str: extensions = " ".join(f"*.{ext}" for ext in sorted(SUPPORTED_AUDIO_EXT)) @@ -378,17 +288,6 @@ def _audio_files_filter() -> str: return f"{audio} ({extensions});;{every} (*)" -def subtitle_delay_txt(delay_ms: int) -> str: - """A delay as it reads, signed, since which way it goes is the point.""" - - milliseconds = translate("Subtitle Delay", "ms") - - if not delay_ms: - return f"0 {milliseconds}" - - return f"{delay_ms:+d} {milliseconds}" - - def list_fail_wait_txt(wait_sec: int) -> str: if not wait_sec: return translate("When Video Fails", "At once") @@ -422,69 +321,6 @@ def _subtitle_track_file_stem(track) -> str | None: return track.language.replace("\\", "/").rsplit("/", 1)[-1].casefold() -def _is_reopen_needed(old: Video | None, new: Video) -> bool: - """Whether a change needs the video opened again rather than adjusted. - - Instance options decide which VLC process a video is played in, so a - change to one only takes hold in a new process. The subtitle encoding - is a media option rather than an instance one -- as an instance option - it would fork the process pool one way per encoding -- but it is settled - when the input opens just the same, so it needs reopening too. - """ - - if get_vlc_options(old) != get_vlc_options(new): - return True - - return old is not None and old.subtitle_encoding != new.subtitle_encoding - - -def _is_deinterlace_reload_needed( - current: Video, deinterlace: VideoDeinterlace, mode: VideoDeinterlaceMode -) -> bool: - """Whether a deinterlace setting needs the video opened again. - - libVLC 3 switches a player's deinterlacing on or off, mode and all, while - it plays; but it has no call to put a player back on auto, which is only - where a player starts, and on auto the mode comes from the VLC instance. - """ - - if deinterlace != VideoDeinterlace.AUTO: - return False - - return ( - current.deinterlace != VideoDeinterlace.AUTO or current.deinterlace_mode != mode - ) - - -class SyncedView(NamedTuple): - """A video's view, for the others to follow: see VideoBlock.synced_view.""" - - view: SharedView - # where the picture stands is a zoom to a selection's doing, which a - # reset of the zoom then undoes in the others too - is_zoomed_to_selection: bool - - -def _segment_mark(segment: SponsorSegment) -> SeekMark: - """A SponsorBlock segment as the seek bar marks it, by its colour.""" - - if segment.category == HIGHLIGHT_CATEGORY: - return SeekMark( - time_ms=segment.start_ms, - label=category_name(segment.category), - kind=SeekMarkKind.HIGHLIGHT, - color=CATEGORY_COLORS[segment.category], - ) - - return SeekMark( - time_ms=segment.start_ms, - label=category_name(segment.category), - kind=SeekMarkKind.SEGMENT, - end_ms=segment.end_ms, - color=CATEGORY_COLORS[segment.category], - ) - - class VideoBlock(QWidget): load_video = pyqtSignal(MediaInput) @@ -3863,7 +3699,7 @@ def apply_snapshot(self, snapshot: Video): self._start_load() return - if self.video_tracks and _is_deinterlace_reload_needed( + if self.video_tracks and is_deinterlace_reload_needed( self.video_params, snapshot.deinterlace, snapshot.deinterlace_mode ): self._take_snapshot_params(snapshot) @@ -3926,7 +3762,7 @@ def _apply_snapshot_of_another_file(self, snapshot: Video): def set_video(self, video_params: Video): is_first_video = self.video_params is None - is_reopen_needed = _is_reopen_needed(self.video_params, video_params) + is_reopen = is_reopen_needed(self.video_params, video_params) # another video in place of one waiting for its list: not read for it if not is_first_video and self.video_params.id != video_params.id: @@ -3961,7 +3797,7 @@ def set_video(self, video_params: Video): self._has_item_played = False # Shut down current video - if not is_first_video or is_reopen_needed: + if not is_first_video or is_reopen: self.reset() if self.video_params.is_stopped and not self.is_video_initialized: @@ -4549,7 +4385,7 @@ def set_deinterlace_mode(self, mode: VideoDeinterlaceMode): def set_deinterlace_params( self, deinterlace: VideoDeinterlace, mode: VideoDeinterlaceMode ): - is_reload_needed = _is_deinterlace_reload_needed( + is_reload_needed = is_deinterlace_reload_needed( self.video_params, deinterlace, mode ) @@ -4677,7 +4513,7 @@ def _update_seek_marks(self): for index, chapter in enumerate(self._chapters) ) - segments = (_segment_mark(segment) for segment in self._shown_segments) + segments = (segment_mark(segment) for segment in self._shown_segments) bookmarks = () if self.is_video_initialized and not self.is_live: diff --git a/gridplayer/widgets/video_block_guards.py b/gridplayer/widgets/video_block_guards.py new file mode 100644 index 00000000..df785948 --- /dev/null +++ b/gridplayer/widgets/video_block_guards.py @@ -0,0 +1,75 @@ +"""Commands of a VideoBlock that only make sense for some videos, left +undone, quietly, for the rest.""" + + +def only_initialized(func): + def wrapper(*args, **kwargs): + self = args[0] + if not self.is_video_initialized: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_seekable(func): + def wrapper(*args, **kwargs): + self = args[0] + if self.is_live: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_with_video_tacks(func): + def wrapper(*args, **kwargs): + self = args[0] + if not self.video_tracks: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_live(func): + def wrapper(*args, **kwargs): + self = args[0] + if not self.is_live: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_local_file(func): + def wrapper(*args, **kwargs): + self = args[0] + if not self.is_local_file: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_navigable(func): + """Only for a video with others to go on to: the files of its folder, or + the items of its list, links among them.""" + + def wrapper(*args, **kwargs): + self = args[0] + if not self.is_navigable: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_streamable(func): + def wrapper(*args, **kwargs): + self = args[0] + if not self.streams: + return None + return func(*args, **kwargs) + + return wrapper diff --git a/tests/test_deinterlace.py b/tests/test_deinterlace.py index 09391ed6..956bde7b 100644 --- a/tests/test_deinterlace.py +++ b/tests/test_deinterlace.py @@ -21,10 +21,14 @@ from gridplayer.params.menu import SECTIONS, SUBMENUS from gridplayer.params.static import VideoDeinterlace, VideoDeinterlaceMode from gridplayer.settings import Settings -from gridplayer.utils.libvlc_options_parser import DeinterlaceModeMap, get_vlc_options +from gridplayer.utils.libvlc_options_parser import ( + DeinterlaceModeMap, + get_vlc_options, + is_deinterlace_reload_needed, +) from gridplayer.vlc_player.player_base import VlcPlayerBase from gridplayer.vlc_player.static import MediaInput -from gridplayer.widgets.video_block import VideoBlock, _is_deinterlace_reload_needed +from gridplayer.widgets.video_block import VideoBlock URI = "http://example.com/a.mp4" @@ -182,7 +186,7 @@ class TestWhenItTakesAReload: ], ) def test_getting_to_auto_or_changing_its_mode(self, current, wanted): - assert _is_deinterlace_reload_needed(_video(*current), *wanted) + assert is_deinterlace_reload_needed(_video(*current), *wanted) @pytest.mark.parametrize( ("current", "wanted"), @@ -195,7 +199,7 @@ def test_getting_to_auto_or_changing_its_mode(self, current, wanted): ], ) def test_everything_else_is_done_in_place(self, current, wanted): - assert not _is_deinterlace_reload_needed(_video(*current), *wanted) + assert not is_deinterlace_reload_needed(_video(*current), *wanted) def _block(deinterlace, mode, has_video=True): diff --git a/tests/test_subtitle_encoding.py b/tests/test_subtitle_encoding.py index ebb6486b..aa2ea1f2 100644 --- a/tests/test_subtitle_encoding.py +++ b/tests/test_subtitle_encoding.py @@ -28,12 +28,12 @@ ) from gridplayer.player.managers.video_blocks import VideoBlocksManager from gridplayer.settings import Settings -from gridplayer.utils.libvlc_options_parser import get_vlc_options +from gridplayer.utils.libvlc_options_parser import get_vlc_options, is_reopen_needed from gridplayer.vlc_player.player_base import VlcPlayerBase from gridplayer.vlc_player.static import MediaInput from gridplayer.widgets.combo_box import MAX_VISIBLE_COMBO_ITEMS from gridplayer.widgets.defaults_form import DefaultsForm -from gridplayer.widgets.video_block import VideoBlock, _is_reopen_needed +from gridplayer.widgets.video_block import VideoBlock URI = "http://example.com/a.mp4" @@ -222,19 +222,19 @@ class TestTakingHold: """It is settled at the open, so a change has to reopen the video.""" def test_a_different_set_reopens(self): - assert _is_reopen_needed( + assert is_reopen_needed( Video(uri=URI, subtitle_encoding=""), Video(uri=URI, subtitle_encoding="KOI8-R"), ) def test_the_same_set_leaves_the_video_alone(self): - assert not _is_reopen_needed( + assert not is_reopen_needed( Video(uri=URI, subtitle_encoding="KOI8-R"), Video(uri=URI, subtitle_encoding="KOI8-R"), ) def test_the_first_video_has_nothing_to_differ_from(self): - assert not _is_reopen_needed(None, Video(uri=URI, subtitle_encoding="KOI8-R")) + assert not is_reopen_needed(None, Video(uri=URI, subtitle_encoding="KOI8-R")) def test_a_change_reaches_the_videos_already_open(self): block = _block("") diff --git a/tests/test_sync.py b/tests/test_sync.py index 3824a30b..a4c5fbf9 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -32,9 +32,9 @@ from gridplayer.player.managers.video_blocks import VideoBlocksManager from gridplayer.playlist_settings import PlaylistSettings from gridplayer.settings import Settings -from gridplayer.utils.aspect_calc import calc_shared_view +from gridplayer.utils.aspect_calc import SyncedView, calc_shared_view from gridplayer.vlc_player.static import VideoTrack -from gridplayer.widgets.video_block import SyncedView, VideoBlock +from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_frame_dummy import VideoFrameDummy from gridplayer.widgets.video_frame_vlc_hw_sp import VideoFrameVLCHWSP diff --git a/tests/test_video_block_audio_delay.py b/tests/test_video_block_audio_delay.py index 1f1c382b..1288d1cb 100644 --- a/tests/test_video_block_audio_delay.py +++ b/tests/test_video_block_audio_delay.py @@ -8,7 +8,7 @@ from PyQt5.QtCore import QSettings from PyQt5.QtWidgets import QApplication -from gridplayer.dialogs.audio_delay import SetAudioDelayDialog +from gridplayer.dialogs.audio_delay import SetAudioDelayDialog, audio_delay_txt from gridplayer.models.video import Video from gridplayer.params.static import ( AUDIO_DELAY_STEP_MS, @@ -16,7 +16,7 @@ MIN_AUDIO_DELAY_MS, ) from gridplayer.settings import Settings -from gridplayer.widgets.video_block import VideoBlock, audio_delay_txt +from gridplayer.widgets.video_block import VideoBlock @pytest.fixture(scope="module", autouse=True) From 54699a63487957eac54128f4fbbb0d555d3cacb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:01:23 +0000 Subject: [PATCH 02/14] refactor: let a command name a method of a part of the video block cmd_active, is_active_runtime_param_set_to and the block's manual_seek, manual_view and manual_rate take a dotted name, "bookmarks.next", as well as a plain one, so a part of the block taken out into an object of its own keeps its commands without the block passing each of them on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/player/managers/active_block.py | 6 +- gridplayer/widgets/video_block.py | 7 +- tests/test_dotted_commands.py | 101 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 tests/test_dotted_commands.py diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index 709ca7cf..916958f2 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -1,4 +1,5 @@ from collections import Counter +from operator import attrgetter from pathlib import Path from PyQt5.QtCore import QEvent, pyqtSignal @@ -175,7 +176,8 @@ def cmd_active(self, command, *args): return None - return getattr(self._ctx.active_block, command)(*args) + # a dotted name, "bookmarks.next", reaches a part of the block + return attrgetter(command)(self._ctx.active_block)(*args) def cmd_crop_dialog(self): if self.is_no_active_block or not self.is_active_has_video(): @@ -225,7 +227,7 @@ def is_active_runtime_param_set_to(self, param_name, param_value): if self.is_no_active_block: return False - active_video_param = getattr(self._ctx.active_block, param_name) + active_video_param = attrgetter(param_name)(self._ctx.active_block) return active_video_param == param_value diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index dad0b934..8bcc9ac8 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -6,6 +6,7 @@ import time from datetime import datetime, timezone from functools import partial +from operator import attrgetter from pathlib import Path from pydantic_extra_types.color import Color @@ -1513,7 +1514,7 @@ def customEvent(self, event): @only_seekable def manual_seek(self, command, *args): # one that went nowhere leaves the others nothing to follow - if getattr(self, command)(*args) is False: + if attrgetter(command)(self)(*args) is False: return # a seek past the end is the end arriving early, and the end action @@ -5531,7 +5532,7 @@ def manual_view(self, command, *args): What the command gives back goes back (see pan_by). """ - result = getattr(self, command)(*args) + result = attrgetter(command)(self)(*args) synced = self.synced_view() @@ -5737,7 +5738,7 @@ def manual_rate(self, command): """Change the speed as the viewer asked, for the others to follow where speeds are synced (see VideoBlocksManager).""" - getattr(self, command)() + attrgetter(command)(self)() if self.is_video_initialized and not self.is_live: self.sync_rate.emit(self.video_params.rate) diff --git a/tests/test_dotted_commands.py b/tests/test_dotted_commands.py new file mode 100644 index 00000000..f38b8725 --- /dev/null +++ b/tests/test_dotted_commands.py @@ -0,0 +1,101 @@ +"""A command may name a method of a part of the video block, "part.method", +as well as one of the block itself.""" + +from types import SimpleNamespace + +from gridplayer.player.managers.active_block import ActiveBlockManager +from gridplayer.widgets.video_block import VideoBlock + + +def _active_manager(block): + manager = ActiveBlockManager(context=SimpleNamespace(active_block=None)) + manager._ctx.active_block = block + return manager + + +def _playing_block(mocker): + block = mocker.Mock() + block.is_video_initialized = True + block.is_playable = True + block.is_live = False + block.position = 0.5 + block.video_driver.length = 10_000 + block.video_params.rate = 1.5 + return block + + +def test_cmd_active_reaches_a_part_of_the_block(mocker): + block = _playing_block(mocker) + block.bookmarks.seek.return_value = "sought" + + result = _active_manager(block).cmd_active("bookmarks.seek", 3) + + block.bookmarks.seek.assert_called_once_with(3) + assert result == "sought" + + +def test_cmd_active_still_takes_a_plain_name(mocker): + block = _playing_block(mocker) + + _active_manager(block).cmd_active("reload") + + block.reload.assert_called_once_with() + + +def test_cmd_active_drops_a_dotted_command_while_loading(mocker): + block = _playing_block(mocker) + block.is_playable = False + + _active_manager(block).cmd_active("bookmarks.next") + + block.bookmarks.next.assert_not_called() + + +def test_runtime_param_reaches_a_part_of_the_block(mocker): + block = _playing_block(mocker) + block.bookmarks.count = 2 + manager = _active_manager(block) + + assert manager.is_active_runtime_param_set_to("bookmarks.count", 2) + assert not manager.is_active_runtime_param_set_to("bookmarks.count", 3) + + +def test_manual_seek_reaches_a_part_and_syncs_the_others(mocker): + block = _playing_block(mocker) + + VideoBlock.manual_seek(block, "bookmarks.seek", 3) + + block.bookmarks.seek.assert_called_once_with(3) + block.sync_percent.emit.assert_called_once_with(0.5) + block.sync_time.emit.assert_called_once_with(5000) + + +def test_manual_seek_of_a_part_that_went_nowhere_syncs_nothing(mocker): + block = _playing_block(mocker) + block.bookmarks.seek.return_value = False + + VideoBlock.manual_seek(block, "bookmarks.seek", 3) + + block.sync_percent.emit.assert_not_called() + block.sync_time.emit.assert_not_called() + + +def test_manual_view_reaches_a_part_and_gives_back_its_result(mocker): + block = _playing_block(mocker) + block.view.pan_by.return_value = (1.0, 2.0) + block.synced_view.return_value = "synced" + + result = VideoBlock.manual_view(block, "view.pan_by", 3, 4) + + block.view.pan_by.assert_called_once_with(3, 4) + block.sync_view.emit.assert_called_once_with("synced") + assert result == (1.0, 2.0) + + +def test_manual_rate_reaches_a_part_and_syncs_the_others(mocker): + block = _playing_block(mocker) + + VideoBlock.manual_rate(block, "rate.increase") + + block.rate.increase.assert_called_once_with() + block.sync_rate.emit.assert_called_once_with(1.5) From 6141c61551713feed77da4bc4a0d245f05e0d535 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:14:07 +0000 Subject: [PATCH 03/14] refactor: take the video block's bookmarks out into a Bookmarker What is done with a video's bookmarks -- adding, jumping, the marker's menu, renaming, colouring, the manager -- goes to block.bookmarker, its commands reached as "bookmarker.add" and the like. The guards on the block's commands ask the block a part belongs to (VideoBlockPart), and the block keeps the bookmarks themselves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/dialogs/bookmarks.py | 40 +- gridplayer/params/actions.py | 8 +- gridplayer/player/managers/active_block.py | 6 +- gridplayer/player/managers/video_blocks.py | 4 +- gridplayer/widgets/video_block.py | 514 +------------------ gridplayer/widgets/video_block_bookmarker.py | 505 ++++++++++++++++++ gridplayer/widgets/video_block_guards.py | 43 +- tests/test_bookmarks_dialog.py | 8 +- tests/test_dotted_commands.py | 24 +- tests/test_folder_navigation.py | 8 +- tests/test_menu_bookmarks.py | 19 +- tests/test_video_block_bookmarks.py | 213 ++++---- tests/test_video_block_guards.py | 68 +++ tests/test_video_block_repeat.py | 2 +- 14 files changed, 801 insertions(+), 661 deletions(-) create mode 100644 gridplayer/widgets/video_block_bookmarker.py create mode 100644 tests/test_video_block_guards.py diff --git a/gridplayer/dialogs/bookmarks.py b/gridplayer/dialogs/bookmarks.py index a63146f8..ede1d05e 100644 --- a/gridplayer/dialogs/bookmarks.py +++ b/gridplayer/dialogs/bookmarks.py @@ -286,7 +286,7 @@ def set_look(self, palette: QPalette, font: QFont): def reload(self): self.beginResetModel() - self._here = self._block.bookmark_here + self._here = self._block.bookmarker.here self.endResetModel() def set_here(self, here: int | None): @@ -384,7 +384,7 @@ def _foreground(self, row, bookmark, is_time): # with nothing playing none is in reach, nor out of it: the list # reads as it did, but for the play mark - if block.is_video_initialized and not block.is_reachable(bookmark): + if block.is_video_initialized and not block.bookmarker.is_reachable(bookmark): return self._colors.get("out_of_reach") if is_time: @@ -399,7 +399,7 @@ def _out_of_reach_txt(self, row, bookmark) -> str | None: if not self._block.is_video_initialized: return None - if self._block.is_reachable(bookmark): + if self._block.bookmarker.is_reachable(bookmark): return None if bookmark.time_ms >= self.length: @@ -747,7 +747,7 @@ def __init__(self, block, selected=(), parent=None): self._ui_connect() self._update_look() - self._show_position(block.time_to_bookmark, self._length) + self._show_position(block.bookmarker.time_to_add, self._length) self._player.set_marks(self._bar_marks()) self._show_loop() self._reload() @@ -903,7 +903,7 @@ def _ui_connect(self): def reject(self): if self._block.bookmarks != self._original: - self._block.set_bookmarks(self._original) + self._block.bookmarker.replace(self._original) super().reject() @@ -948,10 +948,10 @@ def _show_position(self, position_ms, length_ms): # way counts as there, as VLC reports a time a hair short of where # one landed, and a jump to a bookmark would read as short of it. if self._block.is_video_initialized: - position_ms = self._block.time_to_bookmark + position_ms = self._block.bookmarker.time_to_add self._tell(position_ms) - self._model.set_here(self._block.bookmark_here) + self._model.set_here(self._block.bookmarker.here) self._update_buttons() @@ -1053,7 +1053,7 @@ def _bar_marks(self): if block.is_video_initialized: return block.seek_marks - return block.bookmark_marks(self._length) + return block.bookmarker.marks(self._length) def _play_pause(self): # Stopped, it is played from the start. Loading, or reconnecting, @@ -1096,7 +1096,7 @@ def _shown_now(self): return ( bookmarks, block.is_video_initialized, - tuple(block.is_reachable(bookmark) for bookmark in bookmarks), + tuple(block.bookmarker.is_reachable(bookmark) for bookmark in bookmarks), self._video_length, block.loop_start, block.loop_end, @@ -1147,7 +1147,7 @@ def _edges(self): reachable = [ i for i, bookmark in enumerate(bookmarks[:on_bar]) - if block.is_reachable(bookmark) + if block.bookmarker.is_reachable(bookmark) ] loop_color = QColor( @@ -1375,7 +1375,7 @@ def _marker_tip_txt(self) -> str: on = None if len(marks) > 1 and self._block.is_video_initialized: - on = bookmark_on(marks, self._block.time_to_bookmark) + on = bookmark_on(marks, self._block.bookmarker.time_to_add) lines = [] @@ -1416,7 +1416,7 @@ def _show_hover_tip(self, text): def _on_marker_clicked(self, times): # each of those it stands for in turn, as over the video - row = self._block.marker_bookmark_to_go(times) + row = self._block.bookmarker.marker_to_go(times) if row is None: self._pick_times(times[:1]) @@ -1436,10 +1436,14 @@ def _on_double_click(self, index): def _jump(self): row = self._current_row() - if self._is_done or row is None or not self._block.is_bookmark_reachable(row): + if ( + self._is_done + or row is None + or not self._block.bookmarker.is_reachable_at(row) + ): return - self._block.manual_seek("seek_bookmark", row) + self._block.manual_seek("bookmarker.seek", row) def _edit_name(self): self._edit(Column.NAME) @@ -1459,7 +1463,7 @@ def _add(self): if not block.is_video_initialized: return - time_ms = block.time_to_bookmark + time_ms = block.bookmarker.time_to_add if is_bookmarked(block.bookmarks, time_ms): self._pick_times([self._nearest_time(time_ms)]) @@ -1540,7 +1544,7 @@ def _move_here(self): if row is None or not self._block.is_video_initialized: return - self._move(row, self._block.time_to_bookmark) + self._move(row, self._block.bookmarker.time_to_add) def _move(self, row, time_ms): bookmarks = self._block.bookmarks @@ -1664,7 +1668,7 @@ def _apply(self, bookmarks, picked_times, current_time=None): if self._is_done: return - self._block.set_bookmarks(bookmarks) + self._block.bookmarker.replace(bookmarks) # the video's bar marks them for this one while it plays; with # nothing playing it marks none, and this one goes on its own @@ -1693,7 +1697,7 @@ def _show_row_menu(self, global_pos): QIcon.fromTheme("jump-to"), with_key_txt(translate("Actions", "Jump to Bookmark"), Qt.Key_Enter), ) - jump.setEnabled(self._block.is_bookmark_reachable(row)) + jump.setEnabled(self._block.bookmarker.is_reachable_at(row)) jump.triggered.connect(lambda _=False: self._jump()) menu.addSeparator() diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index e54646d5..094e94b2 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -305,19 +305,19 @@ "Add Bookmark": { "title": translate("Actions", "Add Bookmark"), "icon": "bookmark-add", - "func": ("active", "add_bookmark"), + "func": ("active", "bookmarker.add"), "show_if": "is_active_seekable", }, "Previous Bookmark": { "title": translate("Actions", "Previous Bookmark"), "icon": "bookmark-previous", - "func": ("active", "manual_seek", "previous_bookmark"), + "func": ("active", "manual_seek", "bookmarker.previous"), "show_if": "is_active_has_bookmarks", }, "Next Bookmark": { "title": translate("Actions", "Next Bookmark"), "icon": "bookmark-next", - "func": ("active", "manual_seek", "next_bookmark"), + "func": ("active", "manual_seek", "bookmarker.next"), "show_if": "is_active_has_bookmarks", }, "Bookmark List": { @@ -332,7 +332,7 @@ "Manage Bookmarks": { "title": translate("Actions", "Manage Bookmarks…"), "icon": "bookmark-manage", - "func": ("active", "manage_bookmarks"), + "func": ("active", "bookmarker.manage"), "show_if": "is_active_seekable", }, "Random Loop": { diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index 916958f2..22bc4334 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -470,7 +470,7 @@ def is_active_bookmark_here(self, index) -> bool: if self.is_no_active_block: return False - return self._ctx.active_block.bookmark_here == index + return self._ctx.active_block.bookmarker.here == index def is_active_bookmark_reachable(self, index) -> bool: """Grayed out outside the loop, and past the end of what plays.""" @@ -478,7 +478,7 @@ def is_active_bookmark_reachable(self, index) -> bool: if self.is_no_active_block: return False - return self._ctx.active_block.is_bookmark_reachable(index) + return self._ctx.active_block.bookmarker.is_reachable_at(index) def menu_generator_bookmarks(self): """Every bookmark of the video, its marker in its colour before its @@ -498,7 +498,7 @@ def menu_generator_bookmarks(self): length_ms, ), "icon": bookmark_icon([bookmark.color]), - "func": ("active", "manual_seek", "seek_bookmark", index), + "func": ("active", "manual_seek", "bookmarker.seek", index), "check_if": ("is_active_bookmark_here", index), "is_choice": True, "enable_if": ("is_active_bookmark_reachable", index), diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 244bd020..47c78c8b 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -862,8 +862,8 @@ def _add_video_block(self, video): (self.all_previous_frame, vb.previous_frame), (self.all_next_chapter, vb.next_chapter), (self.all_previous_chapter, vb.previous_chapter), - (self.all_next_bookmark, vb.next_bookmark), - (self.all_previous_bookmark, vb.previous_bookmark), + (self.all_next_bookmark, vb.bookmarker.next), + (self.all_previous_bookmark, vb.bookmarker.previous), (self.all_set_loop_random, vb.set_loop_random), (self.all_set_loop_start, vb.set_loop_start), (self.all_set_loop_end, vb.set_loop_end), diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index 8bcc9ac8..b9af41b8 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -11,12 +11,10 @@ from pydantic_extra_types.color import Color from PyQt5.QtCore import QRect, QRectF, QSize, Qt, QTimer, pyqtSignal -from PyQt5.QtGui import QCursor, QIcon +from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QFileDialog, QStackedLayout, QWidget from gridplayer.dialogs.audio_delay import SetAudioDelayDialog, audio_delay_txt -from gridplayer.dialogs.bookmark_rename_dialog import QBookmarkRenameDialog -from gridplayer.dialogs.bookmarks import BookmarksDialog, held_end_action_txt from gridplayer.dialogs.error_details import ( DetailRows, ErrorDetailsDialog, @@ -43,7 +41,7 @@ AudioTrackId, track_of_file, ) -from gridplayer.models.bookmark import Bookmark, bookmark_color +from gridplayer.models.bookmark import Bookmark from gridplayer.models.chapter import Chapter from gridplayer.models.media_list import ( ItemDetails, @@ -114,17 +112,7 @@ ) from gridplayer.settings import Settings from gridplayer.utils.aspect_calc import SyncedView -from gridplayer.utils.bookmarks import ( - bookmark_at, - default_bookmark_name, - file_key, - is_bookmarked, - is_in_stretch, - next_bookmark_index, - next_bookmark_round, - previous_bookmark_index, - with_bookmark, -) +from gridplayer.utils.bookmarks import file_key from gridplayer.utils.chapters import ( chapter_index_at, chapter_span, @@ -172,7 +160,6 @@ sponsorblock_fetcher, ) from gridplayer.utils.step_sizes import seek_step_ms, zoom_stepped -from gridplayer.utils.time_txt import timed_title from gridplayer.utils.track_language import language_name, normalize, pick_track from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver @@ -195,18 +182,13 @@ wanted_audio_track_id, wanted_subtitle_track_id, ) -from gridplayer.widgets.bookmark_colors import ( - bookmark_color_menu, - bookmark_icon, - color_picked_by_hand, -) from gridplayer.widgets.cell_chrome import ( chrome_color_on_cell, paint_idle_disc, paint_solid_outline, ) -from gridplayer.widgets.custom_menu import CustomMenu from gridplayer.widgets.stacked_layout_floating import QStackedLayoutFloating +from gridplayer.widgets.video_block_bookmarker import Bookmarker from gridplayer.widgets.video_block_guards import ( only_initialized, only_live, @@ -430,18 +412,12 @@ def __init__(self, video_driver, context, **kwargs): # what the seek bar was last told to mark self._seek_marks: tuple[SeekMark, ...] = () - # the bookmarks manager while it is open, to close with the video - self._bookmarks_manager: BookmarksDialog | None = None - - # a bookmark's new name being typed, or its colour picked - self._is_editing_bookmark = False - # the picture switched off while the player is in the tray, to be # switched back on as it comes back; see set_video_decoding_stopped self._is_video_stopped_in_tray = False # shared with other cells, the bookmarks may change in any of them - self._ctx.bookmarks.changed.connect(self._update_seek_marks) + self._ctx.bookmarks.changed.connect(self.update_seek_marks) # Components self.overlay_hide_timer = QTimer(self) @@ -528,6 +504,8 @@ def __init__(self, video_driver, context, **kwargs): self.url_resolver = self.init_url_resolver() self.video_driver: VideoFrameVLC | None = None + self.bookmarker = Bookmarker(self) + sponsorblock_fetcher().fetched.connect(self._sponsor_segments_fetched) # the next, previous or a random file of the folder, to put in this @@ -770,8 +748,8 @@ def init_overlay(self): (overlay.exit_clicked, self.close), (overlay.play_pause_clicked, self.play_pause), (overlay.mute_unmute_clicked, self.mute_unmute), - (overlay.bookmark_clicked, self.jump_to_bookmark_at), - (overlay.bookmark_menu_requested, self.show_bookmark_menu), + (overlay.bookmark_clicked, self.bookmarker.jump_to_marker), + (overlay.bookmark_menu_requested, self.bookmarker.show_marker_menu), (overlay.list_place_clicked, self.show_list_window), (overlay.stop_clicked, self.stop_playback), (overlay.retry_clicked, self.reload), @@ -1369,7 +1347,7 @@ def close(self, notify=True): self._is_closing = True - self._close_bookmarks_manager() + self.bookmarker.close_manager() self.closing.emit() @@ -3592,34 +3570,14 @@ def _hold_end_for_bookmarks(self) -> bool: end_action = self.video_params.end_action - if not self._is_editing_bookmarks or end_action not in LEAVING_END_ACTIONS: + if not self.bookmarker.is_editing or end_action not in LEAVING_END_ACTIONS: return False - self._hold_end(end_action) - - return True - - @property - def _is_editing_bookmarks(self) -> bool: - return self._bookmarks_manager is not None or self._is_editing_bookmark - - def _hold_end(self, end_action: VideoEndAction): - """Pause at the start in place of the end action, and say so where - the bookmarks are being edited.""" - self._pause_at_start() - if self._bookmarks_manager is not None: - self._bookmarks_manager.hold_end(end_action) - return + self.bookmarker.tell_end_held(end_action) - # the box the name is typed in, or the colour picked in, has no room - # to say it: the video does - self.info_change.emit( - translate("Bookmarks", "Paused at the start instead of “{ACTION}”").format( - ACTION=held_end_action_txt(end_action) - ) - ) + return True def stop_playback(self): # stopped from the menu as from its own Stop, nothing left to start @@ -3751,7 +3709,7 @@ def _apply_snapshot_of_another_file(self, snapshot: Video): snapshot has it: the cell went on to another file since, the next on its list or in its folder.""" - self._close_bookmarks_manager() + self.bookmarker.close_manager() self._cancel_steps() self._view_to_follow = None @@ -4493,7 +4451,7 @@ def _site_chapters_playing(self, length: int) -> tuple[Chapter, ...]: def _set_chapters(self, chapters: tuple[Chapter, ...]): self._chapters = chapters - self._update_seek_marks() + self.update_seek_marks() @property def seek_marks(self) -> tuple[SeekMark, ...]: @@ -4501,7 +4459,7 @@ def seek_marks(self) -> tuple[SeekMark, ...]: return self._seek_marks - def _update_seek_marks(self): + def update_seek_marks(self): """Tell the seek bar what to mark: chapters, SponsorBlock's, and the viewer's bookmarks.""" @@ -4518,7 +4476,7 @@ def _update_seek_marks(self): bookmarks = () if self.is_video_initialized and not self.is_live: - bookmarks = self.bookmark_marks(self.video_driver.length) + bookmarks = self.bookmarker.marks(self.video_driver.length) marks = (*chapters, *segments, *bookmarks) @@ -4581,7 +4539,7 @@ def _update_sponsor_segments(self): ) self._skip_spans = skip_spans(segments, modes) - self._update_seek_marks() + self.update_seek_marks() def _sponsor_segments_playing(self) -> tuple[SponsorSegment, ...]: """The segments, if what plays is the video they were marked on.""" @@ -4618,7 +4576,7 @@ def chapter_playing(self) -> int | None: if not self._chapters: return None - return chapter_index_at(self._chapters, self._arriving_time) + return chapter_index_at(self._chapters, self.arriving_time) def is_chapter_reachable(self, index: int) -> bool: """Whether a jump to this chapter would land inside the loop.""" @@ -4637,7 +4595,7 @@ def next_chapter(self): return stop_ms = next_stop( - self._chapters, self._arriving_time, self.loop_start, self.loop_end + self._chapters, self.arriving_time, self.loop_start, self.loop_end ) if stop_ms is None: @@ -4655,7 +4613,7 @@ def previous_chapter(self): self._seek_chapter_stop( previous_stop( - self._chapters, self._arriving_time, self.loop_start, self.loop_end + self._chapters, self.arriving_time, self.loop_start, self.loop_end ) ) @@ -4686,7 +4644,7 @@ def loop_chapter(self): length = self.video_driver.length - start_ms, end_ms = span_at(self._chapters, self._arriving_time, length) + start_ms, end_ms = span_at(self._chapters, self.arriving_time, length) # cleared first: the new start can lie past the old end self.reset_loop() @@ -4698,15 +4656,7 @@ def loop_chapter(self): if end_ms < length: self.set_loop_end_time(end_ms) - # Bookmarks - # - # The places the viewer marked to come back to, kept by the playlist with - # the file they are in (see BookmarksRegistry): a cell gone on to another - # file finds them again when it comes back. A jump to one is the viewer's - # own seek, the same as a click on the bar, so one inside a sponsor is - # played rather than skipped. A loop narrows them down to itself, as it - # does the chapters. One past the end of what plays -- marked on another - # cut of a stream -- is kept, but is nowhere to go. + # the viewer's bookmarks; what is done with them is the Bookmarker's @property def bookmarks(self) -> tuple[Bookmark, ...]: @@ -4715,420 +4665,6 @@ def bookmarks(self) -> tuple[Bookmark, ...]: return self._ctx.bookmarks.get(self.video_params.uri, self.video_params.id) - def set_bookmarks(self, bookmarks): - """Put these in place of the file's bookmarks, in order and one to a - place; every cell they are shared with marks them too.""" - - self._ctx.bookmarks.set(self.video_params.uri, self.video_params.id, bookmarks) - - self._update_seek_marks() - - def bookmark_name(self, index: int) -> str: - return self.bookmarks[index].name or default_bookmark_name(index) - - def bookmark_marks(self, length_ms: int) -> tuple[SeekMark, ...]: - """The bookmarks as the bar marks them, those short of the end of a - video this long.""" - - return tuple( - SeekMark( - time_ms=bookmark.time_ms, - label=bookmark.name or default_bookmark_name(index), - kind=SeekMarkKind.BOOKMARK, - color=bookmark.color, - ) - for index, bookmark in enumerate(self.bookmarks) - if bookmark.time_ms < length_ms - ) - - def _is_bookmark_on_the_bar(self, bookmark: Bookmark) -> bool: - if not self.is_video_initialized or self.is_live: - return False - - return bookmark.time_ms < self.video_driver.length - - def is_bookmark_reachable(self, index: int) -> bool: - """Whether a jump to this bookmark would land inside the loop.""" - - bookmarks = self.bookmarks - - if not 0 <= index < len(bookmarks): - return False - - return self.is_reachable(bookmarks[index]) - - def is_reachable(self, bookmark: Bookmark) -> bool: - """Whether a jump to this bookmark would land inside the loop; for - going through all of them, with the list read once.""" - - if not self._is_bookmark_on_the_bar(bookmark): - return False - - return is_in_stretch(bookmark.time_ms, self.loop_start, self.loop_end) - - @property - def bookmark_here(self) -> int | None: - """The bookmark the video is at, None where it is at none.""" - - if not self.is_video_initialized or self.is_live: - return None - - return bookmark_at(self.bookmarks, self._arriving_time) - - @property - def time_to_bookmark(self) -> int: - """Where a bookmark added now goes: where the video is. - - A seek still on its way is where the viewer means, not where the - stream reports it has got to so far. At the very end, the last moment - before it: one at the end itself would be past the bar, never drawn - and never reached. - """ - - last = max(self.video_driver.length - 1, 0) - - return min(max(self._arriving_time, 0), last) - - @only_initialized - @only_seekable - def add_bookmark(self): - time_ms = self.time_to_bookmark - - if is_bookmarked(self.bookmarks, time_ms): - self.info_change.emit(translate("Bookmarks", "Already bookmarked")) - return - - self.set_bookmarks(with_bookmark(self.bookmarks, Bookmark(time_ms=time_ms))) - - self.info_change.emit(translate("Bookmarks", "Bookmark added")) - - @only_initialized - @only_seekable - def next_bookmark(self): - if not self.bookmarks: - return False - - index = next_bookmark_index( - self.bookmarks, self._arriving_time, self.loop_start, self.loop_end - ) - - if index is None: - self.info_change.emit(translate("Bookmarks", "No next bookmark")) - return False - - self._seek_bookmark(index) - - return True - - @only_initialized - @only_seekable - def previous_bookmark(self): - if not self.bookmarks: - return False - - index = previous_bookmark_index( - self.bookmarks, self._arriving_time, self.loop_start, self.loop_end - ) - - if index is None: - self.info_change.emit(translate("Bookmarks", "No previous bookmark")) - return False - - self._seek_bookmark(index) - - return True - - @only_initialized - @only_seekable - def seek_bookmark(self, index: int): - if not self.is_bookmark_reachable(index): - return False - - self._seek_bookmark(index) - - return True - - def _seek_bookmark(self, index: int): - self.seek(self.bookmarks[index].time_ms) - - self.info_change.emit(self.bookmark_name(index)) - - def _bookmark_indexes(self, times) -> list[int]: - return [ - index - for index, bookmark in enumerate(self.bookmarks) - if bookmark.time_ms in times - ] - - def jump_to_bookmark_at(self, times): - """Go to a bookmark of a marker clicked on, as from the menu: one - click after another, to each of those it stands for in turn.""" - - index = self.marker_bookmark_to_go(times) - - if index is not None: - self.manual_seek("seek_bookmark", index) - - def marker_bookmark_to_go(self, times) -> int | None: - """Which of a marker's bookmarks a click on it goes to: the one after - the one the video is on, round to the first after the last, of those - in reach; None for none in reach.""" - - indexes = [ - index - for index in self._bookmark_indexes(times) - if self.is_bookmark_reachable(index) - ] - - picked = next_bookmark_round( - [self.bookmarks[index] for index in indexes], self._arriving_time - ) - - return None if picked is None else indexes[picked] - - def show_bookmark_menu(self, global_pos, times): - """What can be done with the bookmarks of a marker, where it was - right-clicked.""" - - menu = self.bookmark_menu(times) - - if menu is None: - return - - menu.exec_(global_pos) - - # parented to the window, which would otherwise keep every one - menu.deleteLater() - - def bookmark_menu(self, times) -> CustomMenu | None: - """The menu of a marker: Jump to, Rename, Color and Remove for its - bookmark, or a menu of those for each where it stands for more than - one, those being too close together to click one by one; then Manage - Bookmarks, with its own picked out.""" - - indexes = self._bookmark_indexes(times) - - if not indexes or not self.is_video_initialized: - return None - - menu = CustomMenu(parent=self.window()) - - if len(indexes) == 1: - self._add_bookmark_menu_actions(menu, indexes[0]) - else: - self._add_bookmark_submenus(menu, indexes) - - menu.addSeparator() - - manage = menu.addAction( - QIcon.fromTheme("bookmark-manage"), - translate("Actions", "Manage Bookmarks…"), - ) - manage.triggered.connect( - self._if_bookmarks_unchanged(lambda: self.manage_bookmarks(indexes)) - ) - - return menu - - def _if_bookmarks_unchanged(self, func): - """func, for a menu to call, done only while the bookmarks are as they - were when it was made. - - A menu stays open while the video plays on, to its end and from there - to another file, or closed; what it was made for is gone then. - """ - - uri = self.video_params.uri - bookmarks = self.bookmarks - - def call(_=False): - if self._is_bookmarks_gone(uri, bookmarks): - return - - func() - - return call - - def _is_bookmarks_gone(self, uri, bookmarks) -> bool: - return ( - self._is_closing - or self.video_params is None - or self.video_params.uri != uri - or self.bookmarks != bookmarks - ) - - def _add_bookmark_submenus(self, menu, indexes): - for index in indexes: - submenu = CustomMenu(parent=menu) - submenu.setTitle( - timed_title( - self.bookmark_name(index), - self.bookmarks[index].time_ms, - self.video_driver.length, - ) - ) - # each in its own colour, to be told from the others by it too - submenu.setIcon(bookmark_icon([self.bookmarks[index].color])) - menu.addMenu(submenu) - - self._add_bookmark_menu_actions(submenu, index) - - def _add_bookmark_menu_actions(self, menu, index: int): - jump = menu.addAction( - QIcon.fromTheme("jump-to"), translate("Actions", "Jump to Bookmark") - ) - # grayed out where it is out of reach, as it is in the list - jump.setEnabled(self.is_bookmark_reachable(index)) - jump.triggered.connect( - self._if_bookmarks_unchanged( - lambda: self.manual_seek("seek_bookmark", index) - ) - ) - - menu.addSeparator() - - rename = menu.addAction( - QIcon.fromTheme("bookmark-rename"), translate("Actions", "Rename Bookmark…") - ) - rename.triggered.connect( - self._if_bookmarks_unchanged(lambda: self.rename_bookmark(index)) - ) - - menu.addMenu( - bookmark_color_menu( - menu, - [self.bookmarks[index].color], - slot_for=lambda color: self._if_bookmarks_unchanged( - lambda: self.set_bookmark_color(index, color) - ), - custom_slot=self._if_bookmarks_unchanged( - lambda: self.pick_bookmark_color(index) - ), - ) - ) - - remove = menu.addAction( - QIcon.fromTheme("bookmark-remove"), translate("Actions", "Remove Bookmark") - ) - remove.triggered.connect( - self._if_bookmarks_unchanged(lambda: self.remove_bookmark(index)) - ) - - @only_initialized - @only_seekable - def rename_bookmark(self, index: int): - """Name a bookmark, and colour it; a name left empty takes it away.""" - - uri = self.video_params.uri - bookmarks = self.bookmarks - bookmark = bookmarks[index] - - # the video plays on while the name is typed, held at its start - # rather than taken away at its end (see loop_end_action) - self._is_editing_bookmark = True - - try: - edits = QBookmarkRenameDialog.get_edits( - parent=self.parent(), - title=translate( - "Dialog - Rename bookmark", "Rename bookmark", "Header" - ), - name=bookmark.name or "", - placeholder=self.bookmark_name(index), - color=bookmark.color, - ) - finally: - self._is_editing_bookmark = False - - # and may still be gone at the end of it, closed from elsewhere, or - # another file put in its place: the name goes with it then - if edits is None or self._is_bookmarks_gone(uri, bookmarks): - return - - name, color = edits - - self._edit_bookmark(index, name=name.strip() or None, color=color) - - @only_initialized - @only_seekable - def set_bookmark_color(self, index: int, color: str | None): - """Colour a bookmark; None gives it back the bookmarks' own.""" - - self._edit_bookmark(index, color=color) - - @only_initialized - @only_seekable - def pick_bookmark_color(self, index: int): - """Colour a bookmark in one picked by hand.""" - - uri = self.video_params.uri - bookmarks = self.bookmarks - - # held as it is while it is named - self._is_editing_bookmark = True - - try: - color = color_picked_by_hand(self.parent(), bookmarks[index].color) - finally: - self._is_editing_bookmark = False - - if color is None or self._is_bookmarks_gone(uri, bookmarks): - return - - self._edit_bookmark(index, color=color) - - def _edit_bookmark(self, index: int, **changes): - bookmark = self.bookmarks[index] - - if "color" in changes: - changes["color"] = bookmark_color(changes["color"]) - - edited = bookmark.model_copy(update=changes) - - if edited == bookmark: - return - - bookmarks = list(self.bookmarks) - bookmarks[index] = edited - - self.set_bookmarks(bookmarks) - - @only_initialized - @only_seekable - def remove_bookmark(self, index: int): - remaining = list(self.bookmarks) - del remaining[index] - - self.set_bookmarks(remaining) - - self.info_change.emit(translate("Bookmarks", "Bookmark removed")) - - @only_initialized - @only_seekable - def manage_bookmarks(self, indexes=None): - """The bookmarks manager, the ones told picked out in it, or the one - the video is at.""" - - if indexes is None: - here = self.bookmark_here - indexes = () if here is None else (here,) - - dialog = BookmarksDialog(self, selected=indexes, parent=self.parent()) - - self._bookmarks_manager = dialog - dialog.exec_() - self._bookmarks_manager = None - - dialog.deleteLater() - - def _close_bookmarks_manager(self): - """Close the bookmarks manager with the video it is open on: the video - closed, or another file put in its place. What was done in it stays - done, as it would with OK.""" - - if self._bookmarks_manager is not None: - self._bookmarks_manager.accept() - @only_seekable def set_end_action(self, end_action: VideoEndAction): self._cancel_steps() @@ -5287,7 +4823,7 @@ def seek(self, seek_ms, is_viewers=True): self.video_driver.set_time(seek_ms) @property - def _arriving_time(self) -> int: + def arriving_time(self) -> int: """Where the video is, a seek still on its way counting as there already. Read off the time alone, a seek on a stream sits short of where it @@ -6425,7 +5961,7 @@ def _switch_to(self, new_video: Path | str): self._load_and_play() return - self._close_bookmarks_manager() + self.bookmarker.close_manager() self._cancel_steps() diff --git a/gridplayer/widgets/video_block_bookmarker.py b/gridplayer/widgets/video_block_bookmarker.py new file mode 100644 index 00000000..5519e1f0 --- /dev/null +++ b/gridplayer/widgets/video_block_bookmarker.py @@ -0,0 +1,505 @@ +"""The bookmarks of a video block. + +The places the viewer marked to come back to, kept by the playlist with +the file they are in (see BookmarksRegistry): a cell gone on to another +file finds them again when it comes back. A jump to one is the viewer's +own seek, the same as a click on the bar, so one inside a sponsor is +played rather than skipped. A loop narrows them down to itself, as it +does the chapters. One past the end of what plays -- marked on another +cut of a stream -- is kept, but is nowhere to go. + +The bookmarks themselves are the block's (VideoBlock.bookmarks); this is +what is done with them. +""" + +from PyQt5.QtGui import QIcon + +from gridplayer.dialogs.bookmark_rename_dialog import QBookmarkRenameDialog +from gridplayer.dialogs.bookmarks import BookmarksDialog, held_end_action_txt +from gridplayer.models.bookmark import Bookmark, bookmark_color +from gridplayer.models.seek_mark import SeekMark, SeekMarkKind +from gridplayer.params.static import VideoEndAction +from gridplayer.utils.bookmarks import ( + bookmark_at, + default_bookmark_name, + is_bookmarked, + is_in_stretch, + next_bookmark_index, + next_bookmark_round, + previous_bookmark_index, + with_bookmark, +) +from gridplayer.utils.qt import translate +from gridplayer.utils.time_txt import timed_title +from gridplayer.widgets.bookmark_colors import ( + bookmark_color_menu, + bookmark_icon, + color_picked_by_hand, +) +from gridplayer.widgets.custom_menu import CustomMenu +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_seekable, +) + + +class Bookmarker(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # the bookmarks manager while it is open, to close with the video + self._manager: BookmarksDialog | None = None + + # a bookmark's new name being typed, or its colour picked + self._is_editing_one = False + + def replace(self, bookmarks): + """Put these in place of the file's bookmarks, in order and one to a + place; every cell they are shared with marks them too.""" + + video_params = self._block.video_params + + self._block._ctx.bookmarks.set(video_params.uri, video_params.id, bookmarks) + + self._block.update_seek_marks() + + def name(self, index: int) -> str: + return self._block.bookmarks[index].name or default_bookmark_name(index) + + def marks(self, length_ms: int) -> tuple[SeekMark, ...]: + """The bookmarks as the bar marks them, those short of the end of a + video this long.""" + + return tuple( + SeekMark( + time_ms=bookmark.time_ms, + label=bookmark.name or default_bookmark_name(index), + kind=SeekMarkKind.BOOKMARK, + color=bookmark.color, + ) + for index, bookmark in enumerate(self._block.bookmarks) + if bookmark.time_ms < length_ms + ) + + def _is_on_the_bar(self, bookmark: Bookmark) -> bool: + if not self._block.is_video_initialized or self._block.is_live: + return False + + return bookmark.time_ms < self._block.video_driver.length + + def is_reachable_at(self, index: int) -> bool: + """Whether a jump to this bookmark would land inside the loop.""" + + bookmarks = self._block.bookmarks + + if not 0 <= index < len(bookmarks): + return False + + return self.is_reachable(bookmarks[index]) + + def is_reachable(self, bookmark: Bookmark) -> bool: + """Whether a jump to this bookmark would land inside the loop; for + going through all of them, with the list read once.""" + + if not self._is_on_the_bar(bookmark): + return False + + return is_in_stretch( + bookmark.time_ms, self._block.loop_start, self._block.loop_end + ) + + @property + def here(self) -> int | None: + """The bookmark the video is at, None where it is at none.""" + + if not self._block.is_video_initialized or self._block.is_live: + return None + + return bookmark_at(self._block.bookmarks, self._block.arriving_time) + + @property + def time_to_add(self) -> int: + """Where a bookmark added now goes: where the video is. + + A seek still on its way is where the viewer means, not where the + stream reports it has got to so far. At the very end, the last moment + before it: one at the end itself would be past the bar, never drawn + and never reached. + """ + + last = max(self._block.video_driver.length - 1, 0) + + return min(max(self._block.arriving_time, 0), last) + + @property + def is_editing(self) -> bool: + """The bookmarks manager is open, or a bookmark's name being typed or + its colour picked.""" + + return self._manager is not None or self._is_editing_one + + @only_initialized + @only_seekable + def add(self): + bookmarks = self._block.bookmarks + time_ms = self.time_to_add + + if is_bookmarked(bookmarks, time_ms): + self._block.info_change.emit(translate("Bookmarks", "Already bookmarked")) + return + + self.replace(with_bookmark(bookmarks, Bookmark(time_ms=time_ms))) + + self._block.info_change.emit(translate("Bookmarks", "Bookmark added")) + + @only_initialized + @only_seekable + def next(self): + bookmarks = self._block.bookmarks + + if not bookmarks: + return False + + index = next_bookmark_index( + bookmarks, + self._block.arriving_time, + self._block.loop_start, + self._block.loop_end, + ) + + if index is None: + self._block.info_change.emit(translate("Bookmarks", "No next bookmark")) + return False + + self._seek(index) + + return True + + @only_initialized + @only_seekable + def previous(self): + bookmarks = self._block.bookmarks + + if not bookmarks: + return False + + index = previous_bookmark_index( + bookmarks, + self._block.arriving_time, + self._block.loop_start, + self._block.loop_end, + ) + + if index is None: + self._block.info_change.emit(translate("Bookmarks", "No previous bookmark")) + return False + + self._seek(index) + + return True + + @only_initialized + @only_seekable + def seek(self, index: int): + if not self.is_reachable_at(index): + return False + + self._seek(index) + + return True + + def _seek(self, index: int): + self._block.seek(self._block.bookmarks[index].time_ms) + + self._block.info_change.emit(self.name(index)) + + def _indexes(self, times) -> list[int]: + return [ + index + for index, bookmark in enumerate(self._block.bookmarks) + if bookmark.time_ms in times + ] + + def jump_to_marker(self, times): + """Go to a bookmark of a marker clicked on, as from the menu: one + click after another, to each of those it stands for in turn.""" + + index = self.marker_to_go(times) + + if index is not None: + self._block.manual_seek("bookmarker.seek", index) + + def marker_to_go(self, times) -> int | None: + """Which of a marker's bookmarks a click on it goes to: the one after + the one the video is on, round to the first after the last, of those + in reach; None for none in reach.""" + + bookmarks = self._block.bookmarks + + indexes = [ + index for index in self._indexes(times) if self.is_reachable_at(index) + ] + + picked = next_bookmark_round( + [bookmarks[index] for index in indexes], self._block.arriving_time + ) + + return None if picked is None else indexes[picked] + + def show_marker_menu(self, global_pos, times): + """What can be done with the bookmarks of a marker, where it was + right-clicked.""" + + menu = self.marker_menu(times) + + if menu is None: + return + + menu.exec_(global_pos) + + # parented to the window, which would otherwise keep every one + menu.deleteLater() + + def marker_menu(self, times) -> CustomMenu | None: + """The menu of a marker: Jump to, Rename, Color and Remove for its + bookmark, or a menu of those for each where it stands for more than + one, those being too close together to click one by one; then Manage + Bookmarks, with its own picked out.""" + + indexes = self._indexes(times) + + if not indexes or not self._block.is_video_initialized: + return None + + menu = CustomMenu(parent=self._block.window()) + + if len(indexes) == 1: + self._add_menu_actions(menu, indexes[0]) + else: + self._add_submenus(menu, indexes) + + menu.addSeparator() + + manage = menu.addAction( + QIcon.fromTheme("bookmark-manage"), + translate("Actions", "Manage Bookmarks…"), + ) + manage.triggered.connect(self._if_unchanged(lambda: self.manage(indexes))) + + return menu + + def _if_unchanged(self, func): + """func, for a menu to call, done only while the bookmarks are as they + were when it was made. + + A menu stays open while the video plays on, to its end and from there + to another file, or closed; what it was made for is gone then. + """ + + uri = self._block.video_params.uri + bookmarks = self._block.bookmarks + + def call(_=False): + if self._is_gone(uri, bookmarks): + return + + func() + + return call + + def _is_gone(self, uri, bookmarks) -> bool: + block = self._block + + return ( + block.is_closing + or block.video_params is None + or block.video_params.uri != uri + or block.bookmarks != bookmarks + ) + + def _add_submenus(self, menu, indexes): + bookmarks = self._block.bookmarks + + for index in indexes: + submenu = CustomMenu(parent=menu) + submenu.setTitle( + timed_title( + self.name(index), + bookmarks[index].time_ms, + self._block.video_driver.length, + ) + ) + # each in its own colour, to be told from the others by it too + submenu.setIcon(bookmark_icon([bookmarks[index].color])) + menu.addMenu(submenu) + + self._add_menu_actions(submenu, index) + + def _add_menu_actions(self, menu, index: int): + jump = menu.addAction( + QIcon.fromTheme("jump-to"), translate("Actions", "Jump to Bookmark") + ) + # grayed out where it is out of reach, as it is in the list + jump.setEnabled(self.is_reachable_at(index)) + jump.triggered.connect( + self._if_unchanged( + lambda: self._block.manual_seek("bookmarker.seek", index) + ) + ) + + menu.addSeparator() + + rename = menu.addAction( + QIcon.fromTheme("bookmark-rename"), translate("Actions", "Rename Bookmark…") + ) + rename.triggered.connect(self._if_unchanged(lambda: self.rename(index))) + + menu.addMenu( + bookmark_color_menu( + menu, + [self._block.bookmarks[index].color], + slot_for=lambda color: self._if_unchanged( + lambda: self.set_color(index, color) + ), + custom_slot=self._if_unchanged(lambda: self.pick_color(index)), + ) + ) + + remove = menu.addAction( + QIcon.fromTheme("bookmark-remove"), translate("Actions", "Remove Bookmark") + ) + remove.triggered.connect(self._if_unchanged(lambda: self.remove(index))) + + @only_initialized + @only_seekable + def rename(self, index: int): + """Name a bookmark, and colour it; a name left empty takes it away.""" + + uri = self._block.video_params.uri + bookmarks = self._block.bookmarks + bookmark = bookmarks[index] + + # the video plays on while the name is typed, held at its start + # rather than taken away at its end (see loop_end_action) + self._is_editing_one = True + + try: + edits = QBookmarkRenameDialog.get_edits( + parent=self._block.parent(), + title=translate( + "Dialog - Rename bookmark", "Rename bookmark", "Header" + ), + name=bookmark.name or "", + placeholder=self.name(index), + color=bookmark.color, + ) + finally: + self._is_editing_one = False + + # and may still be gone at the end of it, closed from elsewhere, or + # another file put in its place: the name goes with it then + if edits is None or self._is_gone(uri, bookmarks): + return + + name, color = edits + + self._edit(index, name=name.strip() or None, color=color) + + @only_initialized + @only_seekable + def set_color(self, index: int, color: str | None): + """Colour a bookmark; None gives it back the bookmarks' own.""" + + self._edit(index, color=color) + + @only_initialized + @only_seekable + def pick_color(self, index: int): + """Colour a bookmark in one picked by hand.""" + + uri = self._block.video_params.uri + bookmarks = self._block.bookmarks + + # held as it is while it is named + self._is_editing_one = True + + try: + color = color_picked_by_hand(self._block.parent(), bookmarks[index].color) + finally: + self._is_editing_one = False + + if color is None or self._is_gone(uri, bookmarks): + return + + self._edit(index, color=color) + + def _edit(self, index: int, **changes): + bookmark = self._block.bookmarks[index] + + if "color" in changes: + changes["color"] = bookmark_color(changes["color"]) + + edited = bookmark.model_copy(update=changes) + + if edited == bookmark: + return + + bookmarks = list(self._block.bookmarks) + bookmarks[index] = edited + + self.replace(bookmarks) + + @only_initialized + @only_seekable + def remove(self, index: int): + remaining = list(self._block.bookmarks) + del remaining[index] + + self.replace(remaining) + + self._block.info_change.emit(translate("Bookmarks", "Bookmark removed")) + + @only_initialized + @only_seekable + def manage(self, indexes=None): + """The bookmarks manager, the ones told picked out in it, or the one + the video is at.""" + + if indexes is None: + here = self.here + indexes = () if here is None else (here,) + + dialog = BookmarksDialog( + self._block, selected=indexes, parent=self._block.parent() + ) + + self._manager = dialog + dialog.exec_() + self._manager = None + + dialog.deleteLater() + + def close_manager(self): + """Close the bookmarks manager with the video it is open on: the video + closed, or another file put in its place. What was done in it stays + done, as it would with OK.""" + + if self._manager is not None: + self._manager.accept() + + def tell_end_held(self, end_action: VideoEndAction): + """Say the video was paused at its start in place of the end action, + the bookmarks being edited.""" + + if self._manager is not None: + self._manager.hold_end(end_action) + return + + # the box the name is typed in, or the colour picked in, has no room + # to say it: the video does + self._block.info_change.emit( + translate("Bookmarks", "Paused at the start instead of “{ACTION}”").format( + ACTION=held_end_action_txt(end_action) + ) + ) diff --git a/gridplayer/widgets/video_block_guards.py b/gridplayer/widgets/video_block_guards.py index df785948..cb8dc2ac 100644 --- a/gridplayer/widgets/video_block_guards.py +++ b/gridplayer/widgets/video_block_guards.py @@ -2,10 +2,25 @@ undone, quietly, for the rest.""" +class VideoBlockPart: + """A part of a VideoBlock taken out into an object of its own, its + commands reached as "part.command" (see ActiveBlockManager.cmd_active). + + The guards below ask the block it belongs to. + """ + + def __init__(self, block): + self._block = block + + +def _block_of(obj): + return obj._block if isinstance(obj, VideoBlockPart) else obj + + def only_initialized(func): def wrapper(*args, **kwargs): - self = args[0] - if not self.is_video_initialized: + block = _block_of(args[0]) + if not block.is_video_initialized: return None return func(*args, **kwargs) @@ -14,8 +29,8 @@ def wrapper(*args, **kwargs): def only_seekable(func): def wrapper(*args, **kwargs): - self = args[0] - if self.is_live: + block = _block_of(args[0]) + if block.is_live: return None return func(*args, **kwargs) @@ -24,8 +39,8 @@ def wrapper(*args, **kwargs): def only_with_video_tacks(func): def wrapper(*args, **kwargs): - self = args[0] - if not self.video_tracks: + block = _block_of(args[0]) + if not block.video_tracks: return None return func(*args, **kwargs) @@ -34,8 +49,8 @@ def wrapper(*args, **kwargs): def only_live(func): def wrapper(*args, **kwargs): - self = args[0] - if not self.is_live: + block = _block_of(args[0]) + if not block.is_live: return None return func(*args, **kwargs) @@ -44,8 +59,8 @@ def wrapper(*args, **kwargs): def only_local_file(func): def wrapper(*args, **kwargs): - self = args[0] - if not self.is_local_file: + block = _block_of(args[0]) + if not block.is_local_file: return None return func(*args, **kwargs) @@ -57,8 +72,8 @@ def only_navigable(func): the items of its list, links among them.""" def wrapper(*args, **kwargs): - self = args[0] - if not self.is_navigable: + block = _block_of(args[0]) + if not block.is_navigable: return None return func(*args, **kwargs) @@ -67,8 +82,8 @@ def wrapper(*args, **kwargs): def only_streamable(func): def wrapper(*args, **kwargs): - self = args[0] - if not self.streams: + block = _block_of(args[0]) + if not block.streams: return None return func(*args, **kwargs) diff --git a/tests/test_bookmarks_dialog.py b/tests/test_bookmarks_dialog.py index cc857120..be78e018 100644 --- a/tests/test_bookmarks_dialog.py +++ b/tests/test_bookmarks_dialog.py @@ -112,7 +112,7 @@ def _make(bookmarks=BOOKMARKS): end_action=VideoEndAction.LOOP_FILE, ) ) - block.set_bookmarks(bookmarks) + block.bookmarker.replace(bookmarks) # the time goes where a test says and nowhere else block.video_driver._fake_player_timer.stop() @@ -606,7 +606,7 @@ def test_space_does_too(self, block, dialog): assert not block.video_params.is_paused def test_bookmarks_changed_meanwhile_are_shown(self, block, dialog): - block.set_bookmarks([*BOOKMARKS, Bookmark(time_ms=40000)]) + block.bookmarker.replace([*BOOKMARKS, Bookmark(time_ms=40000)]) assert dialog._model.rowCount() == 4 @@ -734,7 +734,7 @@ def leave_it(): give_up.timeout.connect(leave_it) QTimer.singleShot(0, act) - block.manage_bookmarks() + block.bookmarker.manage() give_up.stop() return opened @@ -1124,7 +1124,7 @@ def test_with_none_it_says_nothing(self, make_block, open_dialog): def test_what_is_being_said_stays_over_it(self, block, dialog): dialog._copy_all() - block.set_bookmarks(BOOKMARKS[:1]) + block.bookmarker.replace(BOOKMARKS[:1]) QApplication.processEvents() assert _status(dialog) == "Bookmarks copied: 3" diff --git a/tests/test_dotted_commands.py b/tests/test_dotted_commands.py index f38b8725..72000c07 100644 --- a/tests/test_dotted_commands.py +++ b/tests/test_dotted_commands.py @@ -26,11 +26,11 @@ def _playing_block(mocker): def test_cmd_active_reaches_a_part_of_the_block(mocker): block = _playing_block(mocker) - block.bookmarks.seek.return_value = "sought" + block.bookmarker.seek.return_value = "sought" - result = _active_manager(block).cmd_active("bookmarks.seek", 3) + result = _active_manager(block).cmd_active("bookmarker.seek", 3) - block.bookmarks.seek.assert_called_once_with(3) + block.bookmarker.seek.assert_called_once_with(3) assert result == "sought" @@ -46,35 +46,35 @@ def test_cmd_active_drops_a_dotted_command_while_loading(mocker): block = _playing_block(mocker) block.is_playable = False - _active_manager(block).cmd_active("bookmarks.next") + _active_manager(block).cmd_active("bookmarker.next") - block.bookmarks.next.assert_not_called() + block.bookmarker.next.assert_not_called() def test_runtime_param_reaches_a_part_of_the_block(mocker): block = _playing_block(mocker) - block.bookmarks.count = 2 + block.bookmarker.count = 2 manager = _active_manager(block) - assert manager.is_active_runtime_param_set_to("bookmarks.count", 2) - assert not manager.is_active_runtime_param_set_to("bookmarks.count", 3) + assert manager.is_active_runtime_param_set_to("bookmarker.count", 2) + assert not manager.is_active_runtime_param_set_to("bookmarker.count", 3) def test_manual_seek_reaches_a_part_and_syncs_the_others(mocker): block = _playing_block(mocker) - VideoBlock.manual_seek(block, "bookmarks.seek", 3) + VideoBlock.manual_seek(block, "bookmarker.seek", 3) - block.bookmarks.seek.assert_called_once_with(3) + block.bookmarker.seek.assert_called_once_with(3) block.sync_percent.emit.assert_called_once_with(0.5) block.sync_time.emit.assert_called_once_with(5000) def test_manual_seek_of_a_part_that_went_nowhere_syncs_nothing(mocker): block = _playing_block(mocker) - block.bookmarks.seek.return_value = False + block.bookmarker.seek.return_value = False - VideoBlock.manual_seek(block, "bookmarks.seek", 3) + VideoBlock.manual_seek(block, "bookmarker.seek", 3) block.sync_percent.emit.assert_not_called() block.sync_time.emit.assert_not_called() diff --git a/tests/test_folder_navigation.py b/tests/test_folder_navigation.py index 07d2e0b6..cba32502 100644 --- a/tests/test_folder_navigation.py +++ b/tests/test_folder_navigation.py @@ -323,9 +323,9 @@ def test_bookmarks_edited_since_the_end_hold_the_video( block = make_block(tree / "a.mp4", is_dir_recursive=True) block.next_video(is_end=True) - block._is_editing_bookmark = True + block.bookmarker._is_editing_one = True held_scans.finish() - block._is_editing_bookmark = False + block.bookmarker._is_editing_one = False assert _uri(block) == tree / "a.mp4" assert block.notices[-1].startswith("Paused at the start instead of") @@ -336,9 +336,9 @@ def test_a_step_the_viewer_asked_for_is_taken_all_the_same( block = make_block(tree / "a.mp4", is_dir_recursive=True) block.next_video() - block._is_editing_bookmark = True + block.bookmarker._is_editing_one = True held_scans.finish() - block._is_editing_bookmark = False + block.bookmarker._is_editing_one = False assert _uri(block) == tree / "b.mp4" diff --git a/tests/test_menu_bookmarks.py b/tests/test_menu_bookmarks.py index 1cbfe6ce..c5f6cb38 100644 --- a/tests/test_menu_bookmarks.py +++ b/tests/test_menu_bookmarks.py @@ -14,6 +14,7 @@ from gridplayer.player.managers.video_blocks import VideoBlocksManager from gridplayer.widgets.keymap_tree_view import DATA_ACTION_ID, fill_keymap_model from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_bookmarker import Bookmarker BOOKMARK_ACTIONS = ( "Add Bookmark", @@ -26,6 +27,15 @@ ICONS = Path(__file__).parent.parent / "gridplayer" / "resources" / "icons" +# the parts of a video block a command may name, "bookmarker.add" +BLOCK_PARTS = {"bookmarker": Bookmarker} + + +def _is_block_command(command): + part, _, method = command.rpartition(".") + + return hasattr(BLOCK_PARTS[part] if part else VideoBlock, method) + def _submenu(items, *path): for name in path: @@ -99,18 +109,21 @@ def test_the_active_video_has_what_they_call(self): func = ACTIONS[name]["func"] method = func[2] if func[1] == "manual_seek" else func[1] - assert hasattr(VideoBlock, method), name + assert _is_block_command(method), name def test_jumps_on_the_active_video_are_seeks_the_others_follow(self): for name in ("Previous Bookmark", "Next Bookmark"): assert ACTIONS[name]["func"][1] == "manual_seek", name def test_every_video_is_wired_up_for_them(self): - for name in ("Previous Bookmark [ALL]", "Next Bookmark [ALL]"): + for name, method in ( + ("Previous Bookmark [ALL]", "bookmarker.previous"), + ("Next Bookmark [ALL]", "bookmarker.next"), + ): command = ACTIONS[name]["func"][1] assert hasattr(VideoBlocksManager, f"all_{command}"), name - assert hasattr(VideoBlock, command), name + assert _is_block_command(method), name def test_what_decides_when_they_show_is_there(self): commands = { diff --git a/tests/test_video_block_bookmarks.py b/tests/test_video_block_bookmarks.py index 36e28688..61dbbb2b 100644 --- a/tests/test_video_block_bookmarks.py +++ b/tests/test_video_block_bookmarks.py @@ -5,6 +5,7 @@ """ import dataclasses +from operator import attrgetter from types import SimpleNamespace from unittest.mock import MagicMock @@ -28,7 +29,7 @@ from gridplayer.settings import Settings from gridplayer.utils.bookmarks import default_bookmark_name from gridplayer.utils.sponsorblock import SkipSpan -from gridplayer.widgets import video_block as video_block_module +from gridplayer.widgets import video_block_bookmarker as bookmarker_module from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_frame_dummy import VideoFrameDummy @@ -114,7 +115,7 @@ def _make(bookmarks=BOOKMARKS, context=None): # None leaves those the file has: another cell's, shared if bookmarks is not None: - block.set_bookmarks(bookmarks) + block.bookmarker.replace(bookmarks) assert block.is_video_initialized @@ -167,7 +168,7 @@ def test_one_is_added_where_the_video_is(self, make_block): block = make_block(bookmarks=()) block.seek(12000) - block.add_bookmark() + block.bookmarker.add() assert block.bookmarks == (Bookmark(time_ms=12000),) assert block.notices == ["Bookmark added"] @@ -176,7 +177,7 @@ def test_it_is_kept_by_the_playlist_with_its_file(self, make_block, tmp_path): block = make_block(bookmarks=()) block.seek(12000) - block.add_bookmark() + block.bookmarker.add() assert block._ctx.bookmarks.entries() == [ FileBookmarks( @@ -186,13 +187,13 @@ def test_it_is_kept_by_the_playlist_with_its_file(self, make_block, tmp_path): def test_it_goes_in_its_place_among_the_rest(self, block): block.seek(20000) - block.add_bookmark() + block.bookmarker.add() assert _times(block) == [10000, 20000, 30000] def test_one_where_there_is_one_already_is_not_added(self, block): block.seek(30200) - block.add_bookmark() + block.bookmarker.add() assert _times(block) == [10000, 30000] assert block.notices == ["Already bookmarked"] @@ -204,7 +205,7 @@ def test_while_a_seek_on_a_stream_lands_it_goes_where_it_was_aimed( block.seek(25000) _reported(block, LANDING_ON_A_STREAM_MS) - block.add_bookmark() + block.bookmarker.add() assert _times(block) == [25000] @@ -212,7 +213,7 @@ def test_a_live_stream_takes_none(self, make_block): block = make_block(bookmarks=()) block.is_live = True - block.add_bookmark() + block.bookmarker.add() assert block.bookmarks == () @@ -220,21 +221,21 @@ def test_at_the_very_end_it_goes_just_before_it(self, make_block): block = make_block(bookmarks=()) block.seek(LENGTH) - block.add_bookmark() + block.bookmarker.add() assert _times(block) == [LENGTH - 1] - assert block.is_bookmark_reachable(0) + assert block.bookmarker.is_reachable_at(0) def test_one_added_now_would_go_where_the_video_is(self, block): block.seek(25000) _reported(block, LANDING_ON_A_STREAM_MS) - assert block.time_to_bookmark == 25000 + assert block.bookmarker.time_to_add == 25000 class TestSettingThemAll: def test_they_are_put_in_order_one_to_a_place(self, block): - block.set_bookmarks( + block.bookmarker.replace( [ Bookmark(time_ms=50000), Bookmark(time_ms=20000, name="First"), @@ -248,7 +249,7 @@ def test_they_are_put_in_order_one_to_a_place(self, block): ) def test_the_bar_shows_them_at_once(self, block): - block.set_bookmarks([Bookmark(time_ms=45000)]) + block.bookmarker.replace([Bookmark(time_ms=45000)]) assert [mark.time_ms for mark in _bookmark_marks(block)] == [45000] @@ -268,7 +269,7 @@ def test_the_hover_label_has_them_too(self, block): def test_one_added_is_marked_at_once(self, block): block.seek(45000) - block.add_bookmark() + block.bookmarker.add() assert [mark.time_ms for mark in _bookmark_marks(block)] == [ 10000, @@ -305,31 +306,31 @@ def test_a_live_stream_marks_none(self, block): class TestJumping: def test_next_goes_from_one_to_the_next(self, block): - block.next_bookmark() + block.bookmarker.next() assert block.time == 10000 - block.next_bookmark() + block.bookmarker.next() assert block.time == 30000 def test_a_jump_names_the_bookmark_it_lands_on(self, block): - block.next_bookmark() - block.next_bookmark() + block.bookmarker.next() + block.bookmarker.next() assert block.notices == ["Bookmark 1", "Goal & Run"] def test_next_from_a_jump_that_landed_short_moves_on(self, block): block.seek(10000 - LANDED_SHORT_MS) - block.next_bookmark() + block.bookmarker.next() assert block.time == 30000 def test_next_while_a_jump_on_a_stream_lands_moves_on(self, make_block): block = make_block(bookmarks=(Bookmark(time_ms=25000), Bookmark(time_ms=40000))) - block.seek_bookmark(0) + block.bookmarker.seek(0) _reported(block, LANDING_ON_A_STREAM_MS) - block.next_bookmark() + block.bookmarker.next() assert block.time == 40000 @@ -337,7 +338,7 @@ def test_next_at_the_last_goes_nowhere_and_says_so(self, block): block.seek(35000) block.loop_end_action = MagicMock() - block.next_bookmark() + block.bookmarker.next() assert block.time == 35000 assert _seeks(block) == [35000] @@ -347,21 +348,21 @@ def test_next_at_the_last_goes_nowhere_and_says_so(self, block): def test_previous_well_past_one_goes_back_to_it(self, block): block.seek(35000) - block.previous_bookmark() + block.bookmarker.previous() assert block.time == 30000 def test_previous_just_past_one_goes_to_the_one_before(self, block): block.seek(30500) - block.previous_bookmark() + block.bookmarker.previous() assert block.time == 10000 def test_previous_at_the_first_goes_nowhere_and_says_so(self, block): block.seek(10000) - block.previous_bookmark() + block.bookmarker.previous() assert block.time == 10000 assert block.notices == ["No previous bookmark"] @@ -370,10 +371,10 @@ def test_ones_close_together_are_each_gone_to(self, make_block): block = make_block(bookmarks=(Bookmark(time_ms=20000), Bookmark(time_ms=20600))) block.seek(20600) - block.previous_bookmark() + block.bookmarker.previous() assert block.time == 20000 - block.next_bookmark() + block.bookmarker.next() assert block.time == 20600 assert block.notices == ["Bookmark 1", "Bookmark 2"] @@ -381,8 +382,8 @@ def test_ones_close_together_are_each_gone_to(self, make_block): def test_a_video_without_any_goes_nowhere_and_says_nothing(self, make_block): block = make_block(bookmarks=()) - block.next_bookmark() - block.previous_bookmark() + block.bookmarker.next() + block.bookmarker.previous() assert block.time == 0 assert block.notices == [] @@ -391,7 +392,7 @@ def test_a_jump_from_the_menu_is_a_seek_the_others_can_follow(self, block): synced = [] block.sync_time.connect(synced.append) - block.manual_seek("seek_bookmark", 1) + block.manual_seek("bookmarker.seek", 1) assert block.time == 30000 assert synced == [30000] @@ -403,7 +404,7 @@ def test_one_that_goes_nowhere_gives_the_others_nothing_to_follow(self, block): block.sync_time.connect(synced.append) block.sync_percent.connect(synced.append) - block.manual_seek("next_bookmark") + block.manual_seek("bookmarker.next") assert synced == [] @@ -411,7 +412,7 @@ def test_one_inside_a_sponsor_is_played_not_skipped(self, block): block._skip_spans = (SkipSpan(25000, 40000, "sponsor"),) block.set_pause(False) - block.seek_bookmark(1) + block.bookmarker.seek(1) _reported(block, 30000) _reported(block, 30400) _reported(block, 31000) @@ -437,38 +438,36 @@ def looped(self, make_block): return block def test_those_outside_it_are_out_of_reach(self, looped): - reachable = [looped.is_bookmark_reachable(i) for i in range(3)] + reachable = [looped.bookmarker.is_reachable_at(i) for i in range(3)] assert reachable == [False, True, False] def test_next_and_previous_pass_them_over(self, looped): - looped.next_bookmark() + looped.bookmarker.next() assert looped.time == 30000 - looped.next_bookmark() + looped.bookmarker.next() assert looped.time == 30000 assert looped.notices[-1] == "No next bookmark" - looped.previous_bookmark() + looped.bookmarker.previous() assert looped.notices[-1] == "No previous bookmark" def test_a_jump_to_one_out_of_reach_goes_nowhere(self, looped): - assert looped.seek_bookmark(0) is False + assert looped.bookmarker.seek(0) is False assert looped.time == 25000 class TestRenaming: @pytest.fixture def answer(self, mocker): - return mocker.patch.object( - video_block_module.QBookmarkRenameDialog, "get_edits" - ) + return mocker.patch.object(bookmarker_module.QBookmarkRenameDialog, "get_edits") def test_one_told_which_is_renamed_wherever_the_video_is(self, block, answer): answer.return_value = ("First", None) block.seek(20000) - block.rename_bookmark(0) + block.bookmarker.rename(0) assert block.bookmarks[0] == Bookmark(time_ms=10000, name="First") assert _bookmark_marks(block)[0].label == "First" @@ -476,17 +475,17 @@ def test_one_told_which_is_renamed_wherever_the_video_is(self, block, answer): def test_the_name_it_has_is_the_one_to_edit(self, block, answer): answer.return_value = ("Goal & Run", None) - block.rename_bookmark(1) + block.bookmarker.rename(1) assert answer.call_args.kwargs["name"] == "Goal & Run" def test_a_name_left_empty_goes_back_to_its_number(self, block, answer): answer.return_value = (" ", None) - block.rename_bookmark(1) + block.bookmarker.rename(1) assert block.bookmarks[1].name is None - assert block.bookmark_name(1) == "Bookmark 2" + assert block.bookmarker.name(1) == "Bookmark 2" assert default_bookmark_name(1) == "Bookmark 2" @@ -494,7 +493,7 @@ class TestRemoving: def test_one_told_which_is_removed_wherever_the_video_is(self, block): block.seek(20000) - block.remove_bookmark(1) + block.bookmarker.remove(1) assert _times(block) == [10000] assert block.notices == ["Bookmark removed"] @@ -504,12 +503,12 @@ def test_one_told_which_is_removed_wherever_the_video_is(self, block): class TestManaging: @pytest.fixture def dialog(self, mocker): - return mocker.patch.object(video_block_module, "BookmarksDialog") + return mocker.patch.object(bookmarker_module, "BookmarksDialog") def test_the_one_the_video_is_at_is_picked_out(self, block, dialog): block.seek(30000 - LANDED_SHORT_MS) - block.manage_bookmarks() + block.bookmarker.manage() assert dialog.call_args.kwargs["selected"] == (1,) dialog.return_value.exec_.assert_called_once() @@ -517,17 +516,17 @@ def test_the_one_the_video_is_at_is_picked_out(self, block, dialog): def test_away_from_any_none_is(self, block, dialog): block.seek(20000) - block.manage_bookmarks() + block.bookmarker.manage() assert dialog.call_args.kwargs["selected"] == () def test_those_told_are(self, block, dialog): - block.manage_bookmarks([0, 1]) + block.bookmarker.manage([0, 1]) assert dialog.call_args.kwargs["selected"] == [0, 1] def test_it_opens_over_the_player(self, block, dialog): - block.manage_bookmarks() + block.bookmarker.manage() assert dialog.call_args.args == (block,) assert dialog.call_args.kwargs["parent"] is block.parent() @@ -535,7 +534,7 @@ def test_it_opens_over_the_player(self, block, dialog): def test_a_live_stream_has_none_to_manage(self, block, dialog): block.is_live = True - block.manage_bookmarks() + block.bookmarker.manage() dialog.assert_not_called() @@ -568,7 +567,7 @@ def test_two_cells_with_the_file_share_them(self, make_block): second = make_block(bookmarks=None, context=context) first.seek(45000) - first.add_bookmark() + first.bookmarker.add() assert _times(second) == [10000, 30000, 45000] assert [mark.time_ms for mark in _bookmark_marks(second)] == [ @@ -584,7 +583,7 @@ def test_kept_by_cell_each_has_its_own(self, make_block): second = make_block(bookmarks=None, context=context) first.seek(45000) - first.add_bookmark() + first.bookmarker.add() assert _times(first) == [10000, 30000, 45000] assert second.bookmarks == () @@ -595,7 +594,7 @@ def test_a_snapshot_leaves_them_as_they_are(self, block): ) block.seek(45000) - block.add_bookmark() + block.bookmarker.add() block.apply_snapshot(snapshot.videos[0]) @@ -658,8 +657,8 @@ def test_and_shows_it_in_the_menu(self, make_block): def test_a_row_jumps_as_a_seek_the_others_can_follow(self, block): rows = _Manager(block).menu_generator_bookmarks() - assert rows[1]["func"] == ("active", "manual_seek", "seek_bookmark", 1) - assert hasattr(VideoBlock, rows[1]["func"][2]) + assert rows[1]["func"] == ("active", "manual_seek", "bookmarker.seek", 1) + assert callable(attrgetter(rows[1]["func"][2])(block)) def test_the_one_the_video_is_at_is_ticked(self, block): manager = _Manager(block) @@ -762,24 +761,26 @@ def _item(self, menu, title): return next(action for action in menu.actions() if action.text() == title) def test_it_offers_jump_to_rename_and_remove_then_the_manager(self, block): - menu = block.bookmark_menu((30000,)) + menu = block.bookmarker.marker_menu((30000,)) assert self._titles(menu) == self.ITEMS + self.MANAGE def test_rename_has_the_bookmarks_own_icon(self, block, mocker): named = mocker.patch.object( - video_block_module.QIcon, "fromTheme", side_effect=lambda _name: QIcon() + bookmarker_module.QIcon, "fromTheme", side_effect=lambda _name: QIcon() ) - block.bookmark_menu((30000,)) + block.bookmarker.marker_menu((30000,)) assert mocker.call("bookmark-rename") in named.call_args_list def test_the_manager_opens_with_its_bookmark_picked_out(self, block, mocker): - dialog = mocker.patch.object(video_block_module, "BookmarksDialog") + dialog = mocker.patch.object(bookmarker_module, "BookmarksDialog") block.seek(20000) - self._item(block.bookmark_menu((30000,)), "Manage Bookmarks…").trigger() + self._item( + block.bookmarker.marker_menu((30000,)), "Manage Bookmarks…" + ).trigger() assert dialog.call_args.kwargs["selected"] == [1] @@ -787,7 +788,7 @@ def test_jump_to_goes_there_as_a_seek_the_others_can_follow(self, block): synced = [] block.sync_time.connect(synced.append) - self._item(block.bookmark_menu((30000,)), "Jump to Bookmark").trigger() + self._item(block.bookmarker.marker_menu((30000,)), "Jump to Bookmark").trigger() assert block.time == 30000 assert synced == [30000] @@ -797,31 +798,31 @@ def test_jump_to_one_outside_the_loop_is_grayed_out(self, block): block.seek(25000) block.set_loop_start_time(20000) - jump = self._item(block.bookmark_menu((10000,)), "Jump to Bookmark") + jump = self._item(block.bookmarker.marker_menu((10000,)), "Jump to Bookmark") assert not jump.isEnabled() def test_remove_takes_that_one_away_wherever_the_video_is(self, block): block.seek(20000) - self._item(block.bookmark_menu((30000,)), "Remove Bookmark").trigger() + self._item(block.bookmarker.marker_menu((30000,)), "Remove Bookmark").trigger() assert _times(block) == [10000] def test_rename_names_that_one(self, block, mocker): mocker.patch.object( - video_block_module.QBookmarkRenameDialog, + bookmarker_module.QBookmarkRenameDialog, "get_edits", return_value=("Win", None), ) block.seek(20000) - self._item(block.bookmark_menu((30000,)), "Rename Bookmark…").trigger() + self._item(block.bookmarker.marker_menu((30000,)), "Rename Bookmark…").trigger() assert block.bookmarks[1] == Bookmark(time_ms=30000, name="Win") def test_a_shared_marker_has_a_menu_for_each_it_stands_for(self, block): - menu = block.bookmark_menu((10000, 30000)) + menu = block.bookmarker.marker_menu((10000, 30000)) submenus = [action.menu() for action in menu.actions() if action.menu()] @@ -835,41 +836,41 @@ def test_a_shared_marker_has_a_menu_for_each_it_stands_for(self, block): assert [self._titles(submenu) for submenu in submenus] == [self.ITEMS] * 2 def test_its_manager_opens_with_all_of_them_picked_out(self, block, mocker): - dialog = mocker.patch.object(video_block_module, "BookmarksDialog") + dialog = mocker.patch.object(bookmarker_module, "BookmarksDialog") - menu = block.bookmark_menu((10000, 30000)) + menu = block.bookmarker.marker_menu((10000, 30000)) self._item(menu, "Manage Bookmarks…").trigger() assert dialog.call_args.kwargs["selected"] == [0, 1] def test_each_of_those_jumps_to_its_own(self, block): - menu = block.bookmark_menu((10000, 30000)) + menu = block.bookmarker.marker_menu((10000, 30000)) self._item(menu.actions()[0].menu(), "Jump to Bookmark").trigger() assert block.time == 10000 def test_and_removes_its_own(self, block): - menu = block.bookmark_menu((10000, 30000)) + menu = block.bookmarker.marker_menu((10000, 30000)) self._item(menu.actions()[1].menu(), "Remove Bookmark").trigger() assert _times(block) == [10000] def test_with_its_bookmarks_gone_there_is_none(self, block): - assert block.bookmark_menu((12345,)) is None + assert block.bookmarker.marker_menu((12345,)) is None def test_left_open_while_another_file_came_it_leaves_that_one_be( self, block, tmp_path, mocker ): - dialog = mocker.patch.object(video_block_module, "BookmarksDialog") + dialog = mocker.patch.object(bookmarker_module, "BookmarksDialog") (tmp_path / "other.mkv").touch() - menu = block.bookmark_menu((30000,)) + menu = block.bookmarker.marker_menu((30000,)) block.next_video() block.video_driver._fake_player_timer.stop() theirs = (Bookmark(time_ms=5000), Bookmark(time_ms=20000)) - block.set_bookmarks(theirs) + block.bookmarker.replace(theirs) for title in ("Remove Bookmark", "Jump to Bookmark", "Manage Bookmarks…"): self._item(menu, title).trigger() @@ -886,15 +887,15 @@ def test_a_name_typed_while_another_file_came_is_dropped( def typed(**_kwargs): block.next_video() - block.set_bookmarks(theirs) + block.bookmarker.replace(theirs) return "Win", "#ff3d3d" mocker.patch.object( - video_block_module.QBookmarkRenameDialog, "get_edits", side_effect=typed + bookmarker_module.QBookmarkRenameDialog, "get_edits", side_effect=typed ) - self._item(block.bookmark_menu((30000,)), "Rename Bookmark…").trigger() + self._item(block.bookmarker.marker_menu((30000,)), "Rename Bookmark…").trigger() assert block.bookmarks == theirs @@ -920,10 +921,10 @@ def typed(**_kwargs): return "Win", None mocker.patch.object( - video_block_module.QBookmarkRenameDialog, "get_edits", side_effect=typed + bookmarker_module.QBookmarkRenameDialog, "get_edits", side_effect=typed ) - self._item(block.bookmark_menu((30000,)), "Rename Bookmark…").trigger() + self._item(block.bookmarker.marker_menu((30000,)), "Rename Bookmark…").trigger() assert block.video_params.uri == tmp_path / "movie.mkv" assert not block._is_closing @@ -935,18 +936,18 @@ def test_once_named_the_end_is_as_set_again(self, block, tmp_path, mocker): (tmp_path / "other.mkv").touch() block.video_params.end_action = VideoEndAction.NEXT_FILE mocker.patch.object( - video_block_module.QBookmarkRenameDialog, + bookmarker_module.QBookmarkRenameDialog, "get_edits", return_value=("Win", None), ) - block.rename_bookmark(1) + block.bookmarker.rename(1) block.loop_end_action() assert block.video_params.uri == tmp_path / "other.mkv" def test_the_overlay_opens_it_where_it_was_asked_for(self, block, mocker): - shown = mocker.patch.object(video_block_module.CustomMenu, "exec_") + shown = mocker.patch.object(bookmarker_module.CustomMenu, "exec_") block.overlay.bookmark_menu_requested.emit(QPoint(40, 50), (30000,)) @@ -1015,7 +1016,7 @@ class TestColouring: ] def _menu(self, block, times=(30000,)): - menu = block.bookmark_menu(times) + menu = block.bookmarker.marker_menu(times) return next( action.menu() for action in menu.actions() if action.text() == "Color" @@ -1056,7 +1057,7 @@ def test_and_ticked_from_then_on(self, block): assert self._ticked(self._menu(block)) == ["Blue"] def test_default_gives_it_back_the_bookmarks_own(self, block): - block.set_bookmarks( + block.bookmarker.replace( (Bookmark(time_ms=10000), Bookmark(time_ms=30000, color=BLUE)) ) @@ -1065,8 +1066,8 @@ def test_default_gives_it_back_the_bookmarks_own(self, block): assert block.bookmarks[1].color is None def test_the_menu_shows_its_colour(self, block): - block.set_bookmarks((Bookmark(time_ms=30000, color=BLUE),)) - menu = block.bookmark_menu((30000,)) + block.bookmarker.replace((Bookmark(time_ms=30000, color=BLUE),)) + menu = block.bookmarker.marker_menu((30000,)) color_item = self._item(menu, "Color") @@ -1074,7 +1075,7 @@ def test_the_menu_shows_its_colour(self, block): def test_any_other_is_picked_by_hand(self, block, mocker): picker = mocker.patch.object( - video_block_module, "color_picked_by_hand", return_value="#123456" + bookmarker_module, "color_picked_by_hand", return_value="#123456" ) self._item(self._menu(block), "Custom…").trigger() @@ -1084,7 +1085,7 @@ def test_any_other_is_picked_by_hand(self, block, mocker): assert picker.call_args.args[1] is None def test_and_then_custom_is_ticked_in_that_colour(self, block): - block.set_bookmarks((Bookmark(time_ms=30000, color="#123456"),)) + block.bookmarker.replace((Bookmark(time_ms=30000, color="#123456"),)) menu = self._menu(block) @@ -1093,7 +1094,7 @@ def test_and_then_custom_is_ticked_in_that_colour(self, block): def test_picking_by_hand_given_up_leaves_it(self, block, mocker): mocker.patch.object( - video_block_module, "color_picked_by_hand", return_value=None + bookmarker_module, "color_picked_by_hand", return_value=None ) self._item(self._menu(block), "Custom…").trigger() @@ -1112,7 +1113,7 @@ def picked(*_args): return BLUE mocker.patch.object( - video_block_module, "color_picked_by_hand", side_effect=picked + bookmarker_module, "color_picked_by_hand", side_effect=picked ) self._item(self._menu(block), "Custom…").trigger() @@ -1129,26 +1130,26 @@ def test_left_open_while_another_file_came_it_leaves_that_one_be( block.next_video() block.video_driver._fake_player_timer.stop() theirs = (Bookmark(time_ms=5000), Bookmark(time_ms=30000)) - block.set_bookmarks(theirs) + block.bookmarker.replace(theirs) self._item(menu, "Blue").trigger() assert block.bookmarks == theirs def test_a_shared_marker_s_bookmarks_are_each_in_their_own_colour(self, block): - block.set_bookmarks( + block.bookmarker.replace( (Bookmark(time_ms=30000), Bookmark(time_ms=30400, color=BLUE)) ) - menu = block.bookmark_menu((30000, 30400)) + menu = block.bookmarker.marker_menu((30000, 30400)) icons = [action.icon() for action in menu.actions() if action.menu()] assert [_icon_color(icon) for icon in icons] == ["#ffab00", BLUE] def test_each_has_a_colour_menu_of_its_own(self, block): - block.set_bookmarks((Bookmark(time_ms=30000), Bookmark(time_ms=30400))) + block.bookmarker.replace((Bookmark(time_ms=30000), Bookmark(time_ms=30400))) - menu = block.bookmark_menu((30000, 30400)) + menu = block.bookmarker.marker_menu((30000, 30400)) second = [action.menu() for action in menu.actions() if action.menu()][1] color_menu = next( action.menu() for action in second.actions() if action.text() == "Color" @@ -1162,29 +1163,27 @@ def test_each_has_a_colour_menu_of_its_own(self, block): class TestTheRenameBoxColours: @pytest.fixture def answer(self, mocker): - return mocker.patch.object( - video_block_module.QBookmarkRenameDialog, "get_edits" - ) + return mocker.patch.object(bookmarker_module.QBookmarkRenameDialog, "get_edits") def test_it_starts_from_the_colour_it_has(self, block, answer): - block.set_bookmarks((Bookmark(time_ms=30000, color=BLUE),)) + block.bookmarker.replace((Bookmark(time_ms=30000, color=BLUE),)) answer.return_value = None - block.rename_bookmark(0) + block.bookmarker.rename(0) assert answer.call_args.kwargs["color"] == BLUE def test_the_name_and_colour_given_are_its(self, block, answer): answer.return_value = ("Win", BLUE) - block.rename_bookmark(1) + block.bookmarker.rename(1) assert block.bookmarks[1] == Bookmark(time_ms=30000, name="Win", color=BLUE) def test_given_up_it_is_left_as_it_was(self, block, answer): answer.return_value = None - block.rename_bookmark(1) + block.bookmarker.rename(1) assert block.bookmarks[1] == Bookmark(time_ms=30000, name="Goal & Run") @@ -1193,7 +1192,7 @@ class TestTheRenameBox: """The box itself: the name, and the colour picked from circles.""" def test_it_starts_on_default_for_one_with_no_colour(self, mocker): - dialog_class = video_block_module.QBookmarkRenameDialog + dialog_class = bookmarker_module.QBookmarkRenameDialog seen = {} def run(dialog): @@ -1210,7 +1209,7 @@ def run(dialog): assert edits == ("Goal", None) def test_one_picked_is_given(self, mocker): - dialog_class = video_block_module.QBookmarkRenameDialog + dialog_class = bookmarker_module.QBookmarkRenameDialog def run(dialog): dialog.colors._presets[BLUE].setChecked(True) @@ -1225,7 +1224,7 @@ def run(dialog): assert edits == ("Goal", BLUE) def test_one_picked_by_hand_before_is_there_to_keep(self, mocker): - dialog_class = video_block_module.QBookmarkRenameDialog + dialog_class = bookmarker_module.QBookmarkRenameDialog mocker.patch.object(dialog_class, "exec", return_value=True) @@ -1236,7 +1235,7 @@ def test_one_picked_by_hand_before_is_there_to_keep(self, mocker): assert edits == ("", "#123456") def test_given_up_it_gives_nothing(self, mocker): - dialog_class = video_block_module.QBookmarkRenameDialog + dialog_class = bookmarker_module.QBookmarkRenameDialog mocker.patch.object(dialog_class, "exec", return_value=False) diff --git a/tests/test_video_block_guards.py b/tests/test_video_block_guards.py new file mode 100644 index 00000000..d330640f --- /dev/null +++ b/tests/test_video_block_guards.py @@ -0,0 +1,68 @@ +"""The guards on a video block's commands ask the block, whether the command +is the block's own or one of a part of it.""" + +from types import SimpleNamespace + +import pytest + +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_seekable, +) + + +class _Part(VideoBlockPart): + @only_initialized + @only_seekable + def command(self): + return "done" + + +class _Block: + def __init__(self, *, is_video_initialized, is_live): + self.is_video_initialized = is_video_initialized + self.is_live = is_live + + @only_initialized + @only_seekable + def command(self): + return "done" + + +@pytest.mark.parametrize( + ("is_video_initialized", "is_live", "result"), + [ + (True, False, "done"), + (False, False, None), + (True, True, None), + ], +) +def test_a_part_is_guarded_by_its_block(is_video_initialized, is_live, result): + block = SimpleNamespace(is_video_initialized=is_video_initialized, is_live=is_live) + + assert _Part(block).command() == result + + +@pytest.mark.parametrize( + ("is_video_initialized", "is_live", "result"), + [ + (True, False, "done"), + (False, False, None), + (True, True, None), + ], +) +def test_the_block_is_guarded_by_itself(is_video_initialized, is_live, result): + block = _Block(is_video_initialized=is_video_initialized, is_live=is_live) + + assert block.command() == result + + +def test_a_part_asks_its_block_not_itself(): + block = SimpleNamespace(is_video_initialized=False, is_live=False) + part = _Part(block) + + # what the part itself would say is not asked + part.is_video_initialized = True + + assert part.command() is None diff --git a/tests/test_video_block_repeat.py b/tests/test_video_block_repeat.py index 3eb4419c..81833df5 100644 --- a/tests/test_video_block_repeat.py +++ b/tests/test_video_block_repeat.py @@ -34,7 +34,7 @@ def _block(mocker, **video_kwargs): block = mocker.Mock() block.video_params = Video(uri="http://example.com/a.mp4", **video_kwargs) # no bookmarks being edited to hold the video where it is - block._is_editing_bookmarks = False + block.bookmarker.is_editing = False block._hold_end_for_bookmarks.return_value = False block._step_at_end = partial(VideoBlock._step_at_end, block) return block From 57238946c4327bba2dbd4589c92e46ca85088387 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:26:40 +0000 Subject: [PATCH 04/14] refactor: take the video block's view out into a ViewControl Zooming, the zoom to a selection and its hint, following another video's view, crop, alignment and moving the picture go to block.view, their commands reached as "view.scale_increase" and the like. manual_view stays with the block, the others following from there as they do for seeks and speeds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/dialogs/crop.py | 6 +- gridplayer/dialogs/position.py | 16 +- gridplayer/params/actions.py | 68 ++-- gridplayer/player/managers/pan.py | 4 +- gridplayer/player/managers/video_blocks.py | 24 +- gridplayer/player/managers/zoom_select.py | 16 +- gridplayer/utils/aspect_calc.py | 2 +- gridplayer/widgets/video_block.py | 428 +------------------- gridplayer/widgets/video_block_view.py | 440 +++++++++++++++++++++ tests/test_crop_dialog.py | 2 + tests/test_deinterlace.py | 2 + tests/test_dotted_commands.py | 2 +- tests/test_overlay_zoom_selection.py | 52 +-- tests/test_pan.py | 7 +- tests/test_position_dialog.py | 7 +- tests/test_step_sizes.py | 14 +- tests/test_sync.py | 111 +++--- tests/test_video_block_repeat.py | 2 +- tests/test_video_position.py | 157 ++++---- tests/test_zoom_select.py | 7 +- 20 files changed, 709 insertions(+), 658 deletions(-) create mode 100644 gridplayer/widgets/video_block_view.py diff --git a/gridplayer/dialogs/crop.py b/gridplayer/dialogs/crop.py index c57fb4c2..2075021f 100644 --- a/gridplayer/dialogs/crop.py +++ b/gridplayer/dialogs/crop.py @@ -214,14 +214,14 @@ def get_crop(cls, crop=_ZERO_CROP, parent=None): def accept(self): if self._block is not None: - self._block.set_crop(self._spin_crop(), is_silent=False) + self._block.view.set_crop(self._spin_crop(), is_silent=False) super().accept() def reject(self): if self._block is not None: if self._block.video_params.crop != self._original_crop: - self._block.set_crop(self._original_crop, is_silent=True) + self._block.view.set_crop(self._original_crop, is_silent=True) super().reject() @@ -231,7 +231,7 @@ def _on_reset(self): def _on_value_changed(self): if self._block is not None: - self._block.set_crop(self._spin_crop(), is_silent=True) + self._block.view.set_crop(self._spin_crop(), is_silent=True) self._update_size_label() diff --git a/gridplayer/dialogs/position.py b/gridplayer/dialogs/position.py index fe739491..84a702c0 100644 --- a/gridplayer/dialogs/position.py +++ b/gridplayer/dialogs/position.py @@ -237,7 +237,7 @@ def __init__(self, video_block, parent=None): def accept(self): # says where the picture ended up, as a move from the menu does, and # has the others follow it where their views are synced - self._block.manual_view("set_shift", self._block.video_params.shift, False) + self._block.manual_view("view.set_shift", self._block.video_params.shift, False) super().accept() @@ -249,9 +249,9 @@ def reject(self): self._original ): # an alignment lets go of the offset, so it goes back first - self._block.set_anchor(anchor) - self._block.set_shift_past_edges(is_shift_past_edges) - self._block.set_shift(shift, is_silent=True) + self._block.view.set_anchor(anchor) + self._block.view.set_shift_past_edges(is_shift_past_edges) + self._block.view.set_shift(shift, is_silent=True) super().reject() @@ -262,21 +262,21 @@ def changeEvent(self, event): self._update_anchor_icons() def _on_anchor(self, anchor: VideoAnchor): - self._block.set_anchor(anchor) + self._block.view.set_anchor(anchor) self._show_position() def _on_offset_changed(self): shift = VideoShift(self._spins["x"].value(), self._spins["y"].value()) - self._block.set_shift(shift, is_silent=True) + self._block.view.set_shift(shift, is_silent=True) self._show_position() def _on_past_edges_toggled(self, is_checked: bool): - self._block.set_shift_past_edges(is_checked) + self._block.view.set_shift_past_edges(is_checked) self._show_position() def _on_reset(self): - self._block.set_shift(VideoShift(0, 0), is_silent=True) + self._block.view.set_shift(VideoShift(0, 0), is_silent=True) self._show_position() def _show_position(self): diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 094e94b2..7147c66f 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -577,14 +577,14 @@ "title": translate("Actions", "Zoom In"), "keys": ["+", "Ctrl+Wheel-Up"], "icon": "zoom-in", - "func": ("active", "manual_view", "scale_increase"), + "func": ("active", "manual_view", "view.scale_increase"), "show_if": "is_active_has_video", }, "Zoom Out": { "title": translate("Actions", "Zoom Out"), "keys": ["-", "Ctrl+Wheel-Down"], "icon": "zoom-out", - "func": ("active", "manual_view", "scale_decrease"), + "func": ("active", "manual_view", "view.scale_decrease"), "show_if": "is_active_has_video", }, "Zoom to Selection": { @@ -597,13 +597,13 @@ "title": translate("Actions", "Zoom Reset"), "key": "*", "icon": "zoom-reset", - "func": ("active", "manual_view", "scale_reset"), + "func": ("active", "manual_view", "view.scale_reset"), "show_if": "is_active_has_video", }, "Zoom to Cursor": { "title": translate("Zoom", "Zoom to Cursor"), "icon": "empty", - "func": ("active", "toggle_zoom_to_cursor"), + "func": ("active", "view.toggle_zoom_to_cursor"), "check_if": ("is_active_param_set_to", "is_zoom_to_cursor", True), "show_if": "is_active_has_video", }, @@ -617,97 +617,97 @@ "title": translate("Actions", "Crop Left +"), "key": "U", "icon": "empty", - "func": ("active", "crop", 1, 0, 0, 0), + "func": ("active", "view.crop", 1, 0, 0, 0), "show_if": "is_active_has_video", }, "Crop Left -": { "title": translate("Actions", "Crop Left -"), "key": "I", "icon": "empty", - "func": ("active", "crop", -1, 0, 0, 0), + "func": ("active", "view.crop", -1, 0, 0, 0), "show_if": "is_active_has_video", }, "Crop Top +": { "title": translate("Actions", "Crop Top +"), "key": "O", "icon": "empty", - "func": ("active", "crop", 0, 1, 0, 0), + "func": ("active", "view.crop", 0, 1, 0, 0), "show_if": "is_active_has_video", }, "Crop Top -": { "title": translate("Actions", "Crop Top -"), "key": "P", "icon": "empty", - "func": ("active", "crop", 0, -1, 0, 0), + "func": ("active", "view.crop", 0, -1, 0, 0), "show_if": "is_active_has_video", }, "Crop Right +": { "title": translate("Actions", "Crop Right +"), "key": "Alt+U", "icon": "empty", - "func": ("active", "crop", 0, 0, 1, 0), + "func": ("active", "view.crop", 0, 0, 1, 0), "show_if": "is_active_has_video", }, "Crop Right -": { "title": translate("Actions", "Crop Right -"), "key": "Alt+I", "icon": "empty", - "func": ("active", "crop", 0, 0, -1, 0), + "func": ("active", "view.crop", 0, 0, -1, 0), "show_if": "is_active_has_video", }, "Crop Bottom +": { "title": translate("Actions", "Crop Bottom +"), "key": "Alt+O", "icon": "empty", - "func": ("active", "crop", 0, 0, 0, 1), + "func": ("active", "view.crop", 0, 0, 0, 1), "show_if": "is_active_has_video", }, "Crop Bottom -": { "title": translate("Actions", "Crop Bottom -"), "key": "Alt+P", "icon": "empty", - "func": ("active", "crop", 0, 0, 0, -1), + "func": ("active", "view.crop", 0, 0, 0, -1), "show_if": "is_active_has_video", }, "Crop Reset": { "title": translate("Actions", "Crop Reset"), "key": "Y", "icon": "empty", - "func": ("active", "crop_reset"), + "func": ("active", "view.crop_reset"), "show_if": "is_active_has_video", }, "Move Left": { "title": translate("Actions", "Move Left"), "key": "Alt+Left", "icon": "empty", - "func": ("active", "manual_view", "shift_by", -1, 0), + "func": ("active", "manual_view", "view.shift_by", -1, 0), "show_if": "is_active_has_video", }, "Move Right": { "title": translate("Actions", "Move Right"), "key": "Alt+Right", "icon": "empty", - "func": ("active", "manual_view", "shift_by", 1, 0), + "func": ("active", "manual_view", "view.shift_by", 1, 0), "show_if": "is_active_has_video", }, "Move Up": { "title": translate("Actions", "Move Up"), "key": "Alt+Up", "icon": "empty", - "func": ("active", "manual_view", "shift_by", 0, -1), + "func": ("active", "manual_view", "view.shift_by", 0, -1), "show_if": "is_active_has_video", }, "Move Down": { "title": translate("Actions", "Move Down"), "key": "Alt+Down", "icon": "empty", - "func": ("active", "manual_view", "shift_by", 0, 1), + "func": ("active", "manual_view", "view.shift_by", 0, 1), "show_if": "is_active_has_video", }, "Move Past Edges": { "title": translate("Actions", "Move Past Edges"), "icon": "empty", - "func": ("active", "toggle_shift_past_edges"), + "func": ("active", "view.toggle_shift_past_edges"), "check_if": ("is_active_param_set_to", "is_shift_past_edges", True), "show_if": "is_active_has_video", }, @@ -720,13 +720,13 @@ "Position Reset": { "title": translate("Actions", "Position Reset"), "icon": "empty", - "func": ("active", "manual_view", "shift_reset"), + "func": ("active", "manual_view", "view.shift_reset"), "show_if": "is_active_has_video", }, "Align Center": { "title": translate("Alignment", "Align Center"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.CENTER), + "func": ("active", "manual_view", "view.set_anchor", VideoAnchor.CENTER), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.CENTER), "is_choice": True, "show_if": "is_active_has_video", @@ -734,7 +734,7 @@ "Align Top": { "title": translate("Alignment", "Align Top"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.TOP), + "func": ("active", "manual_view", "view.set_anchor", VideoAnchor.TOP), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.TOP), "is_choice": True, "show_if": "is_active_has_video", @@ -742,7 +742,7 @@ "Align Bottom": { "title": translate("Alignment", "Align Bottom"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.BOTTOM), + "func": ("active", "manual_view", "view.set_anchor", VideoAnchor.BOTTOM), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.BOTTOM), "is_choice": True, "show_if": "is_active_has_video", @@ -750,7 +750,7 @@ "Align Left": { "title": translate("Alignment", "Align Left"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.LEFT), + "func": ("active", "manual_view", "view.set_anchor", VideoAnchor.LEFT), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.LEFT), "is_choice": True, "show_if": "is_active_has_video", @@ -758,7 +758,7 @@ "Align Right": { "title": translate("Alignment", "Align Right"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.RIGHT), + "func": ("active", "manual_view", "view.set_anchor", VideoAnchor.RIGHT), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.RIGHT), "is_choice": True, "show_if": "is_active_has_video", @@ -766,7 +766,7 @@ "Align Top Left": { "title": translate("Alignment", "Align Top Left"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.TOP_LEFT), + "func": ("active", "manual_view", "view.set_anchor", VideoAnchor.TOP_LEFT), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.TOP_LEFT), "is_choice": True, "show_if": "is_active_has_video", @@ -774,7 +774,7 @@ "Align Top Right": { "title": translate("Alignment", "Align Top Right"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.TOP_RIGHT), + "func": ("active", "manual_view", "view.set_anchor", VideoAnchor.TOP_RIGHT), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.TOP_RIGHT), "is_choice": True, "show_if": "is_active_has_video", @@ -782,7 +782,12 @@ "Align Bottom Left": { "title": translate("Alignment", "Align Bottom Left"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.BOTTOM_LEFT), + "func": ( + "active", + "manual_view", + "view.set_anchor", + VideoAnchor.BOTTOM_LEFT, + ), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.BOTTOM_LEFT), "is_choice": True, "show_if": "is_active_has_video", @@ -790,7 +795,12 @@ "Align Bottom Right": { "title": translate("Alignment", "Align Bottom Right"), "icon": "empty", - "func": ("active", "manual_view", "set_anchor", VideoAnchor.BOTTOM_RIGHT), + "func": ( + "active", + "manual_view", + "view.set_anchor", + VideoAnchor.BOTTOM_RIGHT, + ), "check_if": ("is_active_param_set_to", "anchor", VideoAnchor.BOTTOM_RIGHT), "is_choice": True, "show_if": "is_active_has_video", @@ -1105,7 +1115,7 @@ "Sync Others (Zoom and Position)": { "title": translate("Actions", "Zoom and Position"), "icon": "zoom", - "func": ("active", "sync_others_view"), + "func": ("active", "view.sync_others"), "show_if": "is_active_has_video", }, "Stream Quality": { diff --git a/gridplayer/player/managers/pan.py b/gridplayer/player/managers/pan.py index 93a9aee9..aca27a68 100644 --- a/gridplayer/player/managers/pan.py +++ b/gridplayer/player/managers/pan.py @@ -109,7 +109,7 @@ def mouse_move(self, event): left_x, left_y = self._left_over left_over = self._block.manual_view( - "pan_by", moved.x() + left_x, moved.y() + left_y + "view.pan_by", moved.x() + left_x, moved.y() + left_y ) self._left_over = left_over or (0.0, 0.0) @@ -128,7 +128,7 @@ def mouse_release(self, event): return None # says where the picture ended up, as a move from the menu does - block.set_shift(block.video_params.shift) + block.view.set_shift(block.video_params.shift) return True diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 47c78c8b..25e7852d 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -555,7 +555,7 @@ def sync_view_single(self, leader: VideoBlock, synced: SyncedView): def _follow_view(self, leader: VideoBlock, synced: SyncedView): for vb in self._ctx.video_blocks: if vb is not leader: - vb.follow_view(synced) + vb.view.follow(synced) def _refresh_synced_view(self): """The view to sync to as the one it was taken from now has it, all @@ -566,7 +566,7 @@ def _refresh_synced_view(self): return leader = self._ctx.video_blocks.by_id(self._view_leader_id) - synced = leader.synced_view() if leader is not None else None + synced = leader.view.synced() if leader is not None else None if synced is not None: self._ctx.synced_view = synced @@ -877,16 +877,16 @@ def _add_video_block(self, video): (self.all_rate_increase, vb.rate_increase), (self.all_rate_decrease, vb.rate_decrease), (self.all_rate_reset, vb.rate_reset), - (self.all_scale_increase, vb.scale_increase), - (self.all_scale_decrease, vb.scale_decrease), - (self.all_scale_reset, vb.scale_reset), - (self.all_set_zoom_to_cursor, vb.set_zoom_to_cursor), - (self.all_crop, vb.crop), - (self.all_crop_reset, vb.crop_reset), - (self.all_set_anchor, vb.set_anchor), - (self.all_shift_by, vb.shift_by), - (self.all_shift_reset, vb.shift_reset), - (self.all_set_shift_past_edges, vb.set_shift_past_edges), + (self.all_scale_increase, vb.view.scale_increase), + (self.all_scale_decrease, vb.view.scale_decrease), + (self.all_scale_reset, vb.view.scale_reset), + (self.all_set_zoom_to_cursor, vb.view.set_zoom_to_cursor), + (self.all_crop, vb.view.crop), + (self.all_crop_reset, vb.view.crop_reset), + (self.all_set_anchor, vb.view.set_anchor), + (self.all_shift_by, vb.view.shift_by), + (self.all_shift_reset, vb.view.shift_reset), + (self.all_set_shift_past_edges, vb.view.set_shift_past_edges), (self.all_set_aspect, vb.set_aspect), (self.all_set_transform, vb.set_transform), (self.all_take_screenshot, vb.take_screenshot), diff --git a/gridplayer/player/managers/zoom_select.py b/gridplayer/player/managers/zoom_select.py index 66bb05f7..5a44d997 100644 --- a/gridplayer/player/managers/zoom_select.py +++ b/gridplayer/player/managers/zoom_select.py @@ -16,7 +16,7 @@ class ZoomSelectManager(ManagerBase): A drag over the picture with one of the drags it is set to in the keymap, the left button with Ctrl by default; or the next left drag after Zoom to Selection is picked, whatever is held. Let go of, the part dragged over is fitted into the cell, in its - middle (see VideoBlock.zoom_to_selection). + middle (see ViewControl.zoom_to_selection). The press is taken here, and the moves after it, before a drag would move the video to another cell or the picture about. A click with no @@ -179,7 +179,7 @@ def mouse_move(self, event): self._is_selecting = True self._set_cursor() - self._block.show_zoom_selection(self._selection()) + self._block.view.show_zoom_selection(self._selection()) # the outline says it all now self._unhint() @@ -204,7 +204,7 @@ def window_release(self, event): self._swallowed_buttons.add(event.button()) if not selection.isEmpty(): - block.manual_view("zoom_to_selection", selection) + block.manual_view("view.zoom_to_selection", selection) def mouse_release(self, event): """A release as it comes to a widget, after the window had it.""" @@ -289,7 +289,7 @@ def _selectable_block(self, pressed): if block is None or not _is_part_of(pressed, block): return None - if block.zoom_selection_area() is None: + if block.view.zoom_selection_area() is None: return None return block @@ -304,7 +304,7 @@ def _start(self, block, button): def _selection(self) -> QRect: """The part of the picture dragged over, in the cell's pixels.""" - area = self._block.zoom_selection_area() + area = self._block.view.zoom_selection_area() if area is None: return QRect() @@ -354,14 +354,14 @@ def _hint(self, block): if block is None or block is self._hinted: return - if block.zoom_selection_area() is None: + if block.view.zoom_selection_area() is None: return self._unhint() self._hinted = block block.destroyed.connect(self._forget_hinted) - block.show_zoom_hint(True) + block.view.show_zoom_hint(True) def _hint_under_pointer(self): # off the videos, the one it was last over goes on saying it @@ -392,7 +392,7 @@ def _stop(self): block.destroyed.disconnect(self._forget_block) if self._is_selecting: - block.show_zoom_selection(None) + block.view.show_zoom_selection(None) if self._is_cursor_set: QApplication.restoreOverrideCursor() diff --git a/gridplayer/utils/aspect_calc.py b/gridplayer/utils/aspect_calc.py index 2e78f442..9224b32f 100644 --- a/gridplayer/utils/aspect_calc.py +++ b/gridplayer/utils/aspect_calc.py @@ -582,7 +582,7 @@ class SharedView: class SyncedView(NamedTuple): - """A video's view, for the others to follow: see VideoBlock.synced_view.""" + """A video's view, for the others to follow: see ViewControl.synced.""" view: SharedView # where the picture stands is a zoom to a selection's doing, which a diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index b9af41b8..833a352a 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -10,7 +10,7 @@ from pathlib import Path from pydantic_extra_types.color import Color -from PyQt5.QtCore import QRect, QRectF, QSize, Qt, QTimer, pyqtSignal +from PyQt5.QtCore import QSize, Qt, QTimer, pyqtSignal from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QFileDialog, QStackedLayout, QWidget @@ -99,19 +99,14 @@ ListSourceKind, NetworkRetryMode, SponsorBlockMode, - VideoAnchor, VideoAspect, - VideoCrop, VideoDeinterlace, VideoDeinterlaceMode, VideoEndAction, VideoInitialState, - VideoShift, VideoTransform, - ViewParams, ) from gridplayer.settings import Settings -from gridplayer.utils.aspect_calc import SyncedView from gridplayer.utils.bookmarks import file_key from gridplayer.utils.chapters import ( chapter_index_at, @@ -159,7 +154,7 @@ skip_spans, sponsorblock_fetcher, ) -from gridplayer.utils.step_sizes import seek_step_ms, zoom_stepped +from gridplayer.utils.step_sizes import seek_step_ms from gridplayer.utils.track_language import language_name, normalize, pick_track from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver @@ -198,6 +193,7 @@ only_streamable, only_with_video_tacks, ) +from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_vlc_base import VideoFrameVLC from gridplayer.widgets.video_overlay import ( OverlayBlock, @@ -361,16 +357,6 @@ def __init__(self, video_driver, context, **kwargs): self._is_closing = False self._drop_indicator = DropIndicator.NONE - # the part of the picture being selected to zoom to, while it is - self._zoom_selection: QRect | None = None - # how to select one, said once Zoom to Selection is picked - self._is_zoom_hint_shown = False - # where the picture stands is a zoom to a selection's doing, which - # the zoom's reset undoes along with it - self._is_zoomed_to_selection = False - # another video's view, to show once this one is playing - self._view_to_follow: SyncedView | None = None - self._title = None self._color = None self._default_title = None @@ -505,6 +491,7 @@ def __init__(self, video_driver, context, **kwargs): self.video_driver: VideoFrameVLC | None = None self.bookmarker = Bookmarker(self) + self.view = ViewControl(self) sponsorblock_fetcher().fetched.connect(self._sponsor_segments_fetched) @@ -588,7 +575,7 @@ def init_video_driver(self) -> VideoFrameVLC: qt_connect( (video_driver.video_ready, self.load_video_finish), (video_driver.picture_shown, self._picture_shown), - (video_driver.view_followed, self._view_followed), + (video_driver.view_followed, self.view.remember_followed), (video_driver.tracks_changed, self.tracks_changed), (video_driver.chapters_changed, self._update_chapters), (video_driver.time_changed, self.time_changed), @@ -3287,7 +3274,7 @@ def hide_overlay(self): return # it shows a selection to zoom to, or how to make one - if self._is_zoom_ui(): + if self.view.is_zoom_ui(): return if self._ctx.is_disable_overlay: @@ -3643,7 +3630,7 @@ def apply_snapshot(self, snapshot: Video): return # the snapshot's view is the one to show - self._view_to_follow = None + self.view.forget_view_to_follow() self.color = snapshot.color.as_hex() @@ -3678,12 +3665,12 @@ def apply_snapshot(self, snapshot: Video): self.set_deinterlace_params(snapshot.deinterlace, snapshot.deinterlace_mode) self.set_muted(snapshot.is_muted) self.set_pause(snapshot.is_paused) - self.set_scale(snapshot.scale, is_silent=True) - self.set_zoom_to_cursor(snapshot.is_zoom_to_cursor) - self.set_crop(snapshot.crop, is_silent=True) - self.set_anchor(snapshot.anchor) - self.set_shift_past_edges(snapshot.is_shift_past_edges) - self.set_shift(snapshot.shift, is_silent=True) + self.view.set_scale(snapshot.scale, is_silent=True) + self.view.set_zoom_to_cursor(snapshot.is_zoom_to_cursor) + self.view.set_crop(snapshot.crop, is_silent=True) + self.view.set_anchor(snapshot.anchor) + self.view.set_shift_past_edges(snapshot.is_shift_past_edges) + self.view.set_shift(snapshot.shift, is_silent=True) self.set_volume(snapshot.volume) self.seek(snapshot.current_position) @@ -3712,7 +3699,7 @@ def _apply_snapshot_of_another_file(self, snapshot: Video): self.bookmarker.close_manager() self._cancel_steps() - self._view_to_follow = None + self.view.forget_view_to_follow() self._held_item = None self._title = None self._default_title = None @@ -4282,7 +4269,7 @@ def load_video_finish(self): if not self._is_status_kept_for_picture: self.video_status.hide() - self._follow_synced_view() + self.view.follow_synced() self.video_driver.show() self.is_stopped_change.emit(self.is_stopped) @@ -4871,405 +4858,24 @@ def step_frame(self, frames): self.seek_shift_ms(new_time - self.time) - @only_with_video_tacks - @only_initialized - def scale_increase(self): - self._zoom_to(zoom_stepped(self.video_params.scale, 1)) - - @only_with_video_tacks - @only_initialized - def scale_decrease(self): - self._zoom_to(zoom_stepped(self.video_params.scale, -1)) - - @only_with_video_tacks - @only_initialized - def scale_reset(self): - """Back to the whole picture, and, zoomed about the pointer or to a - selection, back in its place: where it stands then is the zooms' - doing, not a move.""" - - if self.video_params.is_zoom_to_cursor or self._is_zoomed_to_selection: - self._set_zoom(1.0, VideoShift(0, 0)) - else: - self.set_scale(1.0) - - self._is_zoomed_to_selection = False - - @only_with_video_tacks - @only_initialized - def set_scale(self, scale, is_silent=False): - self.video_params.scale = scale - - self.video_driver.set_scale(scale) - - if not is_silent: - self.info_change.emit(f"Zoom: {scale}") - - def _zoom_to(self, scale): - """Zoom about the pointer where this video zooms to it, else as usual.""" - - focus = self._zoom_focus() - - if focus is None or scale == self.video_params.scale: - self.set_scale(scale) - return - - self._set_zoom(scale, self.video_driver.zoomed_shift(scale, focus)) - - def _set_zoom(self, scale, shift: VideoShift): - self.video_params.scale = scale - self.video_params.shift = shift - - self.video_driver.set_zoom(scale, shift) - - self.info_change.emit(f"Zoom: {scale}") - - def _zoom_focus(self) -> tuple[int, int] | None: - """Where in the pane a zoom goes about, None for the anchor's place. - - The pointer, where this video zooms to it and it is over the video. - A zoom picked from the context menu goes about where the menu was - opened, the pointer being on the menu's item by then. - """ - - if not self.video_params.is_zoom_to_cursor or not self.isVisible(): - return None - - pointer = getattr(self._ctx, "context_menu_pos", None) - - if pointer is None: - pointer = QCursor.pos() - - if not self.rect().contains(self.mapFromGlobal(pointer)): - return None - - focus = self.video_driver.mapFromGlobal(pointer) - - return focus.x(), focus.y() - - @only_with_video_tacks - @only_initialized - def zoom_to_selection(self, selection: QRect): - """Fit a part of the picture into the cell, in its middle. - - The part is in this cell's pixels, where it was selected over the - picture as drawn; see VideoFrameVLC.selection_zoom. - """ - - origin = self.video_driver.pos() - zoom = self.video_driver.selection_zoom( - ( - selection.x() - origin.x(), - selection.y() - origin.y(), - selection.width(), - selection.height(), - ) - ) - - if zoom is None: - return - - self._is_zoomed_to_selection = True - self._set_zoom(*zoom) - - def zoom_selection_area(self) -> QRect | None: - """Where in this cell a part of the picture can be selected to zoom - to: over the picture as drawn. None where there is none on show.""" - - if not self.is_video_initialized or not self.video_tracks: - return None - - area = self.video_driver.shown_picture_area() - - if area is None: - return None - - x, y, width, height = area - origin = self.video_driver.pos() - - return ( - QRectF(x + origin.x(), y + origin.y(), width, height) - .toAlignedRect() - .intersected(self.rect()) - ) - - def show_zoom_selection(self, selection: QRect | None): - """Mark the part of the picture being selected to zoom to. - - On the overlay, with its chrome out of the way, and shown even where - it is off or the cell is too small for it. In place of the hint, - which it says all of. None once the selecting is over, which puts - the overlay back the way it would be. - """ - - was_zoom_ui = self._is_zoom_ui() - - if selection is not None: - self._zoom_selection = QRect(selection) - self._is_zoom_hint_shown = False - - self.overlay.set_zoom_hint(False) - self.overlay.set_zoom_selection(selection) - elif self._zoom_selection is not None: - self._zoom_selection = None - - self.overlay.set_zoom_selection(None) - - self._zoom_ui_changed(was_zoom_ui) - - def show_zoom_hint(self, is_shown: bool): - """Say how to select a part of the picture to zoom to, or not. - - Once Zoom to Selection is picked, until the selecting starts, on - the overlay as the selection itself is (see show_zoom_selection). - """ - - if is_shown == self._is_zoom_hint_shown: - return - - was_zoom_ui = self._is_zoom_ui() - self._is_zoom_hint_shown = is_shown - - self.overlay.set_zoom_hint(is_shown) - - self._zoom_ui_changed(was_zoom_ui) - - def _is_zoom_ui(self) -> bool: - return self._zoom_selection is not None or self._is_zoom_hint_shown - - def _zoom_ui_changed(self, was_zoom_ui: bool): - """The overlay shown for zooming to a selection, with that alone on - it, or put back the way it would be once it is done with.""" - - if self._is_zoom_ui() == was_zoom_ui: - return - - if self._is_zoom_ui(): - self.overlay_hide_timer.stop() - self.overlay.set_is_chrome_visible(False) - self.overlay.show() - return - - self.overlay.set_is_chrome_visible(not self._ctx.is_drag_ui) - - if self._ctx.is_disable_overlay or not self.is_overlay_fits: - self.overlay_hide_timer.stop() - self.overlay.hide() - return - - self.show_overlay() - def manual_view(self, command, *args): """Zoom or move the picture as the viewer asked, for the others to follow where their views are synced (see VideoBlocksManager). The view as it then is, even where the command left it as it was: the others come to show what this one does, from wherever they are. - What the command gives back goes back (see pan_by). + What the command gives back goes back (see ViewControl.pan_by). """ result = attrgetter(command)(self)(*args) - synced = self.synced_view() + synced = self.view.synced() if synced is not None: self.sync_view.emit(synced) return result - @only_with_video_tacks - @only_initialized - def sync_others_view(self): - """Have the others show the part of their pictures this one shows.""" - - synced = self.synced_view() - - if synced is not None: - self.sync_view_single.emit(synced) - - def synced_view(self) -> SyncedView | None: - """This video's view, for the others to follow; None for none.""" - - if not self.is_video_initialized or not self.video_tracks: - return None - - shared = self.video_driver.shared_view() - - if shared is None: - return None - - return SyncedView(shared, self._is_zoomed_to_selection) - - def follow_view(self, synced: SyncedView): - """Show the part of the picture another video shows of its own. - - Quietly, the other saying what it shows. One that is not playing yet - shows it once it is (see load_video_finish), and one waiting for its - frame's size once that is known (see VideoFrameVLC.follow_view). - """ - - if not self.is_video_initialized: - self._view_to_follow = synced - return - - if not self.video_tracks: - return - - self._is_zoomed_to_selection = synced.is_zoomed_to_selection - - view = self.video_driver.follow_view(synced.view) - - if view is not None: - self._view_followed(view) - - def _view_followed(self, view: ViewParams): - self.video_params.scale = view.scale - self.video_params.anchor = view.anchor - self.video_params.shift = view.shift - - def _follow_synced_view(self): - """Show the view this one was to follow once playing, or else the - one the videos are synced to, having just loaded.""" - - synced = self._view_to_follow or getattr(self._ctx, "synced_view", None) - self._view_to_follow = None - - if synced is not None: - self.follow_view(synced) - - @only_with_video_tacks - @only_initialized - def toggle_zoom_to_cursor(self): - self.set_zoom_to_cursor(not self.video_params.is_zoom_to_cursor) - - def set_zoom_to_cursor(self, is_zoom_to_cursor: bool): - """Zoom about the point under the pointer, or about the anchor's. - - Kept for a video stopped or still loading too: nothing but a zoom - looks at it, and that is only made with a picture on show. - """ - - self.video_params.is_zoom_to_cursor = is_zoom_to_cursor - - @only_with_video_tacks - @only_initialized - def crop(self, left, top, right, bottom, is_silent=False): - crop = VideoCrop( - self.video_params.crop.Left + left, - self.video_params.crop.Top + top, - self.video_params.crop.Right + right, - self.video_params.crop.Bottom + bottom, - ) - - self.set_crop(crop, is_silent) - - @only_with_video_tacks - @only_initialized - def crop_reset(self, is_silent=False): - self.set_crop(VideoCrop(0, 0, 0, 0), is_silent) - - @only_with_video_tacks - @only_initialized - def set_crop(self, crop: VideoCrop, is_silent=False): - crop = VideoCrop( - max(crop.Left, 0), - max(crop.Top, 0), - max(crop.Right, 0), - max(crop.Bottom, 0), - ) - - if self.video_params.crop != crop: - self.video_params.crop = crop - self.video_driver.set_crop(self.video_params.crop) - - if not is_silent: - self.info_change.emit( - "Crop: L{} T{} R{} B{}".format(*self.video_params.crop) - ) - - @only_with_video_tacks - @only_initialized - def set_anchor(self, anchor: VideoAnchor): - """Put the picture against a side or corner, or in the middle. - - Picking one is putting the picture there, so a move made from the - one before is let go of. - """ - - self.video_params.anchor = anchor - self.video_driver.set_anchor(anchor) - - self.set_shift(VideoShift(0, 0), is_silent=True) - self._is_zoomed_to_selection = False - - @only_with_video_tacks - @only_initialized - def set_shift(self, shift: VideoShift, is_silent=False): - if self.video_params.shift != shift: - self.video_params.shift = shift - self.video_driver.set_shift(shift) - - if not is_silent: - self.info_change.emit("Position: X{} Y{}".format(*shift)) - - @only_with_video_tacks - @only_initialized - def shift_by(self, steps_x: int, steps_y: int): - """Move so many steps right and down, left and up negative. - - Where the picture is bigger than the pane, that is looking further - that way; where it is smaller, the picture goes that way itself. - """ - - self.set_shift(self.video_driver.shifted_by(steps_x, steps_y)) - - @only_with_video_tacks - @only_initialized - def pan_by(self, moved_x: float, moved_y: float) -> tuple[float, float]: - """Take the picture along with the pointer, so many pane pixels. - - Right and down, left and up negative. The picture goes the way the - pointer does, as a sheet of paper under a hand would. Hands back - what is left over of the move, for the next one to go on from. - """ - - shift, left_over = self.video_driver.dragged_shift(moved_x, moved_y) - - self.set_shift(shift, is_silent=True) - - return left_over - - @only_with_video_tacks - @only_initialized - def shift_reset(self): - self.set_shift(VideoShift(0, 0)) - self._is_zoomed_to_selection = False - - @only_with_video_tacks - @only_initialized - def toggle_shift_past_edges(self): - self.set_shift_past_edges(not self.video_params.is_shift_past_edges) - - def set_shift_past_edges(self, is_shift_past_edges: bool): - """Let the picture be moved past the edges of the pane, or not. - - Kept within them again, it stays where they hold it, which is where - the next move goes from. Kept for a video stopped or still loading - too, which takes it up with the rest of its view as it loads. - """ - - self.video_params.is_shift_past_edges = is_shift_past_edges - - if self.video_driver is None: - return - - # one loading already has its view: it goes by this one instead - self.video_driver.set_shift_past_edges(is_shift_past_edges) - - if self.is_video_initialized and self.video_tracks: - # no steps is where the picture stands now - self.set_shift(self.video_driver.shifted_by(0, 0), is_silent=True) - def manual_rate(self, command): """Change the speed as the viewer asked, for the others to follow where speeds are synced (see VideoBlocksManager).""" diff --git a/gridplayer/widgets/video_block_view.py b/gridplayer/widgets/video_block_view.py new file mode 100644 index 00000000..a564e00b --- /dev/null +++ b/gridplayer/widgets/video_block_view.py @@ -0,0 +1,440 @@ +"""What of its picture a video block shows: zoomed, cropped, moved, and +the same as another video shows of its own, where views are synced.""" + +from PyQt5.QtCore import QRect, QRectF +from PyQt5.QtGui import QCursor + +from gridplayer.params.static import VideoAnchor, VideoCrop, VideoShift, ViewParams +from gridplayer.utils.aspect_calc import SyncedView +from gridplayer.utils.step_sizes import zoom_stepped +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_with_video_tacks, +) + + +class ViewControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # the part of the picture being selected to zoom to, while it is + self._zoom_selection: QRect | None = None + # how to select one, said once Zoom to Selection is picked + self._is_zoom_hint_shown = False + # where the picture stands is a zoom to a selection's doing, which + # the zoom's reset undoes along with it + self._is_zoomed_to_selection = False + # another video's view, to show once this one is playing + self._view_to_follow: SyncedView | None = None + + @only_with_video_tacks + @only_initialized + def scale_increase(self): + self._zoom_to(zoom_stepped(self._block.video_params.scale, 1)) + + @only_with_video_tacks + @only_initialized + def scale_decrease(self): + self._zoom_to(zoom_stepped(self._block.video_params.scale, -1)) + + @only_with_video_tacks + @only_initialized + def scale_reset(self): + """Back to the whole picture, and, zoomed about the pointer or to a + selection, back in its place: where it stands then is the zooms' + doing, not a move.""" + + if self._block.video_params.is_zoom_to_cursor or self._is_zoomed_to_selection: + self._set_zoom(1.0, VideoShift(0, 0)) + else: + self.set_scale(1.0) + + self._is_zoomed_to_selection = False + + @only_with_video_tacks + @only_initialized + def set_scale(self, scale, is_silent=False): + self._block.video_params.scale = scale + + self._block.video_driver.set_scale(scale) + + if not is_silent: + self._block.info_change.emit(f"Zoom: {scale}") + + def _zoom_to(self, scale): + """Zoom about the pointer where this video zooms to it, else as usual.""" + + focus = self._zoom_focus() + + if focus is None or scale == self._block.video_params.scale: + self.set_scale(scale) + return + + self._set_zoom(scale, self._block.video_driver.zoomed_shift(scale, focus)) + + def _set_zoom(self, scale, shift: VideoShift): + self._block.video_params.scale = scale + self._block.video_params.shift = shift + + self._block.video_driver.set_zoom(scale, shift) + + self._block.info_change.emit(f"Zoom: {scale}") + + def _zoom_focus(self) -> tuple[int, int] | None: + """Where in the pane a zoom goes about, None for the anchor's place. + + The pointer, where this video zooms to it and it is over the video. + A zoom picked from the context menu goes about where the menu was + opened, the pointer being on the menu's item by then. + """ + + block = self._block + + if not block.video_params.is_zoom_to_cursor or not block.isVisible(): + return None + + pointer = getattr(block._ctx, "context_menu_pos", None) + + if pointer is None: + pointer = QCursor.pos() + + if not block.rect().contains(block.mapFromGlobal(pointer)): + return None + + focus = block.video_driver.mapFromGlobal(pointer) + + return focus.x(), focus.y() + + @only_with_video_tacks + @only_initialized + def zoom_to_selection(self, selection: QRect): + """Fit a part of the picture into the cell, in its middle. + + The part is in this cell's pixels, where it was selected over the + picture as drawn; see VideoFrameVLC.selection_zoom. + """ + + video_driver = self._block.video_driver + + origin = video_driver.pos() + zoom = video_driver.selection_zoom( + ( + selection.x() - origin.x(), + selection.y() - origin.y(), + selection.width(), + selection.height(), + ) + ) + + if zoom is None: + return + + self._is_zoomed_to_selection = True + self._set_zoom(*zoom) + + def zoom_selection_area(self) -> QRect | None: + """Where in this cell a part of the picture can be selected to zoom + to: over the picture as drawn. None where there is none on show.""" + + block = self._block + + if not block.is_video_initialized or not block.video_tracks: + return None + + area = block.video_driver.shown_picture_area() + + if area is None: + return None + + x, y, width, height = area + origin = block.video_driver.pos() + + return ( + QRectF(x + origin.x(), y + origin.y(), width, height) + .toAlignedRect() + .intersected(block.rect()) + ) + + def show_zoom_selection(self, selection: QRect | None): + """Mark the part of the picture being selected to zoom to. + + On the overlay, with its chrome out of the way, and shown even where + it is off or the cell is too small for it. In place of the hint, + which it says all of. None once the selecting is over, which puts + the overlay back the way it would be. + """ + + was_zoom_ui = self.is_zoom_ui() + + if selection is not None: + self._zoom_selection = QRect(selection) + self._is_zoom_hint_shown = False + + self._block.overlay.set_zoom_hint(False) + self._block.overlay.set_zoom_selection(selection) + elif self._zoom_selection is not None: + self._zoom_selection = None + + self._block.overlay.set_zoom_selection(None) + + self._zoom_ui_changed(was_zoom_ui) + + def show_zoom_hint(self, is_shown: bool): + """Say how to select a part of the picture to zoom to, or not. + + Once Zoom to Selection is picked, until the selecting starts, on + the overlay as the selection itself is (see show_zoom_selection). + """ + + if is_shown == self._is_zoom_hint_shown: + return + + was_zoom_ui = self.is_zoom_ui() + self._is_zoom_hint_shown = is_shown + + self._block.overlay.set_zoom_hint(is_shown) + + self._zoom_ui_changed(was_zoom_ui) + + def is_zoom_ui(self) -> bool: + """The overlay shows a selection to zoom to, or how to make one.""" + + return self._zoom_selection is not None or self._is_zoom_hint_shown + + def _zoom_ui_changed(self, was_zoom_ui: bool): + """The overlay shown for zooming to a selection, with that alone on + it, or put back the way it would be once it is done with.""" + + block = self._block + + if self.is_zoom_ui() == was_zoom_ui: + return + + if self.is_zoom_ui(): + block.overlay_hide_timer.stop() + block.overlay.set_is_chrome_visible(False) + block.overlay.show() + return + + block.overlay.set_is_chrome_visible(not block._ctx.is_drag_ui) + + if block._ctx.is_disable_overlay or not block.is_overlay_fits: + block.overlay_hide_timer.stop() + block.overlay.hide() + return + + block.show_overlay() + + @only_with_video_tacks + @only_initialized + def sync_others(self): + """Have the others show the part of their pictures this one shows.""" + + synced = self.synced() + + if synced is not None: + self._block.sync_view_single.emit(synced) + + def synced(self) -> SyncedView | None: + """This video's view, for the others to follow; None for none.""" + + block = self._block + + if not block.is_video_initialized or not block.video_tracks: + return None + + shared = block.video_driver.shared_view() + + if shared is None: + return None + + return SyncedView(shared, self._is_zoomed_to_selection) + + def follow(self, synced: SyncedView): + """Show the part of the picture another video shows of its own. + + Quietly, the other saying what it shows. One that is not playing yet + shows it once it is (see load_video_finish), and one waiting for its + frame's size once that is known (see VideoFrameVLC.follow_view). + """ + + block = self._block + + if not block.is_video_initialized: + self._view_to_follow = synced + return + + if not block.video_tracks: + return + + self._is_zoomed_to_selection = synced.is_zoomed_to_selection + + view = block.video_driver.follow_view(synced.view) + + if view is not None: + self.remember_followed(view) + + def remember_followed(self, view: ViewParams): + video_params = self._block.video_params + + video_params.scale = view.scale + video_params.anchor = view.anchor + video_params.shift = view.shift + + def follow_synced(self): + """Show the view this one was to follow once playing, or else the + one the videos are synced to, having just loaded.""" + + synced = self._view_to_follow or getattr(self._block._ctx, "synced_view", None) + self._view_to_follow = None + + if synced is not None: + self.follow(synced) + + def forget_view_to_follow(self): + """Let go of another video's view this one was to show once playing: + one of its own is to be shown instead.""" + + self._view_to_follow = None + + @only_with_video_tacks + @only_initialized + def toggle_zoom_to_cursor(self): + self.set_zoom_to_cursor(not self._block.video_params.is_zoom_to_cursor) + + def set_zoom_to_cursor(self, is_zoom_to_cursor: bool): + """Zoom about the point under the pointer, or about the anchor's. + + Kept for a video stopped or still loading too: nothing but a zoom + looks at it, and that is only made with a picture on show. + """ + + self._block.video_params.is_zoom_to_cursor = is_zoom_to_cursor + + @only_with_video_tacks + @only_initialized + def crop(self, left, top, right, bottom, is_silent=False): + video_params = self._block.video_params + + crop = VideoCrop( + video_params.crop.Left + left, + video_params.crop.Top + top, + video_params.crop.Right + right, + video_params.crop.Bottom + bottom, + ) + + self.set_crop(crop, is_silent) + + @only_with_video_tacks + @only_initialized + def crop_reset(self, is_silent=False): + self.set_crop(VideoCrop(0, 0, 0, 0), is_silent) + + @only_with_video_tacks + @only_initialized + def set_crop(self, crop: VideoCrop, is_silent=False): + video_params = self._block.video_params + + crop = VideoCrop( + max(crop.Left, 0), + max(crop.Top, 0), + max(crop.Right, 0), + max(crop.Bottom, 0), + ) + + if video_params.crop != crop: + video_params.crop = crop + self._block.video_driver.set_crop(video_params.crop) + + if not is_silent: + self._block.info_change.emit( + "Crop: L{} T{} R{} B{}".format(*video_params.crop) + ) + + @only_with_video_tacks + @only_initialized + def set_anchor(self, anchor: VideoAnchor): + """Put the picture against a side or corner, or in the middle. + + Picking one is putting the picture there, so a move made from the + one before is let go of. + """ + + self._block.video_params.anchor = anchor + self._block.video_driver.set_anchor(anchor) + + self.set_shift(VideoShift(0, 0), is_silent=True) + self._is_zoomed_to_selection = False + + @only_with_video_tacks + @only_initialized + def set_shift(self, shift: VideoShift, is_silent=False): + video_params = self._block.video_params + + if video_params.shift != shift: + video_params.shift = shift + self._block.video_driver.set_shift(shift) + + if not is_silent: + self._block.info_change.emit("Position: X{} Y{}".format(*shift)) + + @only_with_video_tacks + @only_initialized + def shift_by(self, steps_x: int, steps_y: int): + """Move so many steps right and down, left and up negative. + + Where the picture is bigger than the pane, that is looking further + that way; where it is smaller, the picture goes that way itself. + """ + + self.set_shift(self._block.video_driver.shifted_by(steps_x, steps_y)) + + @only_with_video_tacks + @only_initialized + def pan_by(self, moved_x: float, moved_y: float) -> tuple[float, float]: + """Take the picture along with the pointer, so many pane pixels. + + Right and down, left and up negative. The picture goes the way the + pointer does, as a sheet of paper under a hand would. Hands back + what is left over of the move, for the next one to go on from. + """ + + shift, left_over = self._block.video_driver.dragged_shift(moved_x, moved_y) + + self.set_shift(shift, is_silent=True) + + return left_over + + @only_with_video_tacks + @only_initialized + def shift_reset(self): + self.set_shift(VideoShift(0, 0)) + self._is_zoomed_to_selection = False + + @only_with_video_tacks + @only_initialized + def toggle_shift_past_edges(self): + self.set_shift_past_edges(not self._block.video_params.is_shift_past_edges) + + def set_shift_past_edges(self, is_shift_past_edges: bool): + """Let the picture be moved past the edges of the pane, or not. + + Kept within them again, it stays where they hold it, which is where + the next move goes from. Kept for a video stopped or still loading + too, which takes it up with the rest of its view as it loads. + """ + + block = self._block + + block.video_params.is_shift_past_edges = is_shift_past_edges + + if block.video_driver is None: + return + + # one loading already has its view: it goes by this one instead + block.video_driver.set_shift_past_edges(is_shift_past_edges) + + if block.is_video_initialized and block.video_tracks: + # no steps is where the picture stands now + self.set_shift(block.video_driver.shifted_by(0, 0), is_silent=True) diff --git a/tests/test_crop_dialog.py b/tests/test_crop_dialog.py index 7daf7f37..815191a6 100644 --- a/tests/test_crop_dialog.py +++ b/tests/test_crop_dialog.py @@ -31,6 +31,8 @@ def __init__(self, crop=NO_CROP, aspect=VideoAspect.FIT): self.video_tracks = { 1: SimpleNamespace(video_dimensions=(640, 360), is_turned=False) } + # the block's view part, done by the fake itself + self.view = self def set_crop(self, crop, is_silent=False): clamped = VideoCrop(*(max(v, 0) for v in crop)) diff --git a/tests/test_deinterlace.py b/tests/test_deinterlace.py index 956bde7b..d971e60d 100644 --- a/tests/test_deinterlace.py +++ b/tests/test_deinterlace.py @@ -29,6 +29,7 @@ from gridplayer.vlc_player.player_base import VlcPlayerBase from gridplayer.vlc_player.static import MediaInput from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_view import ViewControl URI = "http://example.com/a.mp4" @@ -269,6 +270,7 @@ def _snapshot_block(self, current, wanted): block = _block(*current) block.is_video_initialized = True block._default_title = "a" + block.view = Mock(spec=ViewControl) block._take_snapshot_params = partial(VideoBlock._take_snapshot_params, block) snapshot = _video(*wanted) diff --git a/tests/test_dotted_commands.py b/tests/test_dotted_commands.py index 72000c07..f6c38b20 100644 --- a/tests/test_dotted_commands.py +++ b/tests/test_dotted_commands.py @@ -83,7 +83,7 @@ def test_manual_seek_of_a_part_that_went_nowhere_syncs_nothing(mocker): def test_manual_view_reaches_a_part_and_gives_back_its_result(mocker): block = _playing_block(mocker) block.view.pan_by.return_value = (1.0, 2.0) - block.synced_view.return_value = "synced" + block.view.synced.return_value = "synced" result = VideoBlock.manual_view(block, "view.pan_by", 3, 4) diff --git a/tests/test_overlay_zoom_selection.py b/tests/test_overlay_zoom_selection.py index 7810290e..e79997f7 100644 --- a/tests/test_overlay_zoom_selection.py +++ b/tests/test_overlay_zoom_selection.py @@ -1,11 +1,9 @@ """The part of a picture being selected to zoom to, outlined on the overlay. -See ZoomSelectManager for the selecting, and VideoBlock.show_zoom_selection +See ZoomSelectManager for the selecting, and ViewControl.show_zoom_selection for the overlay shown for it. """ -from functools import partial - import pytest from PyQt5.QtCore import QPoint, QRect, Qt from PyQt5.QtGui import QFontMetrics, QRegion @@ -164,18 +162,10 @@ def test_hidden_there_are_none(self, overlay): def _block(mocker, **ctx): - from gridplayer.widgets.video_block import VideoBlock + from gridplayer.widgets.video_block_view import ViewControl block = mocker.Mock() - block._zoom_selection = None - block._is_zoom_hint_shown = False - for name in ( - "show_zoom_selection", - "show_zoom_hint", - "_is_zoom_ui", - "_zoom_ui_changed", - ): - setattr(block, name, partial(getattr(VideoBlock, name), block)) + block.view = ViewControl(block) block._ctx.is_drag_ui = False block._ctx.is_disable_overlay = ctx.get("is_disable_overlay", False) block._ctx.is_overlay_hide_on_timeout = True @@ -186,12 +176,11 @@ def _block(mocker, **ctx): class TestTheCellWhileSelecting: def test_the_overlay_is_shown_with_the_selection_alone(self, mocker): - from gridplayer.widgets.video_block import VideoBlock block = _block(mocker) - VideoBlock.show_zoom_selection(block, SELECTION) - VideoBlock.show_zoom_selection(block, QRect(100, 80, 250, 150)) + block.view.show_zoom_selection(SELECTION) + block.view.show_zoom_selection(QRect(100, 80, 250, 150)) assert block.overlay.set_zoom_selection.call_args_list == [ mocker.call(SELECTION), @@ -207,44 +196,41 @@ def test_it_is_not_hidden_meanwhile(self, mocker): block = _block(mocker) - VideoBlock.show_zoom_selection(block, SELECTION) + block.view.show_zoom_selection(SELECTION) VideoBlock.hide_overlay(block) block.overlay.hide.assert_not_called() def test_done_the_overlay_is_as_it_would_be(self, mocker): - from gridplayer.widgets.video_block import VideoBlock block = _block(mocker) - VideoBlock.show_zoom_selection(block, SELECTION) - VideoBlock.show_zoom_selection(block, None) + block.view.show_zoom_selection(SELECTION) + block.view.show_zoom_selection(None) block.overlay.set_zoom_selection.assert_called_with(None) block.overlay.set_is_chrome_visible.assert_called_with(True) block.show_overlay.assert_called_once_with() - assert block._zoom_selection is None + assert block.view._zoom_selection is None @pytest.mark.parametrize( "ctx", [{"is_disable_overlay": True}, {"is_overlay_fits": False}] ) def test_done_an_overlay_that_is_off_goes_again(self, mocker, ctx): - from gridplayer.widgets.video_block import VideoBlock block = _block(mocker, **ctx) - VideoBlock.show_zoom_selection(block, SELECTION) - VideoBlock.show_zoom_selection(block, None) + block.view.show_zoom_selection(SELECTION) + block.view.show_zoom_selection(None) block.overlay.hide.assert_called_once_with() block.show_overlay.assert_not_called() def test_with_none_going_on_nothing_changes(self, mocker): - from gridplayer.widgets.video_block import VideoBlock block = _block(mocker) - VideoBlock.show_zoom_selection(block, None) + block.view.show_zoom_selection(None) block.overlay.set_zoom_selection.assert_not_called() block.show_overlay.assert_not_called() @@ -254,7 +240,7 @@ class TestTheHintOnTheCell: def test_the_overlay_is_shown_with_the_hint_alone(self, mocker): block = _block(mocker) - block.show_zoom_hint(True) + block.view.show_zoom_hint(True) block.overlay.set_zoom_hint.assert_called_once_with(True) block.overlay.set_is_chrome_visible.assert_called_once_with(False) @@ -266,7 +252,7 @@ def test_it_is_not_hidden_meanwhile(self, mocker): block = _block(mocker) - block.show_zoom_hint(True) + block.view.show_zoom_hint(True) VideoBlock.hide_overlay(block) block.overlay.hide.assert_not_called() @@ -274,10 +260,10 @@ def test_it_is_not_hidden_meanwhile(self, mocker): def test_from_the_hint_to_the_selection_the_chrome_stays_away(self, mocker): block = _block(mocker) - block.show_zoom_hint(True) - block.show_zoom_selection(SELECTION) + block.view.show_zoom_hint(True) + block.view.show_zoom_selection(SELECTION) # as the selecting goes on, the hint is let go of again - block.show_zoom_hint(False) + block.view.show_zoom_hint(False) block.overlay.set_zoom_hint.assert_called_with(False) block.overlay.set_is_chrome_visible.assert_called_once_with(False) @@ -286,8 +272,8 @@ def test_from_the_hint_to_the_selection_the_chrome_stays_away(self, mocker): def test_let_go_of_the_overlay_is_as_it_would_be(self, mocker): block = _block(mocker) - block.show_zoom_hint(True) - block.show_zoom_hint(False) + block.view.show_zoom_hint(True) + block.view.show_zoom_hint(False) block.overlay.set_is_chrome_visible.assert_called_with(True) block.show_overlay.assert_called_once_with() diff --git a/tests/test_pan.py b/tests/test_pan.py index 752f56fb..1e91d9a1 100644 --- a/tests/test_pan.py +++ b/tests/test_pan.py @@ -1,3 +1,4 @@ +from operator import attrgetter from types import SimpleNamespace import pytest @@ -39,11 +40,13 @@ def __init__(self, left_over=(0.0, 0.0)): self.shifts = [] self.synced = [] self._left_over = left_over + # the block's view part, done by the fake itself + self.view = self def manual_view(self, command, *args): # as VideoBlock does, the others following where their views are synced self.synced.append(command) - return getattr(self, command)(*args) + return attrgetter(command)(self)(*args) def pan_by(self, moved_x, moved_y): self.pans.append((moved_x, moved_y)) @@ -119,7 +122,7 @@ def test_a_drag_takes_the_picture_along(): assert block.pans == [(30, -10), (10, 5)] assert QApplication.overrideCursor().shape() == Qt.ClosedHandCursor # each move for the others to follow, where their views are synced - assert block.synced == ["pan_by", "pan_by"] + assert block.synced == ["view.pan_by", "view.pan_by"] def test_what_is_left_over_goes_on_to_the_next_move(): diff --git a/tests/test_position_dialog.py b/tests/test_position_dialog.py index ca1fe427..4ffd0097 100644 --- a/tests/test_position_dialog.py +++ b/tests/test_position_dialog.py @@ -1,3 +1,4 @@ +from operator import attrgetter from types import SimpleNamespace from unittest.mock import MagicMock @@ -42,6 +43,8 @@ def __init__(self, anchor=VideoAnchor.CENTER, shift=NO_SHIFT, is_past=False): ) self.shifts = [] self.synced = [] + # the block's view part, done by the fake itself + self.view = self self.video_driver = _StubFrame(vlc_options=[]) self.video_driver.resize(900, 900) @@ -66,7 +69,7 @@ def __init__(self, anchor=VideoAnchor.CENTER, shift=NO_SHIFT, is_past=False): def manual_view(self, command, *args): self.synced.append(command) - return getattr(self, command)(*args) + return attrgetter(command)(self)(*args) def set_anchor(self, anchor): self.video_params.anchor = anchor @@ -175,7 +178,7 @@ def test_ok_says_where_the_picture_ended_up(): assert block.shifts[-1] == (VideoShift(0, -30), False) assert dialog.result() == QDialog.Accepted # once, for the others to follow where their views are synced - assert block.synced == ["set_shift"] + assert block.synced == ["view.set_shift"] def test_cancel_puts_back_what_there_was(): diff --git a/tests/test_step_sizes.py b/tests/test_step_sizes.py index 50a6812d..580476f6 100644 --- a/tests/test_step_sizes.py +++ b/tests/test_step_sizes.py @@ -6,7 +6,6 @@ """ import math -from functools import partial from unittest.mock import Mock import pytest @@ -32,6 +31,7 @@ from gridplayer.widgets.defaults_form import DefaultsForm from gridplayer.widgets.keymap_tree_view import _action_title from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_view import ViewControl STEP_KEYS = ( "playlist/seek_step_short", @@ -371,22 +371,22 @@ def _block(self, scale): block.video_params = Mock(scale=scale) block.video_tracks = [0] block.is_video_initialized = True - block.scale_increase = partial(VideoBlock.scale_increase, block) - block.scale_decrease = partial(VideoBlock.scale_decrease, block) + block.view = ViewControl(block) + block.view._zoom_to = Mock() return block def test_zoom_in_goes_by_the_playlist_step(self): _zoom(50) block = self._block(1.0) - block.scale_increase() + block.view.scale_increase() - block._zoom_to.assert_called_once_with(1.5) + block.view._zoom_to.assert_called_once_with(1.5) def test_zoom_out_goes_by_the_playlist_step(self): _zoom(20, is_proportional=True) block = self._block(1.44) - block.scale_decrease() + block.view.scale_decrease() - block._zoom_to.assert_called_once_with(1.2) + block.view._zoom_to.assert_called_once_with(1.2) diff --git a/tests/test_sync.py b/tests/test_sync.py index a4c5fbf9..370c90cd 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -35,6 +35,7 @@ from gridplayer.utils.aspect_calc import SyncedView, calc_shared_view from gridplayer.vlc_player.static import VideoTrack from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_dummy import VideoFrameDummy from gridplayer.widgets.video_frame_vlc_hw_sp import VideoFrameVLCHWSP @@ -127,9 +128,9 @@ def test_syncing_the_others_once_is_out_of_playback_and_has_the_view(self): action = ACTIONS["Sync Others (Zoom and Position)"] - assert action["func"] == ("active", "sync_others_view") + assert action["func"] == ("active", "view.sync_others") assert action["show_if"] == "is_active_has_video" - assert hasattr(VideoBlock, "sync_others_view") + assert hasattr(ViewControl, "sync_others") @pytest.mark.parametrize( "name", @@ -149,8 +150,11 @@ def test_syncing_the_others_once_is_out_of_playback_and_has_the_view(self): def test_the_viewer_s_zooms_and_moves_are_for_the_others_to_follow(self, name): target, manual, command, *_ = ACTIONS[name]["func"] + part, _, method = command.partition(".") + assert (target, manual) == ("active", "manual_view") - assert hasattr(VideoBlock, command) + assert part == "view" + assert hasattr(ViewControl, method) @pytest.mark.parametrize("name", ["Faster", "Slower", "Normal"]) def test_the_viewer_s_speeds_are_for_the_others_to_follow(self, name): @@ -192,8 +196,6 @@ def _block(is_initialized=True, has_picture=True, is_live=False): block.is_video_initialized = is_initialized block.video_tracks = {0: object()} if has_picture else {} block.is_live = is_live - block._is_zoomed_to_selection = False - block._view_to_follow = None block._is_state_change_in_progress = False block._ctx = SimpleNamespace(synced_view=None) block.video_driver = Mock() @@ -201,50 +203,60 @@ def _block(is_initialized=True, has_picture=True, is_live=False): for name in ( "manual_view", - "sync_others_view", - "synced_view", - "follow_view", - "_view_followed", - "_follow_synced_view", "manual_rate", "follow_rate", "play_pause", ): setattr(block, name, partial(getattr(VideoBlock, name), block)) + block.view = Mock(spec=ViewControl) + block.view._block = block + block.view._is_zoomed_to_selection = False + block.view._view_to_follow = None + + for name in ( + "sync_others", + "synced", + "follow", + "remember_followed", + "follow_synced", + "forget_view_to_follow", + ): + setattr(block.view, name, partial(getattr(ViewControl, name), block.view)) + return block class TestTheViewersOwnView: def test_it_is_changed_and_given_for_the_others_to_follow(self): block = _block() - block.pan_by.return_value = (0.5, 0.0) + block.view.pan_by.return_value = (0.5, 0.0) - assert block.manual_view("pan_by", 3, 4) == (0.5, 0.0) + assert block.manual_view("view.pan_by", 3, 4) == (0.5, 0.0) - block.pan_by.assert_called_once_with(3, 4) + block.view.pan_by.assert_called_once_with(3, 4) block.sync_view.emit.assert_called_once_with(SyncedView(SHARED, False)) def test_it_says_where_the_picture_is_from_a_zoom_to_a_selection(self): block = _block() - block._is_zoomed_to_selection = True + block.view._is_zoomed_to_selection = True - block.manual_view("scale_reset") + block.manual_view("view.scale_reset") block.sync_view.emit.assert_called_once_with(SyncedView(SHARED, True)) def test_with_no_picture_there_is_nothing_to_follow(self): block = _block(has_picture=False) - block.manual_view("scale_increase") + block.manual_view("view.scale_increase") - block.scale_increase.assert_called_once_with() + block.view.scale_increase.assert_called_once_with() block.sync_view.emit.assert_not_called() def test_the_others_can_be_given_it_once(self): block = _block() - block.sync_others_view() + block.view.sync_others() block.sync_view_single.emit.assert_called_once_with(SyncedView(SHARED, False)) block.sync_view.emit.assert_not_called() @@ -256,13 +268,13 @@ def test_it_shows_what_the_other_does_and_keeps_it(self): view = ViewParams(scale=2.0, anchor=VideoAnchor.TOP, shift=VideoShift(10, 20)) block.video_driver.follow_view.return_value = view - block.follow_view(SyncedView(SHARED, True)) + block.view.follow(SyncedView(SHARED, True)) block.video_driver.follow_view.assert_called_once_with(SHARED) assert block.video_params.scale == 2.0 assert block.video_params.anchor == VideoAnchor.TOP assert block.video_params.shift == VideoShift(10, 20) - assert block._is_zoomed_to_selection is True + assert block.view._is_zoomed_to_selection is True # quietly: the other says what it shows block.info_change.emit.assert_not_called() block.sync_view.emit.assert_not_called() @@ -271,7 +283,7 @@ def test_waiting_for_its_frame_size_it_keeps_its_own_until_then(self): block = _block() block.video_driver.follow_view.return_value = None - block.follow_view(SyncedView(SHARED, False)) + block.view.follow(SyncedView(SHARED, False)) assert block.video_params.scale == 1.0 @@ -279,58 +291,58 @@ def test_not_playing_yet_it_shows_it_once_it_is(self): block = _block(is_initialized=False) synced = SyncedView(SHARED, False) - block.follow_view(synced) + block.view.follow(synced) block.video_driver.follow_view.assert_not_called() - assert block._view_to_follow == synced + assert block.view._view_to_follow == synced def test_without_a_picture_there_is_nothing_to_show_it_in(self): block = _block(has_picture=False) - block.follow_view(SyncedView(SHARED, True)) + block.view.follow(SyncedView(SHARED, True)) block.video_driver.follow_view.assert_not_called() - assert block._is_zoomed_to_selection is False + assert block.view._is_zoomed_to_selection is False def test_loaded_it_shows_the_one_it_was_to_follow(self): block = _block() kept = SyncedView(SHARED, True) - block._view_to_follow = kept + block.view._view_to_follow = kept block._ctx.synced_view = SyncedView(SHARED, False) - block.follow_view = Mock() + block.view.follow = Mock() - block._follow_synced_view() + block.view.follow_synced() - block.follow_view.assert_called_once_with(kept) - assert block._view_to_follow is None + block.view.follow.assert_called_once_with(kept) + assert block.view._view_to_follow is None def test_loaded_otherwise_it_shows_the_one_they_are_synced_to(self): block = _block() synced = SyncedView(SHARED, False) block._ctx.synced_view = synced - block.follow_view = Mock() + block.view.follow = Mock() - block._follow_synced_view() + block.view.follow_synced() - block.follow_view.assert_called_once_with(synced) + block.view.follow.assert_called_once_with(synced) def test_loaded_with_neither_it_shows_its_own(self): block = _block() - block.follow_view = Mock() + block.view.follow = Mock() - block._follow_synced_view() + block.view.follow_synced() - block.follow_view.assert_not_called() + block.view.follow.assert_not_called() def test_a_snapshot_s_view_is_not_overruled_by_one_to_follow(self): block = _block(is_initialized=False) block._default_title = None - block._view_to_follow = SyncedView(SHARED, False) + block.view._view_to_follow = SyncedView(SHARED, False) block.apply_snapshot = partial(VideoBlock.apply_snapshot, block) block.apply_snapshot(Video(uri=URI, playback_state=VideoInitialState.STOPPED)) - assert block._view_to_follow is None + assert block.view._view_to_follow is None class TestTheViewersOwnPlayAndPause: @@ -390,6 +402,7 @@ def test_a_live_stream_has_none(self): def _stub(name, is_hidden=False): block = Mock(spec=VideoBlock) + block.view = Mock(spec=ViewControl) block.id = name block.video_id = name block.video_params = Video(uri=URI) @@ -414,7 +427,7 @@ def test_off_the_others_are_left_alone(self): manager.sync_view(leader, SyncedView(SHARED, False)) - other.follow_view.assert_not_called() + other.view.follow.assert_not_called() assert manager._ctx.synced_view is None def test_on_the_others_follow_and_it_is_kept_for_those_loading_after(self): @@ -425,9 +438,9 @@ def test_on_the_others_follow_and_it_is_kept_for_those_loading_after(self): manager.sync_view(leader, synced) - leader.follow_view.assert_not_called() - other.follow_view.assert_called_once_with(synced) - third.follow_view.assert_called_once_with(synced) + leader.view.follow.assert_not_called() + other.view.follow.assert_called_once_with(synced) + third.view.follow.assert_called_once_with(synced) assert manager._ctx.synced_view == synced def test_once_the_others_follow_synced_or_not(self): @@ -437,7 +450,7 @@ def test_once_the_others_follow_synced_or_not(self): manager.sync_view_single(leader, synced) - other.follow_view.assert_called_once_with(synced) + other.view.follow.assert_called_once_with(synced) assert manager._ctx.synced_view is None def test_once_with_their_views_synced_is_the_view_to_sync_to(self): @@ -448,7 +461,7 @@ def test_once_with_their_views_synced_is_the_view_to_sync_to(self): manager.sync_view_single(leader, synced) - other.follow_view.assert_called_once_with(synced) + other.view.follow.assert_called_once_with(synced) assert manager._ctx.synced_view == synced def test_switched_off_and_on_it_starts_afresh(self): @@ -480,7 +493,7 @@ def test_zoomed_for_all_the_view_is_taken_again_from_where_it_came(self): manager.set_view_sync(True) manager.sync_view(leader, SyncedView(SHARED, False)) zoomed = SyncedView(SHARED, True) - leader.synced_view.return_value = zoomed + leader.view.synced.return_value = zoomed manager.cmd_all("scale_increase") @@ -495,7 +508,7 @@ def test_for_all_anything_else_leaves_it_be(self): manager.cmd_all("rate_increase") - leader.synced_view.assert_not_called() + leader.view.synced.assert_not_called() assert manager._ctx.synced_view == synced def test_forgotten_those_loading_after_show_their_own(self): @@ -720,7 +733,7 @@ def test_a_zoom_is_followed_by_the_others_and_by_one_loaded_after(self, grid): leader, other = add(2) manager.set_view_sync(True) - leader.manual_view("scale_increase") + leader.manual_view("view.scale_increase") assert other.video_params.scale == pytest.approx(1.1) assert other.video_driver._view == leader.video_driver._view @@ -732,9 +745,9 @@ def test_a_zoom_is_followed_by_the_others_and_by_one_loaded_after(self, grid): def test_the_others_are_shown_the_view_once(self, grid): manager, add = grid leader, other = add(2) - leader.scale_increase() + leader.view.scale_increase() - leader.sync_others_view() + leader.view.sync_others() assert other.video_params.scale == pytest.approx(1.1) assert manager._ctx.synced_view is None diff --git a/tests/test_video_block_repeat.py b/tests/test_video_block_repeat.py index 81833df5..d616037d 100644 --- a/tests/test_video_block_repeat.py +++ b/tests/test_video_block_repeat.py @@ -163,7 +163,7 @@ def test_stopped_overlay_uses_hide_timer(mocker): block.is_loading = False block._is_error = False block._is_status_kept_for_picture = False - block._is_zoom_ui.return_value = False + block.view.is_zoom_ui.return_value = False block._is_list_window_shown = False block.isVisible.return_value = True diff --git a/tests/test_video_position.py b/tests/test_video_position.py index a6abfeea..e6fefc27 100644 --- a/tests/test_video_position.py +++ b/tests/test_video_position.py @@ -38,9 +38,10 @@ from gridplayer.settings import Settings from gridplayer.utils.aspect_calc import calc_view_geometry, calc_vlc_picture from gridplayer.vlc_player.static import VideoTrack -from gridplayer.widgets import video_block as video_block_module +from gridplayer.widgets import video_block_view as video_block_view_module from gridplayer.widgets import video_frame_vlc_base as vlc_base from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_vlc_hw_sp import VideoFrameVLCHWSP URI = "http://example.com/a.mp4" @@ -232,7 +233,7 @@ def _all_videos_submenu(name): return next(item for item in video if isinstance(item, tuple) and item[0] == name) -def _assert_set_for_all(name, param): +def _assert_set_for_all(name, param, owner=ViewControl): """On and Off, each setting it so for every video, and ticked where every video already has it so.""" @@ -243,7 +244,7 @@ def _assert_set_for_all(name, param): target, command, arg = action["func"] assert (target, arg) == ("all", value) - assert hasattr(VideoBlock, command) + assert hasattr(owner, command) assert hasattr(VideoBlocksManager, f"all_{command}") assert action["check_if"] == ("is_all_videos_param_set_to", param, value) @@ -277,8 +278,12 @@ def test_every_alignment_reaches_the_video_and_shows_what_is_in_force(self): target, manual, command, anchor = action["func"] # the viewer's own, for the others to follow where synced - assert (target, manual, command) == ("active", "manual_view", "set_anchor") - assert hasattr(VideoBlock, command) + assert (target, manual, command) == ( + "active", + "manual_view", + "view.set_anchor", + ) + assert hasattr(ViewControl, command.removeprefix("view.")) assert action["check_if"] == ("is_active_param_set_to", "anchor", anchor) @pytest.mark.parametrize( @@ -294,7 +299,7 @@ def test_the_moves_are_on_alt_and_the_arrows(self, name, key, steps): action = ACTIONS[name] assert action["key"] == key - assert action["func"] == ("active", "manual_view", "shift_by", *steps) + assert action["func"] == ("active", "manual_view", "view.shift_by", *steps) assert name in _submenu("video_active", "Position") all_videos = ACTIONS[f"{name} [ALL]"] @@ -308,13 +313,13 @@ def test_past_the_edges_is_a_toggle_after_the_moves(self): action = ACTIONS["Move Past Edges"] assert position[position.index("Move Down") + 1] == "Move Past Edges" - assert action["func"] == ("active", "toggle_shift_past_edges") + assert action["func"] == ("active", "view.toggle_shift_past_edges") assert action["check_if"] == ( "is_active_param_set_to", "is_shift_past_edges", True, ) - assert hasattr(VideoBlock, "toggle_shift_past_edges") + assert hasattr(ViewControl, "toggle_shift_past_edges") all_videos = _all_videos_submenu("Position") @@ -338,7 +343,7 @@ def test_the_reset_is_last(self): assert ACTIONS["Position Reset"]["func"] == ( "active", "manual_view", - "shift_reset", + "view.shift_reset", ) assert _all_videos_submenu("Position")[-1] == "Position Reset [ALL]" @@ -353,14 +358,14 @@ def test_zooming_to_the_cursor_is_a_toggle_at_the_bottom_of_zoom(self): action = ACTIONS["Zoom to Cursor"] assert _submenu("video_active", "Zoom")[-2:] == ("---", "Zoom to Cursor") - assert action["func"] == ("active", "toggle_zoom_to_cursor") + assert action["func"] == ("active", "view.toggle_zoom_to_cursor") assert action["check_if"] == ( "is_active_param_set_to", "is_zoom_to_cursor", True, ) assert action["show_if"] == "is_active_has_video" - assert hasattr(VideoBlock, "toggle_zoom_to_cursor") + assert hasattr(ViewControl, "toggle_zoom_to_cursor") def test_a_selection_to_zoom_to_is_picked_for_from_zoom(self): """The next drag over a video selects; see ZoomSelectManager.""" @@ -396,7 +401,7 @@ def test_every_video_can_be_set_to_loop_at_random_or_not(self): "Random Loop On [ALL]", "Random Loop Off [ALL]", ) - _assert_set_for_all("Random Loop", "is_start_random") + _assert_set_for_all("Random Loop", "is_start_random", owner=VideoBlock) def test_the_old_flips_for_all_are_gone(self): """Each video flipped its own way: mixed, they stayed mixed.""" @@ -413,16 +418,7 @@ def _block(anchor=VideoAnchor.CENTER, shift=NO_SHIFT, is_shift_past_edges=False) block.video_tracks = [0] block.is_video_initialized = True block.video_driver = Mock() - for name in ( - "set_anchor", - "set_shift", - "shift_by", - "pan_by", - "shift_reset", - "toggle_shift_past_edges", - "set_shift_past_edges", - ): - setattr(block, name, partial(getattr(VideoBlock, name), block)) + block.view = ViewControl(block) return block @@ -430,7 +426,7 @@ class TestOnOneVideo: def test_aligning_puts_the_picture_there(self): block = _block() - block.set_anchor(VideoAnchor.TOP) + block.view.set_anchor(VideoAnchor.TOP) assert block.video_params.anchor == VideoAnchor.TOP block.video_driver.set_anchor.assert_called_once_with(VideoAnchor.TOP) @@ -438,7 +434,7 @@ def test_aligning_puts_the_picture_there(self): def test_aligning_lets_go_of_a_move_made_before(self): block = _block(shift=VideoShift(40, -20)) - block.set_anchor(VideoAnchor.BOTTOM) + block.view.set_anchor(VideoAnchor.BOTTOM) assert block.video_params.shift == NO_SHIFT block.video_driver.set_shift.assert_called_once_with(NO_SHIFT) @@ -448,7 +444,7 @@ def test_a_move_goes_where_the_picture_can(self): block = _block() block.video_driver.shifted_by.return_value = VideoShift(54, 0) - block.shift_by(1, 0) + block.view.shift_by(1, 0) block.video_driver.shifted_by.assert_called_once_with(1, 0) block.video_driver.set_shift.assert_called_once_with(VideoShift(54, 0)) @@ -459,7 +455,7 @@ def test_a_move_that_goes_nowhere_says_so_all_the_same(self): block = _block(shift=VideoShift(420, 0)) block.video_driver.shifted_by.return_value = VideoShift(420, 0) - block.shift_by(1, 0) + block.view.shift_by(1, 0) block.video_driver.set_shift.assert_not_called() block.info_change.emit.assert_called_once_with("Position: X420 Y0") @@ -470,7 +466,7 @@ def test_a_drag_takes_it_along_quietly(self): block = _block() block.video_driver.dragged_shift.return_value = (VideoShift(-7, 3), (0.5, 0)) - left_over = block.pan_by(-8.0, 2.5) + left_over = block.view.pan_by(-8.0, 2.5) block.video_driver.dragged_shift.assert_called_once_with(-8.0, 2.5) block.video_driver.set_shift.assert_called_once_with(VideoShift(-7, 3)) @@ -481,7 +477,7 @@ def test_a_drag_takes_it_along_quietly(self): def test_the_reset_brings_it_back_to_the_alignment(self): block = _block(anchor=VideoAnchor.TOP, shift=VideoShift(0, -30)) - block.shift_reset() + block.view.shift_reset() assert block.video_params.anchor == VideoAnchor.TOP assert block.video_params.shift == NO_SHIFT @@ -490,7 +486,7 @@ def test_it_can_be_let_past_the_edges(self): block = _block() block.video_driver.shifted_by.return_value = NO_SHIFT - block.toggle_shift_past_edges() + block.view.toggle_shift_past_edges() assert block.video_params.is_shift_past_edges block.video_driver.set_shift_past_edges.assert_called_once_with(True) @@ -499,7 +495,7 @@ def test_kept_within_them_again_it_stays_where_they_hold_it(self): block = _block(shift=VideoShift(0, -960), is_shift_past_edges=True) block.video_driver.shifted_by.return_value = VideoShift(0, -420) - block.toggle_shift_past_edges() + block.view.toggle_shift_past_edges() assert not block.video_params.is_shift_past_edges block.video_driver.set_shift_past_edges.assert_called_once_with(False) @@ -515,7 +511,7 @@ def test_a_stopped_video_keeps_it_for_when_it_plays(self): block.is_video_initialized = False block.video_tracks = [] - block.set_shift_past_edges(True) + block.view.set_shift_past_edges(True) assert block.video_params.is_shift_past_edges @@ -524,7 +520,7 @@ def test_one_still_loading_gets_it_into_its_view(self): block.is_video_initialized = False block.video_tracks = [] - block.set_shift_past_edges(True) + block.view.set_shift_past_edges(True) assert block.video_params.is_shift_past_edges block.video_driver.set_shift_past_edges.assert_called_once_with(True) @@ -535,8 +531,8 @@ def test_a_video_without_a_picture_is_left_alone(self): block = _block() block.video_tracks = [] - block.set_anchor(VideoAnchor.TOP) - block.shift_by(1, 0) + block.view.set_anchor(VideoAnchor.TOP) + block.view.shift_by(1, 0) assert block.video_params.anchor == VideoAnchor.CENTER block.video_driver.set_anchor.assert_not_called() @@ -558,23 +554,8 @@ def _zooming_block(is_zoom_to_cursor=True, scale=1.0, shift=NO_SHIFT, focus=(600 block.video_driver.zoomed_shift.return_value = VideoShift(-90, 90) block.video_driver.selection_zoom.return_value = (3.0, VideoShift(240, 240)) block.video_driver.pos.return_value = QPoint(0, 0) - block._zoom_focus.return_value = focus - block._is_zoomed_to_selection = False - for name in ( - "scale_increase", - "scale_decrease", - "scale_reset", - "set_scale", - "_zoom_to", - "_set_zoom", - "toggle_zoom_to_cursor", - "set_zoom_to_cursor", - "zoom_to_selection", - "set_shift", - "shift_reset", - "set_anchor", - ): - setattr(block, name, partial(getattr(VideoBlock, name), block)) + block.view = ViewControl(block) + block.view._zoom_focus = Mock(return_value=focus) return block @@ -582,7 +563,7 @@ class TestZoomingToTheCursor: def test_it_zooms_and_moves_the_picture_in_one_go(self): block = _zooming_block() - block.scale_increase() + block.view.scale_increase() block.video_driver.zoomed_shift.assert_called_once_with(1.1, (600, 300)) block.video_driver.set_zoom.assert_called_once_with(1.1, VideoShift(-90, 90)) @@ -594,7 +575,7 @@ def test_it_zooms_and_moves_the_picture_in_one_go(self): def test_out_as_well_as_in(self): block = _zooming_block(scale=2.0) - block.scale_decrease() + block.view.scale_decrease() block.video_driver.zoomed_shift.assert_called_once_with(1.9, (600, 300)) assert block.video_params.scale == 1.9 @@ -602,7 +583,7 @@ def test_out_as_well_as_in(self): def test_with_the_pointer_elsewhere_it_zooms_about_the_alignment(self): block = _zooming_block(shift=VideoShift(20, 0), focus=None) - block.scale_increase() + block.view.scale_increase() block.video_driver.set_scale.assert_called_once_with(1.1) block.video_driver.zoomed_shift.assert_not_called() @@ -611,7 +592,7 @@ def test_with_the_pointer_elsewhere_it_zooms_about_the_alignment(self): def test_as_far_as_it_goes_it_does_not_move(self): block = _zooming_block(scale=MAX_SCALE) - block.scale_increase() + block.view.scale_increase() block.video_driver.zoomed_shift.assert_not_called() block.video_driver.set_scale.assert_called_once_with(MAX_SCALE) @@ -619,7 +600,7 @@ def test_as_far_as_it_goes_it_does_not_move(self): def test_the_reset_puts_the_picture_back_in_its_place(self): block = _zooming_block(scale=3.0, shift=VideoShift(-90, 90), focus=None) - block.scale_reset() + block.view.scale_reset() block.video_driver.set_zoom.assert_called_once_with(1.0, NO_SHIFT) assert block.video_params.scale == 1.0 @@ -630,7 +611,7 @@ def test_zoomed_about_the_alignment_the_reset_leaves_a_move_alone(self): is_zoom_to_cursor=False, scale=3.0, shift=VideoShift(-90, 90) ) - block.scale_reset() + block.view.scale_reset() block.video_driver.set_scale.assert_called_once_with(1.0) block.video_driver.set_zoom.assert_not_called() @@ -639,18 +620,18 @@ def test_zoomed_about_the_alignment_the_reset_leaves_a_move_alone(self): def test_it_can_be_switched_on_and_off(self): block = _zooming_block(is_zoom_to_cursor=False) - block.toggle_zoom_to_cursor() + block.view.toggle_zoom_to_cursor() assert block.video_params.is_zoom_to_cursor - block.toggle_zoom_to_cursor() + block.view.toggle_zoom_to_cursor() assert not block.video_params.is_zoom_to_cursor def test_a_video_without_a_picture_is_left_alone(self): block = _zooming_block() block.video_tracks = [] - block.scale_increase() - block.toggle_zoom_to_cursor() + block.view.scale_increase() + block.view.toggle_zoom_to_cursor() block.video_driver.zoomed_shift.assert_not_called() assert block.video_params.is_zoom_to_cursor @@ -661,7 +642,7 @@ def test_a_stopped_video_keeps_it_for_when_it_plays(self): block.is_video_initialized = False block.video_tracks = [] - block.set_zoom_to_cursor(True) + block.view.set_zoom_to_cursor(True) assert block.video_params.is_zoom_to_cursor @@ -670,7 +651,7 @@ class TestZoomingToASelection: def test_it_zooms_and_moves_the_picture_in_one_go(self): block = _zooming_block(is_zoom_to_cursor=False) - block.zoom_to_selection(QRect(100, 100, 300, 300)) + block.view.zoom_to_selection(QRect(100, 100, 300, 300)) block.video_driver.selection_zoom.assert_called_once_with((100, 100, 300, 300)) block.video_driver.set_zoom.assert_called_once_with(3.0, VideoShift(240, 240)) @@ -682,7 +663,7 @@ def test_the_selection_is_taken_to_the_frame_s_pixels(self): block = _zooming_block() block.video_driver.pos.return_value = QPoint(10, 20) - block.zoom_to_selection(QRect(100, 100, 300, 200)) + block.view.zoom_to_selection(QRect(100, 100, 300, 200)) block.video_driver.selection_zoom.assert_called_once_with((90, 80, 300, 200)) @@ -690,20 +671,20 @@ def test_with_nothing_to_zoom_to_nothing_changes(self): block = _zooming_block(shift=VideoShift(20, 0)) block.video_driver.selection_zoom.return_value = None - block.zoom_to_selection(QRect(0, 0, 50, 50)) + block.view.zoom_to_selection(QRect(0, 0, 50, 50)) block.video_driver.set_zoom.assert_not_called() block.info_change.emit.assert_not_called() assert block.video_params.shift == VideoShift(20, 0) - assert not block._is_zoomed_to_selection + assert not block.view._is_zoomed_to_selection def test_the_reset_puts_the_picture_back_in_its_place(self): """As zoomed about the pointer: where it stands is the zoom's doing.""" block = _zooming_block(is_zoom_to_cursor=False) - block.zoom_to_selection(QRect(100, 100, 300, 300)) - block.scale_reset() + block.view.zoom_to_selection(QRect(100, 100, 300, 300)) + block.view.scale_reset() block.video_driver.set_zoom.assert_called_with(1.0, NO_SHIFT) block.video_driver.set_scale.assert_not_called() @@ -712,19 +693,19 @@ def test_the_reset_puts_the_picture_back_in_its_place(self): def test_moved_on_after_it_the_reset_still_puts_it_back(self): block = _zooming_block(is_zoom_to_cursor=False) - block.zoom_to_selection(QRect(100, 100, 300, 300)) - block.set_shift(VideoShift(300, 200)) - block.scale_reset() + block.view.zoom_to_selection(QRect(100, 100, 300, 300)) + block.view.set_shift(VideoShift(300, 200)) + block.view.scale_reset() block.video_driver.set_zoom.assert_called_with(1.0, NO_SHIFT) def test_once_reset_the_next_reset_leaves_a_move_alone(self): block = _zooming_block(is_zoom_to_cursor=False) - block.zoom_to_selection(QRect(100, 100, 300, 300)) - block.scale_reset() - block.set_shift(VideoShift(-90, 90)) - block.scale_reset() + block.view.zoom_to_selection(QRect(100, 100, 300, 300)) + block.view.scale_reset() + block.view.set_shift(VideoShift(-90, 90)) + block.view.scale_reset() block.video_driver.set_scale.assert_called_once_with(1.0) assert block.video_params.shift == VideoShift(-90, 90) @@ -732,17 +713,17 @@ def test_once_reset_the_next_reset_leaves_a_move_alone(self): @pytest.mark.parametrize( "put_back", [ - lambda block: block.shift_reset(), - lambda block: block.set_anchor(VideoAnchor.TOP), + lambda block: block.view.shift_reset(), + lambda block: block.view.set_anchor(VideoAnchor.TOP), ], ) def test_put_back_in_its_place_the_zoom_s_reset_is_the_usual_one(self, put_back): block = _zooming_block(is_zoom_to_cursor=False) - block.zoom_to_selection(QRect(100, 100, 300, 300)) + block.view.zoom_to_selection(QRect(100, 100, 300, 300)) put_back(block) - block.set_shift(VideoShift(-90, 90)) - block.scale_reset() + block.view.set_shift(VideoShift(-90, 90)) + block.view.scale_reset() block.video_driver.set_scale.assert_called_once_with(1.0) @@ -750,7 +731,7 @@ def test_a_video_without_a_picture_is_left_alone(self): block = _zooming_block() block.video_tracks = [] - block.zoom_to_selection(QRect(100, 100, 300, 300)) + block.view.zoom_to_selection(QRect(100, 100, 300, 300)) block.video_driver.selection_zoom.assert_not_called() @@ -837,10 +818,10 @@ def _focus( block.video_driver.mapFromGlobal.side_effect = lambda pos: pos - QPoint(100, 100) monkeypatch.setattr( - video_block_module, "QCursor", Mock(pos=Mock(return_value=pointer)) + video_block_view_module, "QCursor", Mock(pos=Mock(return_value=pointer)) ) - return VideoBlock._zoom_focus(block) + return ViewControl(block)._zoom_focus() class TestWhereTheZoomGoesAbout: @@ -894,6 +875,7 @@ def test_a_snapshot_puts_the_picture_back_where_it_was(): block.video_tracks = [0] block.is_video_initialized = True block._default_title = "a" + block.view = Mock(spec=ViewControl) snapshot = Video( uri=URI, @@ -903,9 +885,9 @@ def test_a_snapshot_puts_the_picture_back_where_it_was(): ) VideoBlock.apply_snapshot(block, snapshot) - block.set_anchor.assert_called_once_with(VideoAnchor.RIGHT) - block.set_shift_past_edges.assert_called_once_with(True) - block.set_shift.assert_called_once_with(VideoShift(0, 25), is_silent=True) + block.view.set_anchor.assert_called_once_with(VideoAnchor.RIGHT) + block.view.set_shift_past_edges.assert_called_once_with(True) + block.view.set_shift.assert_called_once_with(VideoShift(0, 25), is_silent=True) def test_a_snapshot_puts_back_what_the_zoom_goes_about(): @@ -914,10 +896,11 @@ def test_a_snapshot_puts_back_what_the_zoom_goes_about(): block.video_tracks = [0] block.is_video_initialized = True block._default_title = "a" + block.view = Mock(spec=ViewControl) VideoBlock.apply_snapshot(block, Video(uri=URI, is_zoom_to_cursor=True)) - block.set_zoom_to_cursor.assert_called_once_with(True) + block.view.set_zoom_to_cursor.assert_called_once_with(True) def _track(size, orientation=VideoTransform.NONE): diff --git a/tests/test_zoom_select.py b/tests/test_zoom_select.py index 85a79484..2dc73bfd 100644 --- a/tests/test_zoom_select.py +++ b/tests/test_zoom_select.py @@ -1,3 +1,4 @@ +from operator import attrgetter from types import SimpleNamespace import pytest @@ -44,6 +45,8 @@ def __init__(self, parent): self.zooms = [] self.hints = [] self.synced = [] + # the block's view part, done by the fake itself + self.view = self def mapFromGlobal(self, pos): return QPoint(pos) @@ -57,7 +60,7 @@ def show_zoom_selection(self, selection): def manual_view(self, command, *args): # as VideoBlock does, the others following where their views are synced self.synced.append(command) - return getattr(self, command)(*args) + return attrgetter(command)(self)(*args) def zoom_to_selection(self, selection): self.zooms.append(selection) @@ -193,7 +196,7 @@ def test_a_drag_selects_and_letting_go_zooms_to_it(self, manager, block): assert block.zooms == [QRect(100, 100, 300, 200)] assert _cursor_shape() is None # for the others to follow, where their views are synced - assert block.synced == ["zoom_to_selection"] + assert block.synced == ["view.zoom_to_selection"] def test_dragged_up_and_left_it_is_the_same(self, manager, block): _press(manager, block) From d2e36a5020d6cdc9815e04e2f92ca392abf4f14e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:35:32 +0000 Subject: [PATCH 05/14] refactor: take the video block's chapters and SponsorBlock out into parts The chapters, and the jumps and loops between them, go to block.chapters, the chapters themselves now block.chapters.all and their commands "chapters.next" and the like. What SponsorBlock found, what of it is marked and the skipping go to block.sponsorblock. The block keeps what the site said of the video, its YouTube ID and length, for both to go by, and puts the seek bar's marks together from them and the bookmarks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/params/actions.py | 6 +- gridplayer/player/managers/active_block.py | 12 +- gridplayer/player/managers/video_blocks.py | 12 +- gridplayer/widgets/video_block.py | 363 ++---------------- gridplayer/widgets/video_block_chapters.py | 194 ++++++++++ .../widgets/video_block_sponsorblock.py | 206 ++++++++++ tests/conftest.py | 23 ++ tests/test_menu_bookmarks.py | 16 +- tests/test_menu_chapters.py | 14 +- tests/test_video_block_bookmarks.py | 4 +- tests/test_video_block_chapters.py | 93 ++--- tests/test_video_block_repeat.py | 4 +- tests/test_video_block_sponsorblock.py | 14 +- 13 files changed, 537 insertions(+), 424 deletions(-) create mode 100644 gridplayer/widgets/video_block_chapters.py create mode 100644 gridplayer/widgets/video_block_sponsorblock.py diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 7147c66f..2a8c4fae 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -284,13 +284,13 @@ "Previous Chapter": { "title": translate("Actions", "Previous Chapter"), "icon": "chapter-previous", - "func": ("active", "manual_seek", "previous_chapter"), + "func": ("active", "manual_seek", "chapters.previous"), "show_if": "is_active_has_chapters", }, "Next Chapter": { "title": translate("Actions", "Next Chapter"), "icon": "chapter-next", - "func": ("active", "manual_seek", "next_chapter"), + "func": ("active", "manual_seek", "chapters.next"), "show_if": "is_active_has_chapters", }, "Chapter List": { @@ -366,7 +366,7 @@ "Loop Chapter": { "title": translate("Actions", "Loop Chapter"), "icon": "loop-chapter", - "func": ("active", "loop_chapter"), + "func": ("active", "chapters.loop"), "show_if": "is_active_has_chapters", }, "When Finished Loop File": { diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index 22bc4334..b757a93e 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -420,13 +420,13 @@ def is_active_has_chapters(self): if not self.is_active_seekable(): return False - return bool(self._ctx.active_block.chapters) + return bool(self._ctx.active_block.chapters.all) def is_active_chapter_playing(self, index) -> bool: if self.is_no_active_block: return False - return self._ctx.active_block.chapter_playing == index + return self._ctx.active_block.chapters.playing == index def is_active_chapter_reachable(self, index) -> bool: """Grayed out where the loop leaves the chapter out altogether.""" @@ -434,7 +434,7 @@ def is_active_chapter_reachable(self, index) -> bool: if self.is_no_active_block: return False - return self._ctx.active_block.is_chapter_reachable(index) + return self._ctx.active_block.chapters.is_reachable(index) def menu_generator_chapters(self): """Every chapter of the file, with where it starts beside its name.""" @@ -448,16 +448,16 @@ def menu_generator_chapters(self): return [ { "title": timed_title( - block.chapter_name(index), chapter.start_ms, length_ms + block.chapters.name(index), chapter.start_ms, length_ms ), "icon": "empty", - "func": ("active", "manual_seek", "seek_chapter", index), + "func": ("active", "manual_seek", "chapters.seek", index), "check_if": ("is_active_chapter_playing", index), "is_choice": True, "enable_if": ("is_active_chapter_reachable", index), "show_if": "is_active_has_chapters", } - for index, chapter in enumerate(block.chapters) + for index, chapter in enumerate(block.chapters.all) ] def is_active_has_bookmarks(self): diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 25e7852d..89dd83b5 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -629,7 +629,7 @@ def apply_sponsorblock_settings(self): """ for vb in self._ctx.video_blocks: - vb.apply_sponsorblock_settings() + vb.sponsorblock.apply_settings() def is_any_videos_initialized(self): return bool(self._ctx.video_blocks.initialized) @@ -659,8 +659,8 @@ def is_any_videos_have_subtitles(self): return any(vb.has_subtitles for vb in self._ctx.video_blocks.initialized) def is_any_videos_have_chapters(self): - # a live stream never has any; see VideoBlock._update_chapters - return any(vb.chapters for vb in self._ctx.video_blocks.initialized) + # a live stream never has any; see ChapterControl.update + return any(vb.chapters.all for vb in self._ctx.video_blocks.initialized) def is_all_videos_param_set_to(self, param_name, param_value): """Whether every video has it so, which ticks the choice for all. @@ -860,14 +860,14 @@ def _add_video_block(self, video): (self.all_seek, vb.seek), (self.all_next_frame, vb.next_frame), (self.all_previous_frame, vb.previous_frame), - (self.all_next_chapter, vb.next_chapter), - (self.all_previous_chapter, vb.previous_chapter), + (self.all_next_chapter, vb.chapters.next), + (self.all_previous_chapter, vb.chapters.previous), (self.all_next_bookmark, vb.bookmarker.next), (self.all_previous_bookmark, vb.bookmarker.previous), (self.all_set_loop_random, vb.set_loop_random), (self.all_set_loop_start, vb.set_loop_start), (self.all_set_loop_end, vb.set_loop_end), - (self.all_loop_chapter, vb.loop_chapter), + (self.all_loop_chapter, vb.chapters.loop), (self.all_reset_loop, vb.reset_loop), (self.all_set_end_action, vb.set_end_action), (self.all_set_dir_recursive, vb.set_dir_recursive), diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index 833a352a..6ed82b42 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -1,4 +1,3 @@ -import contextlib import dataclasses import logging import random @@ -42,7 +41,6 @@ track_of_file, ) from gridplayer.models.bookmark import Bookmark -from gridplayer.models.chapter import Chapter from gridplayer.models.media_list import ( ItemDetails, ListItem, @@ -52,7 +50,6 @@ merged_details, ) from gridplayer.models.seek_mark import SeekMark, SeekMarkKind -from gridplayer.models.sponsor_segment import SponsorSegment from gridplayer.models.stream import ( STANDING_QUALITIES, STREAM_QUALITY_AUTO, @@ -80,7 +77,6 @@ ) from gridplayer.params import env from gridplayer.params.extensions import SUPPORTED_AUDIO_EXT, SUPPORTED_SUBTITLE_EXT -from gridplayer.params.sponsorblock import category_name from gridplayer.params.static import ( AUDIO_DELAY_STEP_MS, CHROME_MIN_SIZE, @@ -98,7 +94,6 @@ ListFailAction, ListSourceKind, NetworkRetryMode, - SponsorBlockMode, VideoAspect, VideoDeinterlace, VideoDeinterlaceMode, @@ -108,16 +103,6 @@ ) from gridplayer.settings import Settings from gridplayer.utils.bookmarks import file_key -from gridplayer.utils.chapters import ( - chapter_index_at, - chapter_span, - clean_chapters, - is_site_length_playing, - is_span_reachable, - next_stop, - previous_stop, - span_at, -) from gridplayer.utils.drop_zone import DropIndicator from gridplayer.utils.external_audio import ( discover_audio_files, @@ -144,15 +129,7 @@ screenshots_dir, ) from gridplayer.utils.sponsorblock import ( - SKIP_END_MARGIN_MS, - SKIP_MIN_MS, - SkipSpan, - category_modes, - is_sponsorblock_enabled, segment_mark, - skip_span_at, - skip_spans, - sponsorblock_fetcher, ) from gridplayer.utils.step_sizes import seek_step_ms from gridplayer.utils.track_language import language_name, normalize, pick_track @@ -184,6 +161,7 @@ ) from gridplayer.widgets.stacked_layout_floating import QStackedLayoutFloating from gridplayer.widgets.video_block_bookmarker import Bookmarker +from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_guards import ( only_initialized, only_live, @@ -193,6 +171,7 @@ only_streamable, only_with_video_tacks, ) +from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_vlc_base import VideoFrameVLC from gridplayer.widgets.video_overlay import ( @@ -372,28 +351,11 @@ def __init__(self, video_driver, context, **kwargs): # last time update seen, to tell a finished pass from a running one self._last_time = None - # the file's chapters, or the site's where it has none, as far as - # they are any use; see clean_chapters - self._chapters: tuple[Chapter, ...] = () - - # what the site a URL resolved on lists, and how long it says the - # video is, for when the file lists none; see _update_chapters - self._site_chapters: tuple[Chapter, ...] = () + # how long the site a URL resolved on says the video is, to know its + # chapters and SponsorBlock's segments by; and the ID SponsorBlock + # knows a YouTube video by self._site_length_ms = 0 - - # the ID SponsorBlock knows a YouTube video by, and every segment its - # users marked in it; see _update_sponsor_segments self._youtube_id: str | None = None - self._sponsor_segments: tuple[SponsorSegment, ...] = () - - # the ones the settings mark on the seek bar, and the stretches they - # skip, each as far as the video playing is the one they were for - self._shown_segments: tuple[SponsorSegment, ...] = () - self._skip_spans: tuple[SkipSpan, ...] = () - - # a stretch to be skipped that the viewer went into, or the one just - # skipped, left to play until the time is out of it - self._span_left_alone: SkipSpan | None = None # what the seek bar was last told to mark self._seek_marks: tuple[SeekMark, ...] = () @@ -492,8 +454,8 @@ def __init__(self, video_driver, context, **kwargs): self.bookmarker = Bookmarker(self) self.view = ViewControl(self) - - sponsorblock_fetcher().fetched.connect(self._sponsor_segments_fetched) + self.chapters = ChapterControl(self) + self.sponsorblock = SponsorBlockControl(self) # the next, previous or a random file of the folder, to put in this # one's place @@ -577,7 +539,7 @@ def init_video_driver(self) -> VideoFrameVLC: (video_driver.picture_shown, self._picture_shown), (video_driver.view_followed, self.view.remember_followed), (video_driver.tracks_changed, self.tracks_changed), - (video_driver.chapters_changed, self._update_chapters), + (video_driver.chapters_changed, self.chapters.update), (video_driver.time_changed, self.time_changed), (video_driver.playback_status_changed, self.playback_status_changed), (video_driver.error, self.video_driver_error), @@ -630,9 +592,7 @@ def _destroy_video_driver(self): # what SponsorBlock found is only marked again once something is # playing - self._shown_segments = () - self._skip_spans = () - self._span_left_alone = None + self.sponsorblock.forget_shown() # a seek made on it has nothing left to land on self._seek_target = None @@ -642,7 +602,7 @@ def _destroy_video_driver(self): # whatever opens next brings chapters of its own, or none; and the # bookmarks are only marked once there is a length to mark them on - self._set_chapters(()) + self.chapters.clear() def _release_video_driver(self): if self.video_driver is None: @@ -806,9 +766,7 @@ def cleanup(self): self._log.debug(f"{self.id}: cleaning up resolver") self.url_resolver.cleanup() - # the lookup outlives the video, it answers every one of them - with contextlib.suppress(TypeError): - sponsorblock_fetcher().fetched.disconnect(self._sponsor_segments_fetched) + self.sponsorblock.stop_listening() self._folder_navigator.close() @@ -1740,6 +1698,13 @@ def youtube_id(self) -> str | None: return self._youtube_id + @property + def site_length_ms(self) -> int: + """How long the site this URL resolved on says the video is, 0 for + one it did not say of, or a file.""" + + return self._site_length_ms + @property def video_tracks(self): if self.video_driver is None or not self.is_video_initialized: @@ -3324,56 +3289,7 @@ def time_changed(self, new_time): self.loop_end_action() else: - self._skip_sponsor_segment() - - def _skip_sponsor_segment(self): - """Pass over a stretch set to be skipped, if the video played into one. - - Played into it, not put there: a seek into one is the viewer choosing - to watch it, and it is left alone until the time is out of it. Never - from a seek still landing either, which time_changed has already seen - to: that is seconds short of where it is going on a stream, and could - be inside a stretch the seek was made to get past. - """ - - span = skip_span_at(self._skip_spans, self.time) - - if span is None: - self._span_left_alone = None - return - - if span == self._span_left_alone or self.video_params.is_paused: - return - - if span.end_ms - self.time < SKIP_MIN_MS: - return - - is_to_end = span.end_ms >= self.loop_end - SKIP_END_MARGIN_MS - - # one that takes up the whole of what is played would only take the - # video round to the start of it again, which is back inside it - if is_to_end and span.start_ms <= self.loop_start: - return - - self._log.debug(f"Skipping {span.category} from {self.time} to {span.end_ms}") - - self.info_change.emit( - translate("SponsorBlock", "Skipped: {CATEGORY}").format( - CATEGORY=category_name(span.category) - ) - ) - - # One that runs to the end of the pass ends it, whatever that means - # for this video: another pass, the next file, a stop. A seek right - # into the end is also where a seek on a stream is least reliable. - if is_to_end: - self.loop_end_action() - return - - self.seek(span.end_ms, is_viewers=False) - - # VLC reports where a seek landed a hair short, inside it still - self._span_left_alone = span + self.sponsorblock.skip_if_played_into() def _is_loop_wrapped(self, new_time) -> bool: """Has the video begun another pass since the last time update?""" @@ -3849,15 +3765,12 @@ def _start_load(self): # the video may not be the one that was here, whatever else stays; # a URL is resolved again and brings the site's list back with it - self._site_chapters = () + self.chapters.set_site_chapters(()) self._site_length_ms = 0 # and SponsorBlock is asked about it again, if it is one to ask about self._youtube_id = None - self._sponsor_segments = () - self._shown_segments = () - self._skip_spans = () - self._span_left_alone = None + self.sponsorblock.forget() # a file is never adaptive; a URL says so once its stream is picked self._is_adaptive = False @@ -3899,11 +3812,11 @@ def set_video_url(self, video: ResolvedVideo): self.is_live = video.is_live # kept, like the streams, through a switch of quality or of dub - self._site_chapters = video.chapters + self.chapters.set_site_chapters(video.chapters) self._site_length_ms = video.duration_ms self._youtube_id = video.youtube_id - self._look_up_sponsor_segments() + self.sponsorblock.look_up() self._learn_item_details(video.details) @@ -4257,8 +4170,8 @@ def load_video_finish(self): if getattr(self._ctx, "is_video_decoding_stopped", False): self.set_video_decoding_stopped(True) - self._update_chapters() - self._update_sponsor_segments() + self.chapters.update() + self.sponsorblock.update() self.is_audio_present_change.emit(bool(self.audio_tracks)) @@ -4395,51 +4308,6 @@ def reset_loop(self): self.set_loop_start_time(None) self.set_loop_end_time(None) - # Chapters - # - # Every jump is a seek to where a chapter starts, never libVLC's own - # chapter calls: those move the time without seek() knowing, and a jump - # back of any size then reads as the video having finished a pass, which - # sets off the end action. A loop narrows everything down to itself, the - # same as it does for the seek bar. - - @property - def chapters(self) -> tuple[Chapter, ...]: - return self._chapters - - def _update_chapters(self): - if not self.is_video_initialized or self.is_live: - self._set_chapters(()) - return - - length = self.video_driver.length - - # the file's own are in the time the player keeps, where the site's - # are only as good as the site's idea of what it is serving - chapters = self.video_driver.chapters or self._site_chapters_playing(length) - - self._set_chapters(clean_chapters(chapters, length)) - - def _site_chapters_playing(self, length: int) -> tuple[Chapter, ...]: - """The site's chapters, if what plays is the video they were made for.""" - - if not self._site_chapters: - return () - - if not is_site_length_playing(length, self._site_length_ms): - self._log.debug( - f"Site chapters left out, the site says {self._site_length_ms}ms" - f" but {length}ms is playing" - ) - return () - - return self._site_chapters - - def _set_chapters(self, chapters: tuple[Chapter, ...]): - self._chapters = chapters - - self.update_seek_marks() - @property def seek_marks(self) -> tuple[SeekMark, ...]: """What the seek bar marks, as last told by seek_marks_change.""" @@ -4453,13 +4321,15 @@ def update_seek_marks(self): chapters = ( SeekMark( time_ms=chapter.start_ms, - label=self.chapter_name(index), + label=self.chapters.name(index), kind=SeekMarkKind.CHAPTER, ) - for index, chapter in enumerate(self._chapters) + for index, chapter in enumerate(self.chapters.all) ) - segments = (segment_mark(segment) for segment in self._shown_segments) + segments = ( + segment_mark(segment) for segment in self.sponsorblock.shown_segments + ) bookmarks = () if self.is_video_initialized and not self.is_live: @@ -4474,175 +4344,6 @@ def update_seek_marks(self): self.seek_marks_change.emit(marks) - # SponsorBlock. Its segments are no chapters: they overlap the chapters, - # leave gaps between one another, and are nowhere to jump to. They are - # marked on the bar in a look of their own, and some are skipped. - - def apply_sponsorblock_settings(self): - """Take up a change to the settings: to what is looked up, or to what - is done with what was found.""" - - self._look_up_sponsor_segments() - self._update_sponsor_segments() - - def _look_up_sponsor_segments(self): - """Ask SponsorBlock about the video, if it is one to ask about.""" - - if self._youtube_id is None or self._site_length_ms <= 0: - return - - if not is_sponsorblock_enabled(): - return - - segments = sponsorblock_fetcher().fetch(self._youtube_id, self._site_length_ms) - - # on their way otherwise, see _sponsor_segments_fetched - if segments is not None: - self._set_sponsor_segments(segments) - - def _sponsor_segments_fetched(self, video_id: str, segments): - # another video's, or the one before this video was switched - if video_id != self._youtube_id: - return - - self._set_sponsor_segments(segments) - - def _set_sponsor_segments(self, segments: tuple[SponsorSegment, ...]): - self._sponsor_segments = segments - - self._update_sponsor_segments() - - def _update_sponsor_segments(self): - """Work out which of the segments to mark, and what to skip.""" - - segments = self._sponsor_segments_playing() - - modes = category_modes() - - self._shown_segments = tuple( - segment - for segment in segments - if modes[segment.category] is not SponsorBlockMode.OFF - ) - self._skip_spans = skip_spans(segments, modes) - - self.update_seek_marks() - - def _sponsor_segments_playing(self) -> tuple[SponsorSegment, ...]: - """The segments, if what plays is the video they were marked on.""" - - if not self._sponsor_segments or not is_sponsorblock_enabled(): - return () - - if not self.is_video_initialized or self.is_live: - return () - - length = self.video_driver.length - - if not is_site_length_playing(length, self._site_length_ms): - self._log.debug( - f"SponsorBlock segments left out, the site says" - f" {self._site_length_ms}ms but {length}ms is playing" - ) - return () - - return self._sponsor_segments - - def chapter_name(self, index: int) -> str: - name = self._chapters[index].name - - if name: - return name - - return translate("Actions", "Chapter {NUMBER}").format(NUMBER=index + 1) - - @property - def chapter_playing(self) -> int | None: - """Which chapter the video is in, None before the first one starts.""" - - if not self._chapters: - return None - - return chapter_index_at(self._chapters, self.arriving_time) - - def is_chapter_reachable(self, index: int) -> bool: - """Whether a jump to this chapter would land inside the loop.""" - - if not self.is_video_initialized or not 0 <= index < len(self._chapters): - return False - - span = chapter_span(self._chapters, index, self.video_driver.length) - - return is_span_reachable(span, self.loop_start, self.loop_end) - - @only_initialized - @only_seekable - def next_chapter(self): - if not self._chapters: - return - - stop_ms = next_stop( - self._chapters, self.arriving_time, self.loop_start, self.loop_end - ) - - if stop_ms is None: - # past the last chapter is the end, arriving early - self.loop_end_action() - return - - self._seek_chapter_stop(stop_ms) - - @only_initialized - @only_seekable - def previous_chapter(self): - if not self._chapters: - return - - self._seek_chapter_stop( - previous_stop( - self._chapters, self.arriving_time, self.loop_start, self.loop_end - ) - ) - - @only_initialized - @only_seekable - def seek_chapter(self, index: int): - if not self.is_chapter_reachable(index): - return - - # a chapter the loop starts part way into is joined where it does - self._seek_chapter_stop(max(self._chapters[index].start_ms, self.loop_start)) - - def _seek_chapter_stop(self, stop_ms: int): - self.seek(stop_ms) - - index = chapter_index_at(self._chapters, stop_ms) - - if index is not None: - self.info_change.emit(self.chapter_name(index)) - - @only_initialized - @only_seekable - def loop_chapter(self): - """Loop the chapter the video is in, as a segment of its own.""" - - if not self._chapters: - return - - length = self.video_driver.length - - start_ms, end_ms = span_at(self._chapters, self.arriving_time, length) - - # cleared first: the new start can lie past the old end - self.reset_loop() - - self.set_loop_start_time(start_ms) - - # the last chapter runs to the end of the file, which is where an - # unset end already is - if end_ms < length: - self.set_loop_end_time(end_ms) - # the viewer's bookmarks; what is done with them is the Bookmarker's @property @@ -4802,9 +4503,7 @@ def seek(self, seek_ms, is_viewers=True): ) self._seek_behind = behind if is_behind and self._seek_retries else None - self._span_left_alone = ( - skip_span_at(self._skip_spans, seek_ms) if is_viewers else None - ) + self.sponsorblock.note_seek(seek_ms, is_viewers) self.time = seek_ms self.video_driver.set_time(seek_ms) diff --git a/gridplayer/widgets/video_block_chapters.py b/gridplayer/widgets/video_block_chapters.py new file mode 100644 index 00000000..7d42bebf --- /dev/null +++ b/gridplayer/widgets/video_block_chapters.py @@ -0,0 +1,194 @@ +"""The chapters of a video block. + +Every jump is a seek to where a chapter starts, never libVLC's own +chapter calls: those move the time without seek() knowing, and a jump +back of any size then reads as the video having finished a pass, which +sets off the end action. A loop narrows everything down to itself, the +same as it does for the seek bar. +""" + +from gridplayer.models.chapter import Chapter +from gridplayer.utils.chapters import ( + chapter_index_at, + chapter_span, + clean_chapters, + is_site_length_playing, + is_span_reachable, + next_stop, + previous_stop, + span_at, +) +from gridplayer.utils.qt import translate +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_seekable, +) + + +class ChapterControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # the file's chapters, or the site's where it has none, as far as + # they are any use; see clean_chapters + self._chapters: tuple[Chapter, ...] = () + + # what the site a URL resolved on lists, for when the file lists + # none; see update + self._site_chapters: tuple[Chapter, ...] = () + + @property + def all(self) -> tuple[Chapter, ...]: + return self._chapters + + def set_site_chapters(self, chapters: tuple[Chapter, ...]): + """What the site lists, as the URL resolved; none for a video that + may not be the one it was listed for.""" + + self._site_chapters = chapters + + def update(self): + block = self._block + + if not block.is_video_initialized or block.is_live: + self._set(()) + return + + length = block.video_driver.length + + # the file's own are in the time the player keeps, where the site's + # are only as good as the site's idea of what it is serving + chapters = block.video_driver.chapters or self._site_chapters_playing(length) + + self._set(clean_chapters(chapters, length)) + + def _site_chapters_playing(self, length: int) -> tuple[Chapter, ...]: + """The site's chapters, if what plays is the video they were made for.""" + + if not self._site_chapters: + return () + + site_length_ms = self._block.site_length_ms + + if not is_site_length_playing(length, site_length_ms): + self._block._log.debug( + f"Site chapters left out, the site says {site_length_ms}ms" + f" but {length}ms is playing" + ) + return () + + return self._site_chapters + + def clear(self): + """No chapters until update finds some, the seek bar told so.""" + + self._set(()) + + def _set(self, chapters: tuple[Chapter, ...]): + self._chapters = chapters + + self._block.update_seek_marks() + + def name(self, index: int) -> str: + name = self._chapters[index].name + + if name: + return name + + return translate("Actions", "Chapter {NUMBER}").format(NUMBER=index + 1) + + @property + def playing(self) -> int | None: + """Which chapter the video is in, None before the first one starts.""" + + if not self._chapters: + return None + + return chapter_index_at(self._chapters, self._block.arriving_time) + + def is_reachable(self, index: int) -> bool: + """Whether a jump to this chapter would land inside the loop.""" + + block = self._block + + if not block.is_video_initialized or not 0 <= index < len(self._chapters): + return False + + span = chapter_span(self._chapters, index, block.video_driver.length) + + return is_span_reachable(span, block.loop_start, block.loop_end) + + @only_initialized + @only_seekable + def next(self): + block = self._block + + if not self._chapters: + return + + stop_ms = next_stop( + self._chapters, block.arriving_time, block.loop_start, block.loop_end + ) + + if stop_ms is None: + # past the last chapter is the end, arriving early + block.loop_end_action() + return + + self._seek_stop(stop_ms) + + @only_initialized + @only_seekable + def previous(self): + block = self._block + + if not self._chapters: + return + + self._seek_stop( + previous_stop( + self._chapters, block.arriving_time, block.loop_start, block.loop_end + ) + ) + + @only_initialized + @only_seekable + def seek(self, index: int): + if not self.is_reachable(index): + return + + # a chapter the loop starts part way into is joined where it does + self._seek_stop(max(self._chapters[index].start_ms, self._block.loop_start)) + + def _seek_stop(self, stop_ms: int): + self._block.seek(stop_ms) + + index = chapter_index_at(self._chapters, stop_ms) + + if index is not None: + self._block.info_change.emit(self.name(index)) + + @only_initialized + @only_seekable + def loop(self): + """Loop the chapter the video is in, as a segment of its own.""" + + block = self._block + + if not self._chapters: + return + + length = block.video_driver.length + + start_ms, end_ms = span_at(self._chapters, block.arriving_time, length) + + # cleared first: the new start can lie past the old end + block.reset_loop() + + block.set_loop_start_time(start_ms) + + # the last chapter runs to the end of the file, which is where an + # unset end already is + if end_ms < length: + block.set_loop_end_time(end_ms) diff --git a/gridplayer/widgets/video_block_sponsorblock.py b/gridplayer/widgets/video_block_sponsorblock.py new file mode 100644 index 00000000..49def0b7 --- /dev/null +++ b/gridplayer/widgets/video_block_sponsorblock.py @@ -0,0 +1,206 @@ +"""What SponsorBlock's users marked in a video block's YouTube video. + +Its segments are no chapters: they overlap the chapters, leave gaps +between one another, and are nowhere to jump to. They are marked on the +bar in a look of their own, and some are skipped. +""" + +import contextlib + +from gridplayer.models.sponsor_segment import SponsorSegment +from gridplayer.params.sponsorblock import category_name +from gridplayer.params.static import SponsorBlockMode +from gridplayer.utils.chapters import is_site_length_playing +from gridplayer.utils.qt import translate +from gridplayer.utils.sponsorblock import ( + SKIP_END_MARGIN_MS, + SKIP_MIN_MS, + SkipSpan, + category_modes, + is_sponsorblock_enabled, + skip_span_at, + skip_spans, + sponsorblock_fetcher, +) +from gridplayer.widgets.video_block_guards import VideoBlockPart + + +class SponsorBlockControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # every segment SponsorBlock's users marked in the video, the one + # the block's youtube_id is; see update + self._segments: tuple[SponsorSegment, ...] = () + + # the ones the settings mark on the seek bar, and the stretches they + # skip, each as far as the video playing is the one they were for + self._shown_segments: tuple[SponsorSegment, ...] = () + self._skip_spans: tuple[SkipSpan, ...] = () + + # a stretch to be skipped that the viewer went into, or the one just + # skipped, left to play until the time is out of it + self._span_left_alone: SkipSpan | None = None + + sponsorblock_fetcher().fetched.connect(self._fetched) + + @property + def shown_segments(self) -> tuple[SponsorSegment, ...]: + """The segments the seek bar marks.""" + + return self._shown_segments + + def stop_listening(self): + """The lookup outlives the video, it answers every one of them.""" + + with contextlib.suppress(TypeError): + sponsorblock_fetcher().fetched.disconnect(self._fetched) + + def apply_settings(self): + """Take up a change to the settings: to what is looked up, or to what + is done with what was found.""" + + self.look_up() + self.update() + + def look_up(self): + """Ask SponsorBlock about the video, if it is one to ask about.""" + + youtube_id = self._block.youtube_id + site_length_ms = self._block.site_length_ms + + if youtube_id is None or site_length_ms <= 0: + return + + if not is_sponsorblock_enabled(): + return + + segments = sponsorblock_fetcher().fetch(youtube_id, site_length_ms) + + # on their way otherwise, see _fetched + if segments is not None: + self._set(segments) + + def _fetched(self, video_id: str, segments): + # another video's, or the one before this video was switched + if video_id != self._block.youtube_id: + return + + self._set(segments) + + def _set(self, segments: tuple[SponsorSegment, ...]): + self._segments = segments + + self.update() + + def forget(self): + """Let go of everything found: the video may not be the one it was + found for.""" + + self._segments = () + self.forget_shown() + + def forget_shown(self): + """Mark and skip nothing until update says to again.""" + + self._shown_segments = () + self._skip_spans = () + self._span_left_alone = None + + def update(self): + """Work out which of the segments to mark, and what to skip.""" + + segments = self._playing() + + modes = category_modes() + + self._shown_segments = tuple( + segment + for segment in segments + if modes[segment.category] is not SponsorBlockMode.OFF + ) + self._skip_spans = skip_spans(segments, modes) + + self._block.update_seek_marks() + + def _playing(self) -> tuple[SponsorSegment, ...]: + """The segments, if what plays is the video they were marked on.""" + + block = self._block + + if not self._segments or not is_sponsorblock_enabled(): + return () + + if not block.is_video_initialized or block.is_live: + return () + + length = block.video_driver.length + site_length_ms = block.site_length_ms + + if not is_site_length_playing(length, site_length_ms): + block._log.debug( + f"SponsorBlock segments left out, the site says" + f" {site_length_ms}ms but {length}ms is playing" + ) + return () + + return self._segments + + def note_seek(self, seek_ms: int, is_viewers: bool): + """A seek of the viewer's into a stretch to be skipped is them choosing + to watch it: it is left alone until the time is out of it.""" + + self._span_left_alone = ( + skip_span_at(self._skip_spans, seek_ms) if is_viewers else None + ) + + def skip_if_played_into(self): + """Pass over a stretch set to be skipped, if the video played into one. + + Played into it, not put there: a seek into one is the viewer choosing + to watch it, and it is left alone until the time is out of it. Never + from a seek still landing either, which time_changed has already seen + to: that is seconds short of where it is going on a stream, and could + be inside a stretch the seek was made to get past. + """ + + block = self._block + + span = skip_span_at(self._skip_spans, block.time) + + if span is None: + self._span_left_alone = None + return + + if span == self._span_left_alone or block.video_params.is_paused: + return + + if span.end_ms - block.time < SKIP_MIN_MS: + return + + is_to_end = span.end_ms >= block.loop_end - SKIP_END_MARGIN_MS + + # one that takes up the whole of what is played would only take the + # video round to the start of it again, which is back inside it + if is_to_end and span.start_ms <= block.loop_start: + return + + block._log.debug(f"Skipping {span.category} from {block.time} to {span.end_ms}") + + block.info_change.emit( + translate("SponsorBlock", "Skipped: {CATEGORY}").format( + CATEGORY=category_name(span.category) + ) + ) + + # One that runs to the end of the pass ends it, whatever that means + # for this video: another pass, the next file, a stop. A seek right + # into the end is also where a seek on a stream is least reliable. + if is_to_end: + block.loop_end_action() + return + + block.seek(span.end_ms, is_viewers=False) + + # VLC reports where a seek landed a hair short, inside it still + self._span_left_alone = span diff --git a/tests/conftest.py b/tests/conftest.py index cee6adb1..96f9f516 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,29 @@ COOKIE_LIFETIME_SEC = 10000 +def is_block_command(command: str) -> bool: + """Whether a VideoBlock command, "reload" or "chapters.next", is there: + the block's own, or one of a part of it (see VideoBlockPart).""" + + from gridplayer.widgets.video_block import VideoBlock + from gridplayer.widgets.video_block_bookmarker import Bookmarker + from gridplayer.widgets.video_block_chapters import ChapterControl + from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl + from gridplayer.widgets.video_block_view import ViewControl + + parts = { + "": VideoBlock, + "bookmarker": Bookmarker, + "chapters": ChapterControl, + "sponsorblock": SponsorBlockControl, + "view": ViewControl, + } + + part, _, method = command.rpartition(".") + + return part in parts and hasattr(parts[part], method) + + class FakeSettings: """A page of settings, answering for itself. diff --git a/tests/test_menu_bookmarks.py b/tests/test_menu_bookmarks.py index c5f6cb38..77b14c54 100644 --- a/tests/test_menu_bookmarks.py +++ b/tests/test_menu_bookmarks.py @@ -13,8 +13,7 @@ from gridplayer.player.managers.bookmarks import BookmarksManager from gridplayer.player.managers.video_blocks import VideoBlocksManager from gridplayer.widgets.keymap_tree_view import DATA_ACTION_ID, fill_keymap_model -from gridplayer.widgets.video_block import VideoBlock -from gridplayer.widgets.video_block_bookmarker import Bookmarker +from tests.conftest import is_block_command BOOKMARK_ACTIONS = ( "Add Bookmark", @@ -27,15 +26,6 @@ ICONS = Path(__file__).parent.parent / "gridplayer" / "resources" / "icons" -# the parts of a video block a command may name, "bookmarker.add" -BLOCK_PARTS = {"bookmarker": Bookmarker} - - -def _is_block_command(command): - part, _, method = command.rpartition(".") - - return hasattr(BLOCK_PARTS[part] if part else VideoBlock, method) - def _submenu(items, *path): for name in path: @@ -109,7 +99,7 @@ def test_the_active_video_has_what_they_call(self): func = ACTIONS[name]["func"] method = func[2] if func[1] == "manual_seek" else func[1] - assert _is_block_command(method), name + assert is_block_command(method), name def test_jumps_on_the_active_video_are_seeks_the_others_follow(self): for name in ("Previous Bookmark", "Next Bookmark"): @@ -123,7 +113,7 @@ def test_every_video_is_wired_up_for_them(self): command = ACTIONS[name]["func"][1] assert hasattr(VideoBlocksManager, f"all_{command}"), name - assert _is_block_command(method), name + assert is_block_command(method), name def test_what_decides_when_they_show_is_there(self): commands = { diff --git a/tests/test_menu_chapters.py b/tests/test_menu_chapters.py index 3c131dc3..413a0f1d 100644 --- a/tests/test_menu_chapters.py +++ b/tests/test_menu_chapters.py @@ -12,7 +12,7 @@ from gridplayer.player.managers.video_blocks import VideoBlocksManager from gridplayer.widgets.custom_menu import CustomMenu from gridplayer.widgets.keymap_tree_view import DATA_ACTION_ID, fill_keymap_model -from gridplayer.widgets.video_block import VideoBlock +from tests.conftest import is_block_command CHAPTER_ACTIONS = ( "Previous Chapter", @@ -89,18 +89,18 @@ def test_the_active_video_has_what_they_call(self): func = ACTIONS[name]["func"] method = func[2] if func[1] == "manual_seek" else func[1] - assert hasattr(VideoBlock, method), name + assert is_block_command(method), name def test_every_video_is_wired_up_for_them(self): - for name in ( - "Previous Chapter [ALL]", - "Next Chapter [ALL]", - "Loop Chapter [ALL]", + for name, method in ( + ("Previous Chapter [ALL]", "chapters.previous"), + ("Next Chapter [ALL]", "chapters.next"), + ("Loop Chapter [ALL]", "chapters.loop"), ): command = ACTIONS[name]["func"][1] assert hasattr(VideoBlocksManager, f"all_{command}"), name - assert hasattr(VideoBlock, command), name + assert is_block_command(method), name def test_jumps_on_the_active_video_are_seeks_the_others_follow(self): for name in ("Previous Chapter", "Next Chapter"): diff --git a/tests/test_video_block_bookmarks.py b/tests/test_video_block_bookmarks.py index 61dbbb2b..23512f98 100644 --- a/tests/test_video_block_bookmarks.py +++ b/tests/test_video_block_bookmarks.py @@ -409,7 +409,7 @@ def test_one_that_goes_nowhere_gives_the_others_nothing_to_follow(self, block): assert synced == [] def test_one_inside_a_sponsor_is_played_not_skipped(self, block): - block._skip_spans = (SkipSpan(25000, 40000, "sponsor"),) + block.sponsorblock._skip_spans = (SkipSpan(25000, 40000, "sponsor"),) block.set_pause(False) block.bookmarker.seek(1) @@ -726,7 +726,7 @@ def test_one_outside_the_loop_goes_nowhere(self, block): assert synced == [] def test_one_inside_a_sponsor_is_played_not_skipped(self, block): - block._skip_spans = (SkipSpan(25000, 40000, "sponsor"),) + block.sponsorblock._skip_spans = (SkipSpan(25000, 40000, "sponsor"),) block.set_pause(False) block.overlay.bookmark_clicked.emit((30000,)) diff --git a/tests/test_video_block_chapters.py b/tests/test_video_block_chapters.py index 83565e6c..ba4028ed 100644 --- a/tests/test_video_block_chapters.py +++ b/tests/test_video_block_chapters.py @@ -5,6 +5,7 @@ """ import dataclasses +from operator import attrgetter from types import SimpleNamespace from unittest.mock import MagicMock @@ -119,28 +120,28 @@ def test_chapters_the_file_lists_late_are_marked_when_they_come(self, block): block.video_driver._on_chapters_changed(late) - assert block.chapters == late + assert block.chapters.all == late assert [mark.label for mark in _marks(block)] == ["A", "B"] def test_stopping_takes_the_marks_away(self, block): block.stop_playback() - assert block.chapters == () + assert block.chapters.all == () assert _marks(block) == () class TestJumping: def test_next_goes_from_chapter_to_chapter(self, block): - block.next_chapter() + block.chapters.next() assert block.time == 10000 - block.next_chapter() + block.chapters.next() assert block.time == 25000 def test_next_from_a_seek_that_landed_short_moves_on(self, block): block.seek(25000 - LANDED_SHORT_MS) - block.next_chapter() + block.chapters.next() assert block.time == 40000 @@ -148,28 +149,28 @@ def test_past_the_last_chapter_is_the_end_arriving(self, block): block.seek(45000) block.loop_end_action = MagicMock() - block.next_chapter() + block.chapters.next() block.loop_end_action.assert_called_once_with() def test_the_end_of_a_looping_file_starts_it_over(self, block): block.seek(45000) - block.next_chapter() + block.chapters.next() assert block.time == 0 def test_previous_near_a_chapter_start_goes_to_the_one_before(self, block): block.seek(26000) - block.previous_chapter() + block.chapters.previous() assert block.time == 10000 def test_previous_well_into_a_chapter_starts_it_over(self, block): block.seek(30000) - block.previous_chapter() + block.chapters.previous() assert block.time == 25000 @@ -178,7 +179,7 @@ def test_a_jump_names_the_chapter_it_lands_in(self, block): block.info_change.connect(shown.append) block.seek(12000) - block.next_chapter() + block.chapters.next() assert shown == ["Tom & Jerry"] @@ -186,7 +187,7 @@ def test_a_jump_from_the_menu_is_a_seek_the_others_can_follow(self, block): synced = [] block.sync_time.connect(synced.append) - block.manual_seek("seek_chapter", 3) + block.manual_seek("chapters.seek", 3) assert block.time == 40000 assert synced == [40000] @@ -195,8 +196,8 @@ def test_a_video_without_chapters_goes_nowhere(self, block): block.video_driver._on_chapters_changed(()) block.loop_end_action = MagicMock() - block.next_chapter() - block.previous_chapter() + block.chapters.next() + block.chapters.previous() assert block.time == 0 block.loop_end_action.assert_not_called() @@ -206,7 +207,7 @@ class TestLooping: def test_loop_chapter_loops_the_one_the_video_is_in(self, block): block.seek(12000) - block.loop_chapter() + block.chapters.loop() assert block.video_params.loop_start == 10000 assert block.video_params.loop_end == 25000 @@ -216,7 +217,7 @@ def test_loop_chapter_loops_the_one_the_video_is_in(self, block): def test_the_last_chapter_loops_to_the_end_of_the_file(self, block): block.seek(45000) - block.loop_chapter() + block.chapters.loop() assert block.video_params.loop_start == 40000 assert block.video_params.loop_end is None @@ -225,38 +226,38 @@ def test_a_loop_further_on_replaces_the_one_before(self, block): """Its start lies past the old end, which the loop setters refuse.""" block.seek(12000) - block.loop_chapter() + block.chapters.loop() # a seek would be held inside the loop, so the video is simply put # where a loop left some other way would leave it block.video_params.current_position = 30000 - block.loop_chapter() + block.chapters.loop() assert block.video_params.loop_start == 25000 assert block.video_params.loop_end == 40000 def test_chapters_outside_the_loop_are_out_of_reach(self, block): block.seek(12000) - block.loop_chapter() + block.chapters.loop() - reachable = [block.is_chapter_reachable(i) for i in range(len(CHAPTERS))] + reachable = [block.chapters.is_reachable(i) for i in range(len(CHAPTERS))] assert reachable == [False, True, False, False] def test_a_jump_to_one_out_of_reach_goes_nowhere(self, block): block.seek(12000) - block.loop_chapter() + block.chapters.loop() - block.seek_chapter(2) + block.chapters.seek(2) assert block.time == 12000 def test_next_inside_a_loop_comes_back_round_to_its_start(self, block): block.seek(12000) - block.loop_chapter() + block.chapters.loop() - block.next_chapter() + block.chapters.next() assert block.time == 10000 @@ -272,52 +273,52 @@ def _reported(block, time_ms): class TestJumpingOnAStream: def test_next_while_a_jump_is_still_landing_moves_on(self, block): - block.seek_chapter(2) + block.chapters.seek(2) _reported(block, LANDING_ON_A_STREAM_MS) - block.next_chapter() + block.chapters.next() assert block.time == 40000 def test_a_jump_still_landing_is_in_the_chapter_it_was_aimed_at(self, block): - block.seek_chapter(2) + block.chapters.seek(2) _reported(block, LANDING_ON_A_STREAM_MS) - assert block.chapter_playing == 2 + assert block.chapters.playing == 2 def test_a_seek_from_the_bar_counts_the_same(self, block): block.seek(25000) _reported(block, LANDING_ON_A_STREAM_MS) - assert block.chapter_playing == 2 + assert block.chapters.playing == 2 def test_loop_chapter_while_a_jump_is_landing_loops_that_one(self, block): - block.seek_chapter(2) + block.chapters.seek(2) _reported(block, LANDING_ON_A_STREAM_MS) - block.loop_chapter() + block.chapters.loop() assert (block.loop_start, block.loop_end) == (25000, 40000) def test_once_seen_past_its_aim_the_time_is_taken_as_it_comes(self, block): - block.seek_chapter(2) + block.chapters.seek(2) _reported(block, 25000) _reported(block, 25400) _reported(block, LANDING_ON_A_STREAM_MS) - assert block.chapter_playing == 1 + assert block.chapters.playing == 1 def test_far_short_of_its_aim_the_video_is_somewhere_else(self, block): # a seek that never happened, while the video played on elsewhere - block.seek_chapter(3) + block.chapters.seek(3) _reported(block, 12000) - assert block.chapter_playing == 1 + assert block.chapters.playing == 1 def test_a_looped_chapter_coming_round_is_sent_back_once(self, block, mocker): - block.seek_chapter(2) + block.chapters.seek(2) _reported(block, 25400) - block.loop_chapter() + block.chapters.loop() set_time = mocker.spy(block.video_driver, "set_time") @@ -352,8 +353,8 @@ def test_every_chapter_is_listed_with_where_it_starts(self, block): def test_a_row_jumps_as_a_seek_the_others_can_follow(self, block): rows = _Manager(block).menu_generator_chapters() - assert rows[2]["func"] == ("active", "manual_seek", "seek_chapter", 2) - assert hasattr(VideoBlock, rows[2]["func"][2]) + assert rows[2]["func"] == ("active", "manual_seek", "chapters.seek", 2) + assert callable(attrgetter(rows[2]["func"][2])(block)) def test_the_chapter_playing_is_ticked(self, block): manager = _Manager(block) @@ -368,7 +369,7 @@ def test_rows_outside_the_loop_are_grayed_out(self, block): manager = _Manager(block) block.seek(12000) - block.loop_chapter() + block.chapters.loop() enabled = [manager.is_active_chapter_reachable(i) for i in range(len(CHAPTERS))] @@ -472,28 +473,28 @@ class TestSiteChapters: def test_a_url_whose_file_lists_none_gets_the_sites(self, url_block): block = url_block(_resolved()) - assert block.chapters == SITE_CHAPTERS + assert block.chapters.all == SITE_CHAPTERS assert [mark.label for mark in _marks(block)] == ["Intro", "Build", "Outro"] def test_the_files_own_come_first(self, url_block): block = url_block(_resolved(), frame=_ChapterFrame) - assert block.chapters == CHAPTERS + assert block.chapters.all == CHAPTERS def test_a_site_out_on_the_length_is_left_out(self, url_block): block = url_block(_resolved(duration_ms=LENGTH + 30000)) - assert block.chapters == () + assert block.chapters.all == () def test_a_site_that_gives_no_length_is_taken_at_its_word(self, url_block): block = url_block(_resolved(duration_ms=0)) - assert block.chapters == SITE_CHAPTERS + assert block.chapters.all == SITE_CHAPTERS def test_a_live_stream_has_none(self, url_block): block = url_block(_resolved(), frame=_LiveFrame) - assert block.chapters == () + assert block.chapters.all == () def test_they_last_through_a_switch_of_quality(self, url_block): block = url_block(_resolved()) @@ -502,7 +503,7 @@ def test_they_last_through_a_switch_of_quality(self, url_block): assert block.is_video_initialized assert block.stream_quality_playing == "360p" - assert block.chapters == SITE_CHAPTERS + assert block.chapters.all == SITE_CHAPTERS def test_a_file_put_in_the_urls_place_brings_none_of_them( self, url_block, tmp_path @@ -515,4 +516,4 @@ def test_a_file_put_in_the_urls_place_brings_none_of_them( block.set_video(Video(uri=video_file, playback_state=VideoInitialState.PAUSED)) assert block.is_video_initialized - assert block.chapters == () + assert block.chapters.all == () diff --git a/tests/test_video_block_repeat.py b/tests/test_video_block_repeat.py index d616037d..1a2254ed 100644 --- a/tests/test_video_block_repeat.py +++ b/tests/test_video_block_repeat.py @@ -585,7 +585,7 @@ def test_seek_holds_off_wrap_detection(mocker): block.loop_start = 0 block.loop_end = 2000 block.video_driver.length = 2000 - block._skip_spans = () + block.sponsorblock._skip_spans = () VideoBlock.seek(block, 1500) @@ -608,7 +608,7 @@ def test_seek_marks_itself_before_it_is_made(mocker): block.loop_start = 0 block.loop_end = 10000 block.video_driver.length = 10000 - block._skip_spans = () + block.sponsorblock._skip_spans = () seen = {} def _set_time(seek_ms): diff --git a/tests/test_video_block_sponsorblock.py b/tests/test_video_block_sponsorblock.py index d8df572d..61297fcd 100644 --- a/tests/test_video_block_sponsorblock.py +++ b/tests/test_video_block_sponsorblock.py @@ -127,7 +127,7 @@ def _found(block, *segments, site_length_ms=LENGTH): block._youtube_id = VIDEO_ID block._site_length_ms = site_length_ms - block._sponsor_segments_fetched(VIDEO_ID, segments) + block.sponsorblock._fetched(VIDEO_ID, segments) def _reported(block, time_ms): @@ -386,7 +386,7 @@ def test_a_highlight_is_a_place(self, block): assert mark.time_ms == 120000 def test_they_go_along_with_the_chapters(self, block): - block._set_chapters(()) + block.chapters._set(()) _found(block, SPONSOR) assert block.marks == tuple(_segment_marks(block)) @@ -412,7 +412,7 @@ def test_an_answer_about_another_video_is_not_this_ones(self, block): block._youtube_id = VIDEO_ID block._site_length_ms = LENGTH - block._sponsor_segments_fetched("another", (SPONSOR,)) + block.sponsorblock._fetched("another", (SPONSOR,)) assert _segment_marks(block) == [] @@ -422,7 +422,7 @@ def test_switched_off_nothing_is_marked_or_skipped(self, block): _found(block, SPONSOR) Settings().set("sponsorblock/enabled", False) - block.apply_sponsorblock_settings() + block.sponsorblock.apply_settings() _reported(block, 60100) assert _segment_marks(block) == [] @@ -432,7 +432,7 @@ def test_a_category_changed_is_taken_up_at_once(self, block): _found(block, SPONSOR) Settings().set("sponsorblock/sponsor", SponsorBlockMode.SHOW) - block.apply_sponsorblock_settings() + block.sponsorblock.apply_settings() _reported(block, 60100) assert _seeks(block) == [] @@ -447,11 +447,11 @@ def test_switched_on_a_video_already_playing_is_looked_up(self, block, mocker): sponsorblock.sponsorblock_fetcher(), "fetch", return_value=(SPONSOR,) ) - block.apply_sponsorblock_settings() + block.sponsorblock.apply_settings() fetch.assert_not_called() Settings().set("sponsorblock/enabled", True) - block.apply_sponsorblock_settings() + block.sponsorblock.apply_settings() fetch.assert_called_once_with(VIDEO_ID, LENGTH) assert len(_segment_marks(block)) == 1 From 17440244ffbbae402e7b0fd097e55dc511e5b632 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:44:07 +0000 Subject: [PATCH 06/14] refactor: take the video block's subtitles out into a SubtitleControl The subtitle tracks, the choice among them, the files kept beside the video, the preferred languages, the encoding and the delay go to block.subtitles, their commands "subtitles.set_track" and the like. A stand-in for the block in tests is given its parts by mock_block_parts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/dialogs/subtitle_delay.py | 6 +- gridplayer/params/actions.py | 10 +- gridplayer/player/managers/active_block.py | 86 +-- gridplayer/player/managers/video_blocks.py | 12 +- gridplayer/widgets/video_block.py | 713 +------------------ gridplayer/widgets/video_block_subtitles.py | 729 ++++++++++++++++++++ tests/conftest.py | 34 +- tests/test_deinterlace.py | 4 +- tests/test_menu_subtitle_delay.py | 11 +- tests/test_menu_subtitle_track.py | 26 +- tests/test_subtitle_encoding.py | 25 +- tests/test_video_block_subtitles.py | 100 +-- tests/test_video_position.py | 5 +- 13 files changed, 897 insertions(+), 864 deletions(-) create mode 100644 gridplayer/widgets/video_block_subtitles.py diff --git a/gridplayer/dialogs/subtitle_delay.py b/gridplayer/dialogs/subtitle_delay.py index 77fdb092..81cc5bb1 100644 --- a/gridplayer/dialogs/subtitle_delay.py +++ b/gridplayer/dialogs/subtitle_delay.py @@ -104,14 +104,14 @@ def get_delay_ms(cls, delay_ms=0, parent=None) -> int | None: def accept(self): if self._block is not None: # the same number over again, this time said out loud - self._block.set_subtitle_delay(self.spinbox.value()) + self._block.subtitles.set_delay(self.spinbox.value()) super().accept() def reject(self): if self._block is not None: if self._block.video_params.subtitle_delay_ms != self._original_delay_ms: - self._block.set_subtitle_delay(self._original_delay_ms, is_silent=True) + self._block.subtitles.set_delay(self._original_delay_ms, is_silent=True) super().reject() @@ -120,4 +120,4 @@ def _on_reset(self): def _on_value_changed(self, delay_ms): if self._block is not None: - self._block.set_subtitle_delay(delay_ms, is_silent=True) + self._block.subtitles.set_delay(delay_ms, is_silent=True) diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 2a8c4fae..491092e1 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -1180,8 +1180,8 @@ "title": "{}: %v".format(translate("Actions", "Delay")), "keymap_title": translate("Actions", "Set Delay"), "icon": "empty", - "func": ("active", "subtitle_delay_dialog"), - "value_getter": ("active", "get_subtitle_delay"), + "func": ("active", "subtitles.delay_dialog"), + "value_getter": ("active", "subtitles.get_delay"), "show_if": "is_active_has_subtitles", }, "Subtitle Delay Later": { @@ -1190,20 +1190,20 @@ # to the J and K the sound is moved with, in the same order "key": "H", "icon": "empty", - "func": ("active", "subtitle_delay_increase"), + "func": ("active", "subtitles.delay_increase"), "show_if": "is_active_has_subtitles", }, "Subtitle Delay Earlier": { "title": translate("Actions", "Earlier"), "key": "G", "icon": "empty", - "func": ("active", "subtitle_delay_decrease"), + "func": ("active", "subtitles.delay_decrease"), "show_if": "is_active_has_subtitles", }, "Subtitle Delay Reset": { "title": translate("Actions", "Reset"), "icon": "empty", - "func": ("active", "subtitle_delay_reset"), + "func": ("active", "subtitles.delay_reset"), "show_if": "is_active_has_subtitles", }, "Audio Mode - Original": { diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index b757a93e..7d94df34 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -68,19 +68,19 @@ "play_external_audio", "remove_external_audio", "set_external_audio_autodiscover", - "set_subtitle_track", - "apply_subtitle_preference", - "apply_subtitle_default", - "disable_subtitles", - "subtitle_languages_dialog", - "get_subtitle_languages", - "add_external_subtitles_dialog", - "show_external_subtitle", - "remove_external_subtitles", - "set_external_subtitle_autodiscover", - "set_subtitle_encoding", - "subtitle_encoding_dialog", - "get_subtitle_encoding", + "subtitles.set_track", + "subtitles.apply_preference", + "subtitles.apply_default", + "subtitles.disable", + "subtitles.languages_dialog", + "subtitles.get_languages", + "subtitles.add_external_dialog", + "subtitles.show_external", + "subtitles.remove_external", + "subtitles.set_external_autodiscover", + "subtitles.set_encoding", + "subtitles.encoding_dialog", + "subtitles.get_encoding", } ) @@ -371,7 +371,7 @@ def is_active_has_subtitles(self): if not self.is_active_initialized(): return False - return self._ctx.active_block.has_subtitles + return self._ctx.active_block.subtitles.has_any def is_active_subtitle_track(self, track_id): """Ticked where this track is the one that was named by hand. @@ -389,15 +389,15 @@ def is_active_subtitle_track(self, track_id): block = self._ctx.active_block if isinstance(selection, SubtitleExternal): - if block.external_subtitle_tracks.get(track_id) != selection.file: + if block.subtitles.external_tracks.get(track_id) != selection.file: return False - return block.subtitle_number_in_file(track_id, selection.file) == ( + return block.subtitles.number_in_file(track_id, selection.file) == ( selection.track ) if isinstance(selection, SubtitleLanguage): - return block.subtitle_language_key(track_id) == selection.tag + return block.subtitles.language_key(track_id) == selection.tag return isinstance(selection, SubtitleTrackId) and selection.id == track_id @@ -951,7 +951,7 @@ def menu_generator_subtitle_track(self): external = self._external_subtitle_menu_items() - if not block.subtitle_tracks: + if not block.subtitles.tracks: # a video carrying none is still worth looking beside, which is # where most subtitles live anyway return external or {} @@ -977,14 +977,14 @@ def _subtitle_default_menu_items(self): subtitled passages, which is exactly who wants this row. """ - if self._ctx.active_block.default_subtitle_track_id is None: + if self._ctx.active_block.subtitles.default_track_id is None: return [] return [ { "title": translate("Subtitle Track", "Default"), "icon": "empty", - "func": ("active", "apply_subtitle_default"), + "func": ("active", "subtitles.apply_default"), "check_if": "is_active_subtitle_default", "is_choice": True, "show_if": "is_active_initialized", @@ -1004,7 +1004,7 @@ def _preferred_subtitle_menu_item(self): return { "title": title, "icon": "empty", - "func": ("active", "apply_subtitle_preference"), + "func": ("active", "subtitles.apply_preference"), "check_if": "is_active_subtitle_preferred", "is_choice": True, "show_if": "is_active_initialized", @@ -1021,7 +1021,7 @@ def _preferred_subtitle_name(self) -> str | None: block = self._ctx.active_block - track = block.subtitle_tracks.get(block.preferred_subtitle_track_id) + track = block.subtitles.tracks.get(block.subtitles.preferred_track_id) if track is None: return None @@ -1040,19 +1040,19 @@ def _subtitle_choice_menu_items(self): block = self._ctx.active_block - external_ids = set(block.external_subtitle_track_ids) - showing = block.subtitle_track_showing + external_ids = set(block.subtitles.external_track_ids) + showing = block.subtitles.track_showing return [ { "title": _subtitle_track_title(track), "icon": "play" if track_id == showing else "empty", - "func": ("active", "set_subtitle_track", track_id), + "func": ("active", "subtitles.set_track", track_id), "check_if": ("is_active_subtitle_track", track_id), "is_choice": True, "show_if": "is_active_initialized", } - for track_id, track in block.subtitle_tracks.items() + for track_id, track in block.subtitles.tracks.items() if track_id not in external_ids ] @@ -1072,19 +1072,19 @@ def _external_subtitle_menu_items(self): files = self._attached_subtitle_menu_items() + [ { - "title": block.subtitle_file_name(file_path), + "title": block.subtitles.file_name(file_path), "icon": "empty", - "func": ("active", "show_external_subtitle", str(file_path)), + "func": ("active", "subtitles.show_external", str(file_path)), "show_if": "is_active_local_file", } - for file_path in block.offered_subtitle_files + for file_path in block.subtitles.offered_files ] commands = [ { "title": translate("Actions", "Add External Subtitles..."), "icon": "empty", - "func": ("active", "add_external_subtitles_dialog"), + "func": ("active", "subtitles.add_external_dialog"), "show_if": "is_active_local_file", } ] @@ -1094,7 +1094,7 @@ def _external_subtitle_menu_items(self): { "title": translate("Actions", "Remove External Subtitles"), "icon": "empty", - "func": ("active", "remove_external_subtitles"), + "func": ("active", "subtitles.remove_external"), "show_if": "is_active_local_file", } ) @@ -1105,7 +1105,7 @@ def _external_subtitle_menu_items(self): "icon": "empty", "func": ( "active", - "set_external_subtitle_autodiscover", + "subtitles.set_external_autodiscover", not block.video_params.is_external_subtitle_autodiscover, ), "check_if": ( @@ -1132,12 +1132,12 @@ def _attached_subtitle_menu_items(self): block = self._ctx.active_block - by_track = block.external_subtitle_tracks + by_track = block.subtitles.external_tracks if not by_track: return [] - showing = block.subtitle_track_showing + showing = block.subtitles.track_showing tracks_per_file = Counter(by_track.values()) seen_in_file: Counter = Counter() @@ -1145,7 +1145,7 @@ def _attached_subtitle_menu_items(self): rows = [] for track_id, file_path in by_track.items(): - if track_id not in block.subtitle_tracks: + if track_id not in block.subtitles.tracks: continue number = None @@ -1158,12 +1158,12 @@ def _attached_subtitle_menu_items(self): rows.append( { "title": _external_subtitle_title( - block.subtitle_file_name(file_path), - block.external_subtitle_track_name(track_id), + block.subtitles.file_name(file_path), + block.subtitles.external_track_name(track_id), number, ), "icon": "play" if track_id == showing else "empty", - "func": ("active", "set_subtitle_track", track_id), + "func": ("active", "subtitles.set_track", track_id), "check_if": ("is_active_subtitle_track", track_id), "is_choice": True, "show_if": "is_active_local_file", @@ -1333,7 +1333,7 @@ def _subtitles_off_menu_item(): return { "title": translate("Subtitle Track", "Off"), "icon": "empty", - "func": ("active", "disable_subtitles"), + "func": ("active", "subtitles.disable"), "check_if": "is_active_subtitle_disabled", "is_choice": True, "show_if": "is_active_initialized", @@ -1346,8 +1346,8 @@ def _subtitle_languages_menu_item(): return { "title": "{}: %v".format(translate("Actions", "Languages")), "icon": "empty", - "func": ("active", "subtitle_languages_dialog"), - "value_getter": ("active", "get_subtitle_languages"), + "func": ("active", "subtitles.languages_dialog"), + "value_getter": ("active", "subtitles.get_languages"), "show_if": "is_active_initialized", } @@ -1366,8 +1366,8 @@ def _subtitle_encoding_menu_item(): return { "title": "{}: %v".format(translate("Actions", "Encoding")), "icon": "empty", - "func": ("active", "subtitle_encoding_dialog"), - "value_getter": ("active", "get_subtitle_encoding"), + "func": ("active", "subtitles.encoding_dialog"), + "value_getter": ("active", "subtitles.get_encoding"), "show_if": "is_active_initialized", } diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 89dd83b5..ea1234e8 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -619,7 +619,7 @@ def apply_subtitle_encoding(self, encoding: str): """ for vb in self._ctx.video_blocks: - vb.set_subtitle_encoding(encoding) + vb.subtitles.set_encoding(encoding) def apply_sponsorblock_settings(self): """Mark and skip in the videos already open as the settings now say. @@ -656,7 +656,7 @@ def is_any_videos_have_video(self): return any(vb.video_tracks for vb in self._ctx.video_blocks.initialized) def is_any_videos_have_subtitles(self): - return any(vb.has_subtitles for vb in self._ctx.video_blocks.initialized) + return any(vb.subtitles.has_any for vb in self._ctx.video_blocks.initialized) def is_any_videos_have_chapters(self): # a live stream never has any; see ChapterControl.update @@ -896,10 +896,10 @@ def _add_video_block(self, video): (self.all_audio_delay_increase, vb.audio_delay_increase), (self.all_audio_delay_decrease, vb.audio_delay_decrease), (self.all_audio_delay_reset, vb.audio_delay_reset), - (self.all_set_subtitle_delay, vb.set_subtitle_delay), - (self.all_subtitle_delay_increase, vb.subtitle_delay_increase), - (self.all_subtitle_delay_decrease, vb.subtitle_delay_decrease), - (self.all_subtitle_delay_reset, vb.subtitle_delay_reset), + (self.all_set_subtitle_delay, vb.subtitles.set_delay), + (self.all_subtitle_delay_increase, vb.subtitles.delay_increase), + (self.all_subtitle_delay_decrease, vb.subtitles.delay_decrease), + (self.all_subtitle_delay_reset, vb.subtitles.delay_reset), (self.all_volume_increase, vb.volume_increase), (self.all_volume_decrease, vb.volume_decrease), (self.all_set_muted, vb.set_muted), diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index 6ed82b42..dd311f88 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -25,11 +25,6 @@ QCustomTextInput, ) from gridplayer.dialogs.rename_dialog import QVideoRenameDialog -from gridplayer.dialogs.subtitle_delay import ( - SetSubtitleDelayDialog, - subtitle_delay_txt, -) -from gridplayer.dialogs.subtitle_encoding import SetSubtitleEncodingDialog from gridplayer.exceptions import PlayerException from gridplayer.models.audio_selection import ( AudioDefault, @@ -58,38 +53,23 @@ Streams, short_quality_name, ) -from gridplayer.models.subtitle_selection import ( - SubtitleDefault, - SubtitleDisabled, - SubtitleExternal, - SubtitleLanguage, - SubtitlePreferred, - SubtitleTrackId, -) -from gridplayer.models.subtitle_selection import ( - track_of_file as subtitle_track_of_file, -) from gridplayer.models.video import ( Video, VideoBlockMime, default_audio_selection, - default_subtitle_selection, ) from gridplayer.params import env -from gridplayer.params.extensions import SUPPORTED_AUDIO_EXT, SUPPORTED_SUBTITLE_EXT +from gridplayer.params.extensions import SUPPORTED_AUDIO_EXT from gridplayer.params.static import ( AUDIO_DELAY_STEP_MS, CHROME_MIN_SIZE, MAX_AUDIO_DELAY_MS, MAX_LIST_FAIL_WAIT_SEC, MAX_RATE, - MAX_SUBTITLE_DELAY_MS, MIN_AUDIO_DELAY_MS, MIN_RATE, - MIN_SUBTITLE_DELAY_MS, OVERLAY_ACTIVITY_EVENT, PLAYER_ID_LENGTH, - SUBTITLE_DELAY_STEP_MS, ListEndAction, ListFailAction, ListSourceKind, @@ -109,10 +89,6 @@ silenced_by_seeking, unplayable_as_audio_slave, ) -from gridplayer.utils.external_subtitles import ( - discover_subtitle_files, - subtitle_file_label, -) from gridplayer.utils.folder_navigator import FolderNavigator, FolderStep from gridplayer.utils.libvlc_options_parser import ( get_vlc_options, @@ -132,7 +108,7 @@ segment_mark, ) from gridplayer.utils.step_sizes import seek_step_ms -from gridplayer.utils.track_language import language_name, normalize, pick_track +from gridplayer.utils.track_language import normalize, pick_track from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver from gridplayer.utils.video_errors import ( @@ -152,7 +128,6 @@ is_loop_wrapped, seek_landing, wanted_audio_track_id, - wanted_subtitle_track_id, ) from gridplayer.widgets.cell_chrome import ( chrome_color_on_cell, @@ -172,6 +147,7 @@ only_with_video_tacks, ) from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl +from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_vlc_base import VideoFrameVLC from gridplayer.widgets.video_overlay import ( @@ -253,32 +229,6 @@ def list_fail_wait_txt(wait_sec: int) -> str: return translate("When Video Fails", "{SECONDS} s").format(SECONDS=wait_sec) -def _subtitle_files_filter() -> str: - extensions = " ".join(f"*.{ext}" for ext in sorted(SUPPORTED_SUBTITLE_EXT)) - - subtitles = translate( - "Dialog - Add external subtitles", "Subtitles", "File formats" - ) - every = translate("Dialog - Add external subtitles", "All", "File formats") - - return f"{subtitles} ({extensions});;{every} (*)" - - -def _subtitle_track_file_stem(track) -> str | None: - """The file a subtitle track came from, as libVLC named it. - - libVLC puts the title it made for an attached file where an embedded - track's language goes, and that title is the file's own path without - its extension. Nothing but a file ever ends up there, so what reads as - a path is the one sign of where a track came from. - """ - - if track is None or not track.language: - return None - - return track.language.replace("\\", "/").rsplit("/", 1)[-1].casefold() - - class VideoBlock(QWidget): load_video = pyqtSignal(MediaInput) @@ -417,12 +367,6 @@ def __init__(self, video_driver, context, **kwargs): # until the next one whatever track is picked meanwhile self._attached_audio_file: Path | None = None - # the subtitle files libVLC has been handed so far, in the order it - # was handed them, which is the order their tracks come back in. - # More than one is allowed here: each arrives as a track of its own - # wearing the name of the file it came from. - self._attached_subtitle_files: list[Path] = [] - self._quality_adapt_timer = QTimer(self) self._quality_adapt_timer.setSingleShot(True) self._quality_adapt_timer.timeout.connect(self._adapt_stream_quality) @@ -456,6 +400,7 @@ def __init__(self, video_driver, context, **kwargs): self.view = ViewControl(self) self.chapters = ChapterControl(self) self.sponsorblock = SponsorBlockControl(self) + self.subtitles = SubtitleControl(self) # the next, previous or a random file of the folder, to put in this # one's place @@ -2317,644 +2262,6 @@ def _adopt_audio_if_silent(self) -> None: self.add_external_audio(found) - # Subtitles - # - # The same shape as the sound above, with two differences that run all - # the way through it: a video starts with none showing, and a file - # picked for one goes on where it stands, since libVLC names every - # subtitle track after whatever brought it and none of them ever has to - # be told apart by counting. - - @property - def subtitle_tracks(self): - if self.video_driver is None or not self.is_video_initialized: - return {} - - return self.video_driver.subtitle_tracks - - @property - def has_subtitles(self) -> bool: - """Whether there is a subtitle to show and a picture to show it on.""" - - if self.video_driver is None or not self.is_video_initialized: - return False - - return self.video_driver.has_subtitles - - @property - def subtitle_track_showing(self) -> int | None: - """Which subtitle is on screen, whatever was asked for. - - What was asked for can be a preference rather than a track, and - then this is the only place the answer it came to is written down. - """ - - if self.video_driver is None or not self.is_video_initialized: - return None - - return self.video_driver.cur_subtitle_track_id - - @property - def preferred_subtitle_track_id(self) -> int | None: - """The subtitle this video would show, were nothing picked by hand.""" - - return pick_track(self.video_params.subtitle_languages, self._own_subtitles) - - @property - def _own_subtitles(self) -> dict: - """The subtitle tracks the video carried, without the attached files. - - libVLC writes an attached file's own name where an embedded track's - language belongs, so a file left in here is a path pretending to be - a language, and every question about languages gets a wrong answer. - """ - - from_file = set(self.external_subtitle_track_ids) - - return { - track_id: track - for track_id, track in self.subtitle_tracks.items() - if track_id not in from_file - } - - def set_subtitle_track(self, track_id): - """Show this subtitle, or DISABLED_TRACK to show none at all.""" - - self._log.debug(f"Set subtitle track {track_id}") - - # a subtitle chosen by hand outranks the languages asked for, and - # has to outlive the reload that would otherwise resolve them again - self.video_params.subtitle_selection = self._subtitle_selection_for(track_id) - - # a video part way through a reload has no tracks to switch between - # yet, and will settle on this one once it does - if self.is_video_initialized: - self.video_driver.set_subtitle_track(track_id) - self._nudge_subtitles_into_view() - - def disable_subtitles(self): - self.set_subtitle_track(DISABLED_TRACK) - - def _subtitle_selection_for(self, track_id): - """How to remember this subtitle, by the steadiest name it answers to. - - A file of its own keeps its name whatever the tracks do, a language - outlives a source that renumbers them, and an id is what is left - where neither says anything. - """ - - if track_id == DISABLED_TRACK: - return SubtitleDisabled() - - external_file = self.external_subtitle_tracks.get(track_id) - - if external_file is not None: - return SubtitleExternal( - file=external_file, - track=self.subtitle_number_in_file(track_id, external_file), - ) - - language = self.subtitle_language_key(track_id) - - if language is not None: - return SubtitleLanguage(tag=language) - - return SubtitleTrackId(id=track_id) - - def subtitle_number_in_file(self, track_id, file_path: Path) -> int: - """Which of the tracks that file brought this one is.""" - - from_file = [ - other_id - for other_id, other_file in self.external_subtitle_tracks.items() - if other_file == file_path - ] - - return from_file.index(track_id) if track_id in from_file else 0 - - def subtitle_language_key(self, track_id) -> str | None: - """The language to remember this subtitle by, where one will do. - - An id is only as good as the media it was read from, and a stream - is demuxed afresh on every reload. A language outlives that, but - only while it picks out one track rather than several, and only for - a track the video carried: see _own_subtitles. - """ - - own_subtitles = self._own_subtitles - - track = own_subtitles.get(track_id) - - if track is None or not track.language: - return None - - wanted = normalize(track.language) - - if wanted is None: - # a track tagged with nothing anyone can name is one that only - # its number picks out - return None - - namesakes = [ - other - for other in own_subtitles.values() - if normalize(other.language) == wanted - ] - - return track.language if len(namesakes) == 1 else None - - def restore_subtitle_selection(self, selection) -> None: - """Put a remembered choice back in force, as a snapshot does.""" - - self.video_params.subtitle_selection = selection - - self._apply_wanted_subtitle_track() - - def apply_subtitle_default(self): - """Show whichever subtitle the container marks, as VLC would alone.""" - - self.video_params.subtitle_selection = SubtitleDefault() - - self._apply_wanted_subtitle_track() - - def apply_subtitle_preference(self): - """Follow the preferred languages again.""" - - self.video_params.subtitle_selection = SubtitlePreferred() - - self._apply_wanted_subtitle_track() - - def _apply_wanted_subtitle_track(self): - """Put the settings into effect on the video that is already playing.""" - - if self.video_driver is None or not self.is_video_initialized: - # part way through a reload, which will settle this on its own - return - - track_id = self._wanted_subtitle_track_id() - - if track_id is None: - # "whatever the container marks", which is what it opened on - track_id = self.default_subtitle_track_id - - if track_id is None: - return - - self.video_driver.set_subtitle_track(track_id) - self._nudge_subtitles_into_view() - - def _wanted_subtitle_track_id(self) -> int | None: - """Which subtitle this video's settings call for, its own files included. - - Only the block knows which track came out of which file, so a pick - of one is answered here; everything else the video can answer for - itself. - """ - - selection = self.video_params.subtitle_selection - - if isinstance(selection, SubtitleExternal): - from_file = [ - track_id - for track_id, file_path in self.external_subtitle_tracks.items() - if file_path == selection.file - ] - - picked = subtitle_track_of_file(selection, from_file) - - if picked is not None: - return picked - - return wanted_subtitle_track_id( - self.video_params, - self.subtitle_tracks, - self.external_subtitle_track_ids, - ) - - def _nudge_subtitles_into_view(self) -> None: - """Show the subtitle that was just picked, rather than the next one. - - A track switched to part way through a line shows nothing until the - line after it: VLC draws a subtitle when the packet carrying it goes - past, and for the line already on screen that packet went past while - another track was selected. Playing or paused makes no difference -- - paused is only the worse case, since there nothing arrives to end - the wait at all. - - Seeking to where the video already is puts that packet through - again, and is the cheapest thing that does. It costs a visible - hitch, which beats a menu that looks as though it did nothing: - subtitles are sparse, and the next line can be a minute away. - """ - - if self.is_live: - # nothing to seek in, and nothing that stays on screen long - # enough for the wait to matter - return - - self.video_driver.set_time(self.time) - - @property - def discovered_subtitle_files(self) -> list[Path]: - """Subtitle files named after this video that nobody has picked yet. - - Finding them is a directory listing and nothing else, the same as - for the audio: a file here has not been opened and costs nothing - until it is chosen. - """ - - if ( - not self.is_local_file - or not self.video_params.is_external_subtitle_autodiscover - ): - return [] - - attached = set(self.video_params.external_subtitles) - - return [ - found - for found in discover_subtitle_files(self.video_params.uri) - if found not in attached - ] - - @property - def external_subtitle_track_ids(self) -> tuple[int, ...]: - """The tracks the attached files brought, as the player saw them arrive.""" - - if self.video_driver is None or not self.is_video_initialized: - return () - - return self.video_driver.external_subtitle_ids - - @property - def default_subtitle_track_id(self) -> int | None: - """The subtitle the container marks default or forced, if any.""" - - if self.video_driver is None or not self.is_video_initialized: - return None - - return self.video_driver.default_subtitle_track_id - - @property - def attached_subtitle_files(self) -> list[Path]: - """The subtitle files libVLC was really handed, in that order. - - Not the same question as which subtitle was chosen: libVLC never - takes a file back, so one handed over is still there after the - viewer switches to a track of the video's own. - """ - - return list(self._attached_subtitle_files) - - @property - def external_subtitle_tracks(self) -> dict[int, Path]: - """Which file each external subtitle came out of. - - libVLC writes the file's own name into the track where a language - belongs, and that is the one place it ever says where a stream came - from, so that is what these are matched on. Where a name answers to - none of the files, the order they were attached in stands in, which - is right wherever each file brought one track -- which is all but - the rarest of them. - """ - - track_ids = self.external_subtitle_track_ids - files = self._attached_subtitle_files - - if not track_ids or not files: - return {} - - by_stem = {file_path.stem.casefold(): file_path for file_path in files} - - named = { - track_id: by_stem.get( - _subtitle_track_file_stem(self.subtitle_tracks.get(track_id)) - ) - for track_id in track_ids - } - - if all(named.values()): - return named - - return { - track_id: files[min(position, len(files) - 1)] - for position, track_id in enumerate(track_ids) - } - - @property - def offered_subtitle_files(self) -> list[Path]: - """Files this video can be shown with, other than the ones already on.""" - - attached = set(self._attached_subtitle_files) - - known = [ - file_path - for file_path in self.video_params.external_subtitles - # one that is not there cannot be shown, and a row that does - # nothing is worse than no row - if file_path not in attached and file_path.is_file() - ] - - return known + self.discovered_subtitle_files - - @property - def selected_subtitle_slave_uris(self) -> tuple[str, ...]: - """The files the next load is to open this video with.""" - - return tuple(file_path.as_uri() for file_path in self._subtitle_files_to_attach) - - @property - def _subtitle_files_to_attach(self) -> list[Path]: - """The picked files that are still where the playlist left them. - - One that has been renamed, or is on a drive nobody plugged in, is - no use to libVLC, though it keeps its place in the list in case - whatever moved it moves it back. - """ - - return [ - file_path - for file_path in self.video_params.external_subtitles - if file_path.is_absolute() and file_path.is_file() - ] - - def subtitle_file_name(self, file_path: Path) -> str: - """What to call a subtitle file in the menu.""" - - video_path = self.video_params.uri if self.is_local_file else None - - return subtitle_file_label(file_path, video_path) - - def external_subtitle_track_name(self, track_id) -> str | None: - """What tells one track of a file apart from the next, where anything does. - - A file holding several says which is which: a VobSub index carries - a language per track, and so does a subtitle-only Matroska. A file - holding one has nothing to say beyond its own name, and libVLC puts - that name where the language belongs -- so a "language" that is the - file over again is no language at all. - """ - - track = self.subtitle_tracks.get(track_id) - - if track is None or not track.language: - return None - - file_path = self.external_subtitle_tracks.get(track_id) - - if file_path is not None and _subtitle_track_file_stem(track) == ( - file_path.stem.casefold() - ): - return None - - return language_name(track.language) or track.language - - @only_local_file - def add_external_subtitles_dialog(self) -> None: - """Pick subtitle files to show over this video.""" - - file_names, _ = QFileDialog.getOpenFileNames( - self.parent(), - translate( - "Dialog - Add external subtitles", "Add External Subtitles", "Header" - ), - str(self.video_params.uri.parent), - _subtitle_files_filter(), - ) - - self.add_external_subtitles([Path(file_name) for file_name in file_names]) - - def add_external_subtitles(self, file_paths) -> None: - """Show these subtitle files with the video, starting on the first.""" - - new_files = [ - file_path - for file_path in file_paths - if file_path.is_absolute() - and file_path not in self.video_params.external_subtitles - ] - - if not new_files: - return - - self.video_params.external_subtitles = [ - *self.video_params.external_subtitles, - *new_files, - ] - - # subtitles asked for by name outrank the languages asked for, the - # same way picking a track by hand does - self.video_params.subtitle_selection = SubtitleExternal(file=new_files[0]) - - self._put_external_subtitles_into_effect() - - def show_external_subtitle(self, file_name: str) -> None: - """Show this file, whether it has been attached yet or not.""" - - file_path = Path(file_name) - - if file_path in self._attached_subtitle_files: - # it is on already, and may only have been switched away from - from_file = [ - track_id - for track_id, attached in self.external_subtitle_tracks.items() - if attached == file_path - ] - - if from_file: - self.set_subtitle_track(from_file[0]) - - return - - if file_path not in self.video_params.external_subtitles: - self.add_external_subtitles([file_path]) - return - - self.video_params.subtitle_selection = SubtitleExternal(file=file_path) - - self._put_external_subtitles_into_effect() - - def _put_external_subtitles_into_effect(self) -> None: - """Hand over whatever has been picked and is not on yet. - - No reload, ever, which is what sets this apart from external audio: - libVLC takes a subtitle file at any time and names the track it - brings after it, so however many go on they can still be told - apart. Only taking one away costs a load. - """ - - if not self.is_video_initialized: - # part way through a load, which will open them with the video - return - - for file_path in self._subtitle_files_to_attach: - if file_path in self._attached_subtitle_files: - continue - - self._log.debug(f"Adding subtitle file {file_path.name}") - - self._attached_subtitle_files.append(file_path) - - self.video_driver.add_subtitle_slave(file_path.as_uri()) - - def remove_external_subtitles(self) -> None: - """Go back to the subtitles the video carries, if it carries any. - - libVLC has no way to take back a file it has been handed, so the - video is loaded once more, this time without them. - """ - - if not self.video_params.external_subtitles: - return - - self._drop_external_subtitles() - - self.reload() - - def _drop_external_subtitles(self) -> None: - self.video_params.external_subtitles = [] - - # the track that was showing leaves with the file it came from - if isinstance(self.video_params.subtitle_selection, SubtitleExternal): - self.video_params.subtitle_selection = default_subtitle_selection() - - def set_external_subtitle_autodiscover(self, is_on: bool) -> None: - """Whether to offer the subtitle files kept beside this video.""" - - self.video_params.is_external_subtitle_autodiscover = is_on - - def _forget_subtitle_files_that_are_gone(self) -> None: - """Let go of a pick whose file is not where the playlist left it. - - The file keeps its place in the list, in case whatever moved it - moves it back; only the choice of it is dropped, so that the video - opens on something the menu can show as taken. - """ - - selection = self.video_params.subtitle_selection - - if not isinstance(selection, SubtitleExternal): - return - - if selection.file in self._subtitle_files_to_attach: - return - - self._log.debug(f"{selection.file.name} is not there any more") - - self.video_params.subtitle_selection = default_subtitle_selection() - - @only_initialized - def subtitle_languages_dialog(self): - """Edit the languages this video would rather be subtitled in.""" - - languages = QCustomTextInput.get_text( - parent=self.parent(), - title=translate( - "Dialog - Set preferred subtitle languages", - "Preferred subtitle languages", - "Header", - ), - initial_value=self.video_params.subtitle_languages, - placeholder=translate( - "Dialog - Set preferred subtitle languages", "en, ja" - ), - ) - - self.set_subtitle_languages(languages) - - def set_subtitle_languages(self, languages: str): - if languages == self.video_params.subtitle_languages: - return - - self.video_params.subtitle_languages = languages - - if not isinstance(self.video_params.subtitle_selection, SubtitlePreferred): - return - - # the preference is what is being followed, so following it again - # is the whole point of having changed it - self.apply_subtitle_preference() - - def get_subtitle_languages(self) -> str: - return self.video_params.subtitle_languages or translate( - "Subtitle Languages", "any" - ) - - def set_subtitle_encoding(self, encoding: str) -> None: - """Read this video's subtitle files as a different character set. - - Which set a file is read as is settled when the input opens, so the - video is handed back to itself and set_video reopens it. - """ - - if self.video_params.subtitle_encoding == encoding: - return - - self.set_video( - self.video_params.model_copy(update={"subtitle_encoding": encoding}) - ) - - @only_initialized - def subtitle_encoding_dialog(self): - """Pick what this video's subtitle files are read as.""" - - encoding = SetSubtitleEncodingDialog.get_encoding( - encoding=self.video_params.subtitle_encoding, - parent=self.parent(), - ) - - if encoding is None: - return - - self.set_subtitle_encoding(encoding) - - def get_subtitle_encoding(self) -> str: - return self.video_params.subtitle_encoding or translate( - "Subtitle Encoding", "Default" - ) - - @only_initialized - def set_subtitle_delay(self, delay_ms, is_silent=False): - if not self.subtitle_tracks: - return - - delay_ms = min(max(delay_ms, MIN_SUBTITLE_DELAY_MS), MAX_SUBTITLE_DELAY_MS) - - self.video_params.subtitle_delay_ms = delay_ms - self.video_driver.set_subtitle_delay(delay_ms) - - if not is_silent: - self.info_change.emit(f"Subtitle delay: {subtitle_delay_txt(delay_ms)}") - - @only_initialized - def subtitle_delay_increase(self): - self.set_subtitle_delay( - self.video_params.subtitle_delay_ms + SUBTITLE_DELAY_STEP_MS - ) - - @only_initialized - def subtitle_delay_decrease(self): - self.set_subtitle_delay( - self.video_params.subtitle_delay_ms - SUBTITLE_DELAY_STEP_MS - ) - - @only_initialized - def subtitle_delay_reset(self): - self.set_subtitle_delay(0) - - @only_initialized - def subtitle_delay_dialog(self): - if not self.subtitle_tracks: - return - - dialog = SetSubtitleDelayDialog.for_video_block(self, parent=self.parent()) - - dialog.exec_() - - @only_initialized - def get_subtitle_delay(self): - return subtitle_delay_txt(self.video_params.subtitle_delay_ms) - @only_initialized def set_video_track(self, track_id): # only -1 says the sound was switched off; None says it has not been @@ -3574,8 +2881,8 @@ def apply_snapshot(self, snapshot: Video): self.set_audio_channel_mode(snapshot.audio_channel_mode) self.set_audio_delay(snapshot.audio_delay_ms, is_silent=True) - self.restore_subtitle_selection(snapshot.subtitle_selection) - self.set_subtitle_delay(snapshot.subtitle_delay_ms, is_silent=True) + self.subtitles.restore_selection(snapshot.subtitle_selection) + self.subtitles.set_delay(snapshot.subtitle_delay_ms, is_silent=True) self.set_aspect(snapshot.aspect_mode) self.set_deinterlace_params(snapshot.deinterlace, snapshot.deinterlace_mode) @@ -3758,10 +3065,10 @@ def _start_load(self): ) self._forget_audio_file_that_is_gone() - self._forget_subtitle_files_that_are_gone() self._attached_audio_file = self._audio_file_to_attach - self._attached_subtitle_files = self._subtitle_files_to_attach + + self.subtitles.take_files_for_load() # the video may not be the one that was here, whatever else stays; # a URL is resolved again and brings the site's list back with it @@ -3788,7 +3095,7 @@ def _start_load(self): size=self.size_tuple, video=self.video_params, selected_audio_slave=self.selected_audio_slave_uri, - selected_subtitle_slaves=self.selected_subtitle_slave_uris, + selected_subtitle_slaves=self.subtitles.selected_slave_uris, ) ) @@ -5290,7 +4597,7 @@ def _forget_files_of_previous_video(self): """ self._drop_external_audio() - self._drop_external_subtitles() + self.subtitles.drop_external() def rename(self): cur_title = self.title diff --git a/gridplayer/widgets/video_block_subtitles.py b/gridplayer/widgets/video_block_subtitles.py new file mode 100644 index 00000000..e64f6bc5 --- /dev/null +++ b/gridplayer/widgets/video_block_subtitles.py @@ -0,0 +1,729 @@ +"""The subtitles of a video block: its own tracks and files beside it. + +The same shape as the sound (see the block's audio), with two differences +that run all the way through it: a video starts with none showing, and a +file picked for one goes on where it stands, since libVLC names every +subtitle track after whatever brought it and none of them ever has to be +told apart by counting. +""" + +from pathlib import Path + +from PyQt5.QtWidgets import QFileDialog + +from gridplayer.dialogs.input_dialog import QCustomTextInput +from gridplayer.dialogs.subtitle_delay import SetSubtitleDelayDialog, subtitle_delay_txt +from gridplayer.dialogs.subtitle_encoding import SetSubtitleEncodingDialog +from gridplayer.models.subtitle_selection import ( + SubtitleDefault, + SubtitleDisabled, + SubtitleExternal, + SubtitleLanguage, + SubtitlePreferred, + SubtitleTrackId, +) +from gridplayer.models.subtitle_selection import ( + track_of_file as subtitle_track_of_file, +) +from gridplayer.models.video import default_subtitle_selection +from gridplayer.params.extensions import SUPPORTED_SUBTITLE_EXT +from gridplayer.params.static import ( + MAX_SUBTITLE_DELAY_MS, + MIN_SUBTITLE_DELAY_MS, + SUBTITLE_DELAY_STEP_MS, +) +from gridplayer.utils.external_subtitles import ( + discover_subtitle_files, + subtitle_file_label, +) +from gridplayer.utils.qt import translate +from gridplayer.utils.track_language import language_name, normalize, pick_track +from gridplayer.vlc_player.static import DISABLED_TRACK, wanted_subtitle_track_id +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_local_file, +) + + +def _files_filter() -> str: + extensions = " ".join(f"*.{ext}" for ext in sorted(SUPPORTED_SUBTITLE_EXT)) + + subtitles = translate( + "Dialog - Add external subtitles", "Subtitles", "File formats" + ) + every = translate("Dialog - Add external subtitles", "All", "File formats") + + return f"{subtitles} ({extensions});;{every} (*)" + + +def _track_file_stem(track) -> str | None: + """The file a subtitle track came from, as libVLC named it. + + libVLC puts the title it made for an attached file where an embedded + track's language goes, and that title is the file's own path without + its extension. Nothing but a file ever ends up there, so what reads as + a path is the one sign of where a track came from. + """ + + if track is None or not track.language: + return None + + return track.language.replace("\\", "/").rsplit("/", 1)[-1].casefold() + + +class SubtitleControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # the subtitle files libVLC has been handed so far, in the order it + # was handed them, which is the order their tracks come back in. + # More than one is allowed here: each arrives as a track of its own + # wearing the name of the file it came from. + self._attached_files: list[Path] = [] + + def take_files_for_load(self): + """The files the load about to start opens the video with: those + picked that are still there, a pick of one gone let go of.""" + + self._forget_files_that_are_gone() + + self._attached_files = self._files_to_attach + + @property + def tracks(self): + if self._block.video_driver is None or not self._block.is_video_initialized: + return {} + + return self._block.video_driver.subtitle_tracks + + @property + def has_any(self) -> bool: + """Whether there is a subtitle to show and a picture to show it on.""" + + if self._block.video_driver is None or not self._block.is_video_initialized: + return False + + return self._block.video_driver.has_subtitles + + @property + def track_showing(self) -> int | None: + """Which subtitle is on screen, whatever was asked for. + + What was asked for can be a preference rather than a track, and + then this is the only place the answer it came to is written down. + """ + + if self._block.video_driver is None or not self._block.is_video_initialized: + return None + + return self._block.video_driver.cur_subtitle_track_id + + @property + def preferred_track_id(self) -> int | None: + """The subtitle this video would show, were nothing picked by hand.""" + + return pick_track(self._block.video_params.subtitle_languages, self._own) + + @property + def _own(self) -> dict: + """The subtitle tracks the video carried, without the attached files. + + libVLC writes an attached file's own name where an embedded track's + language belongs, so a file left in here is a path pretending to be + a language, and every question about languages gets a wrong answer. + """ + + from_file = set(self.external_track_ids) + + return { + track_id: track + for track_id, track in self.tracks.items() + if track_id not in from_file + } + + def set_track(self, track_id): + """Show this subtitle, or DISABLED_TRACK to show none at all.""" + + self._block._log.debug(f"Set subtitle track {track_id}") + + # a subtitle chosen by hand outranks the languages asked for, and + # has to outlive the reload that would otherwise resolve them again + self._block.video_params.subtitle_selection = self._selection_for(track_id) + + # a video part way through a reload has no tracks to switch between + # yet, and will settle on this one once it does + if self._block.is_video_initialized: + self._block.video_driver.set_subtitle_track(track_id) + self._nudge_into_view() + + def disable(self): + self.set_track(DISABLED_TRACK) + + def _selection_for(self, track_id): + """How to remember this subtitle, by the steadiest name it answers to. + + A file of its own keeps its name whatever the tracks do, a language + outlives a source that renumbers them, and an id is what is left + where neither says anything. + """ + + if track_id == DISABLED_TRACK: + return SubtitleDisabled() + + external_file = self.external_tracks.get(track_id) + + if external_file is not None: + return SubtitleExternal( + file=external_file, + track=self.number_in_file(track_id, external_file), + ) + + language = self.language_key(track_id) + + if language is not None: + return SubtitleLanguage(tag=language) + + return SubtitleTrackId(id=track_id) + + def number_in_file(self, track_id, file_path: Path) -> int: + """Which of the tracks that file brought this one is.""" + + from_file = [ + other_id + for other_id, other_file in self.external_tracks.items() + if other_file == file_path + ] + + return from_file.index(track_id) if track_id in from_file else 0 + + def language_key(self, track_id) -> str | None: + """The language to remember this subtitle by, where one will do. + + An id is only as good as the media it was read from, and a stream + is demuxed afresh on every reload. A language outlives that, but + only while it picks out one track rather than several, and only for + a track the video carried: see _own. + """ + + own_subtitles = self._own + + track = own_subtitles.get(track_id) + + if track is None or not track.language: + return None + + wanted = normalize(track.language) + + if wanted is None: + # a track tagged with nothing anyone can name is one that only + # its number picks out + return None + + namesakes = [ + other + for other in own_subtitles.values() + if normalize(other.language) == wanted + ] + + return track.language if len(namesakes) == 1 else None + + def restore_selection(self, selection) -> None: + """Put a remembered choice back in force, as a snapshot does.""" + + self._block.video_params.subtitle_selection = selection + + self._apply_wanted_track() + + def apply_default(self): + """Show whichever subtitle the container marks, as VLC would alone.""" + + self._block.video_params.subtitle_selection = SubtitleDefault() + + self._apply_wanted_track() + + def apply_preference(self): + """Follow the preferred languages again.""" + + self._block.video_params.subtitle_selection = SubtitlePreferred() + + self._apply_wanted_track() + + def _apply_wanted_track(self): + """Put the settings into effect on the video that is already playing.""" + + if self._block.video_driver is None or not self._block.is_video_initialized: + # part way through a reload, which will settle this on its own + return + + track_id = self._wanted_track_id() + + if track_id is None: + # "whatever the container marks", which is what it opened on + track_id = self.default_track_id + + if track_id is None: + return + + self._block.video_driver.set_subtitle_track(track_id) + self._nudge_into_view() + + def _wanted_track_id(self) -> int | None: + """Which subtitle this video's settings call for, its own files included. + + Only the block knows which track came out of which file, so a pick + of one is answered here; everything else the video can answer for + itself. + """ + + selection = self._block.video_params.subtitle_selection + + if isinstance(selection, SubtitleExternal): + from_file = [ + track_id + for track_id, file_path in self.external_tracks.items() + if file_path == selection.file + ] + + picked = subtitle_track_of_file(selection, from_file) + + if picked is not None: + return picked + + return wanted_subtitle_track_id( + self._block.video_params, + self.tracks, + self.external_track_ids, + ) + + def _nudge_into_view(self) -> None: + """Show the subtitle that was just picked, rather than the next one. + + A track switched to part way through a line shows nothing until the + line after it: VLC draws a subtitle when the packet carrying it goes + past, and for the line already on screen that packet went past while + another track was selected. Playing or paused makes no difference -- + paused is only the worse case, since there nothing arrives to end + the wait at all. + + Seeking to where the video already is puts that packet through + again, and is the cheapest thing that does. It costs a visible + hitch, which beats a menu that looks as though it did nothing: + subtitles are sparse, and the next line can be a minute away. + """ + + if self._block.is_live: + # nothing to seek in, and nothing that stays on screen long + # enough for the wait to matter + return + + self._block.video_driver.set_time(self._block.time) + + @property + def discovered_files(self) -> list[Path]: + """Subtitle files named after this video that nobody has picked yet. + + Finding them is a directory listing and nothing else, the same as + for the audio: a file here has not been opened and costs nothing + until it is chosen. + """ + + if ( + not self._block.is_local_file + or not self._block.video_params.is_external_subtitle_autodiscover + ): + return [] + + attached = set(self._block.video_params.external_subtitles) + + return [ + found + for found in discover_subtitle_files(self._block.video_params.uri) + if found not in attached + ] + + @property + def external_track_ids(self) -> tuple[int, ...]: + """The tracks the attached files brought, as the player saw them arrive.""" + + if self._block.video_driver is None or not self._block.is_video_initialized: + return () + + return self._block.video_driver.external_subtitle_ids + + @property + def default_track_id(self) -> int | None: + """The subtitle the container marks default or forced, if any.""" + + if self._block.video_driver is None or not self._block.is_video_initialized: + return None + + return self._block.video_driver.default_subtitle_track_id + + @property + def attached_files(self) -> list[Path]: + """The subtitle files libVLC was really handed, in that order. + + Not the same question as which subtitle was chosen: libVLC never + takes a file back, so one handed over is still there after the + viewer switches to a track of the video's own. + """ + + return list(self._attached_files) + + @property + def external_tracks(self) -> dict[int, Path]: + """Which file each external subtitle came out of. + + libVLC writes the file's own name into the track where a language + belongs, and that is the one place it ever says where a stream came + from, so that is what these are matched on. Where a name answers to + none of the files, the order they were attached in stands in, which + is right wherever each file brought one track -- which is all but + the rarest of them. + """ + + track_ids = self.external_track_ids + files = self._attached_files + + if not track_ids or not files: + return {} + + by_stem = {file_path.stem.casefold(): file_path for file_path in files} + + named = { + track_id: by_stem.get(_track_file_stem(self.tracks.get(track_id))) + for track_id in track_ids + } + + if all(named.values()): + return named + + return { + track_id: files[min(position, len(files) - 1)] + for position, track_id in enumerate(track_ids) + } + + @property + def offered_files(self) -> list[Path]: + """Files this video can be shown with, other than the ones already on.""" + + attached = set(self._attached_files) + + known = [ + file_path + for file_path in self._block.video_params.external_subtitles + # one that is not there cannot be shown, and a row that does + # nothing is worse than no row + if file_path not in attached and file_path.is_file() + ] + + return known + self.discovered_files + + @property + def selected_slave_uris(self) -> tuple[str, ...]: + """The files the next load is to open this video with.""" + + return tuple(file_path.as_uri() for file_path in self._files_to_attach) + + @property + def _files_to_attach(self) -> list[Path]: + """The picked files that are still where the playlist left them. + + One that has been renamed, or is on a drive nobody plugged in, is + no use to libVLC, though it keeps its place in the list in case + whatever moved it moves it back. + """ + + return [ + file_path + for file_path in self._block.video_params.external_subtitles + if file_path.is_absolute() and file_path.is_file() + ] + + def file_name(self, file_path: Path) -> str: + """What to call a subtitle file in the menu.""" + + video_path = self._block.video_params.uri if self._block.is_local_file else None + + return subtitle_file_label(file_path, video_path) + + def external_track_name(self, track_id) -> str | None: + """What tells one track of a file apart from the next, where anything does. + + A file holding several says which is which: a VobSub index carries + a language per track, and so does a subtitle-only Matroska. A file + holding one has nothing to say beyond its own name, and libVLC puts + that name where the language belongs -- so a "language" that is the + file over again is no language at all. + """ + + track = self.tracks.get(track_id) + + if track is None or not track.language: + return None + + file_path = self.external_tracks.get(track_id) + + if file_path is not None and _track_file_stem(track) == ( + file_path.stem.casefold() + ): + return None + + return language_name(track.language) or track.language + + @only_local_file + def add_external_dialog(self) -> None: + """Pick subtitle files to show over this video.""" + + file_names, _ = QFileDialog.getOpenFileNames( + self._block.parent(), + translate( + "Dialog - Add external subtitles", "Add External Subtitles", "Header" + ), + str(self._block.video_params.uri.parent), + _files_filter(), + ) + + self.add_external([Path(file_name) for file_name in file_names]) + + def add_external(self, file_paths) -> None: + """Show these subtitle files with the video, starting on the first.""" + + new_files = [ + file_path + for file_path in file_paths + if file_path.is_absolute() + and file_path not in self._block.video_params.external_subtitles + ] + + if not new_files: + return + + self._block.video_params.external_subtitles = [ + *self._block.video_params.external_subtitles, + *new_files, + ] + + # subtitles asked for by name outrank the languages asked for, the + # same way picking a track by hand does + self._block.video_params.subtitle_selection = SubtitleExternal( + file=new_files[0] + ) + + self._put_external_into_effect() + + def show_external(self, file_name: str) -> None: + """Show this file, whether it has been attached yet or not.""" + + file_path = Path(file_name) + + if file_path in self._attached_files: + # it is on already, and may only have been switched away from + from_file = [ + track_id + for track_id, attached in self.external_tracks.items() + if attached == file_path + ] + + if from_file: + self.set_track(from_file[0]) + + return + + if file_path not in self._block.video_params.external_subtitles: + self.add_external([file_path]) + return + + self._block.video_params.subtitle_selection = SubtitleExternal(file=file_path) + + self._put_external_into_effect() + + def _put_external_into_effect(self) -> None: + """Hand over whatever has been picked and is not on yet. + + No reload, ever, which is what sets this apart from external audio: + libVLC takes a subtitle file at any time and names the track it + brings after it, so however many go on they can still be told + apart. Only taking one away costs a load. + """ + + if not self._block.is_video_initialized: + # part way through a load, which will open them with the video + return + + for file_path in self._files_to_attach: + if file_path in self._attached_files: + continue + + self._block._log.debug(f"Adding subtitle file {file_path.name}") + + self._attached_files.append(file_path) + + self._block.video_driver.add_subtitle_slave(file_path.as_uri()) + + def remove_external(self) -> None: + """Go back to the subtitles the video carries, if it carries any. + + libVLC has no way to take back a file it has been handed, so the + video is loaded once more, this time without them. + """ + + if not self._block.video_params.external_subtitles: + return + + self.drop_external() + + self._block.reload() + + def drop_external(self) -> None: + """Let go of the files picked, and of the track one of them showed.""" + + self._block.video_params.external_subtitles = [] + + # the track that was showing leaves with the file it came from + if isinstance(self._block.video_params.subtitle_selection, SubtitleExternal): + self._block.video_params.subtitle_selection = default_subtitle_selection() + + def set_external_autodiscover(self, is_on: bool) -> None: + """Whether to offer the subtitle files kept beside this video.""" + + self._block.video_params.is_external_subtitle_autodiscover = is_on + + def _forget_files_that_are_gone(self) -> None: + """Let go of a pick whose file is not where the playlist left it. + + The file keeps its place in the list, in case whatever moved it + moves it back; only the choice of it is dropped, so that the video + opens on something the menu can show as taken. + """ + + selection = self._block.video_params.subtitle_selection + + if not isinstance(selection, SubtitleExternal): + return + + if selection.file in self._files_to_attach: + return + + self._block._log.debug(f"{selection.file.name} is not there any more") + + self._block.video_params.subtitle_selection = default_subtitle_selection() + + @only_initialized + def languages_dialog(self): + """Edit the languages this video would rather be subtitled in.""" + + languages = QCustomTextInput.get_text( + parent=self._block.parent(), + title=translate( + "Dialog - Set preferred subtitle languages", + "Preferred subtitle languages", + "Header", + ), + initial_value=self._block.video_params.subtitle_languages, + placeholder=translate( + "Dialog - Set preferred subtitle languages", "en, ja" + ), + ) + + self.set_languages(languages) + + def set_languages(self, languages: str): + if languages == self._block.video_params.subtitle_languages: + return + + self._block.video_params.subtitle_languages = languages + + if not isinstance( + self._block.video_params.subtitle_selection, SubtitlePreferred + ): + return + + # the preference is what is being followed, so following it again + # is the whole point of having changed it + self.apply_preference() + + def get_languages(self) -> str: + return self._block.video_params.subtitle_languages or translate( + "Subtitle Languages", "any" + ) + + def set_encoding(self, encoding: str) -> None: + """Read this video's subtitle files as a different character set. + + Which set a file is read as is settled when the input opens, so the + video is handed back to itself and set_video reopens it. + """ + + if self._block.video_params.subtitle_encoding == encoding: + return + + self._block.set_video( + self._block.video_params.model_copy(update={"subtitle_encoding": encoding}) + ) + + @only_initialized + def encoding_dialog(self): + """Pick what this video's subtitle files are read as.""" + + encoding = SetSubtitleEncodingDialog.get_encoding( + encoding=self._block.video_params.subtitle_encoding, + parent=self._block.parent(), + ) + + if encoding is None: + return + + self.set_encoding(encoding) + + def get_encoding(self) -> str: + return self._block.video_params.subtitle_encoding or translate( + "Subtitle Encoding", "Default" + ) + + @only_initialized + def set_delay(self, delay_ms, is_silent=False): + if not self.tracks: + return + + delay_ms = min(max(delay_ms, MIN_SUBTITLE_DELAY_MS), MAX_SUBTITLE_DELAY_MS) + + self._block.video_params.subtitle_delay_ms = delay_ms + self._block.video_driver.set_subtitle_delay(delay_ms) + + if not is_silent: + self._block.info_change.emit( + f"Subtitle delay: {subtitle_delay_txt(delay_ms)}" + ) + + @only_initialized + def delay_increase(self): + self.set_delay( + self._block.video_params.subtitle_delay_ms + SUBTITLE_DELAY_STEP_MS + ) + + @only_initialized + def delay_decrease(self): + self.set_delay( + self._block.video_params.subtitle_delay_ms - SUBTITLE_DELAY_STEP_MS + ) + + @only_initialized + def delay_reset(self): + self.set_delay(0) + + @only_initialized + def delay_dialog(self): + if not self.tracks: + return + + dialog = SetSubtitleDelayDialog.for_video_block( + self._block, parent=self._block.parent() + ) + + dialog.exec_() + + @only_initialized + def get_delay(self): + return subtitle_delay_txt(self._block.video_params.subtitle_delay_ms) diff --git a/tests/conftest.py b/tests/conftest.py index 96f9f516..01781da2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,27 +49,47 @@ COOKIE_LIFETIME_SEC = 10000 -def is_block_command(command: str) -> bool: - """Whether a VideoBlock command, "reload" or "chapters.next", is there: - the block's own, or one of a part of it (see VideoBlockPart).""" +def _block_parts(): + """A VideoBlock's parts, by the name the block keeps each under.""" - from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_block_bookmarker import Bookmarker from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl + from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl - parts = { - "": VideoBlock, + return { "bookmarker": Bookmarker, "chapters": ChapterControl, "sponsorblock": SponsorBlockControl, + "subtitles": SubtitleControl, "view": ViewControl, } + +def is_block_command(command: str) -> bool: + """Whether a VideoBlock command, "reload" or "chapters.next", is there: + the block's own, or one of a part of it (see VideoBlockPart).""" + + from gridplayer.widgets.video_block import VideoBlock + part, _, method = command.rpartition(".") + owner = VideoBlock if not part else _block_parts().get(part) + + return owner is not None and hasattr(owner, method) + + +def mock_block_parts(block): + """Give a stand-in for a VideoBlock, Mock(spec=VideoBlock), its parts: + the spec has only what the class has, and the block makes its parts as + it is made.""" + + from unittest.mock import Mock + + for name, part in _block_parts().items(): + setattr(block, name, Mock(spec=part)) - return part in parts and hasattr(parts[part], method) + return block class FakeSettings: diff --git a/tests/test_deinterlace.py b/tests/test_deinterlace.py index d971e60d..c5178411 100644 --- a/tests/test_deinterlace.py +++ b/tests/test_deinterlace.py @@ -29,7 +29,7 @@ from gridplayer.vlc_player.player_base import VlcPlayerBase from gridplayer.vlc_player.static import MediaInput from gridplayer.widgets.video_block import VideoBlock -from gridplayer.widgets.video_block_view import ViewControl +from tests.conftest import mock_block_parts URI = "http://example.com/a.mp4" @@ -270,7 +270,7 @@ def _snapshot_block(self, current, wanted): block = _block(*current) block.is_video_initialized = True block._default_title = "a" - block.view = Mock(spec=ViewControl) + mock_block_parts(block) block._take_snapshot_params = partial(VideoBlock._take_snapshot_params, block) snapshot = _video(*wanted) diff --git a/tests/test_menu_subtitle_delay.py b/tests/test_menu_subtitle_delay.py index 4509e457..e0a1de30 100644 --- a/tests/test_menu_subtitle_delay.py +++ b/tests/test_menu_subtitle_delay.py @@ -12,7 +12,7 @@ from gridplayer.player.managers.actions import ActionsManager from gridplayer.player.managers.active_block import ActiveBlockManager from gridplayer.player.managers.video_blocks import VideoBlocksManager -from gridplayer.widgets.video_block import VideoBlock +from tests.conftest import is_block_command SUBMENU = "Subtitle Delay" @@ -137,11 +137,11 @@ def test_an_active_row_names_something_a_video_can_do(self, name): action = ACTIONS[name] assert action["func"][0] == "active" - assert hasattr(VideoBlock, action["func"][1]) + assert is_block_command(action["func"][1]) assert hasattr(ActiveBlockManager, action["show_if"]) if action.get("value_getter"): - assert hasattr(VideoBlock, action["value_getter"][1]) + assert is_block_command(action["value_getter"][1]) @pytest.mark.parametrize("name", [row for row in ALL_ROWS if row != "---"]) def test_an_all_row_names_something_every_video_can_do(self, name): @@ -152,9 +152,10 @@ def test_an_all_row_names_something_every_video_can_do(self, name): if isinstance(func, tuple): assert func[0] == "all" - # cmd_all emits all_ at every block's method of that name + # cmd_all emits all_ at every block's subtitles, at the + # method of that name less its "subtitle_" assert hasattr(VideoBlocksManager, f"all_{func[1]}") - assert hasattr(VideoBlock, func[1]) + assert is_block_command(f"subtitles.{func[1].removeprefix('subtitle_')}") else: assert hasattr(VideoBlocksManager, func) assert hasattr(VideoBlocksManager, "cmd_set_subtitle_delay") diff --git a/tests/test_menu_subtitle_track.py b/tests/test_menu_subtitle_track.py index 572bc79c..89dffe53 100644 --- a/tests/test_menu_subtitle_track.py +++ b/tests/test_menu_subtitle_track.py @@ -100,18 +100,20 @@ def _external_track_name(track_id): return language_name(track.language) or track.language block = SimpleNamespace( - subtitle_tracks=subtitle_tracks, - has_subtitles=bool(subtitle_tracks), - subtitle_track_showing=showing, - preferred_subtitle_track_id=preferred, - default_subtitle_track_id=default, - external_subtitle_track_ids=tuple(external_tracks), - external_subtitle_tracks=external_tracks, - offered_subtitle_files=list(offered), - subtitle_file_name=lambda path: path.name, - external_subtitle_track_name=_external_track_name, - subtitle_number_in_file=_number_in_file, - subtitle_language_key=_language_key, + subtitles=SimpleNamespace( + tracks=subtitle_tracks, + has_any=bool(subtitle_tracks), + track_showing=showing, + preferred_track_id=preferred, + default_track_id=default, + external_track_ids=tuple(external_tracks), + external_tracks=external_tracks, + offered_files=list(offered), + file_name=lambda path: path.name, + external_track_name=_external_track_name, + number_in_file=_number_in_file, + language_key=_language_key, + ), is_local_file=is_local_file, video_params=SimpleNamespace( subtitle_selection=SubtitleDisabled() if selection is None else selection, diff --git a/tests/test_subtitle_encoding.py b/tests/test_subtitle_encoding.py index aa2ea1f2..56f76478 100644 --- a/tests/test_subtitle_encoding.py +++ b/tests/test_subtitle_encoding.py @@ -7,7 +7,6 @@ input opens. """ -from functools import partial from types import SimpleNamespace from unittest.mock import Mock @@ -34,6 +33,8 @@ from gridplayer.widgets.combo_box import MAX_VISIBLE_COMBO_ITEMS from gridplayer.widgets.defaults_form import DefaultsForm from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_subtitles import SubtitleControl +from tests.conftest import is_block_command URI = "http://example.com/a.mp4" @@ -131,7 +132,7 @@ def _block(encoding): block = Mock(spec=VideoBlock) block.video_params = Video(uri=URI, subtitle_encoding=encoding) - block.set_subtitle_encoding = partial(VideoBlock.set_subtitle_encoding, block) + block.subtitles = SubtitleControl(block) return block @@ -255,7 +256,7 @@ def test_the_menu_asks_the_video_the_same_way(self): block = _block("") - block.set_subtitle_encoding("KOI8-R") + block.subtitles.set_encoding("KOI8-R") assert _reopened_with(block) == ["KOI8-R"] @@ -295,24 +296,24 @@ def test_the_row_reads_back_what_is_in_force_and_opens_the_box(self): row = _subtitle_encoding_menu_item() assert row["title"].endswith(": %v") - assert row["func"] == ("active", "subtitle_encoding_dialog") - assert row["value_getter"] == ("active", "get_subtitle_encoding") - assert hasattr(VideoBlock, "subtitle_encoding_dialog") - assert hasattr(VideoBlock, "get_subtitle_encoding") + assert row["func"] == ("active", "subtitles.encoding_dialog") + assert row["value_getter"] == ("active", "subtitles.get_encoding") + assert is_block_command(row["func"][1]) + assert is_block_command(row["value_getter"][1]) def test_the_readout_names_the_set_that_was_picked(self): - assert VideoBlock.get_subtitle_encoding(_block("KOI8-R")) == "KOI8-R" + assert _block("KOI8-R").subtitles.get_encoding() == "KOI8-R" def test_the_readout_says_default_where_none_was(self): - assert VideoBlock.get_subtitle_encoding(_block("")) == "Default" + assert _block("").subtitles.get_encoding() == "Default" def test_it_still_answers_while_the_video_is_reopening(self): """Picking one is what started the reload the next pick lands in.""" assert { - "subtitle_encoding_dialog", - "get_subtitle_encoding", - "set_subtitle_encoding", + "subtitles.encoding_dialog", + "subtitles.get_encoding", + "subtitles.set_encoding", } <= LOADING_COMMANDS diff --git a/tests/test_video_block_subtitles.py b/tests/test_video_block_subtitles.py index 160108d8..cd15149f 100644 --- a/tests/test_video_block_subtitles.py +++ b/tests/test_video_block_subtitles.py @@ -17,6 +17,7 @@ from gridplayer.settings import Settings from gridplayer.vlc_player.static import DISABLED_TRACK, SubtitleTrack from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_subtitles import SubtitleControl @pytest.fixture(scope="module", autouse=True) @@ -73,40 +74,9 @@ def set_time(self, seek_ms): class _Block: - """A block with the real subtitle methods, and no player behind it.""" + """A block with its real subtitles part, and no player behind it.""" is_local_file = VideoBlock.is_local_file - discovered_subtitle_files = VideoBlock.discovered_subtitle_files - offered_subtitle_files = VideoBlock.offered_subtitle_files - attached_subtitle_files = VideoBlock.attached_subtitle_files - external_subtitle_track_ids = VideoBlock.external_subtitle_track_ids - default_subtitle_track_id = VideoBlock.default_subtitle_track_id - external_subtitle_tracks = VideoBlock.external_subtitle_tracks - selected_subtitle_slave_uris = VideoBlock.selected_subtitle_slave_uris - _subtitle_files_to_attach = VideoBlock._subtitle_files_to_attach - _own_subtitles = VideoBlock._own_subtitles - - set_subtitle_track = VideoBlock.set_subtitle_track - disable_subtitles = VideoBlock.disable_subtitles - add_external_subtitles = VideoBlock.add_external_subtitles - show_external_subtitle = VideoBlock.show_external_subtitle - remove_external_subtitles = VideoBlock.remove_external_subtitles - _drop_external_subtitles = VideoBlock._drop_external_subtitles - set_external_subtitle_autodiscover = VideoBlock.set_external_subtitle_autodiscover - restore_subtitle_selection = VideoBlock.restore_subtitle_selection - subtitle_file_name = VideoBlock.subtitle_file_name - external_subtitle_track_name = VideoBlock.external_subtitle_track_name - subtitle_language_key = VideoBlock.subtitle_language_key - subtitle_number_in_file = VideoBlock.subtitle_number_in_file - - _subtitle_selection_for = VideoBlock._subtitle_selection_for - _forget_subtitle_files_that_are_gone = ( - VideoBlock._forget_subtitle_files_that_are_gone - ) - _wanted_subtitle_track_id = VideoBlock._wanted_subtitle_track_id - _apply_wanted_subtitle_track = VideoBlock._apply_wanted_subtitle_track - _put_external_subtitles_into_effect = VideoBlock._put_external_subtitles_into_effect - _nudge_subtitles_into_view = VideoBlock._nudge_subtitles_into_view def __init__( self, @@ -125,17 +95,19 @@ def __init__( ) self.is_video_initialized = is_initialized - self.subtitle_tracks = {0: _track("eng")} if tracks is None else tracks self.video_driver = _Driver() + self.video_driver.subtitle_tracks = ( + {0: _track("eng")} if tracks is None else tracks + ) self.is_live = False self.time = 4200 - self._attached_subtitle_files = [] - self.reloads = 0 + self.subtitles = SubtitleControl(self) + def reload(self): self.reloads += 1 @@ -151,9 +123,9 @@ def _attached(block, file_path, track_id, language=None): *block.video_params.external_subtitles, file_path, ] - block._attached_subtitle_files = [*block._attached_subtitle_files, file_path] - block.subtitle_tracks = { - **block.subtitle_tracks, + block.subtitles._attached_files = [*block.subtitles._attached_files, file_path] + block.video_driver.subtitle_tracks = { + **block.video_driver.subtitle_tracks, track_id: _track(language or str(file_path.with_suffix(""))), } block.video_driver.external_subtitle_ids = ( @@ -166,7 +138,7 @@ class TestRememberingWhichSubtitleWasPicked: def test_switching_them_off_is_remembered_as_off(self, tmp_path): block = _block(tmp_path) - block.disable_subtitles() + block.subtitles.disable() assert block.video_params.subtitle_selection == SubtitleDisabled() assert block.video_driver.tracks_set == [DISABLED_TRACK] @@ -174,7 +146,7 @@ def test_switching_them_off_is_remembered_as_off(self, tmp_path): def test_a_track_with_a_language_of_its_own_is_remembered_by_it(self, tmp_path): block = _block(tmp_path, tracks={0: _track("eng"), 1: _track("jpn")}) - block.set_subtitle_track(1) + block.subtitles.set_track(1) assert block.video_params.subtitle_selection == SubtitleLanguage(tag="jpn") @@ -183,14 +155,14 @@ def test_one_of_two_in_the_same_language_is_remembered_by_number(self, tmp_path) block = _block(tmp_path, tracks={0: _track("eng"), 1: _track("eng")}) - block.set_subtitle_track(1) + block.subtitles.set_track(1) assert block.video_params.subtitle_selection == SubtitleTrackId(id=1) def test_a_track_with_no_language_is_remembered_by_number(self, tmp_path): block = _block(tmp_path, tracks={0: _track(None), 1: _track(None)}) - block.set_subtitle_track(1) + block.subtitles.set_track(1) assert block.video_params.subtitle_selection == SubtitleTrackId(id=1) @@ -200,7 +172,7 @@ def test_a_track_out_of_a_file_is_remembered_by_the_file(self, tmp_path): _attached(block, subtitles, track_id=1) - block.set_subtitle_track(1) + block.subtitles.set_track(1) selection = block.video_params.subtitle_selection @@ -214,7 +186,7 @@ def test_a_file_is_never_remembered_by_the_language_libvlc_invented(self, tmp_pa _attached(block, subtitles, track_id=1) - assert block.subtitle_language_key(1) is None + assert block.subtitles.language_key(1) is None class TestFilesKeptBesideTheVideo: @@ -222,32 +194,32 @@ def test_the_ones_named_after_it_are_offered(self, tmp_path): block = _block(tmp_path) _file(tmp_path, "Movie.en.srt") - assert [path.name for path in block.offered_subtitle_files] == ["Movie.en.srt"] + assert [path.name for path in block.subtitles.offered_files] == ["Movie.en.srt"] def test_nothing_is_offered_where_looking_was_turned_off(self, tmp_path): block = _block(tmp_path, autodiscover=False) _file(tmp_path, "Movie.en.srt") - assert block.offered_subtitle_files == [] + assert block.subtitles.offered_files == [] def test_picking_one_hands_it_over_without_a_reload(self, tmp_path): """Which is what sets this apart from an audio file.""" block = _block(tmp_path) - block._attached_subtitle_files = [] + block.subtitles._attached_files = [] subtitles = _file(tmp_path, "Movie.en.srt") - block.show_external_subtitle(str(subtitles)) + block.subtitles.show_external(str(subtitles)) assert block.video_driver.slaves == [subtitles.as_uri()] assert block.reloads == 0 def test_picking_one_is_what_chooses_it(self, tmp_path): block = _block(tmp_path) - block._attached_subtitle_files = [] + block.subtitles._attached_files = [] subtitles = _file(tmp_path, "Movie.en.srt") - block.show_external_subtitle(str(subtitles)) + block.subtitles.show_external(str(subtitles)) assert block.video_params.subtitle_selection == SubtitleExternal( file=subtitles, track=0 @@ -260,7 +232,7 @@ def test_one_already_on_is_only_switched_back_to(self, tmp_path): _attached(block, subtitles, track_id=1) block.video_driver.slaves.clear() - block.show_external_subtitle(str(subtitles)) + block.subtitles.show_external(str(subtitles)) assert block.video_driver.slaves == [] assert block.video_driver.tracks_set == [1] @@ -274,7 +246,7 @@ def test_taking_them_away_costs_a_reload(self, tmp_path): _attached(block, subtitles, track_id=1) block.video_params.subtitle_selection = SubtitleExternal(file=subtitles) - block.remove_external_subtitles() + block.subtitles.remove_external() assert block.reloads == 1 assert block.video_params.external_subtitles == [] @@ -287,7 +259,7 @@ def test_a_file_that_is_gone_is_let_go_of(self, tmp_path): block.video_params.external_subtitles = [subtitles] block.video_params.subtitle_selection = SubtitleExternal(file=subtitles) - block._forget_subtitle_files_that_are_gone() + block.subtitles._forget_files_that_are_gone() assert block.video_params.subtitle_selection == SubtitleDisabled() # the file keeps its place, in case whatever moved it moves it back @@ -305,7 +277,7 @@ def test_which_file_a_track_came_from_is_read_off_its_name(self, tmp_path): _attached(block, first, track_id=1, language="C:\\films\\Movie.ja") _attached(block, second, track_id=2, language="C:\\films\\Movie.en") - assert block.external_subtitle_tracks == {1: second, 2: first} + assert block.subtitles.external_tracks == {1: second, 2: first} def test_one_file_can_bring_several_tracks(self, tmp_path): """A VobSub index off a DVD routinely carries a dozen languages.""" @@ -316,9 +288,9 @@ def test_one_file_can_bring_several_tracks(self, tmp_path): _attached(block, vobsub, track_id=1, language="en") _attached(block, vobsub, track_id=2, language="de") - assert block.external_subtitle_tracks == {1: vobsub, 2: vobsub} - assert block.subtitle_number_in_file(1, vobsub) == 0 - assert block.subtitle_number_in_file(2, vobsub) == 1 + assert block.subtitles.external_tracks == {1: vobsub, 2: vobsub} + assert block.subtitles.number_in_file(1, vobsub) == 0 + assert block.subtitles.number_in_file(2, vobsub) == 1 def test_which_of_them_was_picked_is_part_of_the_choice(self, tmp_path): """Or the video comes back on the first of them after every reload.""" @@ -329,7 +301,7 @@ def test_which_of_them_was_picked_is_part_of_the_choice(self, tmp_path): _attached(block, vobsub, track_id=1, language="en") _attached(block, vobsub, track_id=2, language="de") - block.set_subtitle_track(2) + block.subtitles.set_track(2) assert block.video_params.subtitle_selection == SubtitleExternal( file=vobsub, track=1 @@ -341,7 +313,7 @@ def test_a_track_of_such_a_file_is_named_by_its_language(self, tmp_path): _attached(block, vobsub, track_id=1, language="de") - assert block.external_subtitle_track_name(1) == "German" + assert block.subtitles.external_track_name(1) == "German" def test_a_file_with_one_track_says_nothing_beyond_its_own_name(self, tmp_path): """libVLC puts the file's name where the language belongs.""" @@ -351,7 +323,7 @@ def test_a_file_with_one_track_says_nothing_beyond_its_own_name(self, tmp_path): _attached(block, subtitles, track_id=1) - assert block.external_subtitle_track_name(1) is None + assert block.subtitles.external_track_name(1) is None def test_a_name_that_answers_to_nothing_falls_back_on_the_order(self, tmp_path): block = _block(tmp_path) @@ -362,14 +334,14 @@ def test_a_name_that_answers_to_nothing_falls_back_on_the_order(self, tmp_path): _attached(block, first, track_id=1, language="something else") _attached(block, second, track_id=2, language="something else again") - assert block.external_subtitle_tracks == {1: first, 2: second} + assert block.subtitles.external_tracks == {1: first, 2: second} class TestPuttingTheSettingsIntoEffect: def test_a_video_part_way_through_a_load_settles_it_itself(self, tmp_path): block = _block(tmp_path, is_initialized=False) - block.set_subtitle_track(1) + block.subtitles.set_track(1) assert block.video_driver.tracks_set == [] assert block.video_params.subtitle_selection == SubtitleTrackId(id=1) @@ -385,7 +357,7 @@ def test_the_line_already_on_screen_is_put_through_again(self, tmp_path, is_paus block = _block(tmp_path, is_paused=is_paused) - block.set_subtitle_track(0) + block.subtitles.set_track(0) assert block.video_driver.times_set == [4200] @@ -393,6 +365,6 @@ def test_a_live_stream_has_nothing_to_seek_in(self, tmp_path): block = _block(tmp_path) block.is_live = True - block.set_subtitle_track(0) + block.subtitles.set_track(0) assert block.video_driver.times_set == [] diff --git a/tests/test_video_position.py b/tests/test_video_position.py index e6fefc27..bb138963 100644 --- a/tests/test_video_position.py +++ b/tests/test_video_position.py @@ -43,6 +43,7 @@ from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_vlc_hw_sp import VideoFrameVLCHWSP +from tests.conftest import mock_block_parts URI = "http://example.com/a.mp4" NO_SHIFT = VideoShift(0, 0) @@ -875,7 +876,7 @@ def test_a_snapshot_puts_the_picture_back_where_it_was(): block.video_tracks = [0] block.is_video_initialized = True block._default_title = "a" - block.view = Mock(spec=ViewControl) + mock_block_parts(block) snapshot = Video( uri=URI, @@ -896,7 +897,7 @@ def test_a_snapshot_puts_back_what_the_zoom_goes_about(): block.video_tracks = [0] block.is_video_initialized = True block._default_title = "a" - block.view = Mock(spec=ViewControl) + mock_block_parts(block) VideoBlock.apply_snapshot(block, Video(uri=URI, is_zoom_to_cursor=True)) From e6e496122155a9165598e324c72f1e8faf0325d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:51:30 +0000 Subject: [PATCH 07/14] refactor: take the video block's sound out into an AudioControl The audio tracks and the choice among them, the output devices, the preferred languages, the files kept beside the video, the channel mode and the delay go to block.audio, their commands "audio.set_track" and the like. A dub served as another stream is still the block's to follow, now as follow_audio_language, the audio part going to it for that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/dialogs/audio_delay.py | 6 +- gridplayer/params/actions.py | 26 +- gridplayer/player/managers/active_block.py | 86 +-- gridplayer/player/managers/video_blocks.py | 12 +- gridplayer/widgets/video_block.py | 718 +------------------- gridplayer/widgets/video_block_audio.py | 721 +++++++++++++++++++++ tests/conftest.py | 2 + tests/test_audio_commands_while_loading.py | 19 +- tests/test_audio_track_preference.py | 50 +- tests/test_menu_audio_click.py | 27 +- tests/test_menu_audio_delay.py | 11 +- tests/test_menu_audio_device.py | 8 +- tests/test_menu_audio_track.py | 39 +- tests/test_tray.py | 2 +- tests/test_video_block_audio_delay.py | 33 +- tests/test_video_block_audio_device.py | 17 +- tests/test_video_block_audio_language.py | 9 +- tests/test_video_block_external_audio.py | 138 ++-- 18 files changed, 963 insertions(+), 961 deletions(-) create mode 100644 gridplayer/widgets/video_block_audio.py diff --git a/gridplayer/dialogs/audio_delay.py b/gridplayer/dialogs/audio_delay.py index 08991d4b..f928d12b 100644 --- a/gridplayer/dialogs/audio_delay.py +++ b/gridplayer/dialogs/audio_delay.py @@ -104,14 +104,14 @@ def get_delay_ms(cls, delay_ms=0, parent=None) -> int | None: def accept(self): if self._block is not None: # the same number over again, this time said out loud - self._block.set_audio_delay(self.spinbox.value()) + self._block.audio.set_delay(self.spinbox.value()) super().accept() def reject(self): if self._block is not None: if self._block.video_params.audio_delay_ms != self._original_delay_ms: - self._block.set_audio_delay(self._original_delay_ms, is_silent=True) + self._block.audio.set_delay(self._original_delay_ms, is_silent=True) super().reject() @@ -120,4 +120,4 @@ def _on_reset(self): def _on_value_changed(self, delay_ms): if self._block is not None: - self._block.set_audio_delay(delay_ms, is_silent=True) + self._block.audio.set_delay(delay_ms, is_silent=True) diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 491092e1..73d8d378 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -1146,28 +1146,28 @@ "title": "{}: %v".format(translate("Actions", "Delay")), "keymap_title": translate("Actions", "Set Delay"), "icon": "empty", - "func": ("active", "audio_delay_dialog"), - "value_getter": ("active", "get_audio_delay"), + "func": ("active", "audio.delay_dialog"), + "value_getter": ("active", "audio.get_delay"), "show_if": "is_active_has_audio", }, "Audio Delay Later": { "title": translate("Actions", "Later"), "key": "K", "icon": "empty", - "func": ("active", "audio_delay_increase"), + "func": ("active", "audio.delay_increase"), "show_if": "is_active_has_audio", }, "Audio Delay Earlier": { "title": translate("Actions", "Earlier"), "key": "J", "icon": "empty", - "func": ("active", "audio_delay_decrease"), + "func": ("active", "audio.delay_decrease"), "show_if": "is_active_has_audio", }, "Audio Delay Reset": { "title": translate("Actions", "Reset"), "icon": "empty", - "func": ("active", "audio_delay_reset"), + "func": ("active", "audio.delay_reset"), "show_if": "is_active_has_audio", }, "Subtitle Track": { @@ -1210,7 +1210,7 @@ "title": translate("Audio Mode", "Original"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.UNSET), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.UNSET), "check_if": ( "is_active_param_set_to", "audio_channel_mode", @@ -1222,7 +1222,7 @@ "title": translate("Audio Mode", "Stereo"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.STEREO), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.STEREO), "check_if": ( "is_active_param_set_to", "audio_channel_mode", @@ -1234,7 +1234,7 @@ "title": translate("Audio Mode", "Reverse Stereo"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.RSTEREO), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.RSTEREO), "check_if": ( "is_active_param_set_to", "audio_channel_mode", @@ -1246,7 +1246,7 @@ "title": translate("Audio Mode", "Left"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.LEFT), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.LEFT), "check_if": ( "is_active_param_set_to", "audio_channel_mode", @@ -1258,7 +1258,7 @@ "title": translate("Audio Mode", "Right"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.RIGHT), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.RIGHT), "check_if": ( "is_active_param_set_to", "audio_channel_mode", @@ -1270,7 +1270,7 @@ "title": translate("Audio Mode", "Dolby Surround"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.DOLBYS), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.DOLBYS), "check_if": ( "is_active_param_set_to", "audio_channel_mode", @@ -1282,7 +1282,7 @@ "title": translate("Audio Mode", "Headphones"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.HEADPHONES), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.HEADPHONES), "check_if": ( "is_active_param_set_to", "audio_channel_mode", @@ -1294,7 +1294,7 @@ "title": translate("Audio Mode", "Mono"), "icon": "empty", "show_if": "is_active_has_audio", - "func": ("active", "set_audio_channel_mode", AudioChannelMode.MONO), + "func": ("active", "audio.set_channel_mode", AudioChannelMode.MONO), "check_if": ( "is_active_param_set_to", "audio_channel_mode", diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index 7d94df34..3f950854 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -57,17 +57,17 @@ "set_list_fail_action", "list_fail_wait", "get_list_fail_wait", - "set_audio_track", - "set_audio_device", + "audio.set_track", + "audio.set_device", "set_audio_language", - "apply_audio_preference", - "apply_audio_default", - "audio_languages_dialog", - "get_audio_languages", - "add_external_audio_dialog", - "play_external_audio", - "remove_external_audio", - "set_external_audio_autodiscover", + "audio.apply_preference", + "audio.apply_default", + "audio.languages_dialog", + "audio.get_languages", + "audio.add_external_dialog", + "audio.play_external", + "audio.remove_external", + "audio.set_external_autodiscover", "subtitles.set_track", "subtitles.apply_preference", "subtitles.apply_default", @@ -268,7 +268,7 @@ def is_active_has_audio(self): if not self.is_active_initialized(): return False - return bool(self._ctx.active_block.audio_tracks) + return bool(self._ctx.active_block.audio.tracks) def is_active_has_video(self): if not self.is_active_initialized(): @@ -305,10 +305,10 @@ def is_active_audio_track(self, track_id): if isinstance(selection, AudioExternal): block = self._ctx.active_block - if block.external_audio_tracks.get(track_id) != selection.file: + if block.audio.external_tracks.get(track_id) != selection.file: return False - return block.external_audio_track_ids.index(track_id) == selection.track + return block.audio.external_track_ids.index(track_id) == selection.track return isinstance(selection, AudioTrackId) and selection.id == track_id @@ -329,7 +329,7 @@ def is_active_audio_device(self, device_id) -> bool: return ( chosen is not None - and resolve_device_id(chosen, block.audio_devices) == device_id + and resolve_device_id(chosen, block.audio.devices) == device_id ) def is_active_audio_device_default(self) -> bool: @@ -350,7 +350,7 @@ def is_active_audio_device_missing(self) -> bool: chosen = block.video_params.audio_device - return chosen is not None and not resolve_device_id(chosen, block.audio_devices) + return chosen is not None and not resolve_device_id(chosen, block.audio.devices) def is_active_audio_preferred(self) -> bool: return isinstance(self._active_audio_selection(), AudioPreferred) @@ -644,7 +644,7 @@ def menu_generator_audio_track(self): external = self._external_audio_menu_items() - if not self._ctx.active_block.audio_tracks: + if not self._ctx.active_block.audio.tracks: # a silent video has nothing to choose between, but what is # lying next to it is the whole point of looking return external or {} @@ -657,7 +657,7 @@ def menu_generator_audio_track(self): { "title": translate("Audio Track", "Disable Audio"), "icon": "empty", - "func": ("active", "set_audio_track", DISABLED_TRACK), + "func": ("active", "audio.set_track", DISABLED_TRACK), "check_if": "is_active_audio_disabled", "is_choice": True, "show_if": "is_active_initialized", @@ -679,7 +679,7 @@ def menu_generator_audio_device(self): if self.is_no_active_block: return {} - devices = self._ctx.active_block.audio_devices + devices = self._ctx.active_block.audio.devices if not devices: return {} @@ -690,7 +690,7 @@ def menu_generator_audio_device(self): { "title": device.name or device.id, "icon": "empty", - "func": ("active", "set_audio_device", device), + "func": ("active", "audio.set_device", device), "check_if": ("is_active_audio_device", device.id), "is_choice": True, "show_if": "is_active_has_audio", @@ -714,7 +714,7 @@ def _missing_audio_device_menu_items(self): chosen = block.video_params.audio_device - if chosen is None or resolve_device_id(chosen, block.audio_devices): + if chosen is None or resolve_device_id(chosen, block.audio.devices): return [] return [ @@ -723,7 +723,7 @@ def _missing_audio_device_menu_items(self): chosen.name or chosen.id ), "icon": "empty", - "func": ("active", "set_audio_device", chosen), + "func": ("active", "audio.set_device", chosen), "check_if": "is_active_audio_device_missing", "is_choice": True, "show_if": "is_active_has_audio", @@ -750,17 +750,17 @@ def _external_audio_menu_items(self): { "title": file_path.name, "icon": "empty", - "func": ("active", "play_external_audio", str(file_path)), + "func": ("active", "audio.play_external", str(file_path)), "show_if": "is_active_local_file", } - for file_path in block.offered_audio_files + for file_path in block.audio.offered_files ] commands = [ { "title": translate("Actions", "Add External Audio..."), "icon": "empty", - "func": ("active", "add_external_audio_dialog"), + "func": ("active", "audio.add_external_dialog"), "show_if": "is_active_local_file", } ] @@ -770,7 +770,7 @@ def _external_audio_menu_items(self): { "title": translate("Actions", "Remove External Audio"), "icon": "empty", - "func": ("active", "remove_external_audio"), + "func": ("active", "audio.remove_external"), "show_if": "is_active_local_file", } ) @@ -781,7 +781,7 @@ def _external_audio_menu_items(self): "icon": "empty", "func": ( "active", - "set_external_audio_autodiscover", + "audio.set_external_autodiscover", not block.video_params.is_external_audio_autodiscover, ), "check_if": ( @@ -807,17 +807,17 @@ def _attached_audio_menu_items(self): block = self._ctx.active_block - file_path = block.attached_audio_file + file_path = block.audio.attached_file if file_path is None: return [] - tracks = block.audio_tracks - playing = block.audio_track_playing + tracks = block.audio.tracks + playing = block.audio.track_playing from_file = [ track_id - for track_id in block.external_audio_track_ids + for track_id in block.audio.external_track_ids if track_id in tracks ] @@ -828,7 +828,7 @@ def _attached_audio_menu_items(self): { "title": _external_track_title(file_path, tracks[track_id], number), "icon": "play" if track_id == playing else "empty", - "func": ("active", "set_audio_track", track_id), + "func": ("active", "audio.set_track", track_id), "check_if": ("is_active_audio_track", track_id), "is_choice": True, "show_if": "is_active_local_file", @@ -840,7 +840,7 @@ def _attached_audio_menu_items(self): { "title": file_path.name, "icon": "empty", - "func": ("active", "play_external_audio", str(file_path)), + "func": ("active", "audio.play_external", str(file_path)), "show_if": "is_active_local_file", } ] @@ -882,10 +882,10 @@ def _preferred_audio_track(self): block = self._ctx.active_block - track = block.audio_tracks.get(block.preferred_audio_track_id) + track = block.audio.tracks.get(block.audio.preferred_track_id) if track is None and self.is_active_audio_preferred(): - track = block.audio_tracks.get(block.audio_track_playing) + track = block.audio.tracks.get(block.audio.track_playing) return track @@ -919,19 +919,19 @@ def _audio_choice_menu_items(self): # what came out of a file of its own is listed with that file, under # its own heading, rather than among the tracks the video came with - external_ids = set(block.external_audio_track_ids) - playing = block.audio_track_playing + external_ids = set(block.audio.external_track_ids) + playing = block.audio.track_playing return [ { "title": _audio_track_title(track), "icon": "play" if track_id == playing else "empty", - "func": ("active", "set_audio_track", track_id), + "func": ("active", "audio.set_track", track_id), "check_if": ("is_active_audio_track", track_id), "is_choice": True, "show_if": "is_active_initialized", } - for track_id, track in block.audio_tracks.items() + for track_id, track in block.audio.tracks.items() if track_id not in external_ids ] @@ -1189,7 +1189,7 @@ def _preferred_audio_track_menu_item(self): return { "title": title, "icon": "empty", - "func": ("active", "apply_audio_preference"), + "func": ("active", "audio.apply_preference"), "check_if": "is_active_audio_preferred", "is_choice": True, "show_if": "is_active_initialized", @@ -1286,7 +1286,7 @@ def _audio_device_default_menu_item(): return { "title": translate("Actions", "System Default"), "icon": "empty", - "func": ("active", "set_audio_device", None), + "func": ("active", "audio.set_device", None), "check_if": "is_active_audio_device_default", "is_choice": True, "show_if": "is_active_has_audio", @@ -1303,7 +1303,7 @@ def _audio_default_menu_item(): return { "title": translate("Audio Track", "Default"), "icon": "empty", - "func": ("active", "apply_audio_default"), + "func": ("active", "audio.apply_default"), "check_if": "is_active_audio_default", "is_choice": True, "show_if": "is_active_initialized", @@ -1321,8 +1321,8 @@ def _audio_languages_menu_item(): return { "title": "{}: %v".format(translate("Actions", "Languages")), "icon": "empty", - "func": ("active", "audio_languages_dialog"), - "value_getter": ("active", "get_audio_languages"), + "func": ("active", "audio.languages_dialog"), + "value_getter": ("active", "audio.get_languages"), "show_if": "is_active_initialized", } diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index ea1234e8..078a715d 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -650,7 +650,7 @@ def is_any_videos_live(self): return any(vb.is_live for vb in self._ctx.video_blocks.initialized) def is_any_videos_have_audio(self): - return any(vb.audio_tracks for vb in self._ctx.video_blocks.initialized) + return any(vb.audio.tracks for vb in self._ctx.video_blocks.initialized) def is_any_videos_have_video(self): return any(vb.video_tracks for vb in self._ctx.video_blocks.initialized) @@ -891,11 +891,11 @@ def _add_video_block(self, video): (self.all_set_transform, vb.set_transform), (self.all_take_screenshot, vb.take_screenshot), (self.all_set_auto_reload_timer, vb.set_auto_reload_timer), - (self.all_set_audio_channel_mode, vb.set_audio_channel_mode), - (self.all_set_audio_delay, vb.set_audio_delay), - (self.all_audio_delay_increase, vb.audio_delay_increase), - (self.all_audio_delay_decrease, vb.audio_delay_decrease), - (self.all_audio_delay_reset, vb.audio_delay_reset), + (self.all_set_audio_channel_mode, vb.audio.set_channel_mode), + (self.all_set_audio_delay, vb.audio.set_delay), + (self.all_audio_delay_increase, vb.audio.delay_increase), + (self.all_audio_delay_decrease, vb.audio.delay_decrease), + (self.all_audio_delay_reset, vb.audio.delay_reset), (self.all_set_subtitle_delay, vb.subtitles.set_delay), (self.all_subtitle_delay_increase, vb.subtitles.delay_increase), (self.all_subtitle_delay_decrease, vb.subtitles.delay_decrease), diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index dd311f88..c4ca200b 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -11,9 +11,8 @@ from pydantic_extra_types.color import Color from PyQt5.QtCore import QSize, Qt, QTimer, pyqtSignal from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import QFileDialog, QStackedLayout, QWidget +from PyQt5.QtWidgets import QStackedLayout, QWidget -from gridplayer.dialogs.audio_delay import SetAudioDelayDialog, audio_delay_txt from gridplayer.dialogs.error_details import ( DetailRows, ErrorDetailsDialog, @@ -22,18 +21,14 @@ from gridplayer.dialogs.input_dialog import ( QCustomSpinboxInput, QCustomSpinboxTimeInput, - QCustomTextInput, ) from gridplayer.dialogs.rename_dialog import QVideoRenameDialog from gridplayer.exceptions import PlayerException from gridplayer.models.audio_selection import ( AudioDefault, AudioDisabled, - AudioExternal, AudioLanguage, AudioPreferred, - AudioTrackId, - track_of_file, ) from gridplayer.models.bookmark import Bookmark from gridplayer.models.media_list import ( @@ -56,17 +51,12 @@ from gridplayer.models.video import ( Video, VideoBlockMime, - default_audio_selection, ) from gridplayer.params import env -from gridplayer.params.extensions import SUPPORTED_AUDIO_EXT from gridplayer.params.static import ( - AUDIO_DELAY_STEP_MS, CHROME_MIN_SIZE, - MAX_AUDIO_DELAY_MS, MAX_LIST_FAIL_WAIT_SEC, MAX_RATE, - MIN_AUDIO_DELAY_MS, MIN_RATE, OVERLAY_ACTIVITY_EVENT, PLAYER_ID_LENGTH, @@ -84,11 +74,6 @@ from gridplayer.settings import Settings from gridplayer.utils.bookmarks import file_key from gridplayer.utils.drop_zone import DropIndicator -from gridplayer.utils.external_audio import ( - discover_audio_files, - silenced_by_seeking, - unplayable_as_audio_slave, -) from gridplayer.utils.folder_navigator import FolderNavigator, FolderStep from gridplayer.utils.libvlc_options_parser import ( get_vlc_options, @@ -108,7 +93,6 @@ segment_mark, ) from gridplayer.utils.step_sizes import seek_step_ms -from gridplayer.utils.track_language import normalize, pick_track from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver from gridplayer.utils.video_errors import ( @@ -120,14 +104,12 @@ ) from gridplayer.vlc_player.static import ( DISABLED_TRACK, - NO_TRACK, SEEK_ARRIVAL_MS, MediaInput, SeekLanding, arriving_time, is_loop_wrapped, seek_landing, - wanted_audio_track_id, ) from gridplayer.widgets.cell_chrome import ( chrome_color_on_cell, @@ -135,6 +117,7 @@ paint_solid_outline, ) from gridplayer.widgets.stacked_layout_floating import QStackedLayoutFloating +from gridplayer.widgets.video_block_audio import AudioControl from gridplayer.widgets.video_block_bookmarker import Bookmarker from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_guards import ( @@ -209,19 +192,6 @@ ) -def _file_names(file_paths) -> str: - return ", ".join(file_path.name for file_path in file_paths) - - -def _audio_files_filter() -> str: - extensions = " ".join(f"*.{ext}" for ext in sorted(SUPPORTED_AUDIO_EXT)) - - audio = translate("Dialog - Add external audio", "Audio", "File formats") - every = translate("Dialog - Add external audio", "All", "File formats") - - return f"{audio} ({extensions});;{every} (*)" - - def list_fail_wait_txt(wait_sec: int) -> str: if not wait_sec: return translate("When Video Fails", "At once") @@ -363,10 +333,6 @@ def __init__(self, video_driver, context, **kwargs): self._stream_quality_playing = None self._audio_language_playing = None - # the audio file this load was opened with, which libVLC holds on to - # until the next one whatever track is picked meanwhile - self._attached_audio_file: Path | None = None - self._quality_adapt_timer = QTimer(self) self._quality_adapt_timer.setSingleShot(True) self._quality_adapt_timer.timeout.connect(self._adapt_stream_quality) @@ -401,6 +367,7 @@ def __init__(self, video_driver, context, **kwargs): self.chapters = ChapterControl(self) self.sponsorblock = SponsorBlockControl(self) self.subtitles = SubtitleControl(self) + self.audio = AudioControl(self) # the next, previous or a random file of the folder, to put in this # one's place @@ -483,7 +450,7 @@ def init_video_driver(self) -> VideoFrameVLC: (video_driver.video_ready, self.load_video_finish), (video_driver.picture_shown, self._picture_shown), (video_driver.view_followed, self.view.remember_followed), - (video_driver.tracks_changed, self.tracks_changed), + (video_driver.tracks_changed, self.audio.tracks_changed), (video_driver.chapters_changed, self.chapters.update), (video_driver.time_changed, self.time_changed), (video_driver.playback_status_changed, self.playback_status_changed), @@ -1656,612 +1623,12 @@ def video_tracks(self): return {} return self.video_driver.video_tracks - @property - def audio_tracks(self): - if self.video_driver is None or not self.is_video_initialized: - return {} - return self.video_driver.audio_tracks - - @property - def audio_devices(self): - """Every way out of the machine the process playing this can reach. - - Nothing until it has loaded: the list is read where VLC lives, - and comes back with the media. - """ - - if self.video_driver is None or not self.is_video_initialized: - return () - - return self.video_driver.audio_devices - - def set_audio_track(self, track_id): - self._log.debug( - f"Set audio track {track_id}," - f" initialized={self.is_video_initialized}," - f" video track off={self._is_video_track_off}" - ) - - if track_id == DISABLED_TRACK and self._is_video_track_off: - self._ctx.commands.warning( - translate("Warning", "Cannot disable both video & audio tracks") - ) - return - - # a track chosen by hand outranks the languages asked for, and has - # to outlive the reload that would otherwise resolve them again - self.video_params.audio_selection = self._audio_selection_for(track_id) - - # a video part way through a reload has no tracks to switch between - # yet, and will settle on this one once it does - if self.is_video_initialized: - self.video_driver.set_audio_track(track_id) - - def _audio_selection_for(self, track_id): - """How to remember this track, by the steadiest name it answers to. - - A file of its own keeps its name whatever the tracks do, a language - outlives a source that renumbers them, and an id is what is left - where neither says anything. - """ - - if track_id == DISABLED_TRACK: - return AudioDisabled() - - external_file = self.external_audio_tracks.get(track_id) - - if external_file is not None: - return AudioExternal( - file=external_file, - track=self.external_audio_track_ids.index(track_id), - ) - - language = self._track_language_key(track_id) - - if language is not None: - return AudioLanguage(tag=language) - - return AudioTrackId(id=track_id) - - def set_audio_device(self, device) -> None: - """Send this video's sound another way out of the machine. - - Remembered on the video rather than anywhere shared, which is the - whole point: telling one video's sound from another's is what this - is for, and an id names a jack on this machine and nothing at all - on the next; see models/audio_device.py - """ - - self._log.debug(f"Set audio device {device}") - - self.video_params.audio_device = device - - # a video part way through a load is told when it gets there, by - # whatever opens it; see PlayerBase._apply_wanted_audio_device - if self.is_video_initialized: - self.video_driver.set_audio_device(device) - - def restore_audio_selection(self, selection) -> None: - """Put a remembered choice of sound back in force, as a snapshot does.""" - - self.video_params.audio_selection = selection - - self._apply_wanted_audio_track() - - @property - def _is_video_track_off(self) -> bool: - """Whether there would be nothing left to watch without the sound. - - Only an id of -1 says the picture was switched off. None says the - player had nothing to report when it was last asked, which is not - the same thing and must not cost the sound. - """ - - if self.video_driver is None or not self.is_video_initialized: - # part way through a load, where the player settles this itself - return False - - return self.video_driver.cur_video_track_id == DISABLED_TRACK - - def _track_language_key(self, track_id) -> str | None: - """The language to remember this track by, where one will do. - - An id is only as good as the media it was read from, and a stream - is demuxed afresh on every reload. A language outlives that, but - only while it picks out one track rather than several. - """ - - track = self.audio_tracks.get(track_id) - - if track is None or not track.language: - return None - - namesakes = [ - other - for other in self.audio_tracks.values() - if normalize(other.language) == normalize(track.language) - ] - - return track.language if len(namesakes) == 1 else None - - @property - def audio_track_playing(self) -> int | None: - """Which track the sound is coming from, whatever was asked for. - - What was asked for can be a preference rather than a track, and - then this is the only place the answer it came to is written down. - """ - - if self.video_driver is None or not self.is_video_initialized: - return None - - return self.video_driver.cur_audio_track_id - @property def audio_language_playing(self) -> str | None: """Which of the languages on offer the rung on screen was loaded for.""" return self._audio_language_playing - @property - def preferred_audio_track_id(self) -> int | None: - """The track this file would open with, were nothing picked by hand.""" - - return pick_track(self.video_params.audio_languages, self.audio_tracks) - - def apply_audio_default(self): - """Leave the sound to the video's own file, preference and all. - - A dubbed video's own is the language it was made in, which may be - another one than the dub on screen. - """ - - self.video_params.audio_selection = AudioDefault() - - self._follow_audio_language() - - def apply_audio_preference(self): - """Follow the preferred languages again, whichever way this video dubs.""" - - self.video_params.audio_selection = AudioPreferred() - - self._follow_audio_language() - - def _apply_wanted_audio_track(self): - """Put the settings into effect on the video that is already playing.""" - - if self.video_driver is None or not self.is_video_initialized: - # part way through a reload, which will settle this on its own - return - - track_id = self._wanted_audio_track_id() - - if track_id is None: - if self.video_driver.cur_audio_track_id not in NO_TRACK: - # something is playing already and nothing was asked for by - # name, so there is nothing here worth moving off - return - - # a "disable" is still in force and the preference has no - # opinion, so any track at all beats going on in silence - track_id = next(iter(self.audio_tracks), None) - - if track_id is None: - return - - self.video_driver.set_audio_track(track_id) - - def _wanted_audio_track_id(self) -> int | None: - """Which track this video's settings call for, its own files included. - - Only the block knows which track came out of which file, so a pick - of one is answered here; everything else the video can answer for - itself. - """ - - selection = self.video_params.audio_selection - - if isinstance(selection, AudioDefault): - return self.default_audio_track_id - - if isinstance(selection, AudioExternal): - if selection.file == self.attached_audio_file: - picked = track_of_file(selection, self.external_audio_track_ids) - - if picked is not None: - return picked - - return wanted_audio_track_id(self.video_params, self.audio_tracks) - - @only_initialized - def audio_languages_dialog(self): - """Edit the languages this video would rather be heard in.""" - - languages = QCustomTextInput.get_text( - parent=self.parent(), - title=translate( - "Dialog - Set preferred audio languages", - "Preferred audio languages", - "Header", - ), - initial_value=self.video_params.audio_languages, - placeholder=translate("Dialog - Set preferred audio languages", "en, ja"), - ) - - self.set_audio_languages(languages) - - def set_audio_languages(self, languages: str): - if languages == self.video_params.audio_languages: - return - - self.video_params.audio_languages = languages - - if not isinstance(self.video_params.audio_selection, AudioPreferred): - return - - # the preference is what is being followed, so following it again - # is the whole point of having changed it - self.apply_audio_preference() - - def get_audio_languages(self) -> str: - return self.video_params.audio_languages or translate("Audio Languages", "any") - - @property - def discovered_audio_files(self) -> list[Path]: - """Audio files named after this video that nobody has picked yet. - - Finding them is a directory listing and nothing else. A file here - has not been opened and costs nothing until it is chosen, which is - what makes it safe to look on every video in the grid. - """ - - if ( - not self.is_local_file - or not self.video_params.is_external_audio_autodiscover - ): - return [] - - attached = set(self.video_params.external_audio) - - return [ - found - for found in discover_audio_files(self.video_params.uri) - if found not in attached - ] - - @property - def external_audio_track_ids(self) -> tuple[int, ...]: - """The tracks that came out of a file, as the player saw them arrive. - - More than one where the file holds more than one, which is theirs - to tell apart: with a single file attached, every track past the - video's own came out of it. - """ - - if self.video_driver is None or not self.is_video_initialized: - return () - - return self.video_driver.external_audio_ids - - @property - def default_audio_track_id(self) -> int | None: - """The track the video's own file puts forward. - - What libVLC opened on, as long as that was the video's own doing. - A file attached at load takes the sound for itself as soon as its - stream arrives, and what libVLC opened on is then that file -- no - answer at all to what the video would have played alone. The first - of its own tracks is as close as this gets to one. - """ - - if self.video_driver is None or not self.is_video_initialized: - return None - - from_file = set(self.external_audio_track_ids) - - own_tracks = [ - track_id for track_id in self.audio_tracks if track_id not in from_file - ] - - opened_on = self.video_driver.default_audio_track_id - - if opened_on in own_tracks: - return opened_on - - return own_tracks[0] if own_tracks else None - - @property - def attached_audio_file(self) -> Path | None: - """The external audio libVLC was really handed, if any. - - Not the same question as which sound was chosen: libVLC never takes - a file back, so one handed over is still there after the viewer - switches to a track of the video's own, and its track is still in - the list wearing its name. Only a fresh load changes this. - - One at a time, since libVLC never says which stream came from which - file and two of them would leave the pair to be told apart by - guesswork. - """ - - return self._attached_audio_file - - @property - def _audio_file_to_attach(self) -> Path | None: - """The file the next load is to open this video with. - - A file that is no longer where the playlist left it -- renamed, or - on a drive nobody plugged in -- is no use to libVLC, though it - keeps its place in the list in case whatever moved it moves it back. - """ - - selection = self.video_params.audio_selection - - if not isinstance(selection, AudioExternal): - return None - - if not selection.file.is_absolute() or not selection.file.is_file(): - return None - - return selection.file - - @property - def offered_audio_files(self) -> list[Path]: - """Files this video can be played with, other than the one it is on.""" - - attached = self.attached_audio_file - - known = [ - file_path - for file_path in self.video_params.external_audio - # one that is not there cannot be played, and a row that does - # nothing is worse than no row - if file_path != attached and file_path.is_file() - ] - - return known + self.discovered_audio_files - - @property - def external_audio_tracks(self) -> dict[int, Path]: - """Which file each external track came out of, where there is one.""" - - file_path = self.attached_audio_file - - if file_path is None: - return {} - - return dict.fromkeys(self.external_audio_track_ids, file_path) - - @property - def selected_audio_slave_uri(self) -> str | None: - """The file the video is to be played with, for the player to find.""" - - wanted = self._audio_file_to_attach - - return None if wanted is None else wanted.as_uri() - - @only_local_file - def add_external_audio_dialog(self) -> None: - """Pick audio files to play this video with.""" - - file_names, _ = QFileDialog.getOpenFileNames( - self.parent(), - translate("Dialog - Add external audio", "Add External Audio", "Header"), - str(self.video_params.uri.parent), - _audio_files_filter(), - ) - - self.add_external_audio([Path(file_name) for file_name in file_names]) - - def add_external_audio(self, file_paths) -> None: - """Play these audio files with the video, starting on the first. - - One picked while the video is up goes on where it stands: VLC takes - a file at any time, so nothing has to be loaded again for it. - """ - - new_files = [ - file_path - for file_path in file_paths - if file_path.is_absolute() - and file_path not in self.video_params.external_audio - ] - - if not new_files: - return - - self._warn_about_awkward_audio(new_files) - - self.video_params.external_audio = [ - *self.video_params.external_audio, - *new_files, - ] - - # sound asked for by name outranks the languages asked for, the same - # way picking a track by hand does - self.video_params.audio_selection = AudioExternal(file=new_files[0]) - - self._put_external_audio_into_effect() - - def _forget_audio_file_that_is_gone(self) -> None: - """Let go of a pick whose file is not where the playlist left it. - - A choice naming nothing on the disk is one the menu cannot show as - taken, and a video that looks as though nothing at all is chosen - reads worse than one back on the sound it would otherwise open on. - The file keeps its place in the list, in case whatever moved it - moves it back. - """ - - selection = self.video_params.audio_selection - - if not isinstance(selection, AudioExternal): - return - - if self._audio_file_to_attach is not None: - return - - self._log.debug(f"{selection.file.name} is not there any more") - - self.video_params.audio_selection = default_audio_selection() - - def _put_external_audio_into_effect(self) -> None: - """Play the file that was picked, the cheapest way that is certain. - - libVLC takes a file at any time but never gives one back, and it - never says which stream came from which file. A video that has one - already is therefore loaded again rather than handed a second, - which costs a moment and leaves no room for a mix-up. - """ - - if not self.is_video_initialized: - # part way through a load, which will open it with the video - return - - if self._attached_audio_file is not None: - self._log.debug("A file is on already, loading the video again") - - self.reload() - return - - wanted = self._audio_file_to_attach - - if wanted is not None: - self._attached_audio_file = wanted - - self.video_driver.add_audio_slave(wanted.as_uri()) - - def _warn_about_awkward_audio(self, file_paths) -> None: - """Say so where libVLC will not make a sound of what was picked. - - The files are taken anyway: this is what one build of VLC does with - them, and being told beats a track that sits there silently. - """ - - unplayable = unplayable_as_audio_slave(file_paths) - silenced = silenced_by_seeking(file_paths) - - warnings = [] - - if unplayable: - warnings.append( - translate( - "Warning", - "VLC cannot play {FILES} alongside a video.", - ).format(FILES=_file_names(unplayable)) - ) - - if silenced: - warnings.append( - translate( - "Warning", - "{FILES} will fall silent once the video is seeked.", - ).format(FILES=_file_names(silenced)) - ) - - if not warnings: - return - - warnings.append( - translate("Warning", "Convert to MP3, M4A, FLAC or AC3 to play it.") - ) - - self._ctx.commands.warning("\n\n".join(warnings)) - - def play_external_audio(self, file_name: str) -> None: - """Hear this file, whether it has been attached yet or not.""" - - file_path = Path(file_name) - - if file_path == self.attached_audio_file: - # it is on already, and may only have been switched away from - if self.external_audio_track_ids: - self.set_audio_track(self.external_audio_track_ids[0]) - - return - - if file_path not in self.video_params.external_audio: - self.add_external_audio([file_path]) - return - - self.video_params.audio_selection = AudioExternal(file=file_path) - - self._put_external_audio_into_effect() - - def remove_external_audio(self) -> None: - """Play the video on its own sound again. - - libVLC has no way to take back a file it has been handed, so the - video is loaded once more, this time without them. - """ - - if not self.video_params.external_audio: - return - - self._drop_external_audio() - - self.reload() - - def _drop_external_audio(self) -> None: - self.video_params.external_audio = [] - - # the track that was playing leaves with the file it came from, so - # the video goes back to whatever it would have opened on - if isinstance(self.video_params.audio_selection, AudioExternal): - self.video_params.audio_selection = default_audio_selection() - - def set_external_audio_autodiscover(self, is_on: bool) -> None: - """Whether to offer the audio files kept beside this video.""" - - self.video_params.is_external_audio_autodiscover = is_on - - def tracks_changed(self) -> None: - """Take note of a track that appeared after the video was loaded. - - What is playing is the player's business, not the choice's: writing - it back here is what used to turn "go by my languages" into "play - track three" behind the viewer's back. - """ - - self.is_audio_present_change.emit(bool(self.audio_tracks)) - - if self._is_external_audio_short: - # libVLC took the file and brought nothing back, which some - # formats do when handed to a video that is already playing. - # Opening them with the video is the way that always works. - self._log.debug("External audio brought no track, loading it again") - - self.reload() - - @property - def _is_external_audio_short(self) -> bool: - """Whether the file that was handed over has no track to show for it.""" - - return ( - self.attached_audio_file is not None and not self.external_audio_track_ids - ) - - def _adopt_audio_if_silent(self) -> None: - """Give a video with no sound of its own the one file found beside it. - - A silent video next to an audio file named after it is the one case - where doing nothing reads as a fault rather than as a choice. Where - several were found, which of them to play is nobody's guess but the - viewer's, and they are left in the menu to be picked from. - """ - - if self.audio_tracks or self.video_params.external_audio: - return - - found = self.discovered_audio_files - - if len(found) != 1: - return - - self._log.debug(f"Adopting {found[0].name} as the sound of a silent video") - - self.add_external_audio(found) - @only_initialized def set_video_track(self, track_id): # only -1 says the sound was switched off; None says it has not been @@ -2294,7 +1661,7 @@ def set_video_decoding_stopped(self, is_stopped: bool) -> None: if self._is_video_stopped_in_tray or self.video_params.is_paused: return - if not self.audio_tracks or isinstance( + if not self.audio.tracks or isinstance( self.video_params.audio_selection, AudioDisabled ): return @@ -2326,52 +1693,6 @@ def set_video_decoding_stopped(self, is_stopped: bool) -> None: if not self.is_live and self.time: self.video_driver.set_time(self.time) - @only_initialized - def set_audio_channel_mode(self, mode): - if not self.audio_tracks: - return - - self.video_params.audio_channel_mode = mode - self.video_driver.set_audio_channel_mode(mode) - - @only_initialized - def set_audio_delay(self, delay_ms, is_silent=False): - if not self.audio_tracks: - return - - delay_ms = min(max(delay_ms, MIN_AUDIO_DELAY_MS), MAX_AUDIO_DELAY_MS) - - self.video_params.audio_delay_ms = delay_ms - self.video_driver.set_audio_delay(delay_ms) - - if not is_silent: - self.info_change.emit(f"Audio delay: {audio_delay_txt(delay_ms)}") - - @only_initialized - def audio_delay_increase(self): - self.set_audio_delay(self.video_params.audio_delay_ms + AUDIO_DELAY_STEP_MS) - - @only_initialized - def audio_delay_decrease(self): - self.set_audio_delay(self.video_params.audio_delay_ms - AUDIO_DELAY_STEP_MS) - - @only_initialized - def audio_delay_reset(self): - self.set_audio_delay(0) - - @only_initialized - def audio_delay_dialog(self): - if not self.audio_tracks: - return - - dialog = SetAudioDelayDialog.for_video_block(self, parent=self.parent()) - - dialog.exec_() - - @only_initialized - def get_audio_delay(self): - return audio_delay_txt(self.video_params.audio_delay_ms) - @property def video_id(self) -> str: return str(self.video_params.id) @@ -2876,10 +2197,10 @@ def apply_snapshot(self, snapshot: Video): return self.set_video_track(snapshot.video_track_id) - self.restore_audio_selection(snapshot.audio_selection) + self.audio.restore_selection(snapshot.audio_selection) - self.set_audio_channel_mode(snapshot.audio_channel_mode) - self.set_audio_delay(snapshot.audio_delay_ms, is_silent=True) + self.audio.set_channel_mode(snapshot.audio_channel_mode) + self.audio.set_delay(snapshot.audio_delay_ms, is_silent=True) self.subtitles.restore_selection(snapshot.subtitle_selection) self.subtitles.set_delay(snapshot.subtitle_delay_ms, is_silent=True) @@ -3064,10 +2385,7 @@ def _start_load(self): LoadStage.VIDEO, ) - self._forget_audio_file_that_is_gone() - - self._attached_audio_file = self._audio_file_to_attach - + self.audio.take_file_for_load() self.subtitles.take_files_for_load() # the video may not be the one that was here, whatever else stays; @@ -3094,7 +2412,7 @@ def _start_load(self): is_audio_only=False, size=self.size_tuple, video=self.video_params, - selected_audio_slave=self.selected_audio_slave_uri, + selected_audio_slave=self.audio.selected_slave_uri, selected_subtitle_slaves=self.subtitles.selected_slave_uris, ) ) @@ -3224,9 +2542,9 @@ def set_audio_language(self, language: str | None): f" playing {self._audio_language_playing}" ) - self._follow_audio_language() + self.follow_audio_language() - def _follow_audio_language(self): + def follow_audio_language(self): """Put the sound asked for into effect, in whichever dub it calls for. A dubbed stream is served one language at a time, so another one @@ -3238,7 +2556,7 @@ def _follow_audio_language(self): # the rung on screen already carries it, so there is nothing to # reload -- but the sound may have been switched off since it # was loaded, and asking for a language means wanting it - self._apply_wanted_audio_track() + self.audio.apply_wanted_track() return # the pane is about to be reloaded anyway, and on a ladder that no @@ -3458,8 +2776,8 @@ def load_video_finish(self): self._learn_item_length() - self.set_audio_channel_mode(self.video_params.audio_channel_mode) - self.set_audio_delay(self.video_params.audio_delay_ms, is_silent=True) + self.audio.set_channel_mode(self.video_params.audio_channel_mode) + self.audio.set_delay(self.video_params.audio_delay_ms, is_silent=True) self.set_volume(self.video_params.volume) self.set_muted(self.video_params.is_muted) @@ -3480,9 +2798,9 @@ def load_video_finish(self): self.chapters.update() self.sponsorblock.update() - self.is_audio_present_change.emit(bool(self.audio_tracks)) + self.is_audio_present_change.emit(bool(self.audio.tracks)) - self._adopt_audio_if_silent() + self.audio.adopt_if_silent() # over the video until there is a picture of it to show instead self._is_status_kept_for_picture = self.video_driver.is_picture_pending @@ -4596,7 +3914,7 @@ def _forget_files_of_previous_video(self): again where they are looked for by themselves. """ - self._drop_external_audio() + self.audio.drop_external() self.subtitles.drop_external() def rename(self): diff --git a/gridplayer/widgets/video_block_audio.py b/gridplayer/widgets/video_block_audio.py new file mode 100644 index 00000000..cd0bb209 --- /dev/null +++ b/gridplayer/widgets/video_block_audio.py @@ -0,0 +1,721 @@ +"""The sound of a video block: its tracks, where they go out of the machine, +the languages it would rather be heard in, and audio files kept beside it. + +A dubbed stream that changes its language by loading another rung is the +block's to follow (see VideoBlock.follow_audio_language); everything that +plays within what was loaded is here. +""" + +from pathlib import Path + +from PyQt5.QtWidgets import QFileDialog + +from gridplayer.dialogs.audio_delay import SetAudioDelayDialog, audio_delay_txt +from gridplayer.dialogs.input_dialog import QCustomTextInput +from gridplayer.models.audio_selection import ( + AudioDefault, + AudioDisabled, + AudioExternal, + AudioLanguage, + AudioPreferred, + AudioTrackId, + track_of_file, +) +from gridplayer.models.video import default_audio_selection +from gridplayer.params.extensions import SUPPORTED_AUDIO_EXT +from gridplayer.params.static import ( + AUDIO_DELAY_STEP_MS, + MAX_AUDIO_DELAY_MS, + MIN_AUDIO_DELAY_MS, +) +from gridplayer.utils.external_audio import ( + discover_audio_files, + silenced_by_seeking, + unplayable_as_audio_slave, +) +from gridplayer.utils.qt import translate +from gridplayer.utils.track_language import normalize, pick_track +from gridplayer.vlc_player.static import DISABLED_TRACK, NO_TRACK, wanted_audio_track_id +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_local_file, +) + + +def _file_names(file_paths) -> str: + return ", ".join(file_path.name for file_path in file_paths) + + +def _files_filter() -> str: + extensions = " ".join(f"*.{ext}" for ext in sorted(SUPPORTED_AUDIO_EXT)) + + audio = translate("Dialog - Add external audio", "Audio", "File formats") + every = translate("Dialog - Add external audio", "All", "File formats") + + return f"{audio} ({extensions});;{every} (*)" + + +class AudioControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # the audio file this load was opened with, which libVLC holds on to + # until the next one whatever track is picked meanwhile + self._attached_file: Path | None = None + + def take_file_for_load(self): + """The file the load about to start opens the video with: the one + picked, if it is still there, a pick of one gone let go of.""" + + self._forget_file_that_is_gone() + + self._attached_file = self._file_to_attach + + @property + def tracks(self): + if self._block.video_driver is None or not self._block.is_video_initialized: + return {} + return self._block.video_driver.audio_tracks + + @property + def devices(self): + """Every way out of the machine the process playing this can reach. + + Nothing until it has loaded: the list is read where VLC lives, + and comes back with the media. + """ + + if self._block.video_driver is None or not self._block.is_video_initialized: + return () + + return self._block.video_driver.audio_devices + + def set_track(self, track_id): + self._block._log.debug( + f"Set audio track {track_id}," + f" initialized={self._block.is_video_initialized}," + f" video track off={self._is_video_track_off}" + ) + + if track_id == DISABLED_TRACK and self._is_video_track_off: + self._block._ctx.commands.warning( + translate("Warning", "Cannot disable both video & audio tracks") + ) + return + + # a track chosen by hand outranks the languages asked for, and has + # to outlive the reload that would otherwise resolve them again + self._block.video_params.audio_selection = self._selection_for(track_id) + + # a video part way through a reload has no tracks to switch between + # yet, and will settle on this one once it does + if self._block.is_video_initialized: + self._block.video_driver.set_audio_track(track_id) + + def _selection_for(self, track_id): + """How to remember this track, by the steadiest name it answers to. + + A file of its own keeps its name whatever the tracks do, a language + outlives a source that renumbers them, and an id is what is left + where neither says anything. + """ + + if track_id == DISABLED_TRACK: + return AudioDisabled() + + external_file = self.external_tracks.get(track_id) + + if external_file is not None: + return AudioExternal( + file=external_file, + track=self.external_track_ids.index(track_id), + ) + + language = self._language_key(track_id) + + if language is not None: + return AudioLanguage(tag=language) + + return AudioTrackId(id=track_id) + + def set_device(self, device) -> None: + """Send this video's sound another way out of the machine. + + Remembered on the video rather than anywhere shared, which is the + whole point: telling one video's sound from another's is what this + is for, and an id names a jack on this machine and nothing at all + on the next; see models/audio_device.py + """ + + self._block._log.debug(f"Set audio device {device}") + + self._block.video_params.audio_device = device + + # a video part way through a load is told when it gets there, by + # whatever opens it; see PlayerBase._apply_wanted_audio_device + if self._block.is_video_initialized: + self._block.video_driver.set_audio_device(device) + + def restore_selection(self, selection) -> None: + """Put a remembered choice of sound back in force, as a snapshot does.""" + + self._block.video_params.audio_selection = selection + + self.apply_wanted_track() + + @property + def _is_video_track_off(self) -> bool: + """Whether there would be nothing left to watch without the sound. + + Only an id of -1 says the picture was switched off. None says the + player had nothing to report when it was last asked, which is not + the same thing and must not cost the sound. + """ + + if self._block.video_driver is None or not self._block.is_video_initialized: + # part way through a load, where the player settles this itself + return False + + return self._block.video_driver.cur_video_track_id == DISABLED_TRACK + + def _language_key(self, track_id) -> str | None: + """The language to remember this track by, where one will do. + + An id is only as good as the media it was read from, and a stream + is demuxed afresh on every reload. A language outlives that, but + only while it picks out one track rather than several. + """ + + track = self.tracks.get(track_id) + + if track is None or not track.language: + return None + + namesakes = [ + other + for other in self.tracks.values() + if normalize(other.language) == normalize(track.language) + ] + + return track.language if len(namesakes) == 1 else None + + @property + def track_playing(self) -> int | None: + """Which track the sound is coming from, whatever was asked for. + + What was asked for can be a preference rather than a track, and + then this is the only place the answer it came to is written down. + """ + + if self._block.video_driver is None or not self._block.is_video_initialized: + return None + + return self._block.video_driver.cur_audio_track_id + + @property + def preferred_track_id(self) -> int | None: + """The track this file would open with, were nothing picked by hand.""" + + return pick_track(self._block.video_params.audio_languages, self.tracks) + + def apply_default(self): + """Leave the sound to the video's own file, preference and all. + + A dubbed video's own is the language it was made in, which may be + another one than the dub on screen. + """ + + self._block.video_params.audio_selection = AudioDefault() + + self._block.follow_audio_language() + + def apply_preference(self): + """Follow the preferred languages again, whichever way this video dubs.""" + + self._block.video_params.audio_selection = AudioPreferred() + + self._block.follow_audio_language() + + def apply_wanted_track(self): + """Put the settings into effect on the video that is already playing.""" + + if self._block.video_driver is None or not self._block.is_video_initialized: + # part way through a reload, which will settle this on its own + return + + track_id = self._wanted_track_id() + + if track_id is None: + if self._block.video_driver.cur_audio_track_id not in NO_TRACK: + # something is playing already and nothing was asked for by + # name, so there is nothing here worth moving off + return + + # a "disable" is still in force and the preference has no + # opinion, so any track at all beats going on in silence + track_id = next(iter(self.tracks), None) + + if track_id is None: + return + + self._block.video_driver.set_audio_track(track_id) + + def _wanted_track_id(self) -> int | None: + """Which track this video's settings call for, its own files included. + + Only the block knows which track came out of which file, so a pick + of one is answered here; everything else the video can answer for + itself. + """ + + selection = self._block.video_params.audio_selection + + if isinstance(selection, AudioDefault): + return self.default_track_id + + if isinstance(selection, AudioExternal): + if selection.file == self.attached_file: + picked = track_of_file(selection, self.external_track_ids) + + if picked is not None: + return picked + + return wanted_audio_track_id(self._block.video_params, self.tracks) + + @only_initialized + def languages_dialog(self): + """Edit the languages this video would rather be heard in.""" + + languages = QCustomTextInput.get_text( + parent=self._block.parent(), + title=translate( + "Dialog - Set preferred audio languages", + "Preferred audio languages", + "Header", + ), + initial_value=self._block.video_params.audio_languages, + placeholder=translate("Dialog - Set preferred audio languages", "en, ja"), + ) + + self.set_languages(languages) + + def set_languages(self, languages: str): + if languages == self._block.video_params.audio_languages: + return + + self._block.video_params.audio_languages = languages + + if not isinstance(self._block.video_params.audio_selection, AudioPreferred): + return + + # the preference is what is being followed, so following it again + # is the whole point of having changed it + self.apply_preference() + + def get_languages(self) -> str: + return self._block.video_params.audio_languages or translate( + "Audio Languages", "any" + ) + + @property + def discovered_files(self) -> list[Path]: + """Audio files named after this video that nobody has picked yet. + + Finding them is a directory listing and nothing else. A file here + has not been opened and costs nothing until it is chosen, which is + what makes it safe to look on every video in the grid. + """ + + if ( + not self._block.is_local_file + or not self._block.video_params.is_external_audio_autodiscover + ): + return [] + + attached = set(self._block.video_params.external_audio) + + return [ + found + for found in discover_audio_files(self._block.video_params.uri) + if found not in attached + ] + + @property + def external_track_ids(self) -> tuple[int, ...]: + """The tracks that came out of a file, as the player saw them arrive. + + More than one where the file holds more than one, which is theirs + to tell apart: with a single file attached, every track past the + video's own came out of it. + """ + + if self._block.video_driver is None or not self._block.is_video_initialized: + return () + + return self._block.video_driver.external_audio_ids + + @property + def default_track_id(self) -> int | None: + """The track the video's own file puts forward. + + What libVLC opened on, as long as that was the video's own doing. + A file attached at load takes the sound for itself as soon as its + stream arrives, and what libVLC opened on is then that file -- no + answer at all to what the video would have played alone. The first + of its own tracks is as close as this gets to one. + """ + + if self._block.video_driver is None or not self._block.is_video_initialized: + return None + + from_file = set(self.external_track_ids) + + own_tracks = [track_id for track_id in self.tracks if track_id not in from_file] + + opened_on = self._block.video_driver.default_audio_track_id + + if opened_on in own_tracks: + return opened_on + + return own_tracks[0] if own_tracks else None + + @property + def attached_file(self) -> Path | None: + """The external audio libVLC was really handed, if any. + + Not the same question as which sound was chosen: libVLC never takes + a file back, so one handed over is still there after the viewer + switches to a track of the video's own, and its track is still in + the list wearing its name. Only a fresh load changes this. + + One at a time, since libVLC never says which stream came from which + file and two of them would leave the pair to be told apart by + guesswork. + """ + + return self._attached_file + + @property + def _file_to_attach(self) -> Path | None: + """The file the next load is to open this video with. + + A file that is no longer where the playlist left it -- renamed, or + on a drive nobody plugged in -- is no use to libVLC, though it + keeps its place in the list in case whatever moved it moves it back. + """ + + selection = self._block.video_params.audio_selection + + if not isinstance(selection, AudioExternal): + return None + + if not selection.file.is_absolute() or not selection.file.is_file(): + return None + + return selection.file + + @property + def offered_files(self) -> list[Path]: + """Files this video can be played with, other than the one it is on.""" + + attached = self.attached_file + + known = [ + file_path + for file_path in self._block.video_params.external_audio + # one that is not there cannot be played, and a row that does + # nothing is worse than no row + if file_path != attached and file_path.is_file() + ] + + return known + self.discovered_files + + @property + def external_tracks(self) -> dict[int, Path]: + """Which file each external track came out of, where there is one.""" + + file_path = self.attached_file + + if file_path is None: + return {} + + return dict.fromkeys(self.external_track_ids, file_path) + + @property + def selected_slave_uri(self) -> str | None: + """The file the video is to be played with, for the player to find.""" + + wanted = self._file_to_attach + + return None if wanted is None else wanted.as_uri() + + @only_local_file + def add_external_dialog(self) -> None: + """Pick audio files to play this video with.""" + + file_names, _ = QFileDialog.getOpenFileNames( + self._block.parent(), + translate("Dialog - Add external audio", "Add External Audio", "Header"), + str(self._block.video_params.uri.parent), + _files_filter(), + ) + + self.add_external([Path(file_name) for file_name in file_names]) + + def add_external(self, file_paths) -> None: + """Play these audio files with the video, starting on the first. + + One picked while the video is up goes on where it stands: VLC takes + a file at any time, so nothing has to be loaded again for it. + """ + + new_files = [ + file_path + for file_path in file_paths + if file_path.is_absolute() + and file_path not in self._block.video_params.external_audio + ] + + if not new_files: + return + + self._warn_about_awkward(new_files) + + self._block.video_params.external_audio = [ + *self._block.video_params.external_audio, + *new_files, + ] + + # sound asked for by name outranks the languages asked for, the same + # way picking a track by hand does + self._block.video_params.audio_selection = AudioExternal(file=new_files[0]) + + self._put_external_into_effect() + + def _forget_file_that_is_gone(self) -> None: + """Let go of a pick whose file is not where the playlist left it. + + A choice naming nothing on the disk is one the menu cannot show as + taken, and a video that looks as though nothing at all is chosen + reads worse than one back on the sound it would otherwise open on. + The file keeps its place in the list, in case whatever moved it + moves it back. + """ + + selection = self._block.video_params.audio_selection + + if not isinstance(selection, AudioExternal): + return + + if self._file_to_attach is not None: + return + + self._block._log.debug(f"{selection.file.name} is not there any more") + + self._block.video_params.audio_selection = default_audio_selection() + + def _put_external_into_effect(self) -> None: + """Play the file that was picked, the cheapest way that is certain. + + libVLC takes a file at any time but never gives one back, and it + never says which stream came from which file. A video that has one + already is therefore loaded again rather than handed a second, + which costs a moment and leaves no room for a mix-up. + """ + + if not self._block.is_video_initialized: + # part way through a load, which will open it with the video + return + + if self._attached_file is not None: + self._block._log.debug("A file is on already, loading the video again") + + self._block.reload() + return + + wanted = self._file_to_attach + + if wanted is not None: + self._attached_file = wanted + + self._block.video_driver.add_audio_slave(wanted.as_uri()) + + def _warn_about_awkward(self, file_paths) -> None: + """Say so where libVLC will not make a sound of what was picked. + + The files are taken anyway: this is what one build of VLC does with + them, and being told beats a track that sits there silently. + """ + + unplayable = unplayable_as_audio_slave(file_paths) + silenced = silenced_by_seeking(file_paths) + + warnings = [] + + if unplayable: + warnings.append( + translate( + "Warning", + "VLC cannot play {FILES} alongside a video.", + ).format(FILES=_file_names(unplayable)) + ) + + if silenced: + warnings.append( + translate( + "Warning", + "{FILES} will fall silent once the video is seeked.", + ).format(FILES=_file_names(silenced)) + ) + + if not warnings: + return + + warnings.append( + translate("Warning", "Convert to MP3, M4A, FLAC or AC3 to play it.") + ) + + self._block._ctx.commands.warning("\n\n".join(warnings)) + + def play_external(self, file_name: str) -> None: + """Hear this file, whether it has been attached yet or not.""" + + file_path = Path(file_name) + + if file_path == self.attached_file: + # it is on already, and may only have been switched away from + if self.external_track_ids: + self.set_track(self.external_track_ids[0]) + + return + + if file_path not in self._block.video_params.external_audio: + self.add_external([file_path]) + return + + self._block.video_params.audio_selection = AudioExternal(file=file_path) + + self._put_external_into_effect() + + def remove_external(self) -> None: + """Play the video on its own sound again. + + libVLC has no way to take back a file it has been handed, so the + video is loaded once more, this time without them. + """ + + if not self._block.video_params.external_audio: + return + + self.drop_external() + + self._block.reload() + + def drop_external(self) -> None: + self._block.video_params.external_audio = [] + + # the track that was playing leaves with the file it came from, so + # the video goes back to whatever it would have opened on + if isinstance(self._block.video_params.audio_selection, AudioExternal): + self._block.video_params.audio_selection = default_audio_selection() + + def set_external_autodiscover(self, is_on: bool) -> None: + """Whether to offer the audio files kept beside this video.""" + + self._block.video_params.is_external_audio_autodiscover = is_on + + def tracks_changed(self) -> None: + """Take note of a track that appeared after the video was loaded. + + What is playing is the player's business, not the choice's: writing + it back here is what used to turn "go by my languages" into "play + track three" behind the viewer's back. + """ + + self._block.is_audio_present_change.emit(bool(self.tracks)) + + if self._is_external_short: + # libVLC took the file and brought nothing back, which some + # formats do when handed to a video that is already playing. + # Opening them with the video is the way that always works. + self._block._log.debug("External audio brought no track, loading it again") + + self._block.reload() + + @property + def _is_external_short(self) -> bool: + """Whether the file that was handed over has no track to show for it.""" + + return self.attached_file is not None and not self.external_track_ids + + def adopt_if_silent(self) -> None: + """Give a video with no sound of its own the one file found beside it. + + A silent video next to an audio file named after it is the one case + where doing nothing reads as a fault rather than as a choice. Where + several were found, which of them to play is nobody's guess but the + viewer's, and they are left in the menu to be picked from. + """ + + if self.tracks or self._block.video_params.external_audio: + return + + found = self.discovered_files + + if len(found) != 1: + return + + self._block._log.debug( + f"Adopting {found[0].name} as the sound of a silent video" + ) + + self.add_external(found) + + @only_initialized + def set_channel_mode(self, mode): + if not self.tracks: + return + + self._block.video_params.audio_channel_mode = mode + self._block.video_driver.set_audio_channel_mode(mode) + + @only_initialized + def set_delay(self, delay_ms, is_silent=False): + if not self.tracks: + return + + delay_ms = min(max(delay_ms, MIN_AUDIO_DELAY_MS), MAX_AUDIO_DELAY_MS) + + self._block.video_params.audio_delay_ms = delay_ms + self._block.video_driver.set_audio_delay(delay_ms) + + if not is_silent: + self._block.info_change.emit(f"Audio delay: {audio_delay_txt(delay_ms)}") + + @only_initialized + def delay_increase(self): + self.set_delay(self._block.video_params.audio_delay_ms + AUDIO_DELAY_STEP_MS) + + @only_initialized + def delay_decrease(self): + self.set_delay(self._block.video_params.audio_delay_ms - AUDIO_DELAY_STEP_MS) + + @only_initialized + def delay_reset(self): + self.set_delay(0) + + @only_initialized + def delay_dialog(self): + if not self.tracks: + return + + dialog = SetAudioDelayDialog.for_video_block( + self._block, parent=self._block.parent() + ) + + dialog.exec_() + + @only_initialized + def get_delay(self): + return audio_delay_txt(self._block.video_params.audio_delay_ms) diff --git a/tests/conftest.py b/tests/conftest.py index 01781da2..642ae57e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,7 @@ def _block_parts(): """A VideoBlock's parts, by the name the block keeps each under.""" + from gridplayer.widgets.video_block_audio import AudioControl from gridplayer.widgets.video_block_bookmarker import Bookmarker from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl @@ -59,6 +60,7 @@ def _block_parts(): from gridplayer.widgets.video_block_view import ViewControl return { + "audio": AudioControl, "bookmarker": Bookmarker, "chapters": ChapterControl, "sponsorblock": SponsorBlockControl, diff --git a/tests/test_audio_commands_while_loading.py b/tests/test_audio_commands_while_loading.py index 5dbc2397..c64028dc 100644 --- a/tests/test_audio_commands_while_loading.py +++ b/tests/test_audio_commands_while_loading.py @@ -1,6 +1,7 @@ """Choosing audio while the video that choice reloaded is still loading.""" import logging +from operator import attrgetter from types import SimpleNamespace from unittest.mock import Mock @@ -12,11 +13,11 @@ # video is not playable; dropping it there is what made "Disable Audio" do # nothing after a switch AUDIO_COMMANDS = ( - "set_audio_track", + "audio.set_track", "set_audio_language", - "apply_audio_preference", - "audio_languages_dialog", - "get_audio_languages", + "audio.apply_preference", + "audio.languages_dialog", + "audio.get_languages", ) @@ -43,7 +44,7 @@ def test_audio_choices_survive_a_reload(command): manager.cmd_active(command, -1) - getattr(block, command).assert_called_once_with(-1) + attrgetter(command)(block).assert_called_once_with(-1) @pytest.mark.parametrize("command", AUDIO_COMMANDS) @@ -52,18 +53,18 @@ def test_audio_choices_still_work_on_a_playing_video(command): manager.cmd_active(command, -1) - getattr(block, command).assert_called_once_with(-1) + attrgetter(command)(block).assert_called_once_with(-1) def test_a_command_with_no_business_mid_load_is_still_dropped(): manager, block = _manager(is_playable=False) - manager.cmd_active("set_audio_channel_mode", "stereo") + manager.cmd_active("audio.set_channel_mode", "stereo") - block.set_audio_channel_mode.assert_not_called() + block.audio.set_channel_mode.assert_not_called() def test_nothing_is_called_without_an_active_block(): manager = _Manager(None) - assert manager.cmd_active("set_audio_track", -1) is None + assert manager.cmd_active("audio.set_track", -1) is None diff --git a/tests/test_audio_track_preference.py b/tests/test_audio_track_preference.py index b73290c8..6dfdefa0 100644 --- a/tests/test_audio_track_preference.py +++ b/tests/test_audio_track_preference.py @@ -24,6 +24,7 @@ wanted_audio_track_id, ) from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_audio import AudioControl @pytest.fixture(scope="module", autouse=True) @@ -124,26 +125,21 @@ def test_a_track_picked_by_hand_outranks_the_language_asked_for(mocker): def _block(mocker, languages="", tracks=None): block = mocker.Mock() block._log = logging.getLogger("test") - block.audio_tracks = TRACKS if tracks is None else tracks + block.video_driver.audio_tracks = TRACKS if tracks is None else tracks block.video_params = Video(uri="http://example.com/a.mp4") block.video_params.audio_languages = languages block.video_params.audio_selection = AudioPreferred() block.video_params.video_track_id = 0 - block._track_language_key = partial(VideoBlock._track_language_key, block) - block._audio_selection_for = partial(VideoBlock._audio_selection_for, block) - block._wanted_audio_track_id = partial(VideoBlock._wanted_audio_track_id, block) - block._apply_wanted_audio_track = partial( - VideoBlock._apply_wanted_audio_track, block - ) - # a file with nothing beside it, so every track is one of its own - block.external_audio_tracks = {} + # a file with nothing beside it, so every track is one of its own; and + # a picture, so the sound may be turned off + block.video_driver.external_audio_ids = () + block.video_driver.cur_video_track_id = 0 block.is_video_initialized = True - # there is a picture, so the sound may be turned off - block._is_video_track_off = False # a file is not dubbed by the site, so nothing is served per language block.streams = Streams() block._language_variants = Streams() - block._follow_audio_language = partial(VideoBlock._follow_audio_language, block) + block.follow_audio_language = partial(VideoBlock.follow_audio_language, block) + block.audio = AudioControl(block) return block @@ -151,7 +147,7 @@ def _block(mocker, languages="", tracks=None): def test_picking_a_track_by_hand_records_that_it_was_picked(mocker): block = _block(mocker) - VideoBlock.set_audio_track(block, 1) + block.audio.set_track(1) assert block.video_params.audio_selection == AudioLanguage(tag="jpn") block.video_driver.set_audio_track.assert_called_once_with(1) @@ -162,16 +158,15 @@ def test_disabling_audio_is_a_choice_of_its_own(mocker): block = _block(mocker) - VideoBlock.set_audio_track(block, DISABLED_TRACK) + block.audio.set_track(DISABLED_TRACK) assert block.video_params.audio_selection == AudioDisabled() def test_going_back_to_preferred_applies_the_language_again(mocker): block = _block(mocker, "ja") - block.preferred_audio_track_id = VideoBlock.preferred_audio_track_id.fget(block) - VideoBlock.apply_audio_preference(block) + block.audio.apply_preference() assert block.video_params.audio_selection == AudioPreferred() block.video_driver.set_audio_track.assert_called_once_with(1) @@ -183,7 +178,7 @@ def test_going_back_to_preferred_keeps_the_track_when_nothing_matches(mocker): block = _block(mocker, "de") block.video_driver.cur_audio_track_id = 0 - VideoBlock.apply_audio_preference(block) + block.audio.apply_preference() assert block.video_params.audio_selection == AudioPreferred() block.video_driver.set_audio_track.assert_not_called() @@ -196,7 +191,7 @@ def test_going_back_to_preferred_lifts_a_disable_even_with_no_match(mocker): block.video_params.audio_selection = AudioDisabled() block.video_driver.cur_audio_track_id = DISABLED_TRACK - VideoBlock.apply_audio_preference(block) + block.audio.apply_preference() assert block.video_params.audio_selection == AudioPreferred() block.video_driver.set_audio_track.assert_called_once_with(0) @@ -215,7 +210,7 @@ def test_going_back_to_preferred_refetches_a_dubbed_stream(mocker): block.audio_language = "en" block._audio_language_playing = "tlh" - VideoBlock.apply_audio_preference(block) + block.audio.apply_preference() block.reset.assert_called_once() block.load_stream_quality.assert_called_once() @@ -224,7 +219,7 @@ def test_going_back_to_preferred_refetches_a_dubbed_stream(mocker): def test_the_preferred_track_is_found_by_language(mocker): block = _block(mocker, "English") - assert VideoBlock.preferred_audio_track_id.fget(block) == 0 + assert block.audio.preferred_track_id == 0 NAMESAKES = {0: _track("eng"), 1: _track("eng"), 2: _track("jpn")} @@ -235,7 +230,7 @@ def test_a_pick_is_remembered_by_language_where_that_names_one_track(mocker): block = _block(mocker) - VideoBlock.set_audio_track(block, 1) + block.audio.set_track(1) assert block.video_params.audio_selection == AudioLanguage(tag="jpn") @@ -245,7 +240,7 @@ def test_a_pick_falls_back_to_the_id_where_two_tracks_share_a_language(mocker): block = _block(mocker, tracks=NAMESAKES) - VideoBlock.set_audio_track(block, 1) + block.audio.set_track(1) assert block.video_params.audio_selection == AudioTrackId(id=1) @@ -253,7 +248,7 @@ def test_a_pick_falls_back_to_the_id_where_two_tracks_share_a_language(mocker): def test_a_pick_falls_back_to_the_id_for_an_untagged_track(mocker): block = _block(mocker) - VideoBlock.set_audio_track(block, 2) + block.audio.set_track(2) assert block.video_params.audio_selection == AudioTrackId(id=2) @@ -263,7 +258,7 @@ def test_namesakes_are_spotted_across_spellings_of_the_same_language(mocker): block = _block(mocker, tracks={0: _track("eng"), 1: _track("en")}) - VideoBlock.set_audio_track(block, 0) + block.audio.set_track(0) assert block.video_params.audio_selection == AudioTrackId(id=0) @@ -294,9 +289,8 @@ def test_a_language_this_source_does_not_carry_falls_back_to_the_preference(mock def test_going_back_to_preferred_forgets_the_pick(mocker): block = _block(mocker, "ja") block.video_params.audio_selection = AudioLanguage(tag="fre") - block.preferred_audio_track_id = VideoBlock.preferred_audio_track_id.fget(block) - VideoBlock.apply_audio_preference(block) + block.audio.apply_preference() assert block.video_params.audio_selection == AudioPreferred() @@ -307,7 +301,7 @@ def test_disabling_audio_sticks_while_a_video_is_still_reloading(mocker): block = _block(mocker) block.is_video_initialized = False - VideoBlock.set_audio_track(block, DISABLED_TRACK) + block.audio.set_track(DISABLED_TRACK) assert block.video_params.audio_selection == AudioDisabled() block.video_driver.set_audio_track.assert_not_called() @@ -341,7 +335,7 @@ def test_a_language_nothing_answers_to_leaves_the_video_speaking(mocker): def _player_for(block): player = SimpleNamespace( media_input=SimpleNamespace(video=block.video_params), - _tracks_manager=SimpleNamespace(audio_tracks=block.audio_tracks), + _tracks_manager=SimpleNamespace(audio_tracks=block.audio.tracks), ) return player diff --git a/tests/test_menu_audio_click.py b/tests/test_menu_audio_click.py index 8f565132..145a0c87 100644 --- a/tests/test_menu_audio_click.py +++ b/tests/test_menu_audio_click.py @@ -28,6 +28,7 @@ wanted_audio_track_id, ) from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_audio import AudioControl @pytest.fixture(scope="module", autouse=True) @@ -96,37 +97,20 @@ def set_audio_track(self, track_id): class _Block: - """A block with the real audio methods and the reload stubbed out.""" + """A block with its real audio part and the reload stubbed out.""" _language_variants = VideoBlock._language_variants audio_language_options = VideoBlock.audio_language_options audio_language = VideoBlock.audio_language _language_among = VideoBlock._language_among preferred_audio_language = VideoBlock.preferred_audio_language - preferred_audio_track_id = VideoBlock.preferred_audio_track_id stream_ladder = VideoBlock.stream_ladder - audio_tracks = VideoBlock.audio_tracks is_video_initialized = VideoBlock.is_video_initialized is_local_file = VideoBlock.is_local_file - audio_track_playing = VideoBlock.audio_track_playing audio_language_playing = VideoBlock.audio_language_playing - apply_audio_default = VideoBlock.apply_audio_default - default_audio_track_id = VideoBlock.default_audio_track_id - offered_audio_files = VideoBlock.offered_audio_files - discovered_audio_files = VideoBlock.discovered_audio_files - attached_audio_file = VideoBlock.attached_audio_file - external_audio_track_ids = VideoBlock.external_audio_track_ids set_audio_language = VideoBlock.set_audio_language - apply_audio_preference = VideoBlock.apply_audio_preference - _follow_audio_language = VideoBlock._follow_audio_language - _apply_wanted_audio_track = VideoBlock._apply_wanted_audio_track - set_audio_track = VideoBlock.set_audio_track - _audio_selection_for = VideoBlock._audio_selection_for - _wanted_audio_track_id = VideoBlock._wanted_audio_track_id - _is_video_track_off = VideoBlock._is_video_track_off - _track_language_key = VideoBlock._track_language_key - get_audio_languages = VideoBlock.get_audio_languages + follow_audio_language = VideoBlock.follow_audio_language def __init__(self): self._log = logging.getLogger("test-block") @@ -142,10 +126,11 @@ def __init__(self): ) self.warnings = [] self.reloads = [] - # a stream with nothing beside it, so every track is one of its own - self.external_audio_tracks = {} self.video_track_unmapped = False + # a stream with nothing beside it, so every track is one of its own + self.audio = AudioControl(self) + self.load_stream_quality("best") @property diff --git a/tests/test_menu_audio_delay.py b/tests/test_menu_audio_delay.py index f1e93ef4..0bbdf000 100644 --- a/tests/test_menu_audio_delay.py +++ b/tests/test_menu_audio_delay.py @@ -12,7 +12,7 @@ from gridplayer.player.managers.actions import ActionsManager from gridplayer.player.managers.active_block import ActiveBlockManager from gridplayer.player.managers.video_blocks import VideoBlocksManager -from gridplayer.widgets.video_block import VideoBlock +from tests.conftest import is_block_command SUBMENU = "Audio Delay" @@ -103,11 +103,11 @@ def test_an_active_row_names_something_a_video_can_do(self, name): action = ACTIONS[name] assert action["func"][0] == "active" - assert hasattr(VideoBlock, action["func"][1]) + assert is_block_command(action["func"][1]) assert hasattr(ActiveBlockManager, action["show_if"]) if action.get("value_getter"): - assert hasattr(VideoBlock, action["value_getter"][1]) + assert is_block_command(action["value_getter"][1]) @pytest.mark.parametrize("name", [row for row in ALL_ROWS if row != "---"]) def test_an_all_row_names_something_every_video_can_do(self, name): @@ -115,9 +115,10 @@ def test_an_all_row_names_something_every_video_can_do(self, name): if isinstance(func, tuple): assert func[0] == "all" - # cmd_all emits all_ at every block's method of that name + # cmd_all emits all_ at every block's audio, at the + # method of that name less its "audio_" assert hasattr(VideoBlocksManager, f"all_{func[1]}") - assert hasattr(VideoBlock, func[1]) + assert is_block_command(f"audio.{func[1].removeprefix('audio_')}") else: assert hasattr(VideoBlocksManager, func) assert hasattr(VideoBlocksManager, "cmd_set_audio_delay") diff --git a/tests/test_menu_audio_device.py b/tests/test_menu_audio_device.py index 66f16575..20c5897b 100644 --- a/tests/test_menu_audio_device.py +++ b/tests/test_menu_audio_device.py @@ -10,7 +10,7 @@ from gridplayer.params.actions import ACTIONS from gridplayer.params.menu import SECTIONS from gridplayer.player.managers.active_block import ActiveBlockManager -from gridplayer.widgets.video_block import VideoBlock +from tests.conftest import is_block_command URI = "http://example.com/a.mp4" @@ -45,7 +45,7 @@ def is_no_active_block(self): def _manager(chosen=None, devices=OFFERED): block = SimpleNamespace( - audio_devices=devices, + audio=SimpleNamespace(devices=devices), video_params=Video(uri=URI, audio_device=chosen), ) @@ -186,8 +186,8 @@ def test_every_row_asks_the_video_to_change_device(self): command, method, *_ = item["func"] - assert (command, method) == ("active", "set_audio_device") - assert hasattr(VideoBlock, method) + assert (command, method) == ("active", "audio.set_device") + assert is_block_command(method) def test_a_device_row_hands_over_the_device_itself(self): rows = [item for item in _menu() if item != "---"] diff --git a/tests/test_menu_audio_track.py b/tests/test_menu_audio_track.py index 57a36b41..3f51a729 100644 --- a/tests/test_menu_audio_track.py +++ b/tests/test_menu_audio_track.py @@ -20,6 +20,7 @@ from gridplayer.player.managers.actions import ActionsManager from gridplayer.player.managers.active_block import ActiveBlockManager from gridplayer.vlc_player.static import DISABLED_TRACK, AudioTrack +from tests.conftest import is_block_command @pytest.fixture(scope="module", autouse=True) @@ -95,21 +96,23 @@ def _manager( ) block = SimpleNamespace( - audio_tracks=audio_tracks, + audio=SimpleNamespace( + tracks=audio_tracks, + preferred_track_id=preferred, + track_playing=playing_track, + discovered_files=list(discovered), + offered_files=list(discovered), + external_tracks=external_tracks, + external_track_ids=tuple( + external_tracks if external_ids is None else external_ids + ), + attached_file=next(iter(external_tracks.values()), None), + ), audio_language_options=languages, audio_language=playing_language, - preferred_audio_track_id=preferred, preferred_audio_language=preferred_language, is_local_file=is_local_file, - audio_track_playing=playing_track, audio_language_playing=playing_language, - discovered_audio_files=list(discovered), - offered_audio_files=list(discovered), - external_audio_tracks=external_tracks, - external_audio_track_ids=tuple( - external_tracks if external_ids is None else external_ids - ), - attached_audio_file=next(iter(external_tracks.values()), None), video_params=SimpleNamespace( audio_selection=AudioPreferred() if selection is None else selection, external_audio=external, @@ -165,8 +168,6 @@ def test_a_file_offers_its_tracks(): def test_every_row_calls_something_that_exists_on_a_block(): """A menu entry pointing at nothing fails silently at the click.""" - from gridplayer.widgets.video_block import VideoBlock - menus = [ _manager( tracks=STREAM_TRACKS, languages=("cop", "tlh"), playing_language="tlh" @@ -184,7 +185,7 @@ def test_every_row_calls_something_that_exists_on_a_block(): assert called for name in called: - assert hasattr(VideoBlock, name), name + assert is_block_command(name), name def test_a_dubbed_stream_offers_its_languages_in_the_same_place(): @@ -214,8 +215,8 @@ def test_an_untagged_track_keeps_the_details_it_does_have(): def test_the_preference_row_sits_under_the_entry_that_follows_it(): row = _manager().menu_generator_audio_track()[2] - assert row["func"] == ("active", "audio_languages_dialog") - assert row["value_getter"] == ("active", "get_audio_languages") + assert row["func"] == ("active", "audio.languages_dialog") + assert row["value_getter"] == ("active", "audio.get_languages") def test_preferred_goes_through_the_path_that_knows_about_dubbing(): @@ -223,7 +224,7 @@ def test_preferred_goes_through_the_path_that_knows_about_dubbing(): item = _manager()._preferred_audio_track_menu_item() - assert item["func"] == ("active", "apply_audio_preference") + assert item["func"] == ("active", "audio.apply_preference") def test_preferred_names_the_track_it_resolves_to(): @@ -317,7 +318,7 @@ def test_disabling_audio_is_ticked_by_the_choice_not_by_a_track(): disable = _manager().menu_generator_audio_track()[3] assert disable["check_if"] == "is_active_audio_disabled" - assert disable["func"] == ("active", "set_audio_track", DISABLED_TRACK) + assert disable["func"] == ("active", "audio.set_track", DISABLED_TRACK) @pytest.mark.parametrize( @@ -479,7 +480,7 @@ def test_picking_one_plays_that_file(self): row = _row(menu, "Movie.rus.mp3") - assert row["func"] == ("active", "play_external_audio", str(self.RUSSIAN)) + assert row["func"] == ("active", "audio.play_external", str(self.RUSSIAN)) def test_the_one_that_is_open_is_spelled_out_among_the_files(self): """It answers to its file name, which is the only name it has.""" @@ -557,7 +558,7 @@ def test_clicking_the_detection_sets_it_the_other_way(self, is_on): row = _row(menu, "Detect Audio Files") - assert row["func"] == ("active", "set_external_audio_autodiscover", not is_on) + assert row["func"] == ("active", "audio.set_external_autodiscover", not is_on) class TestAVideoWithNoSoundOfItsOwn: diff --git a/tests/test_tray.py b/tests/test_tray.py index fe7b3e5b..72e4f06f 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -628,7 +628,7 @@ def _block(is_paused=False, track_id=0, audio=True, tracks=(0,)): block._is_video_stopped_in_tray = False block.video_params = Video(uri=URI, is_paused=is_paused, video_track_id=track_id) block.video_tracks = dict.fromkeys(tracks) - block.audio_tracks = {1: None} if audio else {} + block.audio.tracks = {1: None} if audio else {} return block diff --git a/tests/test_video_block_audio_delay.py b/tests/test_video_block_audio_delay.py index 1288d1cb..34ddb8c3 100644 --- a/tests/test_video_block_audio_delay.py +++ b/tests/test_video_block_audio_delay.py @@ -1,6 +1,5 @@ """Moving a video's sound against its picture, and hearing it move.""" -from functools import partial from types import SimpleNamespace from unittest.mock import Mock @@ -16,7 +15,7 @@ MIN_AUDIO_DELAY_MS, ) from gridplayer.settings import Settings -from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_audio import AudioControl @pytest.fixture(scope="module", autouse=True) @@ -33,13 +32,11 @@ def _isolated_settings(tmp_path): def _block(delay_ms=0, has_audio=True): block = Mock() block.is_video_initialized = True - block.audio_tracks = {0: Mock()} if has_audio else {} + block.video_driver.audio_tracks = {0: Mock()} if has_audio else {} block.video_params = Video(uri="http://example.com/a.mp4") block.video_params.audio_delay_ms = delay_ms - # the steps and the reset all go through the setter, which a mock would - # otherwise answer for without moving anything - block.set_audio_delay = partial(VideoBlock.set_audio_delay, block) + block.audio = AudioControl(block) return block @@ -48,7 +45,7 @@ class TestHowFarTheSoundIsMoved: def test_the_video_is_told_and_the_choice_is_kept(self): block = _block() - VideoBlock.set_audio_delay(block, 150) + block.audio.set_delay(150) assert block.video_params.audio_delay_ms == 150 block.video_driver.set_audio_delay.assert_called_once_with(150) @@ -65,24 +62,24 @@ def test_it_goes_no_further_than_the_model_allows(self, asked, landed): block = _block() - VideoBlock.set_audio_delay(block, asked) + block.audio.set_delay(asked) assert block.video_params.audio_delay_ms == landed def test_later_and_earlier_move_it_by_one_step(self): block = _block(delay_ms=100) - VideoBlock.audio_delay_increase(block) + block.audio.delay_increase() assert block.video_params.audio_delay_ms == 100 + AUDIO_DELAY_STEP_MS - VideoBlock.audio_delay_decrease(block) - VideoBlock.audio_delay_decrease(block) + block.audio.delay_decrease() + block.audio.delay_decrease() assert block.video_params.audio_delay_ms == 100 - AUDIO_DELAY_STEP_MS def test_reset_puts_it_back_level(self): block = _block(delay_ms=-350) - VideoBlock.audio_delay_reset(block) + block.audio.delay_reset() assert block.video_params.audio_delay_ms == 0 @@ -91,7 +88,7 @@ def test_a_silent_video_is_left_alone(self): block = _block(has_audio=False) - VideoBlock.set_audio_delay(block, 150) + block.audio.set_delay(150) assert block.video_params.audio_delay_ms == 0 block.video_driver.set_audio_delay.assert_not_called() @@ -103,7 +100,7 @@ class TestSayingSoOverTheVideo: def test_the_new_delay_is_shown(self): block = _block() - VideoBlock.set_audio_delay(block, 150) + block.audio.set_delay(150) block.info_change.emit.assert_called_once_with("Audio delay: +150 ms") @@ -112,14 +109,14 @@ def test_a_silent_change_says_nothing(self): block = _block() - VideoBlock.set_audio_delay(block, 150, is_silent=True) + block.audio.set_delay(150, is_silent=True) block.info_change.emit.assert_not_called() def test_the_menu_reads_the_same_as_the_video(self): block = _block(delay_ms=-50) - assert VideoBlock.get_audio_delay(block) == "-50 ms" + assert block.audio.get_delay() == "-50 ms" @pytest.mark.parametrize( ("delay_ms", "txt"), @@ -135,8 +132,10 @@ class FakeBlock: def __init__(self, delay_ms=0): self.video_params = SimpleNamespace(audio_delay_ms=delay_ms) self.calls = [] + # the block's audio part, done by the fake itself + self.audio = self - def set_audio_delay(self, delay_ms, is_silent=False): + def set_delay(self, delay_ms, is_silent=False): self.video_params.audio_delay_ms = delay_ms self.calls.append((delay_ms, is_silent)) diff --git a/tests/test_video_block_audio_device.py b/tests/test_video_block_audio_device.py index 59490e9f..39467905 100644 --- a/tests/test_video_block_audio_device.py +++ b/tests/test_video_block_audio_device.py @@ -9,7 +9,7 @@ from gridplayer.models.audio_device import AudioDevice from gridplayer.models.video import Video from gridplayer.settings import Settings -from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_audio import AudioControl URI = "http://example.com/a.mp4" @@ -33,6 +33,7 @@ def _block(chosen=None, is_initialized=True, devices=(SPDIF, HEADPHONES)): block.is_video_initialized = is_initialized block.video_params = Video(uri=URI, audio_device=chosen) block.video_driver.audio_devices = devices + block.audio = AudioControl(block) return block @@ -41,7 +42,7 @@ class TestPickingOne: def test_the_video_is_told_and_the_choice_is_kept(self): block = _block() - VideoBlock.set_audio_device(block, SPDIF) + block.audio.set_device(SPDIF) assert block.video_params.audio_device == SPDIF block.video_driver.set_audio_device.assert_called_once_with(SPDIF) @@ -49,7 +50,7 @@ def test_the_video_is_told_and_the_choice_is_kept(self): def test_the_machines_own_is_a_choice_like_any_other(self): block = _block(chosen=SPDIF) - VideoBlock.set_audio_device(block, None) + block.audio.set_device(None) assert block.video_params.audio_device is None block.video_driver.set_audio_device.assert_called_once_with(None) @@ -59,7 +60,7 @@ def test_a_video_still_loading_remembers_without_being_told(self): block = _block(is_initialized=False) - VideoBlock.set_audio_device(block, SPDIF) + block.audio.set_device(SPDIF) assert block.video_params.audio_device == SPDIF block.video_driver.set_audio_device.assert_not_called() @@ -70,7 +71,7 @@ def test_the_choice_belongs_to_the_video_alone(self): first = _block() second = _block() - VideoBlock.set_audio_device(first, SPDIF) + first.audio.set_device(SPDIF) assert second.video_params.audio_device is None @@ -79,15 +80,15 @@ class TestWhatItCanBeSentTo: def test_whatever_the_process_playing_it_can_reach(self): block = _block() - assert VideoBlock.audio_devices.fget(block) == (SPDIF, HEADPHONES) + assert block.audio.devices == (SPDIF, HEADPHONES) def test_nothing_before_it_has_loaded(self): block = _block(is_initialized=False) - assert VideoBlock.audio_devices.fget(block) == () + assert block.audio.devices == () def test_nor_where_there_is_no_player_yet(self): block = _block() block.video_driver = None - assert VideoBlock.audio_devices.fget(block) == () + assert block.audio.devices == () diff --git a/tests/test_video_block_audio_language.py b/tests/test_video_block_audio_language.py index 5e9ea9cc..076de539 100644 --- a/tests/test_video_block_audio_language.py +++ b/tests/test_video_block_audio_language.py @@ -16,6 +16,7 @@ from gridplayer.models.video import Video from gridplayer.settings import Settings from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_audio import AudioControl @pytest.fixture(scope="module", autouse=True) @@ -61,7 +62,7 @@ def _block(mocker, languages="", streams=None): block.video_params.audio_selection = AudioPreferred() block.video_params.stream_quality = STREAM_QUALITY_AUTO block._language_among = partial(VideoBlock._language_among, block) - block._follow_audio_language = partial(VideoBlock._follow_audio_language, block) + block.follow_audio_language = partial(VideoBlock.follow_audio_language, block) return block @@ -230,7 +231,7 @@ def test_going_back_to_default_refetches_the_original(mocker): block._audio_language_playing = "cop" block.audio_language = "en" - VideoBlock.apply_audio_default(block) + AudioControl(block).apply_default() assert block.video_params.audio_selection == AudioDefault() block.reset.assert_called_once() @@ -242,10 +243,10 @@ def test_going_back_to_default_on_the_original_does_not_reload(mocker): block._audio_language_playing = "en" block.audio_language = "en" - VideoBlock.apply_audio_default(block) + AudioControl(block).apply_default() block.reset.assert_not_called() - block._apply_wanted_audio_track.assert_called_once() + block.audio.apply_wanted_track.assert_called_once() def test_a_language_picked_by_hand_outlives_the_playlist(tmp_path): diff --git a/tests/test_video_block_external_audio.py b/tests/test_video_block_external_audio.py index c61c5253..608060e3 100644 --- a/tests/test_video_block_external_audio.py +++ b/tests/test_video_block_external_audio.py @@ -17,6 +17,7 @@ from gridplayer.settings import Settings from gridplayer.vlc_player.static import AudioTrack from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_audio import AudioControl @pytest.fixture(scope="module", autouse=True) @@ -70,36 +71,10 @@ def set_audio_track(self, track_id): class _Block: - """A block with the real external audio methods, and no player behind it.""" + """A block with its real audio part, and no player behind it.""" is_local_file = VideoBlock.is_local_file - discovered_audio_files = VideoBlock.discovered_audio_files - offered_audio_files = VideoBlock.offered_audio_files - attached_audio_file = VideoBlock.attached_audio_file - _audio_file_to_attach = VideoBlock._audio_file_to_attach - external_audio_track_ids = VideoBlock.external_audio_track_ids - default_audio_track_id = VideoBlock.default_audio_track_id - external_audio_tracks = VideoBlock.external_audio_tracks - selected_audio_slave_uri = VideoBlock.selected_audio_slave_uri - - add_external_audio = VideoBlock.add_external_audio - play_external_audio = VideoBlock.play_external_audio - remove_external_audio = VideoBlock.remove_external_audio - _drop_external_audio = VideoBlock._drop_external_audio - set_external_audio_autodiscover = VideoBlock.set_external_audio_autodiscover - tracks_changed = VideoBlock.tracks_changed - apply_audio_default = VideoBlock.apply_audio_default - restore_audio_selection = VideoBlock.restore_audio_selection - _follow_audio_language = VideoBlock._follow_audio_language - - _audio_selection_for = VideoBlock._audio_selection_for - _forget_audio_file_that_is_gone = VideoBlock._forget_audio_file_that_is_gone - _wanted_audio_track_id = VideoBlock._wanted_audio_track_id - _apply_wanted_audio_track = VideoBlock._apply_wanted_audio_track - _put_external_audio_into_effect = VideoBlock._put_external_audio_into_effect - _is_external_audio_short = VideoBlock._is_external_audio_short - _adopt_audio_if_silent = VideoBlock._adopt_audio_if_silent - _warn_about_awkward_audio = VideoBlock._warn_about_awkward_audio + follow_audio_language = VideoBlock.follow_audio_language def __init__( self, @@ -115,11 +90,13 @@ def __init__( self.video_params.is_external_audio_autodiscover = autodiscover self.is_video_initialized = is_initialized - self.audio_tracks = {0: _track("eng")} if tracks is None else tracks # a file, which no site serves one language at a time self.streams = Streams() self.video_driver = _Driver() + self.video_driver.audio_tracks = ( + {0: _track("eng")} if tracks is None else tracks + ) self.video_driver.external_audio_ids = ( () if external_id is None else (external_id,) ) @@ -128,20 +105,21 @@ def __init__( self.is_audio_present_change = SimpleNamespace(emit=self.audio_present.append) self.reloads = 0 - self.selected = [] - self._attached_audio_file = None self.warnings = [] self._ctx = SimpleNamespace( commands=SimpleNamespace(warning=self.warnings.append) ) + self.audio = AudioControl(self) + + # the track picked, rather than the player told of it + self.selected = [] + self.audio.set_track = self.selected.append + def reload(self): self.reloads += 1 - def set_audio_track(self, track_id): - self.selected.append(track_id) - def _block(tmp_path, **kwargs): return _Block(_file(tmp_path, "Movie.mkv"), **kwargs) @@ -155,14 +133,14 @@ def _attached(block, file_path, track_id=1): file_path, ] block.video_params.audio_selection = AudioExternal(file=file_path) - block._attached_audio_file = file_path + block.audio._attached_file = file_path block.video_driver.external_audio_ids = (track_id,) return block def _offered(block): - return [found.name for found in block.offered_audio_files] + return [found.name for found in block.audio.offered_files] class TestPickingAFile: @@ -170,7 +148,7 @@ def test_it_goes_on_the_player_where_it_stands(self, tmp_path): block = _block(tmp_path) audio = _file(tmp_path, "Movie.rus.mp3") - block.add_external_audio([audio]) + block.audio.add_external([audio]) assert block.video_driver.slaves == [audio.as_uri()] assert block.reloads == 0 @@ -181,7 +159,7 @@ def test_it_is_remembered_by_its_name_rather_than_a_track_id(self, tmp_path): block = _block(tmp_path) audio = _file(tmp_path, "Movie.rus.mp3") - block.add_external_audio([audio]) + block.audio.add_external([audio]) assert block.video_params.audio_selection == AudioExternal(file=audio) @@ -189,7 +167,7 @@ def test_one_picked_mid_load_waits_for_the_video(self, tmp_path): block = _block(tmp_path, is_initialized=False) audio = _file(tmp_path, "Movie.rus.mp3") - block.add_external_audio([audio]) + block.audio.add_external([audio]) assert block.video_driver.slaves == [] assert block.video_params.external_audio == [audio] @@ -198,8 +176,8 @@ def test_the_same_file_is_not_taken_twice(self, tmp_path): block = _block(tmp_path) audio = _file(tmp_path, "Movie.rus.mp3") - block.add_external_audio([audio]) - block.add_external_audio([audio]) + block.audio.add_external([audio]) + block.audio.add_external([audio]) assert block.video_params.external_audio == [audio] assert len(block.video_driver.slaves) == 1 @@ -211,7 +189,7 @@ def test_several_at_once_are_kept_but_only_the_first_is_played(self, tmp_path): first = _file(tmp_path, "Movie.rus.mp3") second = _file(tmp_path, "Movie.fra.mp3") - block.add_external_audio([first, second]) + block.audio.add_external([first, second]) assert block.video_params.external_audio == [first, second] assert block.video_driver.slaves == [first.as_uri()] @@ -219,7 +197,7 @@ def test_several_at_once_are_kept_but_only_the_first_is_played(self, tmp_path): def test_the_one_already_playing_is_only_switched_back_to(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) - block.play_external_audio(str(block.attached_audio_file)) + block.audio.play_external(str(block.audio.attached_file)) assert block.selected == [1] assert block.video_driver.slaves == [] @@ -233,7 +211,7 @@ def test_the_video_is_loaded_again_rather_than_handed_a_second(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) other = _file(tmp_path, "Movie.fra.mp3") - block.play_external_audio(str(other)) + block.audio.play_external(str(other)) assert block.reloads == 1 assert block.video_driver.slaves == [] @@ -242,7 +220,7 @@ def test_the_new_file_is_what_the_video_comes_back_on(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) other = _file(tmp_path, "Movie.fra.mp3") - block.play_external_audio(str(other)) + block.audio.play_external(str(other)) assert block.video_params.audio_selection == AudioExternal(file=other) @@ -250,7 +228,7 @@ def test_a_file_that_is_not_on_the_list_yet_joins_it(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) other = _file(tmp_path, "Movie.fra.mp3") - block.play_external_audio(str(other)) + block.audio.play_external(str(other)) assert other in block.video_params.external_audio @@ -259,7 +237,7 @@ def test_a_video_with_none_attached_is_handed_one_where_it_stands(self, tmp_path audio = _file(tmp_path, "Movie.rus.mp3") block.video_params.external_audio = [audio] - block.play_external_audio(str(audio)) + block.audio.play_external(str(audio)) assert block.video_driver.slaves == [audio.as_uri()] assert block.reloads == 0 @@ -309,18 +287,18 @@ def test_the_track_belongs_to_the_file_that_is_playing(self, tmp_path): _block(tmp_path, tracks={0: _track("eng"), 1: _track()}), audio ) - assert block.external_audio_tracks == {1: audio} + assert block.audio.external_tracks == {1: audio} def test_a_video_with_no_file_has_none_of_them(self, tmp_path): block = _block(tmp_path) - assert block.external_audio_tracks == {} + assert block.audio.external_tracks == {} def test_a_file_that_brought_no_track_claims_none(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) block.video_driver.external_audio_ids = () - assert block.external_audio_tracks == {} + assert block.audio.external_tracks == {} class TestHandingItToThePlayer: @@ -329,13 +307,13 @@ def test_the_file_being_listened_to_is_the_one_handed_over(self, tmp_path): block = _attached(_block(tmp_path), audio) block.video_params.external_audio.append(_file(tmp_path, "Movie.fra.mp3")) - assert block.selected_audio_slave_uri == audio.as_uri() + assert block.audio.selected_slave_uri == audio.as_uri() def test_a_video_playing_its_own_sound_hands_over_nothing(self, tmp_path): block = _block(tmp_path) block.video_params.external_audio = [_file(tmp_path, "Movie.rus.mp3")] - assert block.selected_audio_slave_uri is None + assert block.audio.selected_slave_uri is None class TestTakingThemBack: @@ -344,7 +322,7 @@ def test_the_video_is_loaded_again_without_them(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) - block.remove_external_audio() + block.audio.remove_external() assert block.video_params.external_audio == [] assert block.reloads == 1 @@ -352,14 +330,14 @@ def test_the_video_is_loaded_again_without_them(self, tmp_path): def test_a_video_with_none_of_them_is_left_alone(self, tmp_path): block = _block(tmp_path) - block.remove_external_audio() + block.audio.remove_external() assert block.reloads == 0 def test_the_pick_goes_back_to_the_preference_with_the_track(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) - block.remove_external_audio() + block.audio.remove_external() assert block.video_params.audio_selection == AudioDefault() @@ -368,7 +346,7 @@ def test_a_pick_of_the_video_s_own_track_is_kept(self, tmp_path): block.video_params.external_audio = [_file(tmp_path, "Movie.rus.mp3")] block.video_params.audio_selection = AudioTrackId(id=0) - block.remove_external_audio() + block.audio.remove_external() assert block.video_params.audio_selection == AudioTrackId(id=0) @@ -378,7 +356,7 @@ def test_it_takes_the_one_file_found_beside_it(self, tmp_path): block = _block(tmp_path, tracks={}) audio = _file(tmp_path, "Movie.mp3") - block._adopt_audio_if_silent() + block.audio.adopt_if_silent() assert block.video_params.external_audio == [audio] @@ -389,7 +367,7 @@ def test_several_are_left_to_be_asked_about(self, tmp_path): _file(tmp_path, "Movie.rus.mp3") _file(tmp_path, "Movie.eng.mp3") - block._adopt_audio_if_silent() + block.audio.adopt_if_silent() assert block.video_params.external_audio == [] @@ -397,7 +375,7 @@ def test_a_video_that_has_sound_is_left_alone(self, tmp_path): block = _block(tmp_path) _file(tmp_path, "Movie.mp3") - block._adopt_audio_if_silent() + block.audio.adopt_if_silent() assert block.video_params.external_audio == [] @@ -407,7 +385,7 @@ def test_one_that_was_given_a_file_already_is_left_alone(self, tmp_path): block.video_params.external_audio = [picked] _file(tmp_path, "Movie.eng.mp3") - block._adopt_audio_if_silent() + block.audio.adopt_if_silent() assert block.video_params.external_audio == [picked] @@ -424,7 +402,7 @@ def test_the_choice_is_left_as_it_was(self, tmp_path): block.video_driver.cur_audio_track_id = 1 before = block.video_params.audio_selection - block.tracks_changed() + block.audio.tracks_changed() assert block.video_params.audio_selection == before assert block.reloads == 0 @@ -435,21 +413,21 @@ def test_a_file_that_brought_nothing_is_loaded_with_the_video(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.ogg")) block.video_driver.external_audio_ids = () - block.tracks_changed() + block.audio.tracks_changed() assert block.reloads == 1 def test_one_that_brought_its_track_is_left_where_it_is(self, tmp_path): block = _attached(_block(tmp_path), _file(tmp_path, "Movie.rus.mp3")) - block.tracks_changed() + block.audio.tracks_changed() assert block.reloads == 0 def test_a_silent_video_is_no_longer_silent(self, tmp_path): block = _block(tmp_path, tracks={0: _track()}) - block.tracks_changed() + block.audio.tracks_changed() assert block.audio_present == [True] @@ -460,7 +438,7 @@ def test_the_track_the_file_puts_forward_is_played(self, tmp_path): block.video_driver.default_audio_track_id = 1 block.video_params.audio_selection = AudioTrackId(id=0) - block.apply_audio_default() + block.audio.apply_default() assert block.video_params.audio_selection == AudioDefault() assert block.video_driver.tracks_set == [1] @@ -471,7 +449,7 @@ def test_where_libvlc_never_said_the_first_of_its_own_is_taken(self, tmp_path): block = _block(tmp_path, tracks={3: _track("eng"), 4: _track("jpn")}) block.video_driver.default_audio_track_id = None - block.apply_audio_default() + block.audio.apply_default() assert block.video_driver.tracks_set == [3] @@ -485,7 +463,7 @@ def test_a_track_out_of_a_file_is_never_what_it_falls_back_on(self, tmp_path): # what libVLC opened on was the file, as it does once its stream is in block.video_driver.default_audio_track_id = 1 - block.apply_audio_default() + block.audio.apply_default() assert block.video_driver.tracks_set == [0] @@ -496,7 +474,7 @@ class TestAFileLibvlcWillNotKeepUpWith: def test_one_that_never_makes_a_sound_is_called_out(self, tmp_path): block = _block(tmp_path) - block.add_external_audio([_file(tmp_path, "Movie.opus")]) + block.audio.add_external([_file(tmp_path, "Movie.opus")]) assert "Movie.opus" in block.warnings[0] assert "MP3" in block.warnings[0] @@ -504,7 +482,7 @@ def test_one_that_never_makes_a_sound_is_called_out(self, tmp_path): def test_one_that_stops_at_the_first_seek_is_called_out(self, tmp_path): block = _block(tmp_path) - block.add_external_audio([_file(tmp_path, "Movie.ogg")]) + block.audio.add_external([_file(tmp_path, "Movie.ogg")]) assert "seeked" in block.warnings[0] @@ -514,14 +492,14 @@ def test_it_is_taken_all_the_same(self, tmp_path): block = _block(tmp_path) audio = _file(tmp_path, "Movie.opus") - block.add_external_audio([audio]) + block.audio.add_external([audio]) assert block.video_params.external_audio == [audio] def test_a_format_that_plays_is_not_complained_about(self, tmp_path): block = _block(tmp_path) - block.add_external_audio([_file(tmp_path, "Movie.m4a")]) + block.audio.add_external([_file(tmp_path, "Movie.m4a")]) assert block.warnings == [] @@ -535,8 +513,8 @@ def test_it_is_not_handed_to_the_player(self, tmp_path): block.video_params.external_audio = [gone] block.video_params.audio_selection = AudioExternal(file=gone) - assert block.selected_audio_slave_uri is None - assert block.attached_audio_file is None + assert block.audio.selected_slave_uri is None + assert block.audio.attached_file is None def test_it_is_kept_in_the_playlist_all_the_same(self, tmp_path): """Whatever moved it may move it back.""" @@ -553,7 +531,7 @@ def test_it_is_no_reason_to_load_the_video_again(self, tmp_path): block.video_params.external_audio = [gone] block.video_params.audio_selection = AudioExternal(file=gone) - block.tracks_changed() + block.audio.tracks_changed() assert block.reloads == 0 @@ -569,7 +547,7 @@ def test_the_file_s_track_keeps_its_name(self, tmp_path): block.video_params.audio_selection = AudioTrackId(id=0) - assert block.external_audio_tracks == {1: audio} + assert block.audio.external_tracks == {1: audio} def test_it_is_not_offered_again_below(self, tmp_path): """It is in the track list already; offering it twice reads as two files.""" @@ -588,14 +566,14 @@ def test_the_next_load_opens_without_it(self, tmp_path): block.video_params.audio_selection = AudioTrackId(id=0) - assert block.selected_audio_slave_uri is None + assert block.audio.selected_slave_uri is None def test_picking_it_again_needs_no_reload(self, tmp_path): audio = _file(tmp_path, "Movie.rus.mp3") block = _attached(_block(tmp_path), audio) block.video_params.audio_selection = AudioTrackId(id=0) - block.play_external_audio(str(audio)) + block.audio.play_external(str(audio)) assert block.selected == [1] assert block.reloads == 0 @@ -608,7 +586,7 @@ def test_the_pick_falls_back_to_what_the_video_would_open_on(self, tmp_path): block.video_params.external_audio = [gone] block.video_params.audio_selection = AudioExternal(file=gone) - block._forget_audio_file_that_is_gone() + block.audio._forget_file_that_is_gone() assert block.video_params.audio_selection == AudioDefault() @@ -618,7 +596,7 @@ def test_the_file_keeps_its_place_in_the_list(self, tmp_path): block.video_params.external_audio = [gone] block.video_params.audio_selection = AudioExternal(file=gone) - block._forget_audio_file_that_is_gone() + block.audio._forget_file_that_is_gone() assert block.video_params.external_audio == [gone] @@ -627,7 +605,7 @@ def test_a_pick_whose_file_is_there_is_left_alone(self, tmp_path): block = _block(tmp_path) block.video_params.audio_selection = AudioExternal(file=audio) - block._forget_audio_file_that_is_gone() + block.audio._forget_file_that_is_gone() assert block.video_params.audio_selection == AudioExternal(file=audio) From f39397ef75b0976d0698e6b3dab1b968f3e22348 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:08:24 +0000 Subject: [PATCH 08/14] refactor: take screenshots and the loop out of the video block, volume into its audio Screenshots are a ScreenshotControl at block.screenshot ("screenshot.take"), setting the loop a LoopControl at block.loop ("loop.set_start", "loop.toggle_random", ...), and volume and mute go to block.audio, which already had everything else about the sound. The loop's bounds, loop_start and loop_end, stay on the block with the seeking that reads them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/params/actions.py | 16 +- gridplayer/player/managers/video_blocks.py | 16 +- gridplayer/widgets/video_block.py | 192 ++----------------- gridplayer/widgets/video_block_audio.py | 44 ++++- gridplayer/widgets/video_block_chapters.py | 6 +- gridplayer/widgets/video_block_loop.py | 64 +++++++ gridplayer/widgets/video_block_screenshot.py | 95 +++++++++ tests/conftest.py | 4 + tests/test_bookmarks_dialog.py | 18 +- tests/test_screenshots.py | 21 +- tests/test_video_block_bookmarks.py | 12 +- tests/test_video_block_sponsorblock.py | 12 +- tests/test_video_block_stream_seek.py | 2 +- tests/test_video_position.py | 19 +- 14 files changed, 286 insertions(+), 235 deletions(-) create mode 100644 gridplayer/widgets/video_block_loop.py create mode 100644 gridplayer/widgets/video_block_screenshot.py diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 73d8d378..720f7a48 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -338,7 +338,7 @@ "Random Loop": { "title": translate("Actions", "Random Loop"), "icon": "loop-random", - "func": ("active", "toggle_loop_random"), + "func": ("active", "loop.toggle_random"), "check_if": ("is_active_param_set_to", "is_start_random", True), "show_if": "is_active_seekable", }, @@ -346,21 +346,21 @@ "title": translate("Actions", "Set Loop Start"), "key": ",", "icon": "loop-start", - "func": ("active", "set_loop_start"), + "func": ("active", "loop.set_start"), "show_if": "is_active_seekable", }, "Set Loop End": { "title": translate("Actions", "Set Loop End"), "key": ".", "icon": "loop-end", - "func": ("active", "set_loop_end"), + "func": ("active", "loop.set_end"), "show_if": "is_active_seekable", }, "Loop Reset": { "title": translate("Actions", "Loop Reset"), "key": "/", "icon": "loop-reset", - "func": ("active", "reset_loop"), + "func": ("active", "loop.reset"), "show_if": "is_active_seekable", }, "Loop Chapter": { @@ -1097,7 +1097,7 @@ "title": translate("Actions", "Take Screenshot"), "key": "Alt+S", "icon": "screenshot", - "func": ("active", "take_screenshot"), + "func": ("active", "screenshot.take"), "show_if": "is_active_has_video", }, "Seek Others (Percent)": { @@ -1306,13 +1306,13 @@ "title": translate("Audio Volume", "Increase Volume"), "icon": "volume-increase", "show_if": "is_active_has_audio", - "func": ("active", "volume_increase"), + "func": ("active", "audio.volume_increase"), }, "Audio Volume - Decrease": { "title": translate("Audio Volume", "Decrease Volume"), "icon": "volume-decrease", "show_if": "is_active_has_audio", - "func": ("active", "volume_decrease"), + "func": ("active", "audio.volume_decrease"), }, "Audio Volume - Mute / Unmute": { "title": ( @@ -1322,7 +1322,7 @@ "icon": ("volume-mute", "volume-unmute"), "toggle": ("is_active_param_set_to", "is_muted", True), "show_if": "is_active_has_audio", - "func": ("active", "mute_unmute"), + "func": ("active", "audio.mute_unmute"), }, "Copy File Name": { "title": translate("Actions", "Copy File Name"), diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 078a715d..05065130 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -864,11 +864,11 @@ def _add_video_block(self, video): (self.all_previous_chapter, vb.chapters.previous), (self.all_next_bookmark, vb.bookmarker.next), (self.all_previous_bookmark, vb.bookmarker.previous), - (self.all_set_loop_random, vb.set_loop_random), - (self.all_set_loop_start, vb.set_loop_start), - (self.all_set_loop_end, vb.set_loop_end), + (self.all_set_loop_random, vb.loop.set_random), + (self.all_set_loop_start, vb.loop.set_start), + (self.all_set_loop_end, vb.loop.set_end), (self.all_loop_chapter, vb.chapters.loop), - (self.all_reset_loop, vb.reset_loop), + (self.all_reset_loop, vb.loop.reset), (self.all_set_end_action, vb.set_end_action), (self.all_set_dir_recursive, vb.set_dir_recursive), (self.all_set_list_end_action, vb.set_list_end_action), @@ -889,7 +889,7 @@ def _add_video_block(self, video): (self.all_set_shift_past_edges, vb.view.set_shift_past_edges), (self.all_set_aspect, vb.set_aspect), (self.all_set_transform, vb.set_transform), - (self.all_take_screenshot, vb.take_screenshot), + (self.all_take_screenshot, vb.screenshot.take), (self.all_set_auto_reload_timer, vb.set_auto_reload_timer), (self.all_set_audio_channel_mode, vb.audio.set_channel_mode), (self.all_set_audio_delay, vb.audio.set_delay), @@ -900,9 +900,9 @@ def _add_video_block(self, video): (self.all_subtitle_delay_increase, vb.subtitles.delay_increase), (self.all_subtitle_delay_decrease, vb.subtitles.delay_decrease), (self.all_subtitle_delay_reset, vb.subtitles.delay_reset), - (self.all_volume_increase, vb.volume_increase), - (self.all_volume_decrease, vb.volume_decrease), - (self.all_set_muted, vb.set_muted), + (self.all_volume_increase, vb.audio.volume_increase), + (self.all_volume_decrease, vb.audio.volume_decrease), + (self.all_set_muted, vb.audio.set_muted), (self.all_previous_video, vb.previous_video), (self.all_next_video, vb.next_video), (self.all_shuffle_video, vb.shuffle_video), diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index c4ca200b..1a3617d3 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -83,12 +83,6 @@ from gridplayer.utils.list_errors import ReadErrorKind from gridplayer.utils.list_navigator import ListNavigator from gridplayer.utils.qt import MILLISECONDS, count_txt, qt_connect, translate -from gridplayer.utils.screenshots import ( - ScreenshotJob, - ScreenshotName, - remove_frame_file, - screenshots_dir, -) from gridplayer.utils.sponsorblock import ( segment_mark, ) @@ -129,6 +123,8 @@ only_streamable, only_with_video_tacks, ) +from gridplayer.widgets.video_block_loop import LoopControl +from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl @@ -355,10 +351,6 @@ def __init__(self, video_driver, context, **kwargs): # that turns seeks down; see SEEK_RETRIES self._is_adaptive = False - # held until they are done, the pool only has the C++ side of them - self._screenshot_jobs: set[ScreenshotJob] = set() - self._is_screenshot_quiet = False - self.url_resolver = self.init_url_resolver() self.video_driver: VideoFrameVLC | None = None @@ -368,6 +360,8 @@ def __init__(self, video_driver, context, **kwargs): self.sponsorblock = SponsorBlockControl(self) self.subtitles = SubtitleControl(self) self.audio = AudioControl(self) + self.loop = LoopControl(self) + self.screenshot = ScreenshotControl(self) # the next, previous or a random file of the folder, to put in this # one's place @@ -460,7 +454,7 @@ def init_video_driver(self) -> VideoFrameVLC: # Left connected when the driver is let go: a frame asked for # before a reload is still the one that was wanted, and a PNG # nobody receives would be left behind in the temp folder. - (video_driver.screenshot_taken, self._screenshot_taken), + (video_driver.screenshot_taken, self.screenshot.taken), (self.load_video, video_driver.load_video), ) @@ -603,10 +597,10 @@ def init_overlay(self): qt_connect( (overlay.set_vid_pos, partial(self.manual_seek, "seek_percent")), - (overlay.set_volume, self.set_volume), + (overlay.set_volume, self.audio.set_volume), (overlay.exit_clicked, self.close), (overlay.play_pause_clicked, self.play_pause), - (overlay.mute_unmute_clicked, self.mute_unmute), + (overlay.mute_unmute_clicked, self.audio.mute_unmute), (overlay.bookmark_clicked, self.bookmarker.jump_to_marker), (overlay.bookmark_menu_requested, self.bookmarker.show_marker_menu), (overlay.list_place_clicked, self.show_list_window), @@ -2207,7 +2201,7 @@ def apply_snapshot(self, snapshot: Video): self.set_aspect(snapshot.aspect_mode) self.set_deinterlace_params(snapshot.deinterlace, snapshot.deinterlace_mode) - self.set_muted(snapshot.is_muted) + self.audio.set_muted(snapshot.is_muted) self.set_pause(snapshot.is_paused) self.view.set_scale(snapshot.scale, is_silent=True) self.view.set_zoom_to_cursor(snapshot.is_zoom_to_cursor) @@ -2215,11 +2209,11 @@ def apply_snapshot(self, snapshot: Video): self.view.set_anchor(snapshot.anchor) self.view.set_shift_past_edges(snapshot.is_shift_past_edges) self.view.set_shift(snapshot.shift, is_silent=True) - self.set_volume(snapshot.volume) + self.audio.set_volume(snapshot.volume) self.seek(snapshot.current_position) - self.set_loop_start_time(snapshot.loop_start) - self.set_loop_end_time(snapshot.loop_end) + self.loop.set_start_time(snapshot.loop_start) + self.loop.set_end_time(snapshot.loop_end) self.set_rate(snapshot.rate, is_silent=True) self.switch_stream_quality(snapshot.stream_quality) @@ -2779,11 +2773,11 @@ def load_video_finish(self): self.audio.set_channel_mode(self.video_params.audio_channel_mode) self.audio.set_delay(self.video_params.audio_delay_ms, is_silent=True) - self.set_volume(self.video_params.volume) - self.set_muted(self.video_params.is_muted) + self.audio.set_volume(self.video_params.volume) + self.audio.set_muted(self.video_params.is_muted) - self.set_loop_start_time(self.video_params.loop_start) - self.set_loop_end_time(self.video_params.loop_end) + self.loop.set_start_time(self.video_params.loop_start) + self.loop.set_end_time(self.video_params.loop_end) self.set_rate(self.video_params.rate, is_silent=True) self.set_auto_reload_timer(self.video_params.auto_reload_timer_min) @@ -2881,58 +2875,6 @@ def set_deinterlace_params( else: self.video_driver.set_deinterlace(deinterlace, mode) - @only_seekable - def toggle_loop_random(self): - self.set_loop_random(not self.video_params.is_start_random) - - def set_loop_random(self, is_start_random: bool): - """Start each pass somewhere at random, or where the loop starts. - - Kept for a video stopped or still loading too, which goes by it - once it plays, and for one that is live, which never loops. - """ - - self.video_params.is_start_random = is_start_random - - @only_seekable - def set_loop_start(self): - self.set_loop_start_time(self.time) - - @only_seekable - def set_loop_start_time(self, new_time): - if None not in {self.video_params.loop_end, new_time}: - if new_time >= self.video_params.loop_end: - return - - self.video_params.loop_start = new_time - - if new_time is None: - self.loop_start_change.emit(0) - else: - self.loop_start_change.emit(new_time / self.video_driver.length) - - @only_seekable - def set_loop_end(self): - self.set_loop_end_time(self.time) - - @only_seekable - def set_loop_end_time(self, new_time): - if None not in {self.video_params.loop_start, new_time}: - if new_time <= self.video_params.loop_start: - return - - self.video_params.loop_end = new_time - - if new_time is None: - self.loop_end_change.emit(100.0) - else: - self.loop_end_change.emit(new_time / self.video_driver.length) - - @only_seekable - def reset_loop(self): - self.set_loop_start_time(None) - self.set_loop_end_time(None) - @property def seek_marks(self) -> tuple[SeekMark, ...]: """What the seek bar marks, as last told by seek_marks_change.""" @@ -3268,43 +3210,6 @@ def set_pause(self, paused): self.video_driver.set_pause(paused) - def mute_unmute(self): - self.set_muted(not self.video_params.is_muted) - - @only_initialized - def set_muted(self, muted): - self.video_params.is_muted = muted - - self.video_driver.audio_set_mute(self.video_params.is_muted) - - self.is_muted_change.emit(self.video_params.is_muted) - - @only_initialized - def set_volume(self, percent): - self.video_params.volume = round(percent, 2) - - self.video_driver.audio_set_volume(self.video_params.volume) - - self.volume_change.emit(percent) - - @only_initialized - def volume_increase(self): - self.set_muted(False) - - self.video_params.volume += 0.05 - self.video_params.volume = min(round(self.video_params.volume, 2), 1.0) - - self.set_volume(self.video_params.volume) - - @only_initialized - def volume_decrease(self): - self.set_muted(False) - - self.video_params.volume -= 0.05 - self.video_params.volume = max(round(self.video_params.volume, 2), 0) - - self.set_volume(self.video_params.volume) - def play_pause(self): """Play or pause as the viewer asked, the others following where playing and pausing is synced (see VideoBlocksManager).""" @@ -3895,7 +3800,7 @@ def _switch_to(self, new_video: Path | str): self._cancel_steps() - self.reset_loop() + self.loop.reset() self.video_params.current_position = 0 self._forget_files_of_previous_video() # its bookmarks, if it has any, come with the file: see bookmarks @@ -3952,68 +3857,3 @@ def rename(self): self.title = new_name self.color = self.video_params.color.as_hex() - - @only_initialized - @only_with_video_tacks - def take_screenshot(self, is_quiet=False): - # Every video at once would be a warning box per video that fails, - # one on top of the other, and all for the same reason more often - # than not. Quiet says so on the video's overlay instead. - self._is_screenshot_quiet = is_quiet - - self.video_driver.take_screenshot() - - def _screenshot_taken(self, frame, view): - if frame is None: - self._report_screenshot_failure( - translate("Warning", "There is no video frame to take a screenshot of") - ) - return - - try: - name = ScreenshotName( - template=Settings().get("screenshots/filename_template"), - title=self.title or self.video_params.uri_name, - position_ms=0 if self.is_live else (self.time or 0), - now=datetime.now().astimezone(), - ) - - job = ScreenshotJob( - source=frame, - directory=screenshots_dir(Settings().get("screenshots/dir")), - name=name, - image_format=Settings().get("screenshots/format"), - jpg_quality=Settings().get("screenshots/jpg_quality"), - view=view, - ) - except Exception: - if isinstance(frame, str): - remove_frame_file(frame) - raise - - qt_connect( - (job.saved, self._screenshot_saved), - (job.failed, self._screenshot_failed), - (job.saved, partial(self._screenshot_jobs.discard, job)), - (job.failed, partial(self._screenshot_jobs.discard, job)), - ) - - self._screenshot_jobs.add(job) - job.start() - - def _screenshot_saved(self, _path): - self.show_overlay() - self.info_change.emit(translate("Screenshot", "Screenshot saved")) - - def _screenshot_failed(self, error): - self._report_screenshot_failure( - translate("Warning", "Could not save the screenshot"), error - ) - - def _report_screenshot_failure(self, message, detail=""): - if self._is_screenshot_quiet: - self.show_overlay() - self.info_change.emit(translate("Screenshot", "Screenshot failed")) - return - - self._ctx.commands.warning(f"{message}\n\n{detail}" if detail else message) diff --git a/gridplayer/widgets/video_block_audio.py b/gridplayer/widgets/video_block_audio.py index cd0bb209..f503eda7 100644 --- a/gridplayer/widgets/video_block_audio.py +++ b/gridplayer/widgets/video_block_audio.py @@ -1,5 +1,6 @@ """The sound of a video block: its tracks, where they go out of the machine, -the languages it would rather be heard in, and audio files kept beside it. +the languages it would rather be heard in, audio files kept beside it, and +how loud it is. A dubbed stream that changes its language by loading another rung is the block's to follow (see VideoBlock.follow_audio_language); everything that @@ -672,6 +673,47 @@ def adopt_if_silent(self) -> None: self.add_external(found) + def mute_unmute(self): + self.set_muted(not self._block.video_params.is_muted) + + @only_initialized + def set_muted(self, muted): + self._block.video_params.is_muted = muted + + self._block.video_driver.audio_set_mute(self._block.video_params.is_muted) + + self._block.is_muted_change.emit(self._block.video_params.is_muted) + + @only_initialized + def set_volume(self, percent): + self._block.video_params.volume = round(percent, 2) + + self._block.video_driver.audio_set_volume(self._block.video_params.volume) + + self._block.volume_change.emit(percent) + + @only_initialized + def volume_increase(self): + self.set_muted(False) + + self._block.video_params.volume += 0.05 + self._block.video_params.volume = min( + round(self._block.video_params.volume, 2), 1.0 + ) + + self.set_volume(self._block.video_params.volume) + + @only_initialized + def volume_decrease(self): + self.set_muted(False) + + self._block.video_params.volume -= 0.05 + self._block.video_params.volume = max( + round(self._block.video_params.volume, 2), 0 + ) + + self.set_volume(self._block.video_params.volume) + @only_initialized def set_channel_mode(self, mode): if not self.tracks: diff --git a/gridplayer/widgets/video_block_chapters.py b/gridplayer/widgets/video_block_chapters.py index 7d42bebf..3270d7c1 100644 --- a/gridplayer/widgets/video_block_chapters.py +++ b/gridplayer/widgets/video_block_chapters.py @@ -184,11 +184,11 @@ def loop(self): start_ms, end_ms = span_at(self._chapters, block.arriving_time, length) # cleared first: the new start can lie past the old end - block.reset_loop() + block.loop.reset() - block.set_loop_start_time(start_ms) + block.loop.set_start_time(start_ms) # the last chapter runs to the end of the file, which is where an # unset end already is if end_ms < length: - block.set_loop_end_time(end_ms) + block.loop.set_end_time(end_ms) diff --git a/gridplayer/widgets/video_block_loop.py b/gridplayer/widgets/video_block_loop.py new file mode 100644 index 00000000..061727ed --- /dev/null +++ b/gridplayer/widgets/video_block_loop.py @@ -0,0 +1,64 @@ +"""The loop of a video block: the stretch of it played over and over, and +whether each pass starts somewhere at random. + +Where a loop ends up taking the video is the block's (see loop_start, +loop_end and time_changed); here is only how the viewer sets it. +""" + +from gridplayer.widgets.video_block_guards import VideoBlockPart, only_seekable + + +class LoopControl(VideoBlockPart): + @only_seekable + def toggle_random(self): + self.set_random(not self._block.video_params.is_start_random) + + def set_random(self, is_start_random: bool): + """Start each pass somewhere at random, or where the loop starts. + + Kept for a video stopped or still loading too, which goes by it + once it plays, and for one that is live, which never loops. + """ + + self._block.video_params.is_start_random = is_start_random + + @only_seekable + def set_start(self): + self.set_start_time(self._block.time) + + @only_seekable + def set_start_time(self, new_time): + if None not in {self._block.video_params.loop_end, new_time}: + if new_time >= self._block.video_params.loop_end: + return + + self._block.video_params.loop_start = new_time + + if new_time is None: + self._block.loop_start_change.emit(0) + else: + self._block.loop_start_change.emit( + new_time / self._block.video_driver.length + ) + + @only_seekable + def set_end(self): + self.set_end_time(self._block.time) + + @only_seekable + def set_end_time(self, new_time): + if None not in {self._block.video_params.loop_start, new_time}: + if new_time <= self._block.video_params.loop_start: + return + + self._block.video_params.loop_end = new_time + + if new_time is None: + self._block.loop_end_change.emit(100.0) + else: + self._block.loop_end_change.emit(new_time / self._block.video_driver.length) + + @only_seekable + def reset(self): + self.set_start_time(None) + self.set_end_time(None) diff --git a/gridplayer/widgets/video_block_screenshot.py b/gridplayer/widgets/video_block_screenshot.py new file mode 100644 index 00000000..34a61c91 --- /dev/null +++ b/gridplayer/widgets/video_block_screenshot.py @@ -0,0 +1,95 @@ +"""Screenshots of a video block: the frame asked of the player, and the file +it is saved into off the GUI thread.""" + +from datetime import datetime +from functools import partial + +from gridplayer.settings import Settings +from gridplayer.utils.qt import qt_connect, translate +from gridplayer.utils.screenshots import ( + ScreenshotJob, + ScreenshotName, + remove_frame_file, + screenshots_dir, +) +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_with_video_tacks, +) + + +class ScreenshotControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # held until they are done, the pool only has the C++ side of them + self._jobs: set[ScreenshotJob] = set() + self._is_quiet = False + + @only_initialized + @only_with_video_tacks + def take(self, is_quiet=False): + # Every video at once would be a warning box per video that fails, + # one on top of the other, and all for the same reason more often + # than not. Quiet says so on the video's overlay instead. + self._is_quiet = is_quiet + + self._block.video_driver.take_screenshot() + + def taken(self, frame, view): + if frame is None: + self._report_failure( + translate("Warning", "There is no video frame to take a screenshot of") + ) + return + + try: + name = ScreenshotName( + template=Settings().get("screenshots/filename_template"), + title=self._block.title or self._block.video_params.uri_name, + position_ms=0 if self._block.is_live else (self._block.time or 0), + now=datetime.now().astimezone(), + ) + + job = ScreenshotJob( + source=frame, + directory=screenshots_dir(Settings().get("screenshots/dir")), + name=name, + image_format=Settings().get("screenshots/format"), + jpg_quality=Settings().get("screenshots/jpg_quality"), + view=view, + ) + except Exception: + if isinstance(frame, str): + remove_frame_file(frame) + raise + + qt_connect( + (job.saved, self._saved), + (job.failed, self._failed), + (job.saved, partial(self._jobs.discard, job)), + (job.failed, partial(self._jobs.discard, job)), + ) + + self._jobs.add(job) + job.start() + + def _saved(self, _path): + self._block.show_overlay() + self._block.info_change.emit(translate("Screenshot", "Screenshot saved")) + + def _failed(self, error): + self._report_failure( + translate("Warning", "Could not save the screenshot"), error + ) + + def _report_failure(self, message, detail=""): + if self._is_quiet: + self._block.show_overlay() + self._block.info_change.emit(translate("Screenshot", "Screenshot failed")) + return + + self._block._ctx.commands.warning( + f"{message}\n\n{detail}" if detail else message + ) diff --git a/tests/conftest.py b/tests/conftest.py index 642ae57e..2d176f16 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,8 @@ def _block_parts(): from gridplayer.widgets.video_block_audio import AudioControl from gridplayer.widgets.video_block_bookmarker import Bookmarker from gridplayer.widgets.video_block_chapters import ChapterControl + from gridplayer.widgets.video_block_loop import LoopControl + from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl @@ -63,6 +65,8 @@ def _block_parts(): "audio": AudioControl, "bookmarker": Bookmarker, "chapters": ChapterControl, + "loop": LoopControl, + "screenshot": ScreenshotControl, "sponsorblock": SponsorBlockControl, "subtitles": SubtitleControl, "view": ViewControl, diff --git a/tests/test_bookmarks_dialog.py b/tests/test_bookmarks_dialog.py index be78e018..9381ecfb 100644 --- a/tests/test_bookmarks_dialog.py +++ b/tests/test_bookmarks_dialog.py @@ -250,8 +250,8 @@ def test_with_none_there_is_nothing_but_adding_and_pasting( class TestOutOfReach: def test_those_outside_the_loop_say_so(self, block, open_dialog): block.seek(30000) - block.set_loop_start_time(20000) - block.set_loop_end_time(40000) + block.loop.set_start_time(20000) + block.loop.set_end_time(40000) dialog = open_dialog(block) tooltips = [_cell(dialog, row, Column.NAME, Qt.ToolTipRole) for row in range(3)] @@ -260,8 +260,8 @@ def test_those_outside_the_loop_say_so(self, block, open_dialog): def test_the_loop_s_edges_are_drawn_across_the_list(self, block, open_dialog): block.seek(30000) - block.set_loop_start_time(20000) - block.set_loop_end_time(40000) + block.loop.set_start_time(20000) + block.loop.set_end_time(40000) dialog = open_dialog(block) edges = [(row, text) for row, text, _ in dialog._list.edges] @@ -270,8 +270,8 @@ def test_the_loop_s_edges_are_drawn_across_the_list(self, block, open_dialog): def test_a_loop_with_none_inside_is_drawn_where_it_sits(self, block, open_dialog): block.seek(22000) - block.set_loop_start_time(20000) - block.set_loop_end_time(25000) + block.loop.set_start_time(20000) + block.loop.set_end_time(25000) dialog = open_dialog(block) edges = [(row, text) for row, text, _ in dialog._list.edges] @@ -296,7 +296,7 @@ def test_those_past_the_end_are_under_a_line_of_their_own( def test_one_out_of_reach_is_nowhere_to_jump(self, block, open_dialog): block.seek(30000) - block.set_loop_start_time(20000) + block.loop.set_start_time(20000) dialog = open_dialog(block, selected=(0,)) dialog._jump() @@ -700,7 +700,7 @@ def test_failed_there_is_nothing_to_play(self, block, dialog): def test_loaded_again_it_is_all_back(self, block, dialog): block.seek(30000) - block.set_loop_start_time(20000) + block.loop.set_start_time(20000) block.reload() @@ -980,7 +980,7 @@ def test_jump_to_is_grayed_out_for_one_out_of_reach( self, block, open_dialog, shown ): block.seek(30000) - block.set_loop_start_time(20000) + block.loop.set_start_time(20000) dialog = open_dialog(block, selected=(0,)) dialog._list.menu_requested.emit(QPoint(10, 10)) diff --git a/tests/test_screenshots.py b/tests/test_screenshots.py index 029c7f0b..7f464dfe 100644 --- a/tests/test_screenshots.py +++ b/tests/test_screenshots.py @@ -48,7 +48,7 @@ transformed_image, unknown_specifiers, ) -from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_screenshot import ScreenshotControl NOW = datetime(2026, 9, 24, 13, 5, 9, tzinfo=timezone.utc) @@ -541,7 +541,7 @@ def test_it_is_the_last_thing_in_the_video_menu(self): def test_it_asks_the_active_video(self): action = ACTIONS["Take Screenshot"] - assert action["func"] == ("active", "take_screenshot") + assert action["func"] == ("active", "screenshot.take") assert action["show_if"] == "is_active_has_video" def test_it_is_not_one_of_the_grid_snapshots(self): @@ -578,18 +578,21 @@ def test_the_keys_are_one_apart(self): class TestHowAFailureIsSaid: - def _block(self, is_quiet): - return SimpleNamespace( - _is_screenshot_quiet=is_quiet, + def _screenshot(self, is_quiet): + block = SimpleNamespace( show_overlay=MagicMock(), info_change=MagicMock(), _ctx=SimpleNamespace(commands=SimpleNamespace(warning=MagicMock())), ) + screenshot = ScreenshotControl(block) + screenshot._is_quiet = is_quiet + + return block, screenshot def test_one_video_gets_a_warning_with_the_reason(self): - block = self._block(is_quiet=False) + block, screenshot = self._screenshot(is_quiet=False) - VideoBlock._report_screenshot_failure(block, "Could not save", "disk full") + screenshot._report_failure("Could not save", "disk full") block._ctx.commands.warning.assert_called_once_with( "Could not save\n\ndisk full" @@ -599,9 +602,9 @@ def test_one_video_gets_a_warning_with_the_reason(self): def test_every_video_at_once_says_so_on_the_overlay(self): """Not a warning box per video, one on top of the other.""" - block = self._block(is_quiet=True) + block, screenshot = self._screenshot(is_quiet=True) - VideoBlock._report_screenshot_failure(block, "Could not save", "disk full") + screenshot._report_failure("Could not save", "disk full") block._ctx.commands.warning.assert_not_called() block.show_overlay.assert_called_once() diff --git a/tests/test_video_block_bookmarks.py b/tests/test_video_block_bookmarks.py index 23512f98..897df0f2 100644 --- a/tests/test_video_block_bookmarks.py +++ b/tests/test_video_block_bookmarks.py @@ -432,8 +432,8 @@ def looped(self, make_block): ) block.seek(25000) - block.set_loop_start_time(20000) - block.set_loop_end_time(40000) + block.loop.set_start_time(20000) + block.loop.set_end_time(40000) return block @@ -673,7 +673,7 @@ def test_rows_outside_the_loop_are_grayed_out(self, block): manager = _Manager(block) block.seek(25000) - block.set_loop_start_time(20000) + block.loop.set_start_time(20000) enabled = [manager.is_active_bookmark_reachable(i) for i in range(2)] @@ -715,7 +715,7 @@ def test_it_is_a_seek_the_others_can_follow(self, block): def test_one_outside_the_loop_goes_nowhere(self, block): block.seek(25000) - block.set_loop_start_time(20000) + block.loop.set_start_time(20000) synced = [] block.sync_time.connect(synced.append) @@ -796,7 +796,7 @@ def test_jump_to_goes_there_as_a_seek_the_others_can_follow(self, block): def test_jump_to_one_outside_the_loop_is_grayed_out(self, block): block.seek(25000) - block.set_loop_start_time(20000) + block.loop.set_start_time(20000) jump = self._item(block.bookmarker.marker_menu((10000,)), "Jump to Bookmark") @@ -995,7 +995,7 @@ def test_those_outside_the_loop_are_passed_over(self, make_block): block = make_block(self.SHARED) # past the one at 30.6, not near enough the next to be on it block.seek(30800) - block.set_loop_start_time(30500) + block.loop.set_start_time(30500) assert [self._click(block) for _ in range(3)] == [31200, 30600, 31200] diff --git a/tests/test_video_block_sponsorblock.py b/tests/test_video_block_sponsorblock.py index 61297fcd..0d1512cc 100644 --- a/tests/test_video_block_sponsorblock.py +++ b/tests/test_video_block_sponsorblock.py @@ -318,8 +318,8 @@ def test_one_that_is_all_there_is_is_let_play(self, make_block): class TestInALoop: def test_a_loop_inside_one_plays_it(self, block): _found(block, SPONSOR) - block.set_loop_start_time(65000) - block.set_loop_end_time(80000) + block.loop.set_start_time(65000) + block.loop.set_end_time(80000) _reported(block, 70000) @@ -327,8 +327,8 @@ def test_a_loop_inside_one_plays_it(self, block): def test_a_loop_starting_inside_one_plays_it_every_pass(self, block): _found(block, SPONSOR) - block.set_loop_start_time(70000) - block.set_loop_end_time(120000) + block.loop.set_start_time(70000) + block.loop.set_end_time(120000) _reported(block, 119900) _reported(block, 120100) @@ -339,8 +339,8 @@ def test_a_loop_starting_inside_one_plays_it_every_pass(self, block): def test_one_that_runs_past_the_loop_end_ends_the_pass(self, block): _found(block, SPONSOR) - block.set_loop_start_time(40000) - block.set_loop_end_time(80000) + block.loop.set_start_time(40000) + block.loop.set_end_time(80000) _reported(block, 60100) diff --git a/tests/test_video_block_stream_seek.py b/tests/test_video_block_stream_seek.py index 27ea51e7..bf0d8557 100644 --- a/tests/test_video_block_stream_seek.py +++ b/tests/test_video_block_stream_seek.py @@ -114,7 +114,7 @@ def test_landing_short_is_no_pass_coming_round(self, block): assert _seeks(block) == [15000] def test_landing_short_of_a_loop_is_not_sent_back(self, block): - block.set_loop_start_time(12000) + block.loop.set_start_time(12000) block.seek(12000) _reported(block, 12000 - 2300) _reported(block, 12000 - 2290) diff --git a/tests/test_video_position.py b/tests/test_video_position.py index bb138963..6cc1d4ce 100644 --- a/tests/test_video_position.py +++ b/tests/test_video_position.py @@ -9,7 +9,6 @@ """ import json -from functools import partial from types import SimpleNamespace from unittest.mock import MagicMock, Mock @@ -41,6 +40,7 @@ from gridplayer.widgets import video_block_view as video_block_view_module from gridplayer.widgets import video_frame_vlc_base as vlc_base from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_loop import LoopControl from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_vlc_hw_sp import VideoFrameVLCHWSP from tests.conftest import mock_block_parts @@ -234,9 +234,10 @@ def _all_videos_submenu(name): return next(item for item in video if isinstance(item, tuple) and item[0] == name) -def _assert_set_for_all(name, param, owner=ViewControl): +def _assert_set_for_all(name, param, owner=ViewControl, method=None): """On and Off, each setting it so for every video, and ticked where - every video already has it so.""" + every video already has it so; method is what the manager's all_command + signal is wired to, where it is named otherwise.""" assert name in SUBMENUS @@ -245,7 +246,7 @@ def _assert_set_for_all(name, param, owner=ViewControl): target, command, arg = action["func"] assert (target, arg) == ("all", value) - assert hasattr(owner, command) + assert hasattr(owner, method or command) assert hasattr(VideoBlocksManager, f"all_{command}") assert action["check_if"] == ("is_all_videos_param_set_to", param, value) @@ -402,7 +403,9 @@ def test_every_video_can_be_set_to_loop_at_random_or_not(self): "Random Loop On [ALL]", "Random Loop Off [ALL]", ) - _assert_set_for_all("Random Loop", "is_start_random", owner=VideoBlock) + _assert_set_for_all( + "Random Loop", "is_start_random", owner=LoopControl, method="set_random" + ) def test_the_old_flips_for_all_are_gone(self): """Each video flipped its own way: mixed, they stayed mixed.""" @@ -795,12 +798,12 @@ def test_a_video_loops_at_random_as_it_is_told(self): block = Mock(spec=VideoBlock) block.video_params = Video(uri=URI) block.is_live = False - block.set_loop_random = partial(VideoBlock.set_loop_random, block) + block.loop = LoopControl(block) - VideoBlock.set_loop_random(block, True) + block.loop.set_random(True) assert block.video_params.is_start_random - VideoBlock.toggle_loop_random(block) + block.loop.toggle_random() assert not block.video_params.is_start_random From d10c8cd4906704c859ea8602b24b7ce659f25e1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:14:10 +0000 Subject: [PATCH 09/14] refactor: take the video block's recovery from failures out into a RecoveryControl Reloading a network video that failed, the countdown to it, and going on past an item of the list that would not play are block.recovery, with the settings for both ("recovery.set_retry_mode", "recovery.set_fail_action", ...). The block no longer reaches into its timers and counters: it asks for retries, note_played(), stop(), stop_skip() and the like. Showing the error itself stays with the block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/params/actions.py | 18 +- gridplayer/player/managers/active_block.py | 12 +- gridplayer/player/managers/video_blocks.py | 4 +- gridplayer/widgets/video_block.py | 433 ++------------------ gridplayer/widgets/video_block_recovery.py | 441 +++++++++++++++++++++ tests/conftest.py | 2 + tests/test_video_block_lists.py | 88 ++-- tests/test_video_block_network_retry.py | 115 +++--- tests/test_video_errors.py | 52 +-- 9 files changed, 617 insertions(+), 548 deletions(-) create mode 100644 gridplayer/widgets/video_block_recovery.py diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 720f7a48..03956390 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -480,7 +480,7 @@ "When Video Fails Show Error": { "title": translate("When Video Fails", "Show Error"), "icon": "error", - "func": ("active", "set_list_fail_action", ListFailAction.SHOW_ERROR), + "func": ("active", "recovery.set_fail_action", ListFailAction.SHOW_ERROR), "check_if": ( "is_active_param_set_to", "list_fail_action", @@ -492,7 +492,7 @@ "When Video Fails Play Next": { "title": translate("When Video Fails", "Play Next"), "icon": "next-video-file", - "func": ("active", "set_list_fail_action", ListFailAction.PLAY_NEXT), + "func": ("active", "recovery.set_fail_action", ListFailAction.PLAY_NEXT), "check_if": ( "is_active_param_set_to", "list_fail_action", @@ -505,8 +505,8 @@ "title": "{}: %v".format(translate("When Video Fails", "Next After")), "keymap_title": translate("When Video Fails", "Set Next After"), "icon": "empty", - "func": ("active", "list_fail_wait"), - "value_getter": ("active", "get_list_fail_wait"), + "func": ("active", "recovery.fail_wait_dialog"), + "value_getter": ("active", "recovery.get_fail_wait"), "show_if": AND( "is_active_list_cell", ( @@ -1384,7 +1384,7 @@ "On Network Error Show Error": { "title": translate("On Network Error", "Show Error"), "icon": "error", - "func": ("active", "set_network_retry_mode", NetworkRetryMode.OFF), + "func": ("active", "recovery.set_retry_mode", NetworkRetryMode.OFF), "check_if": ( "is_active_param_set_to", "network_retry_mode", @@ -1396,7 +1396,7 @@ "On Network Error Retry Times": { "title": translate("On Network Error", "Reload a Few Times"), "icon": "reload", - "func": ("active", "set_network_retry_mode", NetworkRetryMode.TIMES), + "func": ("active", "recovery.set_retry_mode", NetworkRetryMode.TIMES), "check_if": ( "is_active_param_set_to", "network_retry_mode", @@ -1408,7 +1408,7 @@ "On Network Error Retry Forever": { "title": translate("On Network Error", "Keep Reloading"), "icon": "reload", - "func": ("active", "set_network_retry_mode", NetworkRetryMode.INFINITE), + "func": ("active", "recovery.set_retry_mode", NetworkRetryMode.INFINITE), "check_if": ( "is_active_param_set_to", "network_retry_mode", @@ -1421,8 +1421,8 @@ "title": "{}: %v".format(translate("Actions", "Attempts")), "keymap_title": translate("Actions", "Set Attempts"), "icon": "empty", - "func": ("active", "network_retry_times"), - "value_getter": ("active", "get_network_retry_times"), + "func": ("active", "recovery.retry_times_dialog"), + "value_getter": ("active", "recovery.get_retry_times"), "show_if": AND( NOT("is_active_local_file"), ( diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index 3f950854..8c0efa2f 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -51,12 +51,12 @@ "previous_video", "next_video", "shuffle_video", - "set_network_retry_mode", - "network_retry_times", - "get_network_retry_times", - "set_list_fail_action", - "list_fail_wait", - "get_list_fail_wait", + "recovery.set_retry_mode", + "recovery.retry_times_dialog", + "recovery.get_retry_times", + "recovery.set_fail_action", + "recovery.fail_wait_dialog", + "recovery.get_fail_wait", "audio.set_track", "audio.set_device", "set_audio_language", diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 05065130..55a6fb32 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -872,8 +872,8 @@ def _add_video_block(self, video): (self.all_set_end_action, vb.set_end_action), (self.all_set_dir_recursive, vb.set_dir_recursive), (self.all_set_list_end_action, vb.set_list_end_action), - (self.all_set_list_fail_action, vb.set_list_fail_action), - (self.all_set_list_fail_wait, vb.set_list_fail_wait), + (self.all_set_list_fail_action, vb.recovery.set_fail_action), + (self.all_set_list_fail_wait, vb.recovery.set_fail_wait), (self.all_rate_increase, vb.rate_increase), (self.all_rate_decrease, vb.rate_decrease), (self.all_rate_reset, vb.rate_reset), diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index 1a3617d3..dd978a49 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -55,15 +55,12 @@ from gridplayer.params import env from gridplayer.params.static import ( CHROME_MIN_SIZE, - MAX_LIST_FAIL_WAIT_SEC, MAX_RATE, MIN_RATE, OVERLAY_ACTIVITY_EVENT, PLAYER_ID_LENGTH, ListEndAction, - ListFailAction, ListSourceKind, - NetworkRetryMode, VideoAspect, VideoDeinterlace, VideoDeinterlaceMode, @@ -124,6 +121,7 @@ only_with_video_tacks, ) from gridplayer.widgets.video_block_loop import LoopControl +from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl from gridplayer.widgets.video_block_subtitles import SubtitleControl @@ -162,17 +160,6 @@ # as good as one that never follows it at all MAX_QUALITY_ADAPT_DELAY_S = 3600 -MAX_NETWORK_RETRIES = 1000 - -# how long to wait before trying a failed network video again. A host that -# just turned us away is unlikely to change its mind within a second, and -# retrying forever at full speed would be indistinguishable from an attack -NETWORK_RETRY_DELAYS_S = (1, 2, 5, 15, 30) - -# how many items of a list in a row may fail for the network, or a site -# turning requests away, before it is left at that -MAX_NETWORK_FAILURES_IN_A_ROW = 10 - # the place, over the video, of one taken off its list while it played NOT_ON_LIST_TXT = "\u2013" @@ -188,13 +175,6 @@ ) -def list_fail_wait_txt(wait_sec: int) -> str: - if not wait_sec: - return translate("When Video Fails", "At once") - - return translate("When Video Fails", "{SECONDS} s").format(SECONDS=wait_sec) - - class VideoBlock(QWidget): load_video = pyqtSignal(MediaInput) @@ -296,10 +276,6 @@ def __init__(self, video_driver, context, **kwargs): self._reload_timer = QTimer(self) self._reload_timer.timeout.connect(self.reload) - self._network_retries = 0 - self._network_retry_countdown = 0 - self._network_retry_delay = 0 - # how far it got loading: the stage it is at, and the ones it goes # through self._load_stage: LoadStage | None = None @@ -314,18 +290,6 @@ def __init__(self, video_driver, context, **kwargs): self._proxied_url: str | None = None self._stream_loaded_at = 0.0 - # the note under a failed item's card counting down to the next one; - # precise, as the wait it counts down: a coarse timer on Linux runs - # up to 5% long, and 2 s left said "3 s" - self._skip_note_timer = QTimer(self) - self._skip_note_timer.setTimerType(Qt.PreciseTimer) - self._skip_note_timer.setInterval(1000) - self._skip_note_timer.timeout.connect(self._refresh_skip_note) - - self._network_retry_timer = QTimer(self) - self._network_retry_timer.setInterval(1000) - self._network_retry_timer.timeout.connect(self._network_retry_tick) - self._stream_quality_playing = None self._audio_language_playing = None @@ -362,6 +326,7 @@ def __init__(self, video_driver, context, **kwargs): self.audio = AudioControl(self) self.loop = LoopControl(self) self.screenshot = ScreenshotControl(self) + self.recovery = RecoveryControl(self) # the next, previous or a random file of the folder, to put in this # one's place @@ -394,20 +359,9 @@ def __init__(self, video_driver, context, **kwargs): open_elsewhere=self._uris_open_elsewhere, ) - # items of the list that would not play, one after the other: a - # list that has none that will is not gone round for ever; and of - # them, the ones that failed for the network or a site refusing - self._list_failures_in_a_row = 0 - self._network_failures_in_a_row = 0 - # the List window is open on the cell, and plays in it self._is_list_window_shown = False - self._skip_failed_timer = QTimer(self) - self._skip_failed_timer.setSingleShot(True) - self._skip_failed_timer.setTimerType(Qt.PreciseTimer) - self._skip_failed_timer.timeout.connect(self._skip_failed_item) - # the item playing has played since it was put in the cell: a # reload may yet bring it back, whatever it failed with self._has_item_played = False @@ -606,7 +560,7 @@ def init_overlay(self): (overlay.list_place_clicked, self.show_list_window), (overlay.stop_clicked, self.stop_playback), (overlay.retry_clicked, self.reload), - (overlay.next_clicked, self.skip_failed_item_now), + (overlay.next_clicked, self.recovery.skip_now), (overlay.details_clicked, self.show_error_details), (self.time_change, overlay.set_position), (self.volume_change, overlay.set_volume_position), @@ -665,9 +619,8 @@ def cleanup_start(self): def cleanup(self): self.overlay_hide_timer.stop() self._in_progress_timer.stop() - self._network_retry_timer.stop() self._quality_adapt_timer.stop() - self._skip_failed_timer.stop() + self.recovery.stop() self._log.debug(f"{self.id}: cleaning up resolver") self.url_resolver.cleanup() @@ -738,21 +691,21 @@ def error(self): self._error_tries = 0 self.set_status("error") self.cleanup() - self._schedule_skip_failed_item() + self.recovery.schedule_skip() self._show_error_card() def network_error(self): - if not self._is_reload_futile and self._schedule_network_retry(): + if not self.recovery.is_reload_futile and self.recovery.schedule_retry(): return - self._error_tries = self._network_retries - self._network_retries = 0 + self._error_tries = self.recovery.retries + self.recovery.drop_retries() self._is_error = True self._is_state_change_in_progress = False self.set_status("network-error") self.cleanup() - self._schedule_skip_failed_item() + self.recovery.schedule_skip() self._show_error_card() def _show_error_card(self): @@ -770,34 +723,9 @@ def _show_error_card(self): self._error_stage = self._load_stage self._load_stage = None - self.video_status.show_error(error, self._skip_note()) + self.video_status.show_error(error, self.recovery.skip_note()) self._sync_overlay_mode() - def _skip_note(self) -> str: - if not self._skip_failed_timer.isActive(): - return self._given_up_txt() - - if self._skip_failed_timer.interval() == 0: - return "" - - seconds = max(1, -(-self._skip_failed_timer.remainingTime() // 1000)) - - if self._is_list_going_back: - return translate("Video Status", "Previous file in {SECONDS} s").format( - SECONDS=seconds - ) - - return translate("Video Status", "Next file in {SECONDS} s").format( - SECONDS=seconds - ) - - def _refresh_skip_note(self): - if not self._skip_failed_timer.isActive(): - self._skip_note_timer.stop() - - if self._is_error and self._last_error is not None: - self.video_status.show_error(self._last_error, self._skip_note()) - def show_error_details(self): """Everything known of why it failed, in a window of its own.""" @@ -808,9 +736,9 @@ def show_error_details(self): # held while it is read, for its Try Again to be for this item and # not the one the list would have gone on to meanwhile - is_skipping = self._skip_failed_timer.isActive() - self._skip_failed_timer.stop() - self._refresh_skip_note() + is_skipping = self.recovery.is_skip_counting + self.recovery.stop_skip() + self.recovery.refresh_skip_note() dialog = ErrorDetailsDialog( error, self._error_rows(error), parent=self.window() @@ -821,8 +749,8 @@ def show_error_details(self): # left as it was: on through the list, the count started over if is_skipping and self._last_error is error and not self._is_closing: - self._start_skip_count() - self._refresh_skip_note() + self.recovery.start_skip_count() + self.recovery.refresh_skip_note() def _error_rows(self, error: ShownError) -> DetailRows: """What its Details window says of the video besides.""" @@ -866,7 +794,7 @@ def _error_rows(self, error: ShownError) -> DetailRows: rows.extend(code_rows(error)) if self._error_tries: - limit = self._network_retry_limit + limit = self.recovery.retry_limit if limit > 0: tries = translate("Error Details", "{COUNT} of {LIMIT}").format( @@ -888,94 +816,6 @@ def _error_rows(self, error: ShownError) -> DetailRows: return tuple(rows) - def _schedule_network_retry(self) -> bool: - """Try the video again later instead of giving up on it. - - Streams break for reasons that pass: a host hiccups, a laptop wakes - up with no network yet, a signed URL runs out. Showing the error is - the last resort, not the first answer. - """ - - retry_limit = self._network_retry_limit - - if retry_limit == 0: - return False - - if retry_limit > 0 and self._network_retries >= retry_limit: - self._log.debug(f"Giving up after {self._network_retries} retries") - return False - - self._network_retries += 1 - - delay_idx = min(self._network_retries - 1, len(NETWORK_RETRY_DELAYS_S) - 1) - self._network_retry_countdown = NETWORK_RETRY_DELAYS_S[delay_idx] - self._network_retry_delay = self._network_retry_countdown - - self._log.debug( - f"Network error, retry {self._network_retries}" - f" in {self._network_retry_countdown}s" - ) - - self._is_error = False - self._is_state_change_in_progress = False - - # the driver is of no use until the video is loaded again, and - # holding on to it keeps a VLC player busy for the whole wait - self.cleanup() - - self._load_stage = LoadStage.RECONNECT - self.set_status("processing") - self._show_network_retry_status() - - self._network_retry_timer.start() - - return True - - def _drop_network_retries(self): - self._network_retry_timer.stop() - self._network_retries = 0 - - def _network_retry_tick(self): - self._network_retry_countdown -= 1 - - if self._network_retry_countdown > 0: - self._show_network_retry_status() - return - - self._network_retry_timer.stop() - - if self._is_closing or self.video_params is None: - return - - self.reload() - - def _show_network_retry_status(self): - detail = self._network_retry_attempt_txt - - # what went wrong the last time, in short - error = self._last_error - - if error is not None and (error.where or error.code): - tag = " ".join(part for part in (error.where, error.code) if part) - detail = f"{detail} · {tag}" - - self.video_status.show_reconnect( - self._network_retry_countdown, self._network_retry_delay, detail - ) - - @property - def _network_retry_attempt_txt(self) -> str: - retry_limit = self._network_retry_limit - - if retry_limit < 0: - return translate("Video Status", "Try {ATTEMPT}").format( - ATTEMPT=self._network_retries - ) - - return translate("Video Status", "Try {ATTEMPT} of {LIMIT}").format( - ATTEMPT=self._network_retries, LIMIT=retry_limit - ) - def set_status(self, status): self._is_status_kept_for_picture = False @@ -1018,40 +858,13 @@ def _sync_overlay_mode(self): if mode is OverlayMode.ERROR: self.overlay.set_error_actions( has_details=self._last_error is not None, - can_go_next=self._can_skip_failed_item, - is_going_back=self._is_list_going_back, + can_go_next=self.recovery.can_skip, + is_going_back=self.recovery.is_going_back, ) self.overlay.set_mode(mode) self.overlay.set_is_on_chrome(mode is not OverlayMode.VIDEO) - @property - def _can_skip_failed_item(self) -> bool: - media_list = self.media_list - - return media_list is not None and len(media_list.items) > 1 - - @property - def _is_list_going_back(self) -> bool: - # on to the item before, at the end of each: Next, and the count - # down to it, go back - return ( - self.video_params is not None - and self.video_params.end_action == VideoEndAction.PREVIOUS_FILE - ) - - def skip_failed_item_now(self): - """On to the item that comes next, now rather than in a moment: the - way the list goes by itself where it does, at its end as well.""" - - self._skip_failed_timer.stop() - - if self._is_closing or self.media_list is None: - return - - if not self._step_at_end(self.video_params.end_action): - self.next_video() - def update_status(self, info_text): self.video_status.status_text = translate("Video Status", info_text) self.video_status.percent = 0 @@ -1145,7 +958,7 @@ def _video_detail(self) -> str: return "" def reload(self): - self._network_retry_timer.stop() + self.recovery.stop_retry_countdown() self._quality_adapt_timer.stop() self.is_live = False @@ -1381,40 +1194,6 @@ def seek_timecode(self): self.manual_seek("seek", time_ms) - def set_network_retry_mode(self, mode): - self.video_params.network_retry_mode = mode - - # a new policy starts with a full budget rather than what is left - self._network_retries = 0 - - if not self._network_retry_timer.isActive(): - return - - if self._network_retry_limit != 0: - return - - # the reload this video was counting down to has just been called off - self._network_retry_timer.stop() - self.network_error() - - def network_retry_times(self): - times = QCustomSpinboxInput.get_int( - parent=self.parent(), - title=translate( - "Dialog - Set reload attempts", "Set reload attempts", "Header" - ), - initial_value=self.video_params.network_retry_times, - _min=1, - _max=MAX_NETWORK_RETRIES, - ) - - self.video_params.network_retry_times = times - - self._network_retries = 0 - - def get_network_retry_times(self): - return str(self.video_params.network_retry_times) - @only_streamable def quality_adapt_delay(self): delay_sec = QCustomSpinboxInput.get_int( @@ -2108,7 +1887,7 @@ def stop_playback(self): # stopped from the menu as from its own Stop, nothing left to start # it again by itself: a reconnect counting down, a link still being # resolved, a list being read, the next item of its list (below) - self._drop_network_retries() + self.recovery.drop_retries() self._is_waiting_for_list_read = False if self.url_resolver is not None and self.url_resolver.is_busy: @@ -2277,7 +2056,7 @@ def set_video(self, video_params: Video): # may be reloaded as many times as any; one that is reloaded comes # back here as the cell's first if not is_first_video: - self._drop_network_retries() + self.recovery.drop_retries() self._has_item_played = False # Shut down current video @@ -2338,10 +2117,10 @@ def _present_stopped(self): def _start_load(self): # what went wrong before is behind it, but while it is tried again - if self._network_retries == 0: + if self.recovery.retries == 0: self._last_error = None - self._skip_note_timer.stop() + self.recovery.stop_skip_note() # named from the start, on the overlay shown while it loads: a link # by its file name until its page gives it a title @@ -2631,23 +2410,6 @@ def load_stream_quality(self, wanted_quality: str): ) ) - @property - def _network_retry_limit(self) -> int: - """How many times this video may be reloaded, -1 for no limit.""" - - if self.video_params is None: - return 0 - - mode = self.video_params.network_retry_mode - - if mode == NetworkRetryMode.INFINITE: - return -1 - - if mode == NetworkRetryMode.TIMES: - return max(self.video_params.network_retry_times, 0) - - return 0 - @property def _quality_adapt_delay_ms(self) -> int: """How long a pane has to keep its new size before the stream follows. @@ -2746,7 +2508,7 @@ def _with_origin(self, stream, quality: str): def load_video_finish(self): # the video is playing, so whatever went wrong before is behind us - self._network_retries = 0 + self.recovery.note_played() self._last_error = None # a fresh player has no pass behind it to compare times against @@ -2763,9 +2525,6 @@ def load_video_finish(self): self.color = self.video_params.color.as_hex() - # it plays, so the list is not one without anything that will - self._list_failures_in_a_row = 0 - self._network_failures_in_a_row = 0 self._has_item_played = True self._learn_item_length() @@ -2932,36 +2691,6 @@ def set_list_end_action(self, list_end_action: ListEndAction): self.video_params.list_end_action = list_end_action - def set_list_fail_action(self, list_fail_action: ListFailAction): - """What to do when an item of the list won't play. Kept for a cell - with no list too, for when it has one.""" - - self.video_params.list_fail_action = list_fail_action - - self._restart_skip_count() - - def list_fail_wait(self): - wait_sec = QCustomSpinboxInput.get_int( - parent=self.parent(), - title=translate("When Video Fails", "Play next after (sec)"), - special_text=translate("When Video Fails", "At once"), - initial_value=self.video_params.list_fail_wait_sec, - _min=0, - _max=MAX_LIST_FAIL_WAIT_SEC, - cancel_value=None, - ) - - if wait_sec is not None: - self.set_list_fail_wait(wait_sec) - - def set_list_fail_wait(self, wait_sec: int): - self.video_params.list_fail_wait_sec = wait_sec - - self._restart_skip_count() - - def get_list_fail_wait(self) -> str: - return list_fail_wait_txt(self.video_params.list_fail_wait_sec) - @only_initialized @only_seekable def seek_shift_percent(self, shift_percent): @@ -3257,7 +2986,7 @@ def _cancel_steps(self): viewer did something else with it meanwhile.""" self._folder_navigator.cancel() - self._skip_failed_timer.stop() + self.recovery.stop_skip() def _link_was_a_list(self, resolved: ResolvedList): """The link leads to a list of videos rather than to one: the cell @@ -3615,118 +3344,6 @@ def _follow_new_link(self): self.video_params.uri = item.uri self._held_item = item - def _schedule_skip_failed_item(self): - """Go on to the next item of the list in a moment, where this one - will not play and the cell is set to. - - Not for ever: a list on which nothing plays is gone round once, and - one that fails for the network or a site refusing is left sooner. - """ - - if self.media_list is None or self._is_closing: - return - - self._list_failures_in_a_row += 1 - - if not self._is_item_dead: - self._network_failures_in_a_row += 1 - - if self._is_failed_skipped: - self._start_skip_count() - - @property - def _is_item_dead(self) -> bool: - """Failed for itself: a file that won't play, or a server that - answered it has nothing there. Not the network down or a site - turning requests away, which the next item would fail for too.""" - - if self.video_params.is_local_file: - return True - - error = self._last_error - - return error is not None and not error.is_retryable and not error.is_refused - - def _given_up_txt(self) -> str: - """Why the list no longer goes on past the item failed, if it - doesn't.""" - - media_list = self.media_list - - if ( - media_list is None - or self.video_params is None - or self.video_params.list_fail_action != ListFailAction.PLAY_NEXT - ): - return "" - - # so many in a row is the network, or a site turning every one of - # them away, rather than the videos: asking again for one after the - # other only makes a site that limits how often it is asked say no - # for longer - if self._network_failures_in_a_row > MAX_NETWORK_FAILURES_IN_A_ROW: - return translate("Video Status", "Stopped: {COUNT} failed in a row").format( - COUNT=self._list_failures_in_a_row - ) - - if self._list_failures_in_a_row > len(media_list.items): - return translate("Video Status", "Stopped: nothing on the list plays") - - return "" - - @property - def _is_failed_skipped(self) -> bool: - if self.media_list is None or self._is_closing: - return False - - if self.video_params.list_fail_action != ListFailAction.PLAY_NEXT: - return False - - return not self._given_up_txt() - - @property - def _is_reload_futile(self) -> bool: - """An item that never played, failing for what a reload would only - be told again, where the next one is to be played instead.""" - - error = self._last_error - - return ( - error is not None - and not error.is_retryable - and not self._has_item_played - and self._is_failed_skipped - ) - - def _start_skip_count(self): - self._skip_failed_timer.start(self.video_params.list_fail_wait_sec * 1000) - - if self._skip_failed_timer.interval() > 0: - self._skip_note_timer.start() - - def _restart_skip_count(self): - """The count to the next item after a failed one, once what is done - then was changed: started over, or called off.""" - - if not self._is_error: - return - - if self._is_failed_skipped: - self._start_skip_count() - else: - self._skip_failed_timer.stop() - - self._refresh_skip_note() - - def _skip_failed_item(self): - if self._is_closing or self.media_list is None: - return - - # along the list the way it goes by itself; on to the next where it - # doesn't - if not self._step_at_end(self.video_params.end_action): - self.next_video(is_end=True) - def _uris_open_elsewhere(self) -> list: return [ block.video_params.uri diff --git a/gridplayer/widgets/video_block_recovery.py b/gridplayer/widgets/video_block_recovery.py new file mode 100644 index 00000000..aaaa08bb --- /dev/null +++ b/gridplayer/widgets/video_block_recovery.py @@ -0,0 +1,441 @@ +"""What a video block does when its video will not play: tries a network +video again a while later, and goes on past an item of its list that +failed, to the next one. + +Showing what went wrong is the block's (see VideoBlock.network_error and +VideoBlock.error); this only decides what comes after, and when. +""" + +from PyQt5.QtCore import Qt, QTimer + +from gridplayer.dialogs.input_dialog import QCustomSpinboxInput +from gridplayer.params.static import ( + MAX_LIST_FAIL_WAIT_SEC, + ListFailAction, + NetworkRetryMode, + VideoEndAction, +) +from gridplayer.utils.qt import translate +from gridplayer.widgets.video_block_guards import VideoBlockPart +from gridplayer.widgets.video_status import LoadStage + +MAX_NETWORK_RETRIES = 1000 + +# how long to wait before trying a failed network video again. A host that +# just turned us away is unlikely to change its mind within a second, and +# retrying forever at full speed would be indistinguishable from an attack +NETWORK_RETRY_DELAYS_S = (1, 2, 5, 15, 30) + +# how many items of a list in a row may fail for the network, or a site +# turning requests away, before it is left at that +MAX_NETWORK_FAILURES_IN_A_ROW = 10 + + +def list_fail_wait_txt(wait_sec: int) -> str: + if not wait_sec: + return translate("When Video Fails", "At once") + + return translate("When Video Fails", "{SECONDS} s").format(SECONDS=wait_sec) + + +class RecoveryControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # reloads of the video tried so far, and the one counted down to: + # how long is left of the wait, and how long it was + self._retries = 0 + self._countdown = 0 + self._delay = 0 + + self._retry_timer = QTimer(block) + self._retry_timer.setInterval(1000) + self._retry_timer.timeout.connect(self._retry_tick) + + # items of the list that would not play, one after the other: a + # list that has none that will is not gone round for ever; and of + # them, the ones that failed for the network or a site refusing + self._failures_in_a_row = 0 + self._network_failures_in_a_row = 0 + + self._skip_timer = QTimer(block) + self._skip_timer.setSingleShot(True) + self._skip_timer.setTimerType(Qt.PreciseTimer) + self._skip_timer.timeout.connect(self._skip) + + # the note under a failed item's card counting down to the next one; + # precise, as the wait it counts down: a coarse timer on Linux runs + # up to 5% long, and 2 s left said "3 s" + self._note_timer = QTimer(block) + self._note_timer.setTimerType(Qt.PreciseTimer) + self._note_timer.setInterval(1000) + self._note_timer.timeout.connect(self.refresh_skip_note) + + @property + def retries(self) -> int: + """Reloads tried since the video last played.""" + + return self._retries + + def note_played(self): + """The video plays: whatever went wrong before is behind it, and the + list is not one without anything that will.""" + + self._retries = 0 + self._failures_in_a_row = 0 + self._network_failures_in_a_row = 0 + + def stop(self): + """Count down to nothing: the block is letting its player go.""" + + self._retry_timer.stop() + self._skip_timer.stop() + + def stop_retry_countdown(self): + """The reload counted down to is being made now, the tries kept.""" + + self._retry_timer.stop() + + @property + def is_skip_counting(self) -> bool: + return self._skip_timer.isActive() + + def stop_skip(self): + """Go on to no other item: the viewer did something else.""" + + self._skip_timer.stop() + + def stop_skip_note(self): + self._note_timer.stop() + + def skip_note(self) -> str: + if not self._skip_timer.isActive(): + return self._given_up_txt() + + if self._skip_timer.interval() == 0: + return "" + + seconds = max(1, -(-self._skip_timer.remainingTime() // 1000)) + + if self.is_going_back: + return translate("Video Status", "Previous file in {SECONDS} s").format( + SECONDS=seconds + ) + + return translate("Video Status", "Next file in {SECONDS} s").format( + SECONDS=seconds + ) + + def refresh_skip_note(self): + if not self._skip_timer.isActive(): + self._note_timer.stop() + + if self._block._is_error and self._block._last_error is not None: + self._block.video_status.show_error( + self._block._last_error, self.skip_note() + ) + + def schedule_retry(self) -> bool: + """Try the video again later instead of giving up on it. + + Streams break for reasons that pass: a host hiccups, a laptop wakes + up with no network yet, a signed URL runs out. Showing the error is + the last resort, not the first answer. + """ + + retry_limit = self.retry_limit + + if retry_limit == 0: + return False + + if retry_limit > 0 and self._retries >= retry_limit: + self._block._log.debug(f"Giving up after {self._retries} retries") + return False + + self._retries += 1 + + delay_idx = min(self._retries - 1, len(NETWORK_RETRY_DELAYS_S) - 1) + self._countdown = NETWORK_RETRY_DELAYS_S[delay_idx] + self._delay = self._countdown + + self._block._log.debug( + f"Network error, retry {self._retries} in {self._countdown}s" + ) + + self._block._is_error = False + self._block._is_state_change_in_progress = False + + # the driver is of no use until the video is loaded again, and + # holding on to it keeps a VLC player busy for the whole wait + self._block.cleanup() + + self._block._load_stage = LoadStage.RECONNECT + self._block.set_status("processing") + self._show_retry_status() + + self._retry_timer.start() + + return True + + def drop_retries(self): + self._retry_timer.stop() + self._retries = 0 + + def _retry_tick(self): + self._countdown -= 1 + + if self._countdown > 0: + self._show_retry_status() + return + + self._retry_timer.stop() + + if self._block._is_closing or self._block.video_params is None: + return + + self._block.reload() + + def _show_retry_status(self): + detail = self._attempt_txt + + # what went wrong the last time, in short + error = self._block._last_error + + if error is not None and (error.where or error.code): + tag = " ".join(part for part in (error.where, error.code) if part) + detail = f"{detail} · {tag}" + + self._block.video_status.show_reconnect(self._countdown, self._delay, detail) + + @property + def _attempt_txt(self) -> str: + retry_limit = self.retry_limit + + if retry_limit < 0: + return translate("Video Status", "Try {ATTEMPT}").format( + ATTEMPT=self._retries + ) + + return translate("Video Status", "Try {ATTEMPT} of {LIMIT}").format( + ATTEMPT=self._retries, LIMIT=retry_limit + ) + + @property + def can_skip(self) -> bool: + media_list = self._block.media_list + + return media_list is not None and len(media_list.items) > 1 + + @property + def is_going_back(self) -> bool: + # on to the item before, at the end of each: Next, and the count + # down to it, go back + return ( + self._block.video_params is not None + and self._block.video_params.end_action == VideoEndAction.PREVIOUS_FILE + ) + + def skip_now(self): + """On to the item that comes next, now rather than in a moment: the + way the list goes by itself where it does, at its end as well.""" + + self._skip_timer.stop() + + if self._block._is_closing or self._block.media_list is None: + return + + if not self._block._step_at_end(self._block.video_params.end_action): + self._block.next_video() + + def set_retry_mode(self, mode): + self._block.video_params.network_retry_mode = mode + + # a new policy starts with a full budget rather than what is left + self._retries = 0 + + if not self._retry_timer.isActive(): + return + + if self.retry_limit != 0: + return + + # the reload this video was counting down to has just been called off + self._retry_timer.stop() + self._block.network_error() + + def retry_times_dialog(self): + times = QCustomSpinboxInput.get_int( + parent=self._block.parent(), + title=translate( + "Dialog - Set reload attempts", "Set reload attempts", "Header" + ), + initial_value=self._block.video_params.network_retry_times, + _min=1, + _max=MAX_NETWORK_RETRIES, + ) + + self._block.video_params.network_retry_times = times + + self._retries = 0 + + def get_retry_times(self): + return str(self._block.video_params.network_retry_times) + + @property + def retry_limit(self) -> int: + """How many times this video may be reloaded, -1 for no limit.""" + + if self._block.video_params is None: + return 0 + + mode = self._block.video_params.network_retry_mode + + if mode == NetworkRetryMode.INFINITE: + return -1 + + if mode == NetworkRetryMode.TIMES: + return max(self._block.video_params.network_retry_times, 0) + + return 0 + + def set_fail_action(self, list_fail_action: ListFailAction): + """What to do when an item of the list won't play. Kept for a cell + with no list too, for when it has one.""" + + self._block.video_params.list_fail_action = list_fail_action + + self._restart_skip_count() + + def fail_wait_dialog(self): + wait_sec = QCustomSpinboxInput.get_int( + parent=self._block.parent(), + title=translate("When Video Fails", "Play next after (sec)"), + special_text=translate("When Video Fails", "At once"), + initial_value=self._block.video_params.list_fail_wait_sec, + _min=0, + _max=MAX_LIST_FAIL_WAIT_SEC, + cancel_value=None, + ) + + if wait_sec is not None: + self.set_fail_wait(wait_sec) + + def set_fail_wait(self, wait_sec: int): + self._block.video_params.list_fail_wait_sec = wait_sec + + self._restart_skip_count() + + def get_fail_wait(self) -> str: + return list_fail_wait_txt(self._block.video_params.list_fail_wait_sec) + + def schedule_skip(self): + """Go on to the next item of the list in a moment, where this one + will not play and the cell is set to. + + Not for ever: a list on which nothing plays is gone round once, and + one that fails for the network or a site refusing is left sooner. + """ + + if self._block.media_list is None or self._block._is_closing: + return + + self._failures_in_a_row += 1 + + if not self._is_item_dead: + self._network_failures_in_a_row += 1 + + if self._is_skipped: + self.start_skip_count() + + @property + def _is_item_dead(self) -> bool: + """Failed for itself: a file that won't play, or a server that + answered it has nothing there. Not the network down or a site + turning requests away, which the next item would fail for too.""" + + if self._block.video_params.is_local_file: + return True + + error = self._block._last_error + + return error is not None and not error.is_retryable and not error.is_refused + + def _given_up_txt(self) -> str: + """Why the list no longer goes on past the item failed, if it + doesn't.""" + + media_list = self._block.media_list + + if ( + media_list is None + or self._block.video_params is None + or self._block.video_params.list_fail_action != ListFailAction.PLAY_NEXT + ): + return "" + + # so many in a row is the network, or a site turning every one of + # them away, rather than the videos: asking again for one after the + # other only makes a site that limits how often it is asked say no + # for longer + if self._network_failures_in_a_row > MAX_NETWORK_FAILURES_IN_A_ROW: + return translate("Video Status", "Stopped: {COUNT} failed in a row").format( + COUNT=self._failures_in_a_row + ) + + if self._failures_in_a_row > len(media_list.items): + return translate("Video Status", "Stopped: nothing on the list plays") + + return "" + + @property + def _is_skipped(self) -> bool: + if self._block.media_list is None or self._block._is_closing: + return False + + if self._block.video_params.list_fail_action != ListFailAction.PLAY_NEXT: + return False + + return not self._given_up_txt() + + @property + def is_reload_futile(self) -> bool: + """An item that never played, failing for what a reload would only + be told again, where the next one is to be played instead.""" + + error = self._block._last_error + + return ( + error is not None + and not error.is_retryable + and not self._block._has_item_played + and self._is_skipped + ) + + def start_skip_count(self): + self._skip_timer.start(self._block.video_params.list_fail_wait_sec * 1000) + + if self._skip_timer.interval() > 0: + self._note_timer.start() + + def _restart_skip_count(self): + """The count to the next item after a failed one, once what is done + then was changed: started over, or called off.""" + + if not self._block._is_error: + return + + if self._is_skipped: + self.start_skip_count() + else: + self._skip_timer.stop() + + self.refresh_skip_note() + + def _skip(self): + if self._block._is_closing or self._block.media_list is None: + return + + # along the list the way it goes by itself; on to the next where it + # doesn't + if not self._block._step_at_end(self._block.video_params.end_action): + self._block.next_video(is_end=True) diff --git a/tests/conftest.py b/tests/conftest.py index 2d176f16..2bd23d8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ def _block_parts(): from gridplayer.widgets.video_block_bookmarker import Bookmarker from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_loop import LoopControl + from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl from gridplayer.widgets.video_block_subtitles import SubtitleControl @@ -66,6 +67,7 @@ def _block_parts(): "bookmarker": Bookmarker, "chapters": ChapterControl, "loop": LoopControl, + "recovery": RecoveryControl, "screenshot": ScreenshotControl, "sponsorblock": SponsorBlockControl, "subtitles": SubtitleControl, diff --git a/tests/test_video_block_lists.py b/tests/test_video_block_lists.py index 61bc6778..926d5f97 100644 --- a/tests/test_video_block_lists.py +++ b/tests/test_video_block_lists.py @@ -35,10 +35,10 @@ from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver from gridplayer.utils.video_errors import ShownError -from gridplayer.widgets.video_block import ( +from gridplayer.widgets.video_block import NOT_ON_LIST_TXT, VideoBlock +from gridplayer.widgets.video_block_recovery import ( MAX_NETWORK_FAILURES_IN_A_ROW as MAX_FAILED, ) -from gridplayer.widgets.video_block import NOT_ON_LIST_TXT, VideoBlock from gridplayer.widgets.video_frame_dummy import VideoFrameDummy from gridplayer.widgets.video_overlay import OverlayBlock @@ -216,7 +216,7 @@ def test_a_channel_left_takes_its_reload_with_it(self, make_cell, lists): block.next_video() - assert not block._network_retry_timer.isActive() + assert not block.recovery._retry_timer.isActive() def test_the_next_channel_reloaded_as_many_times_as_any(self, make_cell, lists): channels = lists.add( @@ -665,9 +665,9 @@ def test_is_skipped_where_the_list_plays_by_itself( block = make_cell(file_list) block.error() - assert block._skip_failed_timer.isActive() + assert block.recovery.is_skip_counting - block._skip_failed_timer.timeout.emit() + block.recovery._skip_timer.timeout.emit() assert _uri(block) == files[1] @@ -675,7 +675,7 @@ def test_also_where_it_is_gone_through_by_hand(self, make_cell, file_list, files block = make_cell(file_list, end_action=VideoEndAction.LOOP_FILE) block.error() - block._skip_failed_timer.timeout.emit() + block.recovery._skip_timer.timeout.emit() assert _uri(block) == files[1] @@ -683,7 +683,7 @@ def test_backwards_where_the_list_goes_so(self, make_cell, file_list, files): block = make_cell(file_list, index=1, end_action=VideoEndAction.PREVIOUS_FILE) block.error() - block._skip_failed_timer.timeout.emit() + block.recovery._skip_timer.timeout.emit() assert _uri(block) == files[0] @@ -692,25 +692,25 @@ def test_not_where_set_to_show_the_error(self, make_cell, file_list): block.error() - assert not block._skip_failed_timer.isActive() - assert block._skip_note() == "" + assert not block.recovery.is_skip_counting + assert block.recovery.skip_note() == "" def test_after_the_wait_it_is_set_to(self, make_cell, file_list): block = make_cell(file_list, list_fail_wait_sec=5) block.error() - assert block._skip_failed_timer.interval() == 5000 - assert block._skip_note() == "Next file in 5 s" + assert block.recovery._skip_timer.interval() == 5000 + assert block.recovery.skip_note() == "Next file in 5 s" def test_at_once_with_no_count_said(self, make_cell, file_list, files): block = make_cell(file_list, list_fail_wait_sec=0) block.error() - assert block._skip_failed_timer.interval() == 0 - assert block._skip_note() == "" - assert not block._skip_note_timer.isActive() + assert block.recovery._skip_timer.interval() == 0 + assert block.recovery.skip_note() == "" + assert not block.recovery._note_timer.isActive() QTest.qWait(1) @@ -722,9 +722,9 @@ def test_set_to_show_the_error_while_counting_calls_it_off( block = make_cell(file_list) block.error() - block.set_list_fail_action(ListFailAction.SHOW_ERROR) + block.recovery.set_fail_action(ListFailAction.SHOW_ERROR) - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting assert block.video_status.note == "" def test_set_to_play_next_while_the_error_shows_starts_counting( @@ -733,40 +733,40 @@ def test_set_to_play_next_while_the_error_shows_starts_counting( block = make_cell(file_list, list_fail_action=ListFailAction.SHOW_ERROR) block.error() - block.set_list_fail_action(ListFailAction.PLAY_NEXT) + block.recovery.set_fail_action(ListFailAction.PLAY_NEXT) - assert block._skip_failed_timer.isActive() + assert block.recovery.is_skip_counting assert block.video_status.note == "Next file in 2 s" def test_another_wait_counts_over_with_it(self, make_cell, file_list): block = make_cell(file_list) block.error() - block.set_list_fail_wait(7) + block.recovery.set_fail_wait(7) - assert block._skip_failed_timer.interval() == 7000 + assert block.recovery._skip_timer.interval() == 7000 assert block.video_status.note == "Next file in 7 s" def test_a_wait_set_while_it_plays_is_kept_for_later(self, make_cell, file_list): block = make_cell(file_list) - block.set_list_fail_wait(7) + block.recovery.set_fail_wait(7) - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting assert block.video_params.list_fail_wait_sec == 7 def test_the_wait_cancelled_is_left_as_it_was(self, make_cell, file_list, mocker): block = make_cell(file_list) block.error() mocker.patch( - "gridplayer.widgets.video_block.QCustomSpinboxInput.exec", + "gridplayer.widgets.video_block_recovery.QCustomSpinboxInput.exec", return_value=False, ) - block.list_fail_wait() + block.recovery.fail_wait_dialog() assert block.video_params.list_fail_wait_sec == 2 - assert block._skip_failed_timer.remainingTime() > 1000 + assert block.recovery._skip_timer.remainingTime() > 1000 def test_not_for_a_cell_with_no_list(self, make_cell, file_list, files, lists): block = make_cell(file_list) @@ -774,19 +774,19 @@ def test_not_for_a_cell_with_no_list(self, make_cell, file_list, files, lists): block.error() - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting def test_not_round_a_list_where_none_will(self, make_cell, file_list): block = make_cell(file_list) for _ in range(len(file_list.items)): block.error() - assert block._skip_failed_timer.isActive() - block._skip_failed_timer.stop() + assert block.recovery.is_skip_counting + block.recovery.stop_skip() block.error() - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting assert block.video_status.note == "Stopped: nothing on the list plays" def test_on_past_a_long_stretch_of_files_that_will_not( @@ -798,8 +798,8 @@ def test_on_past_a_long_stretch_of_files_that_will_not( for _ in range(MAX_FAILED + 1): block.error() - assert block._skip_failed_timer.isActive() - block._skip_failed_timer.stop() + assert block.recovery.is_skip_counting + block.recovery.stop_skip() def test_on_past_a_long_stretch_of_links_gone(self, make_cell, lists): block = _long_link_list(make_cell, lists) @@ -807,8 +807,8 @@ def test_on_past_a_long_stretch_of_links_gone(self, make_cell, lists): for _ in range(MAX_FAILED + 1): block._last_error = ShownError(title="Not found", is_retryable=False) block.error() - assert block._skip_failed_timer.isActive() - block._skip_failed_timer.stop() + assert block.recovery.is_skip_counting + block.recovery.stop_skip() def test_not_on_and_on_with_no_network(self, make_cell, lists): block = _long_link_list(make_cell, lists) @@ -816,12 +816,12 @@ def test_not_on_and_on_with_no_network(self, make_cell, lists): for _ in range(MAX_FAILED): block._last_error = ShownError(title="No connection", is_network=True) block.error() - block._skip_failed_timer.stop() + block.recovery.stop_skip() block._last_error = ShownError(title="No connection", is_network=True) block.error() - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting assert block.video_status.note == f"Stopped: {MAX_FAILED + 1} failed in a row" def test_nor_with_a_site_turning_requests_away(self, make_cell, lists): @@ -833,12 +833,12 @@ def test_nor_with_a_site_turning_requests_away(self, make_cell, lists): for _ in range(MAX_FAILED): block._last_error = refused block.error() - block._skip_failed_timer.stop() + block.recovery.stop_skip() block._last_error = refused block.error() - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting def test_links_gone_in_between_count_to_the_stop(self, make_cell, lists): """Said as the failures in a row, the network ones among them.""" @@ -850,9 +850,9 @@ def test_links_gone_in_between_count_to_the_stop(self, make_cell, lists): for n in range(2 * MAX_FAILED + 1): block._last_error = gone if n % 2 else lost block.error() - block._skip_failed_timer.stop() + block.recovery.stop_skip() - assert block._network_failures_in_a_row == MAX_FAILED + 1 + assert block.recovery._network_failures_in_a_row == MAX_FAILED + 1 assert ( block.video_status.note == f"Stopped: {2 * MAX_FAILED + 1} failed in a row" ) @@ -862,12 +862,12 @@ def test_one_that_plays_starts_the_count_over(self, make_cell, file_list): for _ in range(len(file_list.items)): block.error() - block._skip_failed_timer.stop() + block.recovery.stop_skip() block.next_video() block.error() - assert block._skip_failed_timer.isActive() + assert block.recovery.is_skip_counting def test_set_to_show_the_error_it_is_not_counted_over(self, make_cell, file_list): """Failures counted all the same, so the list is not gone round for @@ -880,9 +880,9 @@ def test_set_to_show_the_error_it_is_not_counted_over(self, make_cell, file_list assert block.video_status.note == "" - block.set_list_fail_action(ListFailAction.PLAY_NEXT) + block.recovery.set_fail_action(ListFailAction.PLAY_NEXT) - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting assert block.video_status.note == "Stopped: nothing on the list plays" def test_the_viewer_doing_something_else_keeps_it( @@ -893,7 +893,7 @@ def test_the_viewer_doing_something_else_keeps_it( block.set_end_action(VideoEndAction.LOOP_FILE) - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting def _long_link_list(make_cell, lists): diff --git a/tests/test_video_block_network_retry.py b/tests/test_video_block_network_retry.py index fae733b6..540ec908 100644 --- a/tests/test_video_block_network_retry.py +++ b/tests/test_video_block_network_retry.py @@ -1,6 +1,7 @@ """Reloading a network video that failed, instead of giving up on it.""" import logging +from unittest.mock import call import pytest from PyQt5.QtCore import QSettings @@ -9,7 +10,9 @@ from gridplayer.models.video import Video from gridplayer.params.static import NetworkRetryMode from gridplayer.settings import Settings +from gridplayer.utils.video_errors import ShownError from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_recovery import RecoveryControl @pytest.fixture(scope="module", autouse=True) @@ -23,6 +26,13 @@ def _isolated_settings(tmp_path): settings.settings = QSettings(str(tmp_path / "test.ini"), QSettings.IniFormat) +@pytest.fixture(autouse=True) +def _timers(mocker): + # a stand-in block is no QObject for the part's timers to belong to, + # and what matters is only whether they were started or stopped + mocker.patch("gridplayer.widgets.video_block_recovery.QTimer") + + def _set_retries(mode, times=3): # a fresh Video takes these on, which is where a block reads them from Settings().set("video_defaults/network_retry_mode", mode) @@ -32,12 +42,15 @@ def _set_retries(mode, times=3): def _block(mocker, retries=0, countdown=0): block = mocker.Mock() block._log = logging.getLogger("test") - block._network_retries = retries - block._network_retry_countdown = countdown block._is_closing = False - block._is_reload_futile = False + block._has_item_played = True + block.media_list = None block.video_params = Video(uri="http://example.com/a.mp4") - block._network_retry_limit = VideoBlock._network_retry_limit.fget(block) + block._last_error = ShownError(title="Connection lost", is_network=True) + + block.recovery = RecoveryControl(block) + block.recovery._retries = retries + block.recovery._countdown = countdown return block @@ -45,37 +58,37 @@ def test_no_retry_when_the_setting_says_show_the_error(mocker): _set_retries(NetworkRetryMode.OFF) block = _block(mocker) - assert VideoBlock._schedule_network_retry(block) is False - block._network_retry_timer.start.assert_not_called() + assert block.recovery.schedule_retry() is False + block.recovery._retry_timer.start.assert_not_called() def test_a_retry_is_scheduled_counted_and_waited_for(mocker): _set_retries(NetworkRetryMode.TIMES, times=3) block = _block(mocker) - assert VideoBlock._schedule_network_retry(block) is True + assert block.recovery.schedule_retry() is True - assert block._network_retries == 1 - assert block._network_retry_countdown == 1 + assert block.recovery.retries == 1 + assert block.recovery._countdown == 1 assert block._is_error is False # the player is of no use until the video comes back, and holding it # keeps a VLC process busy for the whole wait block.cleanup.assert_called_once() - block._network_retry_timer.start.assert_called_once() + block.recovery._retry_timer.start.assert_called_once() def test_retries_stop_at_the_configured_number(mocker): _set_retries(NetworkRetryMode.TIMES, times=2) - assert VideoBlock._schedule_network_retry(_block(mocker, retries=1)) is True - assert VideoBlock._schedule_network_retry(_block(mocker, retries=2)) is False + assert _block(mocker, retries=1).recovery.schedule_retry() is True + assert _block(mocker, retries=2).recovery.schedule_retry() is False def test_infinite_never_stops_retrying(mocker): _set_retries(NetworkRetryMode.INFINITE) - assert VideoBlock._schedule_network_retry(_block(mocker, retries=999)) is True + assert _block(mocker, retries=999).recovery.schedule_retry() is True def test_each_retry_waits_longer_than_the_last(mocker): @@ -84,65 +97,65 @@ def test_each_retry_waits_longer_than_the_last(mocker): delays = [] for retries in (0, 1, 2, 3, 4, 20): block = _block(mocker, retries=retries) - VideoBlock._schedule_network_retry(block) - delays.append(block._network_retry_countdown) + block.recovery.schedule_retry() + delays.append(block.recovery._countdown) assert delays == [1, 2, 5, 15, 30, 30] def test_the_error_is_shown_once_there_is_nothing_left_to_try(mocker): + _set_retries(NetworkRetryMode.TIMES, times=2) block = _block(mocker, retries=2) - block._schedule_network_retry.return_value = False VideoBlock.network_error(block) assert block._is_error is True - assert block._network_retries == 0 + assert block._error_tries == 2 + assert block.recovery.retries == 0 block.set_status.assert_called_once_with("network-error") def test_no_error_is_shown_while_a_retry_is_pending(mocker): + _set_retries(NetworkRetryMode.TIMES, times=3) block = _block(mocker) - block._schedule_network_retry.return_value = True VideoBlock.network_error(block) - block.set_status.assert_not_called() - block.cleanup.assert_not_called() + assert block._is_error is False + assert call("network-error") not in block.set_status.call_args_list + block.recovery._retry_timer.start.assert_called_once() def test_the_video_is_reloaded_when_the_countdown_runs_out(mocker): block = _block(mocker, retries=1, countdown=1) - VideoBlock._network_retry_tick(block) + block.recovery._retry_tick() - block._network_retry_timer.stop.assert_called_once() + block.recovery._retry_timer.stop.assert_called_once() block.reload.assert_called_once() def test_the_countdown_is_shown_until_it_runs_out(mocker): block = _block(mocker, retries=1, countdown=3) - VideoBlock._network_retry_tick(block) + block.recovery._retry_tick() - assert block._network_retry_countdown == 2 + assert block.recovery._countdown == 2 block.reload.assert_not_called() - block._show_network_retry_status.assert_called_once() + block.video_status.show_reconnect.assert_called_once() def test_a_closing_block_is_not_reloaded(mocker): block = _block(mocker, retries=1, countdown=1) block._is_closing = True - VideoBlock._network_retry_tick(block) + block.recovery._retry_tick() block.reload.assert_not_called() def _limit(mocker): - block = mocker.Mock() - block.video_params = Video(uri="http://example.com/a.mp4") - return VideoBlock._network_retry_limit.fget(block) + return _block(mocker).recovery.retry_limit def test_the_retry_limit_follows_the_video(mocker): @@ -158,73 +171,69 @@ def test_the_retry_limit_follows_the_video(mocker): def test_two_videos_can_disagree_about_retrying(mocker): _set_retries(NetworkRetryMode.OFF) - stubborn = mocker.Mock() - stubborn.video_params = Video(uri="http://example.com/a.mp4") + stubborn = _block(mocker) stubborn.video_params.network_retry_mode = NetworkRetryMode.INFINITE - giving_up = mocker.Mock() - giving_up.video_params = Video(uri="http://example.com/b.mp4") + giving_up = _block(mocker) - assert VideoBlock._network_retry_limit.fget(stubborn) == -1 - assert VideoBlock._network_retry_limit.fget(giving_up) == 0 + assert stubborn.recovery.retry_limit == -1 + assert giving_up.recovery.retry_limit == 0 def test_a_block_with_no_video_yet_has_nothing_to_retry(mocker): _set_retries(NetworkRetryMode.INFINITE) - block = mocker.Mock() + block = _block(mocker) block.video_params = None - assert VideoBlock._network_retry_limit.fget(block) == 0 + assert block.recovery.retry_limit == 0 def test_a_mode_change_with_nothing_pending_just_records_it(mocker): block = _block(mocker, retries=2) - block._network_retry_timer.isActive.return_value = False + block.recovery._retry_timer.isActive.return_value = False - VideoBlock.set_network_retry_mode(block, NetworkRetryMode.INFINITE) + block.recovery.set_retry_mode(NetworkRetryMode.INFINITE) assert block.video_params.network_retry_mode == NetworkRetryMode.INFINITE - assert block._network_retries == 0 + assert block.recovery.retries == 0 block.network_error.assert_not_called() def test_calling_off_a_pending_reload_shows_the_error_instead(mocker): block = _block(mocker, retries=1) - block._network_retry_timer.isActive.return_value = True - # what the property reports once the new mode is in place - block._network_retry_limit = 0 + block.recovery._retry_timer.isActive.return_value = True - VideoBlock.set_network_retry_mode(block, NetworkRetryMode.OFF) + block.recovery.set_retry_mode(NetworkRetryMode.OFF) - block._network_retry_timer.stop.assert_called_once() + block.recovery._retry_timer.stop.assert_called_once() block.network_error.assert_called_once() def test_a_pending_reload_survives_a_switch_to_another_retrying_mode(mocker): block = _block(mocker, retries=1) - block._network_retry_timer.isActive.return_value = True - block._network_retry_limit = -1 + block.recovery._retry_timer.isActive.return_value = True - VideoBlock.set_network_retry_mode(block, NetworkRetryMode.INFINITE) + block.recovery.set_retry_mode(NetworkRetryMode.INFINITE) - block._network_retry_timer.stop.assert_not_called() + block.recovery._retry_timer.stop.assert_not_called() block.network_error.assert_not_called() def test_a_new_attempt_count_gives_the_video_a_fresh_budget(mocker): block = _block(mocker, retries=2) mocker.patch( - "gridplayer.widgets.video_block.QCustomSpinboxInput.get_int", return_value=7 + "gridplayer.widgets.video_block_recovery.QCustomSpinboxInput.get_int", + return_value=7, ) - VideoBlock.network_retry_times(block) + block.recovery.retry_times_dialog() assert block.video_params.network_retry_times == 7 - assert block._network_retries == 0 + assert block.recovery.retries == 0 def test_the_attempt_count_is_what_the_menu_shows(mocker): block = _block(mocker) block.video_params.network_retry_times = 5 - assert VideoBlock.get_network_retry_times(block) == "5" + assert block.recovery.get_retry_times() == "5" diff --git a/tests/test_video_errors.py b/tests/test_video_errors.py index 896fce4d..a5378380 100644 --- a/tests/test_video_errors.py +++ b/tests/test_video_errors.py @@ -780,7 +780,7 @@ def test_reconnecting_says_what_went_wrong(self, make_cell): assert block.video_status.detail == "Try 1 of 3 · Server HTTP 503" assert block.overlay.mode is OverlayMode.LOADING - block._network_retry_timer.stop() + block.recovery._retry_timer.stop() def test_a_list_item_says_when_the_next_one_comes(self, make_cell, tmp_path): files = [tmp_path / name for name in ("a.mp4", "b.mp4")] @@ -802,7 +802,7 @@ def test_a_list_item_says_when_the_next_one_comes(self, make_cell, tmp_path): assert not block.overlay.next_button.isHidden() assert block.overlay.next_button.text == "Next" - block._skip_failed_timer.stop() + block.recovery.stop_skip() def test_a_list_played_back_to_front_says_the_one_before_comes( self, make_cell, tmp_path @@ -815,7 +815,7 @@ def test_a_list_played_back_to_front_says_the_one_before_comes( assert not block.overlay.next_button.isHidden() assert block.overlay.next_button.text == "Previous" - block._skip_failed_timer.stop() + block.recovery.stop_skip() def test_its_details_in_full(self, make_cell, mocker): block = make_cell( @@ -864,17 +864,17 @@ def test_held_on_the_item_while_its_details_are_read( while_read = [] mocker.patch( "gridplayer.widgets.video_block.ErrorDetailsDialog.exec_", - side_effect=lambda: while_read.append(block._skip_failed_timer.isActive()), + side_effect=lambda: while_read.append(block.recovery.is_skip_counting), ) block.show_error_details() assert while_read == [False] # closed with nothing done in it: on through the list as before - assert block._skip_failed_timer.isActive() + assert block.recovery.is_skip_counting assert block.video_status.note == "Next file in 2 s" - block._skip_failed_timer.stop() + block.recovery.stop_skip() def test_its_try_again_for_the_item_that_failed(self, make_cell, tmp_path, mocker): block, files = _failed_list_item(make_cell, tmp_path) @@ -887,7 +887,7 @@ def test_its_try_again_for_the_item_that_failed(self, make_cell, tmp_path, mocke assert block.video_params.uri == files[0] assert not block._is_error - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting def test_next_goes_the_way_the_list_goes_by_itself( self, make_cell, tmp_path, mocker @@ -953,12 +953,12 @@ def test_stopped_from_the_menu_it_reconnects_no_more(self, make_cell): resolve_error(_http_error(503), "https://site.example/watch") ) block.url_resolver.error.emit() - assert block._network_retry_timer.isActive() + assert block.recovery._retry_timer.isActive() block.stop_playback() - assert not block._network_retry_timer.isActive() - assert block._network_retries == 0 + assert not block.recovery._retry_timer.isActive() + assert block.recovery._retries == 0 assert block.overlay.mode is OverlayMode.STOPPED def test_stopped_from_the_menu_its_list_goes_on_no_further( @@ -968,7 +968,7 @@ def test_stopped_from_the_menu_its_list_goes_on_no_further( block.stop_playback() - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting assert block.video_params.uri == files[0] def test_stopped_while_its_link_resolves_what_it_finds_is_let_go(self, make_cell): @@ -1045,7 +1045,7 @@ def test_the_menu_stops_it_while_it_reconnects(self, make_cell): menu.cmd_active("stop_playback") - assert not block._network_retry_timer.isActive() + assert not block.recovery._retry_timer.isActive() assert block.overlay.mode is OverlayMode.STOPPED def test_the_menu_stops_its_list_going_on_once_it_failed(self, make_cell, tmp_path): @@ -1056,7 +1056,7 @@ def test_the_menu_stops_its_list_going_on_once_it_failed(self, make_cell, tmp_pa menu.cmd_active("stop_playback") - assert not block._skip_failed_timer.isActive() + assert not block.recovery.is_skip_counting assert block.video_params.uri == files[0] assert block.overlay.mode is OverlayMode.STOPPED @@ -1086,26 +1086,26 @@ class TestAListOfLinks: def test_one_gone_is_not_reloaded_but_the_next_played(self, make_cell): block = _failed_link(make_cell, _http_error(404)) - assert not block._network_retry_timer.isActive() - assert block._skip_failed_timer.isActive() + assert not block.recovery._retry_timer.isActive() + assert block.recovery.is_skip_counting assert block.video_status.error.code == "HTTP 404" - block._skip_failed_timer.timeout.emit() + block.recovery._skip_timer.timeout.emit() assert block.video_params.uri == "https://site.example/1" def test_one_that_may_answer_otherwise_is_reloaded_first(self, make_cell): block = _failed_link(make_cell, _http_error(503)) - assert block._network_retry_timer.isActive() - assert not block._skip_failed_timer.isActive() + assert block.recovery._retry_timer.isActive() + assert not block.recovery.is_skip_counting def test_reloaded_where_set_to_show_the_error(self, make_cell): block = _failed_link( make_cell, _http_error(404), list_fail_action=ListFailAction.SHOW_ERROR ) - assert block._network_retry_timer.isActive() + assert block.recovery._retry_timer.isActive() def test_reloaded_once_it_played(self, make_cell): """A link that ran out, while it played, may come back with a new @@ -1116,7 +1116,7 @@ def test_reloaded_once_it_played(self, make_cell): _fail(block, _http_error(403)) - assert block._network_retry_timer.isActive() + assert block.recovery._retry_timer.isActive() def test_the_next_one_is_not_taken_to_have_played(self, make_cell): block = _failed_link(make_cell, None) @@ -1125,7 +1125,7 @@ def test_the_next_one_is_not_taken_to_have_played(self, make_cell): _fail(block, _http_error(403)) - assert not block._network_retry_timer.isActive() + assert not block.recovery._retry_timer.isActive() def test_one_with_no_list_is_reloaded(self, make_cell): block = make_cell( @@ -1136,17 +1136,17 @@ def test_one_with_no_list_is_reloaded(self, make_cell): _fail(block, _http_error(404)) - assert block._network_retry_timer.isActive() + assert block.recovery._retry_timer.isActive() def test_its_menu_is_there_once_it_failed(self, make_cell): block = _failed_link(make_cell, _http_error(404)) menu = _menu_on(block) - menu.cmd_active("set_list_fail_action", ListFailAction.SHOW_ERROR) + menu.cmd_active("recovery.set_fail_action", ListFailAction.SHOW_ERROR) assert block.video_params.list_fail_action is ListFailAction.SHOW_ERROR - assert not block._skip_failed_timer.isActive() - assert menu.cmd_active("get_list_fail_wait") == "2 s" + assert not block.recovery.is_skip_counting + assert menu.cmd_active("recovery.get_fail_wait") == "2 s" def _failed_link(make_cell, error, **video_kwargs): @@ -1201,7 +1201,7 @@ def _failed_list_item(make_cell, tmp_path, end_action=VideoEndAction.NEXT_FILE): block.video_driver_error("Player error") - assert block._skip_failed_timer.isActive() + assert block.recovery.is_skip_counting return block, files From 5fdb1fc97c4af85360df6559f0a284350cf93a1f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:22:25 +0000 Subject: [PATCH 10/14] refactor: take what the video block plays of a link out into a StreamControl Which rung of the quality ladder plays, fitting it to the pane, which dub of a video served one language at a time, what the stream proxy is handed, and reloading a live video every so often are block.stream ("stream.switch_quality", "stream.set_audio_language", ...). The proxy's failure for the stream is asked of it too, as it knows the URL. The ladders themselves, block.streams, stay where the resolver puts them. Tests that borrowed the block's methods one by one now drive a real StreamControl. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/params/actions.py | 4 +- gridplayer/player/managers/active_block.py | 28 +- gridplayer/player/managers/video_blocks.py | 2 +- gridplayer/widgets/video_block.py | 454 +------------------- gridplayer/widgets/video_block_audio.py | 6 +- gridplayer/widgets/video_block_stream.py | 474 +++++++++++++++++++++ tests/conftest.py | 2 + tests/test_audio_commands_while_loading.py | 2 +- tests/test_audio_track_preference.py | 24 +- tests/test_menu_audio_click.py | 39 +- tests/test_menu_audio_track.py | 10 +- tests/test_menu_stream_quality.py | 8 +- tests/test_video_block_audio_language.py | 74 ++-- tests/test_video_block_chapters.py | 4 +- tests/test_video_block_external_audio.py | 13 +- tests/test_video_block_lists.py | 6 +- tests/test_video_block_quality_adapt.py | 139 +++--- tests/test_video_block_sponsorblock.py | 14 +- tests/test_video_block_stream_seek.py | 4 +- tests/test_video_errors.py | 8 +- 20 files changed, 705 insertions(+), 610 deletions(-) create mode 100644 gridplayer/widgets/video_block_stream.py diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 03956390..2926bbdc 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -1377,8 +1377,8 @@ "title": "{}: %v".format(translate("Actions", "Auto Reload")), "keymap_title": translate("Actions", "Set Auto Reload"), "icon": "reload", - "func": ("active", "auto_reload_timer"), - "value_getter": ("active", "get_auto_reload_timer"), + "func": ("active", "stream.reload_timer_dialog"), + "value_getter": ("active", "stream.get_reload_timer"), "show_if": "is_active_live", }, "On Network Error Show Error": { diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index 8c0efa2f..3590aaff 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -44,7 +44,7 @@ # the reload that the next choice would otherwise land in. LOADING_COMMANDS = frozenset( { - "switch_stream_quality", + "stream.switch_quality", "reload", "stop_playback", "close", @@ -59,7 +59,7 @@ "recovery.get_fail_wait", "audio.set_track", "audio.set_device", - "set_audio_language", + "stream.set_audio_language", "audio.apply_preference", "audio.apply_default", "audio.languages_dialog", @@ -513,9 +513,9 @@ def menu_generator_stream_quality(self): # a dubbed video is the same ladder over again in every language, # and only one of them is the one being played - ladder = self._ctx.active_block.stream_ladder + ladder = self._ctx.active_block.stream.ladder - playing = self._ctx.active_block.stream_quality_playing + playing = self._ctx.active_block.stream.quality_playing # a ladder is stored worst first, and a menu of it reads the other way video_rungs = [ @@ -583,7 +583,7 @@ def _standing_quality_menu_item(self, quality: str, title: str): named here is the only sign that it could not. """ - playing = self._ctx.active_block.stream_quality_playing + playing = self._ctx.active_block.stream.quality_playing if self._is_quality_chosen(quality) and playing: title = f"{title} ({playing})" @@ -591,7 +591,7 @@ def _standing_quality_menu_item(self, quality: str, title: str): return { "title": title, "icon": "empty", - "func": ("active", "switch_stream_quality", quality), + "func": ("active", "stream.switch_quality", quality), "check_if": ("is_active_param_set_to", "stream_quality", quality), "is_choice": True, "show_if": "is_active_multistream", @@ -857,8 +857,8 @@ def _preferred_audio_name(self) -> str | None: block = self._ctx.active_block - if len(block.audio_language_options) > 1: - language = block.preferred_audio_language + if len(block.stream.audio_language_options) > 1: + language = block.stream.preferred_audio_language return language_name(language) or language @@ -900,16 +900,16 @@ def _audio_choice_menu_items(self): block = self._ctx.active_block - languages = block.audio_language_options + languages = block.stream.audio_language_options if len(languages) > 1: - playing = block.audio_language_playing + playing = block.stream.audio_language_playing return [ { "title": language_name(language) or language, "icon": "play" if language == playing else "empty", - "func": ("active", "set_audio_language", language), + "func": ("active", "stream.set_audio_language", language), "check_if": ("is_active_audio_language", language), "is_choice": True, "show_if": "is_active_initialized", @@ -1488,8 +1488,8 @@ def _quality_adapt_delay_menu_item(): return { "title": "{}: %v".format(translate("Actions", "Adapt after")), "icon": "empty", - "func": ("active", "quality_adapt_delay"), - "value_getter": ("active", "get_quality_adapt_delay"), + "func": ("active", "stream.adapt_delay_dialog"), + "value_getter": ("active", "stream.get_adapt_delay"), "show_if": ( "is_active_param_set_to", "stream_quality", @@ -1511,7 +1511,7 @@ def _stream_menu_item(quality: str, is_playing: bool = False): return { "title": quality, "icon": "play" if is_playing else "empty", - "func": ("active", "switch_stream_quality", quality), + "func": ("active", "stream.switch_quality", quality), "check_if": ("is_active_param_set_to", "stream_quality", quality), "is_choice": True, "show_if": "is_active_multistream", diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 55a6fb32..31bf6eed 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -890,7 +890,7 @@ def _add_video_block(self, video): (self.all_set_aspect, vb.set_aspect), (self.all_set_transform, vb.set_transform), (self.all_take_screenshot, vb.screenshot.take), - (self.all_set_auto_reload_timer, vb.set_auto_reload_timer), + (self.all_set_auto_reload_timer, vb.stream.set_reload_timer), (self.all_set_audio_channel_mode, vb.audio.set_channel_mode), (self.all_set_audio_delay, vb.audio.set_delay), (self.all_audio_delay_increase, vb.audio.delay_increase), diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index dd978a49..9598088d 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -1,8 +1,6 @@ -import dataclasses import logging import random import secrets -import time from datetime import datetime, timezone from functools import partial from operator import attrgetter @@ -19,16 +17,12 @@ code_rows, ) from gridplayer.dialogs.input_dialog import ( - QCustomSpinboxInput, QCustomSpinboxTimeInput, ) from gridplayer.dialogs.rename_dialog import QVideoRenameDialog from gridplayer.exceptions import PlayerException from gridplayer.models.audio_selection import ( - AudioDefault, AudioDisabled, - AudioLanguage, - AudioPreferred, ) from gridplayer.models.bookmark import Bookmark from gridplayer.models.media_list import ( @@ -41,12 +35,7 @@ ) from gridplayer.models.seek_mark import SeekMark, SeekMarkKind from gridplayer.models.stream import ( - STANDING_QUALITIES, - STREAM_QUALITY_AUTO, - HashableDict, - StreamOrigin, Streams, - short_quality_name, ) from gridplayer.models.video import ( Video, @@ -87,7 +76,6 @@ from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver from gridplayer.utils.video_errors import ( - ProxyFailure, ShownError, file_error, player_error, @@ -113,17 +101,16 @@ from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_guards import ( only_initialized, - only_live, only_local_file, only_navigable, only_seekable, - only_streamable, only_with_video_tacks, ) from gridplayer.widgets.video_block_loop import LoopControl from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl +from gridplayer.widgets.video_block_stream import StreamControl from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl from gridplayer.widgets.video_frame_vlc_base import VideoFrameVLC @@ -156,10 +143,6 @@ # the time shows up where the seek could never have landed, it takes. SEEK_RETRIES = 2 -# a pane that has to sit still for an hour before following its size is -# as good as one that never follows it at all -MAX_QUALITY_ADAPT_DELAY_S = 3600 - # the place, over the video, of one taken off its list while it played NOT_ON_LIST_TXT = "\u2013" @@ -273,29 +256,16 @@ def __init__(self, video_driver, context, **kwargs): self._in_progress_timer.setInterval(IN_PROGRESS_THRESHOLD_MS) self._in_progress_timer.timeout.connect(self.is_in_progress_change) - self._reload_timer = QTimer(self) - self._reload_timer.timeout.connect(self.reload) - # how far it got loading: the stage it is at, and the ones it goes # through self._load_stage: LoadStage | None = None self._load_steps: tuple[LoadStage, ...] = () # what went wrong last, the stage it went wrong at and how many times - # it was tried; the proxy's URL of the stream, for what it was told - # fetching it, and from when + # it was tried self._last_error: ShownError | None = None self._error_stage: LoadStage | None = None self._error_tries = 0 - self._proxied_url: str | None = None - self._stream_loaded_at = 0.0 - - self._stream_quality_playing = None - self._audio_language_playing = None - - self._quality_adapt_timer = QTimer(self) - self._quality_adapt_timer.setSingleShot(True) - self._quality_adapt_timer.timeout.connect(self._adapt_stream_quality) self._seek_settle_timer = QTimer(self) self._seek_settle_timer.setSingleShot(True) @@ -311,10 +281,6 @@ def __init__(self, video_driver, context, **kwargs): # the seek before the last, when the last was made before it landed self._seek_behind: int | None = None - # whether what plays is opened by VLC's adaptive demuxer, the one - # that turns seeks down; see SEEK_RETRIES - self._is_adaptive = False - self.url_resolver = self.init_url_resolver() self.video_driver: VideoFrameVLC | None = None @@ -327,6 +293,7 @@ def __init__(self, video_driver, context, **kwargs): self.loop = LoopControl(self) self.screenshot = ScreenshotControl(self) self.recovery = RecoveryControl(self) + self.stream = StreamControl(self) # the next, previous or a random file of the folder, to put in this # one's place @@ -619,7 +586,7 @@ def cleanup_start(self): def cleanup(self): self.overlay_hide_timer.stop() self._in_progress_timer.stop() - self._quality_adapt_timer.stop() + self.stream.stop_adapting() self.recovery.stop() self._log.debug(f"{self.id}: cleaning up resolver") @@ -663,28 +630,13 @@ def _player_error(self, message: str, vlc_lines: tuple[str, ...]) -> ShownError: return player_error(message, vlc_lines) - failure = self._proxy_failure() + failure = self.stream.proxy_failure() if failure is not None: return proxy_error(failure, vlc_lines) return player_error(message, vlc_lines, url=str(uri)) - def _proxy_failure(self) -> ProxyFailure | None: - if self._proxied_url is None: - return None - - failure = self._ctx.commands.stream_failure(self._proxied_url) - - if not isinstance(failure, ProxyFailure): - return None - - # what it was told fetching it this time, not before - if failure.at < self._stream_loaded_at: - return None - - return failure - def error(self): self._is_error = True self._is_state_change_in_progress = False @@ -930,7 +882,7 @@ def _on_driver_status(self, status: str, percent: int = 0): translate("Video Status", "Buffering {PERCENT}%").format( PERCENT=percent ), - self._video_detail(), + self.stream.quality_detail(), percent, ) return @@ -941,25 +893,12 @@ def _show_opening(self): self._show_stage( LoadStage.VIDEO, translate("Video Status", "Opening…"), - self._video_detail(), + self.stream.quality_detail(), ) - def _video_detail(self) -> str: - """The stream picked, of a link with several. - - A link with one stream has nothing to pick, and what its one - stream is called ("generic", "[0]", "9300k") is no news to anyone. - The Quality menu is left out for it on the same grounds. - """ - - if len(self.streams) > 1 and self._stream_quality_playing: - return short_quality_name(self._stream_quality_playing) - - return "" - def reload(self): self.recovery.stop_retry_countdown() - self._quality_adapt_timer.stop() + self.stream.stop_adapting() self.is_live = False self._is_error = False @@ -1127,7 +1066,7 @@ def minimumSizeHint(self): def resizeEvent(self, event): super().resizeEvent(event) self._apply_overlay_size_policy() - self._schedule_quality_adapt() + self.stream.schedule_adapt() @property def is_overlay_fits(self) -> bool: @@ -1194,77 +1133,6 @@ def seek_timecode(self): self.manual_seek("seek", time_ms) - @only_streamable - def quality_adapt_delay(self): - delay_sec = QCustomSpinboxInput.get_int( - parent=self.parent(), - title=translate( - "Dialog - Set quality adapt delay", "Set quality adapt delay", "Header" - ), - initial_value=self.video_params.quality_adapt_delay_sec, - _min=1, - _max=MAX_QUALITY_ADAPT_DELAY_S, - ) - - self.set_quality_adapt_delay(delay_sec) - - @only_streamable - def set_quality_adapt_delay(self, delay_sec): - self.video_params.quality_adapt_delay_sec = delay_sec - - # a pane already waiting was counting to the old delay - if self._quality_adapt_timer.isActive(): - self._quality_adapt_timer.start(self._quality_adapt_delay_ms) - - @only_streamable - def get_quality_adapt_delay(self): - return "{} {}".format( - self.video_params.quality_adapt_delay_sec, - translate("Quality Adapt Delay", "second(s)"), - ) - - @only_initialized - @only_live - def auto_reload_timer(self): - time_min = QCustomSpinboxInput.get_int( - parent=self.parent(), - title=translate( - "Dialog - Set auto reload timer", "Set auto reload timer", "Header" - ), - special_text=translate("Auto Reload Timer", "Disabled"), - initial_value=self.video_params.auto_reload_timer_min, - _min=0, - _max=1000, - ) - - self.set_auto_reload_timer(time_min) - - @only_initialized - @only_live - def set_auto_reload_timer(self, time_min): - self.video_params.auto_reload_timer_min = time_min - - time_ms = time_min * 60 * 1000 - - if time_ms == 0: - self._reload_timer.stop() - self._reload_timer.setInterval(0) - return - - self._reload_timer.setInterval(time_ms) - self._reload_timer.start() - - @only_initialized - @only_live - def get_auto_reload_timer(self): - if self.video_params.auto_reload_timer_min == 0: - return translate("Auto Reload Timer", "Disabled") - - return "{} {}".format( - self.video_params.auto_reload_timer_min, - translate("Auto Reload Timer", "minute(s)"), - ) - def is_under_cursor(self): return self.rect().contains(self.mapFromGlobal(QCursor.pos())) @@ -1396,12 +1264,6 @@ def video_tracks(self): return {} return self.video_driver.video_tracks - @property - def audio_language_playing(self) -> str | None: - """Which of the languages on offer the rung on screen was loaded for.""" - - return self._audio_language_playing - @only_initialized def set_video_track(self, track_id): # only -1 says the sound was switched off; None says it has not been @@ -1995,8 +1857,8 @@ def apply_snapshot(self, snapshot: Video): self.loop.set_end_time(snapshot.loop_end) self.set_rate(snapshot.rate, is_silent=True) - self.switch_stream_quality(snapshot.stream_quality) - self.set_auto_reload_timer(snapshot.auto_reload_timer_min) + self.stream.switch_quality(snapshot.stream_quality) + self.stream.set_reload_timer(snapshot.auto_reload_timer_min) self._take_snapshot_params(snapshot) @@ -2171,7 +2033,7 @@ def _start_load(self): self.sponsorblock.forget() # a file is never adaptive; a URL says so once its stream is picked - self._is_adaptive = False + self.stream.is_adaptive = False if self.video_params.is_http_url: self._show_resolving_link() @@ -2218,293 +2080,7 @@ def set_video_url(self, video: ResolvedVideo): self._learn_item_details(video.details) - self.load_stream_quality(self.video_params.stream_quality) - - @property - def _language_variants(self) -> Streams: - """Where this video's languages live, out of the two places they can. - - A site either dubs a video by handing back the whole ladder once - per language, or by offering one ladder and several audio tracks - to pair with it. Either way it is a set of streams that differ in - nothing but the language of their sound. - """ - - if not self.streams: - return Streams() - - if self.streams.is_multilingual: - return self.streams - - audio_tracks = next( - ( - stream.audio_tracks - for _, stream in self.streams.items() - if stream.audio_tracks - ), - None, - ) - - return audio_tracks or Streams() - - @property - def audio_language_options(self) -> tuple[str, ...]: - """The languages this video is dubbed into, as the site offered them.""" - - return self._language_variants.languages - - @property - def audio_language(self) -> str | None: - """Which language this video plays, out of the several it is dubbed into. - - Nothing to choose between leaves this empty, and so does a video - whose sound names no language at all. - """ - - return self._language_among(self._language_variants) - - def _language_among(self, variants: Streams) -> str | None: - """Which of these languages the sound asked for comes to.""" - - if not variants.is_multilingual: - return None - - selection = self.video_params.audio_selection - - if isinstance(selection, AudioLanguage) and selection.tag in variants.languages: - return selection.tag - - if isinstance(selection, AudioDefault): - # the languages asked for do not apply to the video's own sound, - # which is the language it was made in - return variants.language_for("") - - return variants.language_for(self.video_params.audio_languages) - - @property - def preferred_audio_language(self) -> str | None: - """Which language the preference alone would settle on. - - What is playing may be a pick made by hand instead, so this is the - only way to say what going back to the preference would give. - """ - - return self._language_variants.language_for(self.video_params.audio_languages) - - @property - def stream_ladder(self) -> Streams: - """The rungs this video may switch between, all in the one language.""" - - return self.streams.for_language(self.audio_language) - - @only_streamable - def set_audio_language(self, language: str | None): - """Play the same video in another one of the languages it is dubbed into. - - The ladder is a different one per language, so the rung has to be - found again; asking for the one playing now finds the same size. - """ - - self.video_params.audio_selection = ( - AudioPreferred() if language is None else AudioLanguage(tag=language) - ) - - self._log.debug( - f"Set audio language {language}," - f" resolved to {self.audio_language}," - f" playing {self._audio_language_playing}" - ) - - self.follow_audio_language() - - def follow_audio_language(self): - """Put the sound asked for into effect, in whichever dub it calls for. - - A dubbed stream is served one language at a time, so another one - means fetching the ladder again rather than reaching for a track - that was never handed over. - """ - - if not self.streams or self.audio_language == self._audio_language_playing: - # the rung on screen already carries it, so there is nothing to - # reload -- but the sound may have been switched off since it - # was loaded, and asking for a language means wanting it - self.audio.apply_wanted_track() - return - - # the pane is about to be reloaded anyway, and on a ladder that no - # longer holds the rung the timer was comparing against - self._quality_adapt_timer.stop() - - self.reset(is_link_kept=True) - - self.load_stream_quality(self.video_params.stream_quality) - - @only_streamable - def switch_stream_quality(self, quality: str): - if quality == self.video_params.stream_quality: - return - - # picking a rung by hand calls off whatever the pane was about to do - self._quality_adapt_timer.stop() - - self.reset(is_link_kept=True) - - self.load_stream_quality(quality) - - @property - def stream_quality_playing(self) -> str | None: - """Which rung of the ladder is on screen, as opposed to what was asked.""" - - return self._stream_quality_playing - - def load_stream_quality(self, wanted_quality: str): - is_auto = wanted_quality == STREAM_QUALITY_AUTO - - ladder = self.stream_ladder - - self._audio_language_playing = self.audio_language - - if is_auto: - quality, stream = ladder.fit_to_height(self._pane_height_px) - else: - quality, stream = ladder.by_quality(wanted_quality) - - # a standing instruction outlives the rung it picked, where a rung - # chosen by name is kept as the name it was chosen by - self.video_params.stream_quality = ( - wanted_quality if wanted_quality in STANDING_QUALITIES else quality - ) - self._stream_quality_playing = quality - - # switching quality resets the block, which leaves it without a driver - self._ensure_video_driver() - - if stream.protocol == "direct": - url = stream.url - self._proxied_url = None - else: - stream = self._with_audio_language(stream) - url = self._ctx.commands.add_stream(self._with_origin(stream, quality)) - self._proxied_url = url if isinstance(url, str) else None - - self._stream_loaded_at = time.monotonic() - - self._is_adaptive = stream.is_adaptive - - self._show_opening() - - self.load_video.emit( - MediaInput( - uri=url, - is_live=self.is_live, - is_audio_only=stream.is_audio_only, - video_codec=stream.video_codec, - is_adaptive=stream.is_adaptive, - size=self.size_tuple, - video=self.video_params, - # the proxy fetches the rest with them already - http_headers=( - self._item_headers if stream.protocol == "direct" else {} - ), - ) - ) - - @property - def _quality_adapt_delay_ms(self) -> int: - """How long a pane has to keep its new size before the stream follows. - - Dragging a window edge resizes every pane dozens of times on the way, - and every switch costs a reload, so the size has to settle down first. - """ - - return max(self.video_params.quality_adapt_delay_sec, 1) * 1000 - - @property - def _pane_height_px(self) -> int: - """How tall this pane is in the pixels a screen actually has. - - The widget measures itself in the logical pixels Qt scales, so on a - 200% display a pane reports half the detail it can really show. - """ - - return int(self.height() * self.devicePixelRatioF()) - - def _schedule_quality_adapt(self): - if self.video_params is None or not self.streams: - return - - if self.video_params.stream_quality != STREAM_QUALITY_AUTO: - return - - self._quality_adapt_timer.start(self._quality_adapt_delay_ms) - - def _adapt_stream_quality(self): - """Fit the stream to the size the pane has settled on.""" - - if self._is_closing or self.video_params is None or not self.streams: - return - - if self.video_params.stream_quality != STREAM_QUALITY_AUTO: - return - - if self._is_error: - # a video that is already failing has the retry timer looking - # after it, and reloading underneath that would only confuse it - return - - if self._is_state_change_in_progress: - self._quality_adapt_timer.start(self._quality_adapt_delay_ms) - return - - quality, _ = self.stream_ladder.fit_to_height(self._pane_height_px) - - if quality == self._stream_quality_playing: - return - - self._log.debug( - f"Pane now fits {quality}, switching from {self._stream_quality_playing}" - ) - - self.reset(is_link_kept=True) - - self.load_stream_quality(STREAM_QUALITY_AUTO) - - def _with_audio_language(self, stream): - """Hand the proxy only the audio tracks in the language being played. - - VLC is served a playlist with one audio rendition in it, so which - of them it is has to be settled here; the proxy is left to pick - the best of whatever it is given. - """ - - if not stream.audio_tracks: - return stream - - language = self._language_among(stream.audio_tracks) - - if language is None: - return stream - - return dataclasses.replace( - stream, audio_tracks=stream.audio_tracks.for_language(language) - ) - - def _with_origin(self, stream, quality: str): - """Tell the proxy where this stream came from. - - Services sign the URLs they hand out and stop honouring them after a - while, so the proxy has to be able to ask for them again rather than - serve a video that dies partway through. - """ - - origin = StreamOrigin( - url=str(self.video_params.uri), - quality=quality, - headers=HashableDict(self._item_headers) or None, - ) - - return dataclasses.replace(stream, origin=origin) + self.stream.load_quality(self.video_params.stream_quality) def load_video_finish(self): # the video is playing, so whatever went wrong before is behind us @@ -2539,7 +2115,7 @@ def load_video_finish(self): self.loop.set_end_time(self.video_params.loop_end) self.set_rate(self.video_params.rate, is_silent=True) - self.set_auto_reload_timer(self.video_params.auto_reload_timer_min) + self.stream.set_reload_timer(self.video_params.auto_reload_timer_min) self.video_params.video_track_id = self.video_driver.cur_video_track_id @@ -2795,7 +2371,7 @@ def seek(self, seek_ms, is_viewers=True): # arrive, which is no sign of it having been turned down is_near_end = self.video_driver.length - seek_ms <= SEEK_ARRIVAL_MS self._seek_retries = ( - SEEK_RETRIES if self._is_adaptive and not is_near_end else 0 + SEEK_RETRIES if self.stream.is_adaptive and not is_near_end else 0 ) self._seek_behind = behind if is_behind and self._seek_retries else None diff --git a/gridplayer/widgets/video_block_audio.py b/gridplayer/widgets/video_block_audio.py index f503eda7..a683dd1e 100644 --- a/gridplayer/widgets/video_block_audio.py +++ b/gridplayer/widgets/video_block_audio.py @@ -3,7 +3,7 @@ how loud it is. A dubbed stream that changes its language by loading another rung is the -block's to follow (see VideoBlock.follow_audio_language); everything that +stream part's to follow (see StreamControl.follow_audio_language); everything that plays within what was loaded is here. """ @@ -229,14 +229,14 @@ def apply_default(self): self._block.video_params.audio_selection = AudioDefault() - self._block.follow_audio_language() + self._block.stream.follow_audio_language() def apply_preference(self): """Follow the preferred languages again, whichever way this video dubs.""" self._block.video_params.audio_selection = AudioPreferred() - self._block.follow_audio_language() + self._block.stream.follow_audio_language() def apply_wanted_track(self): """Put the settings into effect on the video that is already playing.""" diff --git a/gridplayer/widgets/video_block_stream.py b/gridplayer/widgets/video_block_stream.py new file mode 100644 index 00000000..54b40624 --- /dev/null +++ b/gridplayer/widgets/video_block_stream.py @@ -0,0 +1,474 @@ +"""What a video block plays of a link with several streams: which rung of +the quality ladder, fitted to the pane or picked by hand, in which of the +languages it is dubbed into, and handed to the stream proxy with what it +needs to fetch it again; and reloading a live one every so often. + +The tracks of whatever was loaded are the audio part's (see AudioControl); +a dub served as a ladder of its own is here, as it takes a reload. +""" + +import dataclasses +import time + +from PyQt5.QtCore import QTimer + +from gridplayer.dialogs.input_dialog import QCustomSpinboxInput +from gridplayer.models.audio_selection import ( + AudioDefault, + AudioLanguage, + AudioPreferred, +) +from gridplayer.models.stream import ( + STANDING_QUALITIES, + STREAM_QUALITY_AUTO, + HashableDict, + StreamOrigin, + Streams, + short_quality_name, +) +from gridplayer.utils.qt import translate +from gridplayer.utils.video_errors import ProxyFailure +from gridplayer.vlc_player.static import MediaInput +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_initialized, + only_live, + only_streamable, +) + +# a pane that has to sit still for an hour before following its size is +# as good as one that never follows it at all +MAX_QUALITY_ADAPT_DELAY_S = 3600 + + +class StreamControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # the proxy's URL of the stream, for what it was told fetching it, + # and from when + self._proxied_url: str | None = None + self._loaded_at = 0.0 + + # what was loaded, as opposed to what was asked for + self._quality_playing = None + self._audio_language_playing = None + + # whether what plays is opened by VLC's adaptive demuxer, the one + # that turns seeks down; see SEEK_RETRIES in video_block + self.is_adaptive = False + + self._adapt_timer = QTimer(block) + self._adapt_timer.setSingleShot(True) + self._adapt_timer.timeout.connect(self._adapt) + + self._reload_timer = QTimer(block) + self._reload_timer.timeout.connect(block.reload) + + def stop_adapting(self): + """Fit the stream to the pane no more: it is about to be loaded + afresh, or let go of.""" + + self._adapt_timer.stop() + + def proxy_failure(self) -> ProxyFailure | None: + if self._proxied_url is None: + return None + + failure = self._block._ctx.commands.stream_failure(self._proxied_url) + + if not isinstance(failure, ProxyFailure): + return None + + # what it was told fetching it this time, not before + if failure.at < self._loaded_at: + return None + + return failure + + def quality_detail(self) -> str: + """The stream picked, of a link with several. + + A link with one stream has nothing to pick, and what its one + stream is called ("generic", "[0]", "9300k") is no news to anyone. + The Quality menu is left out for it on the same grounds. + """ + + if len(self._block.streams) > 1 and self._quality_playing: + return short_quality_name(self._quality_playing) + + return "" + + @only_streamable + def adapt_delay_dialog(self): + delay_sec = QCustomSpinboxInput.get_int( + parent=self._block.parent(), + title=translate( + "Dialog - Set quality adapt delay", "Set quality adapt delay", "Header" + ), + initial_value=self._block.video_params.quality_adapt_delay_sec, + _min=1, + _max=MAX_QUALITY_ADAPT_DELAY_S, + ) + + self.set_adapt_delay(delay_sec) + + @only_streamable + def set_adapt_delay(self, delay_sec): + self._block.video_params.quality_adapt_delay_sec = delay_sec + + # a pane already waiting was counting to the old delay + if self._adapt_timer.isActive(): + self._adapt_timer.start(self._adapt_delay_ms) + + @only_streamable + def get_adapt_delay(self): + return "{} {}".format( + self._block.video_params.quality_adapt_delay_sec, + translate("Quality Adapt Delay", "second(s)"), + ) + + @only_initialized + @only_live + def reload_timer_dialog(self): + time_min = QCustomSpinboxInput.get_int( + parent=self._block.parent(), + title=translate( + "Dialog - Set auto reload timer", "Set auto reload timer", "Header" + ), + special_text=translate("Auto Reload Timer", "Disabled"), + initial_value=self._block.video_params.auto_reload_timer_min, + _min=0, + _max=1000, + ) + + self.set_reload_timer(time_min) + + @only_initialized + @only_live + def set_reload_timer(self, time_min): + self._block.video_params.auto_reload_timer_min = time_min + + time_ms = time_min * 60 * 1000 + + if time_ms == 0: + self._reload_timer.stop() + self._reload_timer.setInterval(0) + return + + self._reload_timer.setInterval(time_ms) + self._reload_timer.start() + + @only_initialized + @only_live + def get_reload_timer(self): + if self._block.video_params.auto_reload_timer_min == 0: + return translate("Auto Reload Timer", "Disabled") + + return "{} {}".format( + self._block.video_params.auto_reload_timer_min, + translate("Auto Reload Timer", "minute(s)"), + ) + + @property + def audio_language_playing(self) -> str | None: + """Which of the languages on offer the rung on screen was loaded for.""" + + return self._audio_language_playing + + @property + def _language_variants(self) -> Streams: + """Where this video's languages live, out of the two places they can. + + A site either dubs a video by handing back the whole ladder once + per language, or by offering one ladder and several audio tracks + to pair with it. Either way it is a set of streams that differ in + nothing but the language of their sound. + """ + + if not self._block.streams: + return Streams() + + if self._block.streams.is_multilingual: + return self._block.streams + + audio_tracks = next( + ( + stream.audio_tracks + for _, stream in self._block.streams.items() + if stream.audio_tracks + ), + None, + ) + + return audio_tracks or Streams() + + @property + def audio_language_options(self) -> tuple[str, ...]: + """The languages this video is dubbed into, as the site offered them.""" + + return self._language_variants.languages + + @property + def audio_language(self) -> str | None: + """Which language this video plays, out of the several it is dubbed into. + + Nothing to choose between leaves this empty, and so does a video + whose sound names no language at all. + """ + + return self._language_among(self._language_variants) + + def _language_among(self, variants: Streams) -> str | None: + """Which of these languages the sound asked for comes to.""" + + if not variants.is_multilingual: + return None + + selection = self._block.video_params.audio_selection + + if isinstance(selection, AudioLanguage) and selection.tag in variants.languages: + return selection.tag + + if isinstance(selection, AudioDefault): + # the languages asked for do not apply to the video's own sound, + # which is the language it was made in + return variants.language_for("") + + return variants.language_for(self._block.video_params.audio_languages) + + @property + def preferred_audio_language(self) -> str | None: + """Which language the preference alone would settle on. + + What is playing may be a pick made by hand instead, so this is the + only way to say what going back to the preference would give. + """ + + return self._language_variants.language_for( + self._block.video_params.audio_languages + ) + + @property + def ladder(self) -> Streams: + """The rungs this video may switch between, all in the one language.""" + + return self._block.streams.for_language(self.audio_language) + + @only_streamable + def set_audio_language(self, language: str | None): + """Play the same video in another one of the languages it is dubbed into. + + The ladder is a different one per language, so the rung has to be + found again; asking for the one playing now finds the same size. + """ + + self._block.video_params.audio_selection = ( + AudioPreferred() if language is None else AudioLanguage(tag=language) + ) + + self._block._log.debug( + f"Set audio language {language}," + f" resolved to {self.audio_language}," + f" playing {self._audio_language_playing}" + ) + + self.follow_audio_language() + + def follow_audio_language(self): + """Put the sound asked for into effect, in whichever dub it calls for. + + A dubbed stream is served one language at a time, so another one + means fetching the ladder again rather than reaching for a track + that was never handed over. + """ + + if ( + not self._block.streams + or self.audio_language == self._audio_language_playing + ): + # the rung on screen already carries it, so there is nothing to + # reload -- but the sound may have been switched off since it + # was loaded, and asking for a language means wanting it + self._block.audio.apply_wanted_track() + return + + # the pane is about to be reloaded anyway, and on a ladder that no + # longer holds the rung the timer was comparing against + self._adapt_timer.stop() + + self._block.reset(is_link_kept=True) + + self.load_quality(self._block.video_params.stream_quality) + + @only_streamable + def switch_quality(self, quality: str): + if quality == self._block.video_params.stream_quality: + return + + # picking a rung by hand calls off whatever the pane was about to do + self._adapt_timer.stop() + + self._block.reset(is_link_kept=True) + + self.load_quality(quality) + + @property + def quality_playing(self) -> str | None: + """Which rung of the ladder is on screen, as opposed to what was asked.""" + + return self._quality_playing + + def load_quality(self, wanted_quality: str): + is_auto = wanted_quality == STREAM_QUALITY_AUTO + + ladder = self.ladder + + self._audio_language_playing = self.audio_language + + if is_auto: + quality, stream = ladder.fit_to_height(self._pane_height_px) + else: + quality, stream = ladder.by_quality(wanted_quality) + + # a standing instruction outlives the rung it picked, where a rung + # chosen by name is kept as the name it was chosen by + self._block.video_params.stream_quality = ( + wanted_quality if wanted_quality in STANDING_QUALITIES else quality + ) + self._quality_playing = quality + + # switching quality resets the block, which leaves it without a driver + self._block._ensure_video_driver() + + if stream.protocol == "direct": + url = stream.url + self._proxied_url = None + else: + stream = self._with_audio_language(stream) + url = self._block._ctx.commands.add_stream( + self._with_origin(stream, quality) + ) + self._proxied_url = url if isinstance(url, str) else None + + self._loaded_at = time.monotonic() + + self.is_adaptive = stream.is_adaptive + + self._block._show_opening() + + self._block.load_video.emit( + MediaInput( + uri=url, + is_live=self._block.is_live, + is_audio_only=stream.is_audio_only, + video_codec=stream.video_codec, + is_adaptive=stream.is_adaptive, + size=self._block.size_tuple, + video=self._block.video_params, + # the proxy fetches the rest with them already + http_headers=( + self._block._item_headers if stream.protocol == "direct" else {} + ), + ) + ) + + @property + def _adapt_delay_ms(self) -> int: + """How long a pane has to keep its new size before the stream follows. + + Dragging a window edge resizes every pane dozens of times on the way, + and every switch costs a reload, so the size has to settle down first. + """ + + return max(self._block.video_params.quality_adapt_delay_sec, 1) * 1000 + + @property + def _pane_height_px(self) -> int: + """How tall this pane is in the pixels a screen actually has. + + The widget measures itself in the logical pixels Qt scales, so on a + 200% display a pane reports half the detail it can really show. + """ + + return int(self._block.height() * self._block.devicePixelRatioF()) + + def schedule_adapt(self): + if self._block.video_params is None or not self._block.streams: + return + + if self._block.video_params.stream_quality != STREAM_QUALITY_AUTO: + return + + self._adapt_timer.start(self._adapt_delay_ms) + + def _adapt(self): + """Fit the stream to the size the pane has settled on.""" + + if ( + self._block._is_closing + or self._block.video_params is None + or not self._block.streams + ): + return + + if self._block.video_params.stream_quality != STREAM_QUALITY_AUTO: + return + + if self._block._is_error: + # a video that is already failing has the retry timer looking + # after it, and reloading underneath that would only confuse it + return + + if self._block._is_state_change_in_progress: + self._adapt_timer.start(self._adapt_delay_ms) + return + + quality, _ = self.ladder.fit_to_height(self._pane_height_px) + + if quality == self._quality_playing: + return + + self._block._log.debug( + f"Pane now fits {quality}, switching from {self._quality_playing}" + ) + + self._block.reset(is_link_kept=True) + + self.load_quality(STREAM_QUALITY_AUTO) + + def _with_audio_language(self, stream): + """Hand the proxy only the audio tracks in the language being played. + + VLC is served a playlist with one audio rendition in it, so which + of them it is has to be settled here; the proxy is left to pick + the best of whatever it is given. + """ + + if not stream.audio_tracks: + return stream + + language = self._language_among(stream.audio_tracks) + + if language is None: + return stream + + return dataclasses.replace( + stream, audio_tracks=stream.audio_tracks.for_language(language) + ) + + def _with_origin(self, stream, quality: str): + """Tell the proxy where this stream came from. + + Services sign the URLs they hand out and stop honouring them after a + while, so the proxy has to be able to ask for them again rather than + serve a video that dies partway through. + """ + + origin = StreamOrigin( + url=str(self._block.video_params.uri), + quality=quality, + headers=HashableDict(self._block._item_headers) or None, + ) + + return dataclasses.replace(stream, origin=origin) diff --git a/tests/conftest.py b/tests/conftest.py index 2bd23d8e..96ca49d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,6 +59,7 @@ def _block_parts(): from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl + from gridplayer.widgets.video_block_stream import StreamControl from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl @@ -70,6 +71,7 @@ def _block_parts(): "recovery": RecoveryControl, "screenshot": ScreenshotControl, "sponsorblock": SponsorBlockControl, + "stream": StreamControl, "subtitles": SubtitleControl, "view": ViewControl, } diff --git a/tests/test_audio_commands_while_loading.py b/tests/test_audio_commands_while_loading.py index c64028dc..37cd90be 100644 --- a/tests/test_audio_commands_while_loading.py +++ b/tests/test_audio_commands_while_loading.py @@ -14,7 +14,7 @@ # nothing after a switch AUDIO_COMMANDS = ( "audio.set_track", - "set_audio_language", + "stream.set_audio_language", "audio.apply_preference", "audio.languages_dialog", "audio.get_languages", diff --git a/tests/test_audio_track_preference.py b/tests/test_audio_track_preference.py index 6dfdefa0..d445dffb 100644 --- a/tests/test_audio_track_preference.py +++ b/tests/test_audio_track_preference.py @@ -1,8 +1,8 @@ """Applying the language preference to the tracks libVLC found in a file.""" import logging -from functools import partial from types import SimpleNamespace +from unittest.mock import Mock import pytest from PyQt5.QtCore import QSettings @@ -23,8 +23,8 @@ AudioTrack, wanted_audio_track_id, ) -from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_block_audio import AudioControl +from gridplayer.widgets.video_block_stream import StreamControl @pytest.fixture(scope="module", autouse=True) @@ -122,6 +122,16 @@ def test_a_track_picked_by_hand_outranks_the_language_asked_for(mocker): assert _wanted(player) == 1 +@pytest.fixture(autouse=True) +def _timers(mocker): + # a stand-in block is no QObject for the stream part's timers to belong + # to, and none of them is waited for here + mocker.patch( + "gridplayer.widgets.video_block_stream.QTimer", + side_effect=lambda *args, **kwargs: Mock(), + ) + + def _block(mocker, languages="", tracks=None): block = mocker.Mock() block._log = logging.getLogger("test") @@ -137,9 +147,10 @@ def _block(mocker, languages="", tracks=None): block.is_video_initialized = True # a file is not dubbed by the site, so nothing is served per language block.streams = Streams() - block._language_variants = Streams() - block.follow_audio_language = partial(VideoBlock.follow_audio_language, block) block.audio = AudioControl(block) + block.stream = StreamControl(block) + # what is loaded is the quality tests' business; here only that it is + mocker.patch.object(block.stream, "load_quality") return block @@ -207,13 +218,12 @@ def test_going_back_to_preferred_refetches_a_dubbed_stream(mocker): for language in ("tlh", "en") } ) - block.audio_language = "en" - block._audio_language_playing = "tlh" + block.stream._audio_language_playing = "tlh" block.audio.apply_preference() block.reset.assert_called_once() - block.load_stream_quality.assert_called_once() + block.stream.load_quality.assert_called_once() def test_the_preferred_track_is_found_by_language(mocker): diff --git a/tests/test_menu_audio_click.py b/tests/test_menu_audio_click.py index 145a0c87..50aff153 100644 --- a/tests/test_menu_audio_click.py +++ b/tests/test_menu_audio_click.py @@ -10,6 +10,7 @@ from types import SimpleNamespace import pytest +from PyQt5.QtCore import QObject from PyQt5.QtWidgets import QApplication, QWidget from gridplayer.models.audio_selection import ( @@ -29,6 +30,7 @@ ) from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_block_audio import AudioControl +from gridplayer.widgets.video_block_stream import StreamControl @pytest.fixture(scope="module", autouse=True) @@ -96,29 +98,20 @@ def set_audio_track(self, track_id): self.cur_audio_track_id = self.tracks_manager.audio_track -class _Block: - """A block with its real audio part and the reload stubbed out.""" +class _Block(QObject): + """A block with its real audio and stream parts and the reload stubbed + out; a QObject, for the stream part's timers to belong to.""" - _language_variants = VideoBlock._language_variants - audio_language_options = VideoBlock.audio_language_options - audio_language = VideoBlock.audio_language - _language_among = VideoBlock._language_among - preferred_audio_language = VideoBlock.preferred_audio_language - stream_ladder = VideoBlock.stream_ladder is_video_initialized = VideoBlock.is_video_initialized is_local_file = VideoBlock.is_local_file - audio_language_playing = VideoBlock.audio_language_playing - - set_audio_language = VideoBlock.set_audio_language - follow_audio_language = VideoBlock.follow_audio_language def __init__(self): + super().__init__() + self._log = logging.getLogger("test-block") self.streams = _ladder() self.video_params = Video(uri="http://example.com/v") self.video_driver = _Driver() - self._audio_language_playing = None - self._quality_adapt_timer = SimpleNamespace(stop=lambda: None) self._ctx = SimpleNamespace( commands=SimpleNamespace( warning=lambda message: self.warnings.append(message) @@ -131,6 +124,9 @@ def __init__(self): # a stream with nothing beside it, so every track is one of its own self.audio = AudioControl(self) + self.stream = StreamControl(self) + self.stream.load_quality = self.load_stream_quality + self.load_stream_quality("best") @property @@ -140,11 +136,14 @@ def is_playable(self): def reset(self, is_link_kept=False): pass + def reload(self): + pass + def load_stream_quality(self, wanted): - quality, _ = self.stream_ladder.by_quality(wanted) + quality, _ = self.stream.ladder.by_quality(wanted) self.video_params.stream_quality = quality - self._audio_language_playing = self.audio_language + self.stream._audio_language_playing = self.stream.audio_language self.reloads.append(quality) # what the player settles on when the new media comes up @@ -273,7 +272,7 @@ def test_a_language_can_be_picked_again_after_disabling(): _click(block, "Klingon") assert block.video_params.audio_selection == AudioLanguage(tag="tlh") - assert block.audio_language == "tlh" + assert block.stream.audio_language == "tlh" def test_disable_audio_works_when_the_video_track_came_back_unmapped(): @@ -317,7 +316,7 @@ def test_disable_audio_reaches_the_player_after_a_language_was_picked(): def test_disabling_audio_is_still_refused_when_the_picture_is_off(): block = _Block() block.video_driver.cur_video_track_id = DISABLED_TRACK - block.load_stream_quality("best") + block.stream.load_quality("best") _click(block, "Disable Audio") @@ -363,7 +362,7 @@ def test_default_brings_a_dubbed_video_back_to_its_own_language(): _click(block, "Default") assert block.video_params.audio_selection == AudioDefault() - assert block.audio_language_playing == "en" + assert block.stream.audio_language_playing == "en" assert block.reloads[-1] == "720p (en)" # the rung on screen is on the ladder the quality menu lists - assert block.video_params.stream_quality in block.stream_ladder + assert block.video_params.stream_quality in block.stream.ladder diff --git a/tests/test_menu_audio_track.py b/tests/test_menu_audio_track.py index 3f51a729..556f2e2a 100644 --- a/tests/test_menu_audio_track.py +++ b/tests/test_menu_audio_track.py @@ -108,11 +108,13 @@ def _manager( ), attached_file=next(iter(external_tracks.values()), None), ), - audio_language_options=languages, - audio_language=playing_language, - preferred_audio_language=preferred_language, + stream=SimpleNamespace( + audio_language_options=languages, + audio_language=playing_language, + preferred_audio_language=preferred_language, + audio_language_playing=playing_language, + ), is_local_file=is_local_file, - audio_language_playing=playing_language, video_params=SimpleNamespace( audio_selection=AudioPreferred() if selection is None else selection, external_audio=external, diff --git a/tests/test_menu_stream_quality.py b/tests/test_menu_stream_quality.py index 46a988e4..ee67d106 100644 --- a/tests/test_menu_stream_quality.py +++ b/tests/test_menu_stream_quality.py @@ -13,6 +13,7 @@ Streams, ) from gridplayer.player.managers.active_block import ActiveBlockManager +from tests.conftest import is_block_command @pytest.fixture(scope="module", autouse=True) @@ -58,8 +59,7 @@ def _manager(ladder=None, quality=STREAM_QUALITY_AUTO, playing="720p"): ladder = _ladder() if ladder is None else ladder block = SimpleNamespace( - stream_ladder=ladder, - stream_quality_playing=playing, + stream=SimpleNamespace(ladder=ladder, quality_playing=playing), video_params=SimpleNamespace(stream_quality=quality), ) @@ -320,12 +320,10 @@ def test_the_standing_choices_leave_the_mark_to_the_rungs(): def test_every_row_calls_something_that_exists_on_a_block(): """A menu entry pointing at nothing fails silently at the click.""" - from gridplayer.widgets.video_block import VideoBlock - menu = _manager(ladder=_ladder(audio=(AUDIO_RUNG,))).menu_generator_stream_quality() called = [item["func"][1] for item in menu if item != "---"] assert called for name in called: - assert hasattr(VideoBlock, name), name + assert is_block_command(name), name diff --git a/tests/test_video_block_audio_language.py b/tests/test_video_block_audio_language.py index 076de539..14aee947 100644 --- a/tests/test_video_block_audio_language.py +++ b/tests/test_video_block_audio_language.py @@ -1,7 +1,7 @@ """Choosing which language a dubbed video plays in.""" import logging -from functools import partial +from unittest.mock import Mock import pytest from PyQt5.QtCore import QSettings @@ -15,8 +15,8 @@ from gridplayer.models.stream import STREAM_QUALITY_AUTO, Stream, Streams from gridplayer.models.video import Video from gridplayer.settings import Settings -from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_block_audio import AudioControl +from gridplayer.widgets.video_block_stream import StreamControl @pytest.fixture(scope="module", autouse=True) @@ -51,33 +51,42 @@ def _dubbed_ladder(original="en"): ) +@pytest.fixture(autouse=True) +def _timers(mocker): + # a stand-in block is no QObject for the part's timers to belong to, + # and what matters is only whether they were started or stopped + mocker.patch( + "gridplayer.widgets.video_block_stream.QTimer", + side_effect=lambda *args, **kwargs: Mock(), + ) + + def _block(mocker, languages="", streams=None): block = mocker.Mock() block._log = logging.getLogger("test") block.streams = _dubbed_ladder() if streams is None else streams - block._language_variants = VideoBlock._language_variants.fget(block) block.video_params = Video(uri="http://example.com/a.mp4") block.video_params.audio_languages = languages # this file is about the languages, so that is what these follow block.video_params.audio_selection = AudioPreferred() block.video_params.stream_quality = STREAM_QUALITY_AUTO - block._language_among = partial(VideoBlock._language_among, block) - block.follow_audio_language = partial(VideoBlock.follow_audio_language, block) + + block.stream = StreamControl(block) + # what is loaded is the quality tests' business; here only that it is + mocker.patch.object(block.stream, "load_quality") return block def _language(block): - block._language_variants = VideoBlock._language_variants.fget(block) - return VideoBlock.audio_language.fget(block) + return block.stream.audio_language def _options(block): - return VideoBlock.audio_language_options.fget(block) + return block.stream.audio_language_options def _ladder(block): - block.audio_language = _language(block) - return VideoBlock.stream_ladder.fget(block) + return block.stream.ladder def test_the_preferred_language_is_the_one_played(mocker): @@ -144,22 +153,22 @@ def test_the_whole_ladder_shows_when_there_is_one_language(mocker): def test_switching_language_reloads_at_the_same_size(mocker): block = _block(mocker, "en") block.video_params.stream_quality = "720p (en)" - block._audio_language_playing = "en" + block.stream._audio_language_playing = "en" - VideoBlock.set_audio_language(block, "tlh") + block.stream.set_audio_language("tlh") assert block.video_params.audio_selection == AudioLanguage(tag="tlh") block.reset.assert_called_once() - block.load_stream_quality.assert_called_once_with("720p (en)") + block.stream.load_quality.assert_called_once_with("720p (en)") def test_switching_language_leaves_the_preference_list_alone(mocker): """The list is what "Preferred" follows, and picking one is not that.""" block = _block(mocker, "en, de") - block._audio_language_playing = "en" + block.stream._audio_language_playing = "en" - VideoBlock.set_audio_language(block, "tlh") + block.stream.set_audio_language("tlh") assert block.video_params.audio_languages == "en, de" @@ -183,9 +192,9 @@ def test_an_explicit_language_the_video_no_longer_offers_is_dropped(mocker): def test_going_back_to_preferred_clears_the_pick(mocker): block = _block(mocker, "en") block.video_params.audio_selection = AudioLanguage(tag="tlh") - block._audio_language_playing = "tlh" + block.stream._audio_language_playing = "tlh" - VideoBlock.set_audio_language(block, None) + block.stream.set_audio_language(None) assert block.video_params.audio_selection == AudioPreferred() block.reset.assert_called_once() @@ -195,23 +204,22 @@ def test_switching_language_calls_off_a_pending_quality_adapt(mocker): """The rung it was counting towards is not on the new ladder.""" block = _block(mocker, "en") - block._audio_language_playing = "en" + block.stream._audio_language_playing = "en" - VideoBlock.set_audio_language(block, "cop") + block.stream.set_audio_language("cop") - block._quality_adapt_timer.stop.assert_called_once() + block.stream._adapt_timer.stop.assert_called_once() def test_switching_to_the_language_already_playing_does_nothing(mocker): block = _block(mocker, "en") - block._audio_language_playing = "tlh" + block.stream._audio_language_playing = "tlh" block.video_params.audio_selection = AudioLanguage(tag="tlh") - block.audio_language = _language(block) - VideoBlock.set_audio_language(block, "tlh") + block.stream.set_audio_language("tlh") block.reset.assert_not_called() - block.load_stream_quality.assert_not_called() + block.stream.load_quality.assert_not_called() def test_default_is_the_language_the_video_was_made_in(mocker): @@ -228,20 +236,18 @@ def test_going_back_to_default_refetches_the_original(mocker): block = _block(mocker) block.video_params.audio_selection = AudioLanguage(tag="cop") - block._audio_language_playing = "cop" - block.audio_language = "en" + block.stream._audio_language_playing = "cop" AudioControl(block).apply_default() assert block.video_params.audio_selection == AudioDefault() block.reset.assert_called_once() - block.load_stream_quality.assert_called_once() + block.stream.load_quality.assert_called_once() def test_going_back_to_default_on_the_original_does_not_reload(mocker): block = _block(mocker) - block._audio_language_playing = "en" - block.audio_language = "en" + block.stream._audio_language_playing = "en" AudioControl(block).apply_default() @@ -302,7 +308,7 @@ def test_only_the_chosen_language_is_handed_to_the_proxy(mocker): block = _block(mocker, "ja") stream = _paired_stream("en", "ja") - narrowed = VideoBlock._with_audio_language(block, stream) + narrowed = block.stream._with_audio_language(stream) assert {t.language for _, t in narrowed.audio_tracks.items()} == {"ja"} @@ -313,7 +319,7 @@ def test_a_language_picked_by_hand_is_the_one_handed_to_the_proxy(mocker): block = _block(mocker, "ja") block.video_params.audio_selection = AudioLanguage(tag="en") - narrowed = VideoBlock._with_audio_language(block, _paired_stream("en", "ja")) + narrowed = block.stream._with_audio_language(_paired_stream("en", "ja")) assert {t.language for _, t in narrowed.audio_tracks.items()} == {"en"} @@ -323,7 +329,7 @@ def test_every_rendition_of_the_language_is_handed_over(mocker): block = _block(mocker, "ja") - narrowed = VideoBlock._with_audio_language(block, _paired_stream("en", "ja")) + narrowed = block.stream._with_audio_language(_paired_stream("en", "ja")) assert len(narrowed.audio_tracks) == 2 @@ -332,11 +338,11 @@ def test_a_stream_with_one_audio_language_is_handed_over_untouched(mocker): block = _block(mocker, "ja") stream = _paired_stream("en") - assert VideoBlock._with_audio_language(block, stream) is stream + assert block.stream._with_audio_language(stream) is stream def test_a_stream_with_no_audio_tracks_is_handed_over_untouched(mocker): block = _block(mocker, "ja") stream = Stream(url="http://host/v", protocol="http") - assert VideoBlock._with_audio_language(block, stream) is stream + assert block.stream._with_audio_language(stream) is stream diff --git a/tests/test_video_block_chapters.py b/tests/test_video_block_chapters.py index ba4028ed..f32b3736 100644 --- a/tests/test_video_block_chapters.py +++ b/tests/test_video_block_chapters.py @@ -499,10 +499,10 @@ def test_a_live_stream_has_none(self, url_block): def test_they_last_through_a_switch_of_quality(self, url_block): block = url_block(_resolved()) - block.switch_stream_quality("360p") + block.stream.switch_quality("360p") assert block.is_video_initialized - assert block.stream_quality_playing == "360p" + assert block.stream.quality_playing == "360p" assert block.chapters.all == SITE_CHAPTERS def test_a_file_put_in_the_urls_place_brings_none_of_them( diff --git a/tests/test_video_block_external_audio.py b/tests/test_video_block_external_audio.py index 608060e3..996c2467 100644 --- a/tests/test_video_block_external_audio.py +++ b/tests/test_video_block_external_audio.py @@ -4,7 +4,7 @@ from types import SimpleNamespace import pytest -from PyQt5.QtCore import QSettings +from PyQt5.QtCore import QObject, QSettings from PyQt5.QtWidgets import QApplication from gridplayer.models.audio_selection import ( @@ -18,6 +18,7 @@ from gridplayer.vlc_player.static import AudioTrack from gridplayer.widgets.video_block import VideoBlock from gridplayer.widgets.video_block_audio import AudioControl +from gridplayer.widgets.video_block_stream import StreamControl @pytest.fixture(scope="module", autouse=True) @@ -70,11 +71,12 @@ def set_audio_track(self, track_id): self.tracks_set.append(track_id) -class _Block: - """A block with its real audio part, and no player behind it.""" +class _Block(QObject): + """A block with its real audio and stream parts, and no player behind it. + + A QObject, for the stream part's timers to belong to.""" is_local_file = VideoBlock.is_local_file - follow_audio_language = VideoBlock.follow_audio_language def __init__( self, @@ -84,6 +86,8 @@ def __init__( autodiscover=True, external_id=None, ): + super().__init__() + self._log = logging.getLogger("test-block") self.video_params = Video(uri=video_path) @@ -112,6 +116,7 @@ def __init__( ) self.audio = AudioControl(self) + self.stream = StreamControl(self) # the track picked, rather than the player told of it self.selected = [] diff --git a/tests/test_video_block_lists.py b/tests/test_video_block_lists.py index 926d5f97..c4bec84b 100644 --- a/tests/test_video_block_lists.py +++ b/tests/test_video_block_lists.py @@ -535,7 +535,7 @@ class TestWhatTheSiteSaysOfAnItem: PICTURE = "https://i.ytimg.com/vi/a/mqdefault.jpg" def _looked_up(self, block, mocker, details): - mocker.patch.object(block, "load_stream_quality") + mocker.patch.object(block.stream, "load_quality") block.set_video_url( ResolvedVideo(title="A", is_live=False, streams=Streams(), details=details) @@ -1037,7 +1037,7 @@ def test_the_proxy_resolves_it_again_with_them(self, make_cell, channels): block = make_cell(channels) stream = Stream(url=URL.format(0), protocol="hls_proxy") - origin = block._with_origin(stream, "best").origin + origin = block.stream._with_origin(stream, "best").origin assert origin.headers == self.HEADERS @@ -1045,7 +1045,7 @@ def test_none_where_the_list_gives_none(self, make_cell, channels): block = make_cell(channels, index=1) stream = Stream(url=URL.format(1), protocol="hls_proxy") - assert block._with_origin(stream, "best").origin.headers is None + assert block.stream._with_origin(stream, "best").origin.headers is None def test_a_channel_on_its_own_with_its_own( self, make_cell, file_list, no_resolving diff --git a/tests/test_video_block_quality_adapt.py b/tests/test_video_block_quality_adapt.py index 40dda5ff..c22240fb 100644 --- a/tests/test_video_block_quality_adapt.py +++ b/tests/test_video_block_quality_adapt.py @@ -17,7 +17,7 @@ ) from gridplayer.models.video import Video from gridplayer.settings import Settings -from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_stream import StreamControl @pytest.fixture(scope="module", autouse=True) @@ -48,20 +48,41 @@ def _streams(with_audio=False, audio_name="Audio 129kbps"): DELAY_MS = 15000 +@pytest.fixture(autouse=True) +def _timers(mocker): + # a stand-in block is no QObject for the part's timers to belong to, + # and what matters is only whether they were started or stopped + mocker.patch( + "gridplayer.widgets.video_block_stream.QTimer", + side_effect=lambda *args, **kwargs: Mock(), + ) + + def _block(mocker, quality=STREAM_QUALITY_AUTO, playing="360p", pane_height=300): block = mocker.Mock() block._log = logging.getLogger("test") block._is_closing = False block._is_error = False block._is_state_change_in_progress = False + block._item_headers = {} + # these streams name no language, so the ladder is all of them block.streams = _streams() - # these streams name no language, so narrowing by one changes nothing - block.stream_ladder = block.streams - block._stream_quality_playing = playing - block._pane_height_px = pane_height + block.height.return_value = pane_height + block.devicePixelRatioF.return_value = 1.0 block.video_params = Video(uri="http://example.com/a.mp4") block.video_params.stream_quality = quality - block._quality_adapt_delay_ms = DELAY_MS + block.video_params.quality_adapt_delay_sec = DELAY_MS // 1000 + + block.stream = StreamControl(block) + block.stream._quality_playing = playing + return block + + +def _adapting(mocker, **kwargs): + """A block whose next load is only recorded, for what adapting asks.""" + + block = _block(mocker, **kwargs) + mocker.patch.object(block.stream, "load_quality") return block @@ -69,138 +90,143 @@ def test_the_delay_is_taken_from_the_video_in_milliseconds(mocker): block = _block(mocker) block.video_params.quality_adapt_delay_sec = 20 - assert VideoBlock._quality_adapt_delay_ms.fget(block) == 20000 + assert block.stream._adapt_delay_ms == 20000 def test_a_new_delay_restarts_a_pane_that_is_already_waiting(mocker): block = _block(mocker) - block._quality_adapt_timer.isActive.return_value = True - block._quality_adapt_delay_ms = 30000 + block.stream._adapt_timer.isActive.return_value = True - VideoBlock.set_quality_adapt_delay(block, 30) + block.stream.set_adapt_delay(30) assert block.video_params.quality_adapt_delay_sec == 30 - block._quality_adapt_timer.start.assert_called_once_with(30000) + block.stream._adapt_timer.start.assert_called_once_with(30000) def test_a_new_delay_does_not_start_a_pane_that_was_not_waiting(mocker): block = _block(mocker) - block._quality_adapt_timer.isActive.return_value = False + block.stream._adapt_timer.isActive.return_value = False - VideoBlock.set_quality_adapt_delay(block, 30) + block.stream.set_adapt_delay(30) assert block.video_params.quality_adapt_delay_sec == 30 - block._quality_adapt_timer.start.assert_not_called() + block.stream._adapt_timer.start.assert_not_called() def test_a_resize_starts_the_wait_before_anything_is_switched(mocker): block = _block(mocker) - VideoBlock._schedule_quality_adapt(block) + block.stream.schedule_adapt() - block._quality_adapt_timer.start.assert_called_once_with(DELAY_MS) + block.stream._adapt_timer.start.assert_called_once_with(DELAY_MS) def test_a_pane_on_a_rung_the_user_picked_is_left_alone(mocker): block = _block(mocker, quality="1080p") - VideoBlock._schedule_quality_adapt(block) + block.stream.schedule_adapt() - block._quality_adapt_timer.start.assert_not_called() + block.stream._adapt_timer.start.assert_not_called() def test_a_pane_with_nothing_playing_has_no_ladder_to_move_on(mocker): block = _block(mocker) block.streams = Streams() - VideoBlock._schedule_quality_adapt(block) + block.stream.schedule_adapt() + + block.stream._adapt_timer.start.assert_not_called() + + +def test_the_pane_is_measured_in_the_pixels_the_screen_has(mocker): + block = _block(mocker, pane_height=300) + block.devicePixelRatioF.return_value = 2.0 - block._quality_adapt_timer.start.assert_not_called() + assert block.stream._pane_height_px == 600 def test_a_settled_pane_switches_to_the_rung_that_now_fits(mocker): - block = _block(mocker, playing="360p", pane_height=700) + block = _adapting(mocker, playing="360p", pane_height=700) - VideoBlock._adapt_stream_quality(block) + block.stream._adapt() block.reset.assert_called_once() - block.load_stream_quality.assert_called_once_with(STREAM_QUALITY_AUTO) + block.stream.load_quality.assert_called_once_with(STREAM_QUALITY_AUTO) def test_a_pane_that_still_fits_its_rung_is_not_reloaded(mocker): - block = _block(mocker, playing="360p", pane_height=300) + block = _adapting(mocker, playing="360p", pane_height=300) - VideoBlock._adapt_stream_quality(block) + block.stream._adapt() block.reset.assert_not_called() - block.load_stream_quality.assert_not_called() + block.stream.load_quality.assert_not_called() def test_a_pane_in_the_middle_of_loading_is_asked_again_later(mocker): - block = _block(mocker, playing="360p", pane_height=700) + block = _adapting(mocker, playing="360p", pane_height=700) block._is_state_change_in_progress = True - VideoBlock._adapt_stream_quality(block) + block.stream._adapt() - block.load_stream_quality.assert_not_called() - block._quality_adapt_timer.start.assert_called_once_with(DELAY_MS) + block.stream.load_quality.assert_not_called() + block.stream._adapt_timer.start.assert_called_once_with(DELAY_MS) def test_a_failing_pane_is_left_to_the_retry_timer(mocker): - block = _block(mocker, playing="360p", pane_height=700) + block = _adapting(mocker, playing="360p", pane_height=700) block._is_error = True - VideoBlock._adapt_stream_quality(block) + block.stream._adapt() - block.load_stream_quality.assert_not_called() - block._quality_adapt_timer.start.assert_not_called() + block.stream.load_quality.assert_not_called() + block.stream._adapt_timer.start.assert_not_called() def test_a_closing_pane_does_not_start_loading_a_new_stream(mocker): - block = _block(mocker, playing="360p", pane_height=700) + block = _adapting(mocker, playing="360p", pane_height=700) block._is_closing = True - VideoBlock._adapt_stream_quality(block) + block.stream._adapt() - block.load_stream_quality.assert_not_called() + block.stream.load_quality.assert_not_called() def test_auto_survives_the_rung_it_picked(mocker): block = _block(mocker, pane_height=700) - VideoBlock.load_stream_quality(block, STREAM_QUALITY_AUTO) + block.stream.load_quality(STREAM_QUALITY_AUTO) assert block.video_params.stream_quality == STREAM_QUALITY_AUTO - assert block._stream_quality_playing == "720p" + assert block.stream.quality_playing == "720p" def test_a_rung_picked_by_hand_is_recorded_as_the_choice(mocker): block = _block(mocker) - VideoBlock.load_stream_quality(block, "720p") + block.stream.load_quality("720p") assert block.video_params.stream_quality == "720p" - assert block._stream_quality_playing == "720p" + assert block.stream.quality_playing == "720p" def test_best_survives_the_rung_it_picked(mocker): block = _block(mocker) - VideoBlock.load_stream_quality(block, STREAM_QUALITY_BEST) + block.stream.load_quality(STREAM_QUALITY_BEST) assert block.video_params.stream_quality == STREAM_QUALITY_BEST - assert block._stream_quality_playing == "1080p" + assert block.stream.quality_playing == "1080p" def test_audio_only_survives_the_rung_it_picked(mocker): block = _block(mocker) block.streams = _streams(with_audio=True) - block.stream_ladder = block.streams - VideoBlock.load_stream_quality(block, STREAM_QUALITY_AUDIO_ONLY) + block.stream.load_quality(STREAM_QUALITY_AUDIO_ONLY) assert block.video_params.stream_quality == STREAM_QUALITY_AUDIO_ONLY - assert block._stream_quality_playing == "Audio 129kbps" + assert block.stream.quality_playing == "Audio 129kbps" def test_best_is_asked_again_of_a_ladder_that_has_since_changed(mocker): @@ -213,7 +239,7 @@ def test_best_is_asked_again_of_a_ladder_that_has_since_changed(mocker): block = _block(mocker) - VideoBlock.load_stream_quality(block, STREAM_QUALITY_BEST) + block.stream.load_quality(STREAM_QUALITY_BEST) block.streams = Streams( { @@ -221,11 +247,10 @@ def test_best_is_asked_again_of_a_ladder_that_has_since_changed(mocker): for quality in ("480p", "1440p") } ) - block.stream_ladder = block.streams - VideoBlock.load_stream_quality(block, block.video_params.stream_quality) + block.stream.load_quality(block.video_params.stream_quality) - assert block._stream_quality_playing == "1440p" + assert block.stream.quality_playing == "1440p" def test_audio_only_is_not_answered_with_video_after_a_reload(mocker): @@ -238,16 +263,14 @@ def test_audio_only_is_not_answered_with_video_after_a_reload(mocker): block = _block(mocker) block.streams = _streams(with_audio=True) - block.stream_ladder = block.streams - VideoBlock.load_stream_quality(block, STREAM_QUALITY_AUDIO_ONLY) + block.stream.load_quality(STREAM_QUALITY_AUDIO_ONLY) block.streams = _streams(with_audio=True, audio_name="Audio 92kbps [opus]") - block.stream_ladder = block.streams - VideoBlock.load_stream_quality(block, block.video_params.stream_quality) + block.stream.load_quality(block.video_params.stream_quality) - assert block._stream_quality_playing == "Audio 92kbps [opus]" + assert block.stream.quality_playing == "Audio 92kbps [opus]" def _adapt_delay_action(stream_quality, delay_txt="15 second(s)"): @@ -293,7 +316,7 @@ def test_the_delay_entry_stays_out_of_sight_on_a_hand_picked_rung(): def test_the_status_names_the_stream_picked_out_of_several(mocker): block = _block(mocker, playing="720p") - assert VideoBlock._video_detail(block) == "720p" + assert block.stream.quality_detail() == "720p" def test_the_status_names_no_stream_where_there_was_only_one(mocker): @@ -302,7 +325,7 @@ def test_the_status_names_no_stream_where_there_was_only_one(mocker): {"generic": Stream(url="http://host/a.mp4", protocol="direct")} ) - assert VideoBlock._video_detail(block) == "" + assert block.stream.quality_detail() == "" @pytest.mark.parametrize( @@ -319,4 +342,4 @@ def test_the_status_names_no_stream_where_there_was_only_one(mocker): def test_the_status_says_a_stream_by_its_size_alone(mocker, quality, said): block = _block(mocker, playing=quality) - assert VideoBlock._video_detail(block) == said + assert block.stream.quality_detail() == said diff --git a/tests/test_video_block_sponsorblock.py b/tests/test_video_block_sponsorblock.py index 0d1512cc..a1f1f397 100644 --- a/tests/test_video_block_sponsorblock.py +++ b/tests/test_video_block_sponsorblock.py @@ -177,7 +177,7 @@ def test_landing_a_hair_short_of_the_end_is_not_skipped_again(self, block): assert _seeks(block) == [SPONSOR.end_ms] def test_landing_seconds_short_on_a_stream_is_not_skipped_again(self, block): - block._is_adaptive = True + block.stream.is_adaptive = True _found(block, SPONSOR) _reported(block, 60100) @@ -193,7 +193,7 @@ def test_landing_short_for_good_is_not_skipped_again(self, block): Skipped again, it would land in the same place, over and over. """ - block._is_adaptive = True + block.stream.is_adaptive = True _found(block, SPONSOR) _reported(block, 60100) @@ -267,7 +267,7 @@ def test_played_into_again_after_leaving_it_is_skipped(self, block): def test_a_seek_past_one_landing_inside_it_on_a_stream_is_let_land(self, block): """The stream says where the keyframe before the aim is, first.""" - block._is_adaptive = True + block.stream.is_adaptive = True _found(block, SPONSOR) block.seek(95000) @@ -468,7 +468,7 @@ def _resolved(self, youtube_id=VIDEO_ID): ) def test_a_youtube_video_is_looked_up_as_it_resolves(self, block, mocker): - mocker.patch.object(block, "load_stream_quality") + mocker.patch.object(block.stream, "load_quality") fetch = mocker.patch.object( sponsorblock.sponsorblock_fetcher(), "fetch", return_value=None ) @@ -478,7 +478,7 @@ def test_a_youtube_video_is_looked_up_as_it_resolves(self, block, mocker): fetch.assert_called_once_with(VIDEO_ID, LENGTH) def test_anything_else_is_not(self, block, mocker): - mocker.patch.object(block, "load_stream_quality") + mocker.patch.object(block.stream, "load_quality") fetch = mocker.patch.object(sponsorblock.sponsorblock_fetcher(), "fetch") block.set_video_url(self._resolved(youtube_id=None)) @@ -487,7 +487,7 @@ def test_anything_else_is_not(self, block, mocker): def test_nor_anything_with_it_switched_off(self, block, mocker): Settings().set("sponsorblock/enabled", False) - mocker.patch.object(block, "load_stream_quality") + mocker.patch.object(block.stream, "load_quality") fetch = mocker.patch.object(sponsorblock.sponsorblock_fetcher(), "fetch") block.set_video_url(self._resolved()) @@ -495,7 +495,7 @@ def test_nor_anything_with_it_switched_off(self, block, mocker): fetch.assert_not_called() def test_the_answer_arriving_later_is_taken_up(self, block, mocker): - mocker.patch.object(block, "load_stream_quality") + mocker.patch.object(block.stream, "load_quality") mocker.patch.object( sponsorblock.sponsorblock_fetcher(), "fetch", return_value=None ) diff --git a/tests/test_video_block_stream_seek.py b/tests/test_video_block_stream_seek.py index bf0d8557..027a4137 100644 --- a/tests/test_video_block_stream_seek.py +++ b/tests/test_video_block_stream_seek.py @@ -81,7 +81,7 @@ def block(tmp_path, mocker): assert block.is_video_initialized # what a stream's stand-in would have said, where a file's cannot - block._is_adaptive = True + block.stream.is_adaptive = True block.set_time = mocker.spy(block.video_driver, "set_time") @@ -200,7 +200,7 @@ def test_one_into_the_end_is_not_made_again(self, block): assert _seeks(block) == [3000, LENGTH - 1000] def test_one_on_a_file_is_not_made_again(self, block): - block._is_adaptive = False + block.stream.is_adaptive = False block.seek(3000) block.seek(12000) diff --git a/tests/test_video_errors.py b/tests/test_video_errors.py index a5378380..08c9802b 100644 --- a/tests/test_video_errors.py +++ b/tests/test_video_errors.py @@ -724,8 +724,8 @@ def test_a_stream_says_what_the_proxy_was_told(self, make_cell): block = make_cell( "https://site.example/live", network_retry_mode=NetworkRetryMode.OFF ) - block._proxied_url = "http://127.0.0.1:1/?session_id=s" - block._stream_loaded_at = time.monotonic() - 1 + block.stream._proxied_url = "http://127.0.0.1:1/?session_id=s" + block.stream._loaded_at = time.monotonic() - 1 block._ctx.commands.stream_failure.return_value = ProxyFailure( url="https://cdn.example/seg.ts", status=403, reason="Forbidden" ) @@ -740,11 +740,11 @@ def test_what_the_proxy_was_told_before_this_load_left_alone(self, make_cell): block = make_cell( "https://site.example/live", network_retry_mode=NetworkRetryMode.OFF ) - block._proxied_url = "http://127.0.0.1:1/?session_id=s" + block.stream._proxied_url = "http://127.0.0.1:1/?session_id=s" block._ctx.commands.stream_failure.return_value = ProxyFailure( url="https://cdn.example/seg.ts", status=403, at=time.monotonic() - 60 ) - block._stream_loaded_at = time.monotonic() + block.stream._loaded_at = time.monotonic() block.video_driver_error("Video stopped before initialization") From d0afc00e28686019adec24e18529629535493ffa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:29:31 +0000 Subject: [PATCH 11/14] refactor: take what the video block says over its cell out into a StatusControl How far a video got loading, the error card and its Details window, and which face the overlay shows with them are block.status, along with what they keep: last_error, load_stage, load_steps, is_kept_for_picture. Failing itself (error, network_error) stays with the block, which hands the card the tries made. The parts now read the block's is_error and is_closing rather than its private fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/widgets/video_block.py | 393 +++------------------ gridplayer/widgets/video_block_recovery.py | 26 +- gridplayer/widgets/video_block_status.py | 326 +++++++++++++++++ gridplayer/widgets/video_block_stream.py | 6 +- tests/conftest.py | 2 + tests/test_close_teardown.py | 4 +- tests/test_first_picture.py | 8 +- tests/test_overlay_stopped.py | 29 +- tests/test_video_block_lists.py | 12 +- tests/test_video_block_network_retry.py | 13 +- tests/test_video_block_quality_adapt.py | 8 +- tests/test_video_block_repeat.py | 2 +- tests/test_video_errors.py | 18 +- 13 files changed, 447 insertions(+), 400 deletions(-) create mode 100644 gridplayer/widgets/video_block_status.py diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index 9598088d..3ac7f0bb 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -1,7 +1,6 @@ import logging import random import secrets -from datetime import datetime, timezone from functools import partial from operator import attrgetter from pathlib import Path @@ -11,11 +10,6 @@ from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QStackedLayout, QWidget -from gridplayer.dialogs.error_details import ( - DetailRows, - ErrorDetailsDialog, - code_rows, -) from gridplayer.dialogs.input_dialog import ( QCustomSpinboxTimeInput, ) @@ -78,8 +72,6 @@ from gridplayer.utils.video_errors import ( ShownError, file_error, - player_error, - proxy_error, ) from gridplayer.vlc_player.static import ( DISABLED_TRACK, @@ -110,6 +102,7 @@ from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl +from gridplayer.widgets.video_block_status import StatusControl from gridplayer.widgets.video_block_stream import StreamControl from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl @@ -119,14 +112,11 @@ OverlayBlockChild, OverlayBlockFloating, OverlayFakeInvisible, - OverlayMode, is_layered_child_supported, ) from gridplayer.widgets.video_status import ( LoadStage, - StepState, VideoStatus, - step_label, ) IN_PROGRESS_THRESHOLD_MS = 500 @@ -223,10 +213,6 @@ def __init__(self, video_driver, context, **kwargs): self._is_state_change_in_progress = False - # the loading status left over a video just loaded until there is a - # picture of it on show, see VideoFrameVLC.is_picture_pending - self._is_status_kept_for_picture = False - # last time update seen, to tell a finished pass from a running one self._last_time = None @@ -256,17 +242,6 @@ def __init__(self, video_driver, context, **kwargs): self._in_progress_timer.setInterval(IN_PROGRESS_THRESHOLD_MS) self._in_progress_timer.timeout.connect(self.is_in_progress_change) - # how far it got loading: the stage it is at, and the ones it goes - # through - self._load_stage: LoadStage | None = None - self._load_steps: tuple[LoadStage, ...] = () - - # what went wrong last, the stage it went wrong at and how many times - # it was tried - self._last_error: ShownError | None = None - self._error_stage: LoadStage | None = None - self._error_tries = 0 - self._seek_settle_timer = QTimer(self) self._seek_settle_timer.setSingleShot(True) self._seek_settle_timer.setInterval(SEEK_SETTLE_MS) @@ -281,6 +256,9 @@ def __init__(self, video_driver, context, **kwargs): # the seek before the last, when the last was made before it landed self._seek_behind: int | None = None + # first: the resolver says how far it got to it from the start + self.status = StatusControl(self) + self.url_resolver = self.init_url_resolver() self.video_driver: VideoFrameVLC | None = None @@ -335,7 +313,7 @@ def __init__(self, video_driver, context, **kwargs): # shared with other cells, the list may change in any of them self._ctx.lists.changed.connect(self._list_changed) - self._ctx.lists.waiting_progress.connect(self._on_waiting_progress) + self._ctx.lists.waiting_progress.connect(self.status.on_waiting_progress) # its file gone as the playlist opened, waiting for its list to be # read again to go on along it from there @@ -371,7 +349,7 @@ def init_video_driver(self) -> VideoFrameVLC: (video_driver.playback_status_changed, self.playback_status_changed), (video_driver.error, self.video_driver_error), (video_driver.crash, self.crash), - (video_driver.update_status, self._on_driver_status), + (video_driver.update_status, self.status.on_driver_status), # Left connected when the driver is let go: a frame asked for # before a reload is still the one that was wanted, and a PNG # nobody receives would be left behind in the temp folder. @@ -435,7 +413,7 @@ def _release_video_driver(self): if self.video_driver is None: return - self._is_status_kept_for_picture = False + self.status.is_kept_for_picture = False self.load_video.disconnect() self.video_driver.video_ready.disconnect() @@ -466,9 +444,9 @@ def init_url_resolver(self): url_resolver.error.connect(self.network_error) url_resolver.url_resolved.connect(self.set_video_url) url_resolver.list_resolved.connect(self._link_was_a_list) - url_resolver.resolving.connect(self._on_resolving) - url_resolver.reading.connect(self._on_link_list_reading) - url_resolver.failed.connect(self._on_resolve_failed) + url_resolver.resolving.connect(self.status.on_resolving) + url_resolver.reading.connect(self.status.on_link_list_reading) + url_resolver.failed.connect(self.status.resolve_failed) return url_resolver @@ -492,13 +470,13 @@ def reset(self, is_link_kept=False): # shown while the old player is let go: where the load to come # starts, not how far the one before it got; a list read for it # is where it started already - if self._load_stage is not LoadStage.LIST: + if self.status.load_stage is not LoadStage.LIST: if is_link_kept: - self._show_opening() + self.status.show_opening() else: - self._show_load_start() + self.status.show_load_start() - self.set_status("processing") + self.status.show("processing") self._destroy_video_driver() self.reset_url_resolver() @@ -528,7 +506,7 @@ def init_overlay(self): (overlay.stop_clicked, self.stop_playback), (overlay.retry_clicked, self.reload), (overlay.next_clicked, self.recovery.skip_now), - (overlay.details_clicked, self.show_error_details), + (overlay.details_clicked, self.status.show_error_details), (self.time_change, overlay.set_position), (self.volume_change, overlay.set_volume_position), (self.label_change, overlay.set_label), @@ -539,7 +517,7 @@ def init_overlay(self): (self.seek_marks_change, overlay.set_seek_marks), (self.is_paused_change, overlay.set_is_paused), # what it shows is the cell's to say, stopped or not - (self.is_stopped_change, self._sync_overlay_mode), + (self.is_stopped_change, self.status.sync_overlay_mode), (self.is_in_progress_change, overlay.set_is_in_progress), (self.is_muted_change, overlay.set_is_muted), (self.info_change, overlay.set_info_label), @@ -606,295 +584,35 @@ def video_driver_error(self, error: str, vlc_lines: tuple[str, ...] = ()): # has no translator of its own message = translate("Video Error", error) - self._last_error = self._player_error(message, vlc_lines) - self.update_status(message) + self.status.last_error = self.status.player_error(message, vlc_lines) + self.status.set_text(message) if not self.video_params.is_local_file: return self.network_error() return self.error() - def _on_resolve_failed(self, error: ShownError): - self._last_error = error - self.update_status(error.title) - - def _player_error(self, message: str, vlc_lines: tuple[str, ...]) -> ShownError: - """Why VLC could not play it: the file gone, what the proxy was - told fetching the stream, or what VLC said.""" - - uri = self.video_params.uri - - if self.video_params.is_local_file: - if not uri.exists(): - return file_error(ReadErrorKind.MISSING, uri) - - return player_error(message, vlc_lines) - - failure = self.stream.proxy_failure() - - if failure is not None: - return proxy_error(failure, vlc_lines) - - return player_error(message, vlc_lines, url=str(uri)) - def error(self): self._is_error = True self._is_state_change_in_progress = False - self._error_tries = 0 - self.set_status("error") + self.status.show("error") self.cleanup() self.recovery.schedule_skip() - self._show_error_card() + self.status.show_error_card() def network_error(self): if not self.recovery.is_reload_futile and self.recovery.schedule_retry(): return - self._error_tries = self.recovery.retries + tries = self.recovery.retries self.recovery.drop_retries() self._is_error = True self._is_state_change_in_progress = False - self.set_status("network-error") + self.status.show("network-error") self.cleanup() self.recovery.schedule_skip() - self._show_error_card() - - def _show_error_card(self): - """Why it failed, on the cell: in a few words, what that means, - the tag of where it broke, and what comes next.""" - - error = self._last_error - - # not what the status said last: that is how far it loaded - if error is None: - error = ShownError(title=translate("Video Error", "Can't play the video")) - self._last_error = error - - # where it got to, kept for its Details; the next load starts afresh - self._error_stage = self._load_stage - self._load_stage = None - - self.video_status.show_error(error, self.recovery.skip_note()) - self._sync_overlay_mode() - - def show_error_details(self): - """Everything known of why it failed, in a window of its own.""" - - error = self._last_error - - if error is None: - return - - # held while it is read, for its Try Again to be for this item and - # not the one the list would have gone on to meanwhile - is_skipping = self.recovery.is_skip_counting - self.recovery.stop_skip() - self.recovery.refresh_skip_note() - - dialog = ErrorDetailsDialog( - error, self._error_rows(error), parent=self.window() - ) - dialog.try_again.connect(self.reload) - dialog.exec_() - dialog.deleteLater() - - # left as it was: on through the list, the count started over - if is_skipping and self._last_error is error and not self._is_closing: - self.recovery.start_skip_count() - self.recovery.refresh_skip_note() - - def _error_rows(self, error: ShownError) -> DetailRows: - """What its Details window says of the video besides.""" - - uri = self.video_params.uri - - # a link named by itself is said once, as the link - rows = [] - - if self.title and self.title != str(uri): - rows.append((translate("Error Details", "Video"), self.title)) - - media_list = self.media_list - - if media_list is not None: - name = media_list.name or translate("Error Details", "List") - place = self._list_navigator.place() - - if place is not None and place.is_found: - name = translate("Error Details", "{LIST}, {PLACE} of {COUNT}").format( - LIST=name, PLACE=place.index + 1, COUNT=len(media_list.items) - ) - - rows.append((translate("Error Details", "List"), name)) - - if self.video_params.is_local_file: - rows.append((translate("Error Details", "File"), str(uri))) - else: - rows.append((translate("Error Details", "Link"), str(uri))) - - step = { - LoadStage.LIST: translate("Error Details", "Reading the list"), - LoadStage.LINK: translate("Error Details", "Resolving the link"), - LoadStage.VIDEO: translate("Error Details", "Opening the video"), - LoadStage.RECONNECT: translate("Error Details", "Opening the video"), - }.get(self._error_stage) - - if step: - rows.append((translate("Error Details", "Step"), step)) - - rows.extend(code_rows(error)) - - if self._error_tries: - limit = self.recovery.retry_limit - - if limit > 0: - tries = translate("Error Details", "{COUNT} of {LIMIT}").format( - COUNT=self._error_tries, LIMIT=limit - ) - else: - tries = str(self._error_tries) - - rows.append((translate("Error Details", "Tries"), tries)) - - rows.append( - ( - translate("Error Details", "When"), - datetime.fromtimestamp(error.at, tz=timezone.utc) - .astimezone() - .strftime("%H:%M:%S"), - ) - ) - - return tuple(rows) - - def set_status(self, status): - self._is_status_kept_for_picture = False - - self._hide_video_driver() - - self.video_status.icon = status - self.video_status.show() - - # still there, to close the cell or stop it, now for what it shows, - # and painted so along with the status: a reload lets the old player - # go before the event loop comes round to paint it - self._sync_overlay_mode() - self.overlay.lay_out_now() - - self.repaint() - - # a window of its own is not painted along with the cell - if self.overlay.isWindow() and self.overlay.isVisible(): - self.overlay.repaint() - - def _overlay_mode(self) -> OverlayMode: - if self._is_error: - return OverlayMode.ERROR - - if self.is_stopped: - return OverlayMode.STOPPED - - # the status over the cell, whatever player is still behind it, as - # a reload says so before the old one is gone - is_status_shown = not self.video_status.isHidden() - - if self.is_loading or self._is_status_kept_for_picture or is_status_shown: - return OverlayMode.LOADING - - return OverlayMode.VIDEO - - def _sync_overlay_mode(self): - mode = self._overlay_mode() - - if mode is OverlayMode.ERROR: - self.overlay.set_error_actions( - has_details=self._last_error is not None, - can_go_next=self.recovery.can_skip, - is_going_back=self.recovery.is_going_back, - ) - - self.overlay.set_mode(mode) - self.overlay.set_is_on_chrome(mode is not OverlayMode.VIDEO) - - def update_status(self, info_text): - self.video_status.status_text = translate("Video Status", info_text) - self.video_status.percent = 0 - - def _show_stage( - self, - stage: LoadStage, - text: str, - detail: str = "", - percent: int | None = None, - ): - """Loading, at this stage of it, and where that is among the ones - it goes through.""" - - self._load_stage = stage - - self.video_status.show_loading( - stage, text, detail, percent, steps=self._steps_at(stage) - ) - - def _steps_at(self, stage: LoadStage) -> tuple[tuple[str, StepState], ...]: - steps = self._load_steps - - if stage not in steps or len(steps) < 2: - return () - - at = steps.index(stage) - - return tuple( - ( - step_label(step), - StepState.DONE - if index < at - else StepState.CURRENT - if index == at - else StepState.TODO, - ) - for index, step in enumerate(steps) - ) - - def _on_resolving(self, name: str): - self._show_resolving_link(name) - - def _show_resolving_link(self, resolver: str = ""): - self._show_stage( - LoadStage.LINK, - translate("Video Status", "Resolving the link…"), - resolver, - ) - - def _on_link_list_reading(self, said: str): - """The link turned out to be a list, read as it is.""" - - self._load_steps = (LoadStage.LINK, LoadStage.LIST, LoadStage.VIDEO) - self._show_stage(LoadStage.LIST, said) - - def _on_driver_status(self, status: str, percent: int = 0): - """What the player says as it opens the video, said plainly.""" - - if status in {"Buffering", translate("Video Status", "Buffering")}: - self._show_stage( - LoadStage.VIDEO, - translate("Video Status", "Buffering {PERCENT}%").format( - PERCENT=percent - ), - self.stream.quality_detail(), - percent, - ) - return - - self._show_opening() - - def _show_opening(self): - self._show_stage( - LoadStage.VIDEO, - translate("Video Status", "Opening…"), - self.stream.quality_detail(), - ) + self.status.show_error_card(tries) def reload(self): self.recovery.stop_retry_countdown() @@ -922,19 +640,8 @@ def show_reloading(self): return self._is_error = False - self._show_load_start() - self.set_status("processing") - - def _show_load_start(self): - """The first stage of a load begun afresh: its link, or its video.""" - - is_link = self.video_params.is_http_url - self._load_steps = (*((LoadStage.LINK,) if is_link else ()), LoadStage.VIDEO) - - if is_link: - self._show_resolving_link() - else: - self._show_opening() + self.status.show_load_start() + self.status.show("processing") def release_player(self): """Let its player go, waiting until it has.""" @@ -1217,6 +924,12 @@ def list_item(self) -> ListItem | None: return self.media_list.items[place.index] + @property + def is_error(self) -> bool: + """Failed, and showing why, rather than playing or loading.""" + + return self._is_error + @property def is_closing(self) -> bool: return self._is_closing @@ -1483,7 +1196,7 @@ def show_overlay(self): # Kept in step even while it stays hidden: dragging shows it, and # its drop disc is solid over a cell with no picture, see-through # over a playing one - self._sync_overlay_mode() + self.status.sync_overlay_mode() if self._ctx.is_drag_ui or self._ctx.is_disable_overlay: return # Hardware overlays are windows of their own; do not remap them while @@ -1762,7 +1475,7 @@ def stop_playback(self): self._is_error = False # nor as far as it got loading: played again, its steps start afresh - self._load_stage = None + self.status.load_stage = None if self.video_params is not None: self._ctx.lists.stop_waiting(self.video_params.id) @@ -1937,8 +1650,8 @@ def _present_waiting(self): self._present_unloaded("processing", translate("Lists", "Reading the list…")) - self._load_steps = (LoadStage.LIST, LoadStage.VIDEO) - self._show_stage(LoadStage.LIST, translate("Lists", "Reading the list…")) + self.status.load_steps = (LoadStage.LIST, LoadStage.VIDEO) + self.status.show_stage(LoadStage.LIST, translate("Lists", "Reading the list…")) def _present_gone_file(self): """The file the cell is on gone, and its list not read again to go @@ -1948,8 +1661,8 @@ def _present_gone_file(self): self._present_unloaded("error", translate("Lists", "File not found")) def _present_unloaded(self, status: str, text: str): - self.set_status(status) - self.update_status(text) + self.status.show(status) + self.status.set_text(text) if self._default_title is None: self._default_title = self.video_params.uri_name @@ -1957,10 +1670,6 @@ def _present_unloaded(self, status: str, text: str): self.title = self._custom_title or self._default_title self.color = self.video_params.color.as_hex() - def _on_waiting_progress(self, video_id, text: str, percent: int = 0): - if self.video_params is not None and self.video_params.id == video_id: - self._show_stage(LoadStage.LIST, text, percent=percent or None) - def _present_stopped(self): if self._default_title is None: self._default_title = self.video_params.uri_name @@ -1980,7 +1689,7 @@ def _present_stopped(self): def _start_load(self): # what went wrong before is behind it, but while it is tried again if self.recovery.retries == 0: - self._last_error = None + self.status.last_error = None self.recovery.stop_skip_note() @@ -2013,8 +1722,8 @@ def _start_load(self): self._ensure_video_driver() # the list read for it, if it was, then the link, then the video - was_listed = self._load_stage is LoadStage.LIST - self._load_steps = ( + was_listed = self.status.load_stage is LoadStage.LIST + self.status.load_steps = ( *((LoadStage.LIST,) if was_listed else ()), *((LoadStage.LINK,) if self.video_params.is_http_url else ()), LoadStage.VIDEO, @@ -2036,10 +1745,10 @@ def _start_load(self): self.stream.is_adaptive = False if self.video_params.is_http_url: - self._show_resolving_link() + self.status.show_resolving_link() self.url_resolver.resolve(self.video_params.uri, self._item_headers) else: - self._show_opening() + self.status.show_opening() self.load_video.emit( MediaInput( uri=str(self.video_params.uri), @@ -2059,7 +1768,7 @@ def _load_and_play(self): self._is_state_change_in_progress = True self._in_progress_timer.start() self._set_playback_state(VideoInitialState.PLAYING) - self.set_status("processing") + self.status.show("processing") self._start_load() def set_video_url(self, video: ResolvedVideo): @@ -2085,7 +1794,7 @@ def set_video_url(self, video: ResolvedVideo): def load_video_finish(self): # the video is playing, so whatever went wrong before is behind us self.recovery.note_played() - self._last_error = None + self.status.last_error = None # a fresh player has no pass behind it to compare times against self._last_time = None @@ -2132,8 +1841,8 @@ def load_video_finish(self): self.audio.adopt_if_silent() # over the video until there is a picture of it to show instead - self._is_status_kept_for_picture = self.video_driver.is_picture_pending - if not self._is_status_kept_for_picture: + self.status.is_kept_for_picture = self.video_driver.is_picture_pending + if not self.status.is_kept_for_picture: self.video_status.hide() self.view.follow_synced() @@ -2161,10 +1870,10 @@ def _picture_shown(self): over a loading status still counting, for up to as long as the picture takes (see FIRST_PICTURE_TIMEOUT_MS), a stream's longest. """ - if not self._is_status_kept_for_picture: + if not self.status.is_kept_for_picture: return - self._is_status_kept_for_picture = False + self.status.is_kept_for_picture = False self.video_status.hide() self.show_overlay() @@ -2661,7 +2370,7 @@ def play_list_item(self, index: int | None): # none to play, every one around gone if index is None or not 0 <= index < len(media_list.items): - self._last_error = ShownError( + self.status.last_error = ShownError( title=translate("Lists", "Nothing on the list to play") ) self.error() @@ -2971,7 +2680,9 @@ def _files_open_elsewhere(self) -> list[Path]: def switch_video(self, new_video: Path): # If single file in the dir and was removed, highly unlikely but still if new_video is None: - self._last_error = file_error(ReadErrorKind.MISSING, self.video_params.uri) + self.status.last_error = file_error( + ReadErrorKind.MISSING, self.video_params.uri + ) self.error() return diff --git a/gridplayer/widgets/video_block_recovery.py b/gridplayer/widgets/video_block_recovery.py index aaaa08bb..f2195d69 100644 --- a/gridplayer/widgets/video_block_recovery.py +++ b/gridplayer/widgets/video_block_recovery.py @@ -130,9 +130,9 @@ def refresh_skip_note(self): if not self._skip_timer.isActive(): self._note_timer.stop() - if self._block._is_error and self._block._last_error is not None: + if self._block.is_error and self._block.status.last_error is not None: self._block.video_status.show_error( - self._block._last_error, self.skip_note() + self._block.status.last_error, self.skip_note() ) def schedule_retry(self) -> bool: @@ -169,8 +169,8 @@ def schedule_retry(self) -> bool: # holding on to it keeps a VLC player busy for the whole wait self._block.cleanup() - self._block._load_stage = LoadStage.RECONNECT - self._block.set_status("processing") + self._block.status.load_stage = LoadStage.RECONNECT + self._block.status.show("processing") self._show_retry_status() self._retry_timer.start() @@ -190,7 +190,7 @@ def _retry_tick(self): self._retry_timer.stop() - if self._block._is_closing or self._block.video_params is None: + if self._block.is_closing or self._block.video_params is None: return self._block.reload() @@ -199,7 +199,7 @@ def _show_retry_status(self): detail = self._attempt_txt # what went wrong the last time, in short - error = self._block._last_error + error = self._block.status.last_error if error is not None and (error.where or error.code): tag = " ".join(part for part in (error.where, error.code) if part) @@ -241,7 +241,7 @@ def skip_now(self): self._skip_timer.stop() - if self._block._is_closing or self._block.media_list is None: + if self._block.is_closing or self._block.media_list is None: return if not self._block._step_at_end(self._block.video_params.end_action): @@ -336,7 +336,7 @@ def schedule_skip(self): one that fails for the network or a site refusing is left sooner. """ - if self._block.media_list is None or self._block._is_closing: + if self._block.media_list is None or self._block.is_closing: return self._failures_in_a_row += 1 @@ -356,7 +356,7 @@ def _is_item_dead(self) -> bool: if self._block.video_params.is_local_file: return True - error = self._block._last_error + error = self._block.status.last_error return error is not None and not error.is_retryable and not error.is_refused @@ -389,7 +389,7 @@ def _given_up_txt(self) -> str: @property def _is_skipped(self) -> bool: - if self._block.media_list is None or self._block._is_closing: + if self._block.media_list is None or self._block.is_closing: return False if self._block.video_params.list_fail_action != ListFailAction.PLAY_NEXT: @@ -402,7 +402,7 @@ def is_reload_futile(self) -> bool: """An item that never played, failing for what a reload would only be told again, where the next one is to be played instead.""" - error = self._block._last_error + error = self._block.status.last_error return ( error is not None @@ -421,7 +421,7 @@ def _restart_skip_count(self): """The count to the next item after a failed one, once what is done then was changed: started over, or called off.""" - if not self._block._is_error: + if not self._block.is_error: return if self._is_skipped: @@ -432,7 +432,7 @@ def _restart_skip_count(self): self.refresh_skip_note() def _skip(self): - if self._block._is_closing or self._block.media_list is None: + if self._block.is_closing or self._block.media_list is None: return # along the list the way it goes by itself; on to the next where it diff --git a/gridplayer/widgets/video_block_status.py b/gridplayer/widgets/video_block_status.py new file mode 100644 index 00000000..84ab927a --- /dev/null +++ b/gridplayer/widgets/video_block_status.py @@ -0,0 +1,326 @@ +"""What a video block says over its cell while there is no video to show: +how far it got loading, what went wrong and why, and which of the overlay's +faces goes with that. + +When to fail and what comes after is the block's and its recovery's (see +VideoBlock.error and RecoveryControl); this is how it is shown. +""" + +from datetime import datetime, timezone + +from gridplayer.dialogs.error_details import ( + DetailRows, + ErrorDetailsDialog, + code_rows, +) +from gridplayer.utils.list_errors import ReadErrorKind +from gridplayer.utils.qt import translate +from gridplayer.utils.video_errors import ( + ShownError, + file_error, + player_error, + proxy_error, +) +from gridplayer.widgets.video_block_guards import VideoBlockPart +from gridplayer.widgets.video_overlay import OverlayMode +from gridplayer.widgets.video_status import LoadStage, StepState, step_label + + +class StatusControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # how far it got loading: the stage it is at, and the ones it goes + # through + self.load_stage: LoadStage | None = None + self.load_steps: tuple[LoadStage, ...] = () + + # what went wrong last; the stage it went wrong at and how many times + # it was tried, for its Details + self.last_error: ShownError | None = None + self._error_stage: LoadStage | None = None + self._error_tries = 0 + + # the loading status left over a video just loaded until there is a + # picture of it on show, see VideoFrameVLC.is_picture_pending + self.is_kept_for_picture = False + + def resolve_failed(self, error: ShownError): + self.last_error = error + self.set_text(error.title) + + def player_error(self, message: str, vlc_lines: tuple[str, ...]) -> ShownError: + """Why VLC could not play it: the file gone, what the proxy was + told fetching the stream, or what VLC said.""" + + uri = self._block.video_params.uri + + if self._block.video_params.is_local_file: + if not uri.exists(): + return file_error(ReadErrorKind.MISSING, uri) + + return player_error(message, vlc_lines) + + failure = self._block.stream.proxy_failure() + + if failure is not None: + return proxy_error(failure, vlc_lines) + + return player_error(message, vlc_lines, url=str(uri)) + + def show_error_card(self, tries: int = 0): + """Why it failed, on the cell: in a few words, what that means, + the tag of where it broke, and what comes next; tries, the reloads + made of it before it was left at that, for its Details.""" + + self._error_tries = tries + + error = self.last_error + + # not what the status said last: that is how far it loaded + if error is None: + error = ShownError(title=translate("Video Error", "Can't play the video")) + self.last_error = error + + # where it got to, kept for its Details; the next load starts afresh + self._error_stage = self.load_stage + self.load_stage = None + + self._block.video_status.show_error(error, self._block.recovery.skip_note()) + self.sync_overlay_mode() + + def show_error_details(self): + """Everything known of why it failed, in a window of its own.""" + + error = self.last_error + + if error is None: + return + + # held while it is read, for its Try Again to be for this item and + # not the one the list would have gone on to meanwhile + is_skipping = self._block.recovery.is_skip_counting + self._block.recovery.stop_skip() + self._block.recovery.refresh_skip_note() + + dialog = ErrorDetailsDialog( + error, self._error_rows(error), parent=self._block.window() + ) + dialog.try_again.connect(self._block.reload) + dialog.exec_() + dialog.deleteLater() + + # left as it was: on through the list, the count started over + if is_skipping and self.last_error is error and not self._block.is_closing: + self._block.recovery.start_skip_count() + self._block.recovery.refresh_skip_note() + + def _error_rows(self, error: ShownError) -> DetailRows: + """What its Details window says of the video besides.""" + + uri = self._block.video_params.uri + + # a link named by itself is said once, as the link + rows = [] + + if self._block.title and self._block.title != str(uri): + rows.append((translate("Error Details", "Video"), self._block.title)) + + media_list = self._block.media_list + + if media_list is not None: + name = media_list.name or translate("Error Details", "List") + place = self._block._list_navigator.place() + + if place is not None and place.is_found: + name = translate("Error Details", "{LIST}, {PLACE} of {COUNT}").format( + LIST=name, PLACE=place.index + 1, COUNT=len(media_list.items) + ) + + rows.append((translate("Error Details", "List"), name)) + + if self._block.video_params.is_local_file: + rows.append((translate("Error Details", "File"), str(uri))) + else: + rows.append((translate("Error Details", "Link"), str(uri))) + + step = { + LoadStage.LIST: translate("Error Details", "Reading the list"), + LoadStage.LINK: translate("Error Details", "Resolving the link"), + LoadStage.VIDEO: translate("Error Details", "Opening the video"), + LoadStage.RECONNECT: translate("Error Details", "Opening the video"), + }.get(self._error_stage) + + if step: + rows.append((translate("Error Details", "Step"), step)) + + rows.extend(code_rows(error)) + + if self._error_tries: + limit = self._block.recovery.retry_limit + + if limit > 0: + tries = translate("Error Details", "{COUNT} of {LIMIT}").format( + COUNT=self._error_tries, LIMIT=limit + ) + else: + tries = str(self._error_tries) + + rows.append((translate("Error Details", "Tries"), tries)) + + rows.append( + ( + translate("Error Details", "When"), + datetime.fromtimestamp(error.at, tz=timezone.utc) + .astimezone() + .strftime("%H:%M:%S"), + ) + ) + + return tuple(rows) + + def show(self, status): + self.is_kept_for_picture = False + + self._block._hide_video_driver() + + self._block.video_status.icon = status + self._block.video_status.show() + + # still there, to close the cell or stop it, now for what it shows, + # and painted so along with the status: a reload lets the old player + # go before the event loop comes round to paint it + self.sync_overlay_mode() + self._block.overlay.lay_out_now() + + self._block.repaint() + + # a window of its own is not painted along with the cell + if self._block.overlay.isWindow() and self._block.overlay.isVisible(): + self._block.overlay.repaint() + + def _overlay_mode(self) -> OverlayMode: + if self._block.is_error: + return OverlayMode.ERROR + + if self._block.is_stopped: + return OverlayMode.STOPPED + + # the status over the cell, whatever player is still behind it, as + # a reload says so before the old one is gone + is_status_shown = not self._block.video_status.isHidden() + + if self._block.is_loading or self.is_kept_for_picture or is_status_shown: + return OverlayMode.LOADING + + return OverlayMode.VIDEO + + def sync_overlay_mode(self): + mode = self._overlay_mode() + + if mode is OverlayMode.ERROR: + self._block.overlay.set_error_actions( + has_details=self.last_error is not None, + can_go_next=self._block.recovery.can_skip, + is_going_back=self._block.recovery.is_going_back, + ) + + self._block.overlay.set_mode(mode) + self._block.overlay.set_is_on_chrome(mode is not OverlayMode.VIDEO) + + def set_text(self, info_text): + self._block.video_status.status_text = translate("Video Status", info_text) + self._block.video_status.percent = 0 + + def show_stage( + self, + stage: LoadStage, + text: str, + detail: str = "", + percent: int | None = None, + ): + """Loading, at this stage of it, and where that is among the ones + it goes through.""" + + self.load_stage = stage + + self._block.video_status.show_loading( + stage, text, detail, percent, steps=self._steps_at(stage) + ) + + def _steps_at(self, stage: LoadStage) -> tuple[tuple[str, StepState], ...]: + steps = self.load_steps + + if stage not in steps or len(steps) < 2: + return () + + at = steps.index(stage) + + return tuple( + ( + step_label(step), + StepState.DONE + if index < at + else StepState.CURRENT + if index == at + else StepState.TODO, + ) + for index, step in enumerate(steps) + ) + + def on_resolving(self, name: str): + self.show_resolving_link(name) + + def show_resolving_link(self, resolver: str = ""): + self.show_stage( + LoadStage.LINK, + translate("Video Status", "Resolving the link…"), + resolver, + ) + + def on_link_list_reading(self, said: str): + """The link turned out to be a list, read as it is.""" + + self.load_steps = (LoadStage.LINK, LoadStage.LIST, LoadStage.VIDEO) + self.show_stage(LoadStage.LIST, said) + + def on_driver_status(self, status: str, percent: int = 0): + """What the player says as it opens the video, said plainly.""" + + if status in {"Buffering", translate("Video Status", "Buffering")}: + self.show_stage( + LoadStage.VIDEO, + translate("Video Status", "Buffering {PERCENT}%").format( + PERCENT=percent + ), + self._block.stream.quality_detail(), + percent, + ) + return + + self.show_opening() + + def show_opening(self): + self.show_stage( + LoadStage.VIDEO, + translate("Video Status", "Opening…"), + self._block.stream.quality_detail(), + ) + + def show_load_start(self): + """The first stage of a load begun afresh: its link, or its video.""" + + is_link = self._block.video_params.is_http_url + self.load_steps = (*((LoadStage.LINK,) if is_link else ()), LoadStage.VIDEO) + + if is_link: + self.show_resolving_link() + else: + self.show_opening() + + def on_waiting_progress(self, video_id, text: str, percent: int = 0): + if ( + self._block.video_params is not None + and self._block.video_params.id == video_id + ): + self.show_stage(LoadStage.LIST, text, percent=percent or None) diff --git a/gridplayer/widgets/video_block_stream.py b/gridplayer/widgets/video_block_stream.py index 54b40624..0f9c68b9 100644 --- a/gridplayer/widgets/video_block_stream.py +++ b/gridplayer/widgets/video_block_stream.py @@ -355,7 +355,7 @@ def load_quality(self, wanted_quality: str): self.is_adaptive = stream.is_adaptive - self._block._show_opening() + self._block.status.show_opening() self._block.load_video.emit( MediaInput( @@ -406,7 +406,7 @@ def _adapt(self): """Fit the stream to the size the pane has settled on.""" if ( - self._block._is_closing + self._block.is_closing or self._block.video_params is None or not self._block.streams ): @@ -415,7 +415,7 @@ def _adapt(self): if self._block.video_params.stream_quality != STREAM_QUALITY_AUTO: return - if self._block._is_error: + if self._block.is_error: # a video that is already failing has the retry timer looking # after it, and reloading underneath that would only confuse it return diff --git a/tests/conftest.py b/tests/conftest.py index 96ca49d2..84115897 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,6 +59,7 @@ def _block_parts(): from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl + from gridplayer.widgets.video_block_status import StatusControl from gridplayer.widgets.video_block_stream import StreamControl from gridplayer.widgets.video_block_subtitles import SubtitleControl from gridplayer.widgets.video_block_view import ViewControl @@ -71,6 +72,7 @@ def _block_parts(): "recovery": RecoveryControl, "screenshot": ScreenshotControl, "sponsorblock": SponsorBlockControl, + "status": StatusControl, "stream": StreamControl, "subtitles": SubtitleControl, "view": ViewControl, diff --git a/tests/test_close_teardown.py b/tests/test_close_teardown.py index e2e6adbc..8ec863af 100644 --- a/tests/test_close_teardown.py +++ b/tests/test_close_teardown.py @@ -304,7 +304,7 @@ def test_reloading_says_so_over_the_video_as_a_new_cell_does( uri.touch() block.set_video(Video(uri=uri)) - block._show_stage(LoadStage.VIDEO, "Buffering 100%") + block.status.show_stage(LoadStage.VIDEO, "Buffering 100%") block.video_status.hide() block.show_reloading() @@ -334,7 +334,7 @@ def test_reloading_leaves_a_stopped_cell_stopped(cell, tmp_path): def _playing(block, path): block.set_video(Video(uri=path)) - block._show_stage(LoadStage.VIDEO, "Buffering 100%") + block.status.show_stage(LoadStage.VIDEO, "Buffering 100%") block.video_status.hide() diff --git a/tests/test_first_picture.py b/tests/test_first_picture.py index 215ca9ed..4b9bbe68 100644 --- a/tests/test_first_picture.py +++ b/tests/test_first_picture.py @@ -16,6 +16,7 @@ from gridplayer.utils.win32_vlc_vout import VlcPicture from gridplayer.vlc_player.static import VideoTrack from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_status import StatusControl from gridplayer.widgets.video_frame_vlc_base import ( FIRST_PICTURE_PLACE_TIMEOUT_MS, FIRST_PICTURE_TIMEOUT_MS, @@ -404,9 +405,10 @@ def test_cell_status_given_something_else_to_show_stays(mocker): """A status set meanwhile, an error say, is not the loading one.""" block = mocker.Mock() block.video_driver.is_picture_pending = True + block.status = StatusControl(block) VideoBlock.load_video_finish(block) - VideoBlock.set_status(block, "error") + block.status.show("error") VideoBlock._picture_shown(block) block.video_status.hide.assert_not_called() @@ -432,8 +434,8 @@ def test_cell_overlay_says_loading_until_the_picture_is_up(mocker): block = _block_showing_overlays(mocker, is_picture_pending=True) block.is_stopped = False block.is_video_initialized = True - block._overlay_mode = lambda: VideoBlock._overlay_mode(block) - block._sync_overlay_mode = lambda: VideoBlock._sync_overlay_mode(block) + block.is_error = False + block.status = StatusControl(block) VideoBlock.load_video_finish(block) # the mouse moving over it meanwhile diff --git a/tests/test_overlay_stopped.py b/tests/test_overlay_stopped.py index aba0ee24..61db1f79 100644 --- a/tests/test_overlay_stopped.py +++ b/tests/test_overlay_stopped.py @@ -1,5 +1,6 @@ from pathlib import Path from types import SimpleNamespace +from unittest.mock import Mock import pytest from PyQt5.QtCore import QPoint, QRect, Qt @@ -13,6 +14,7 @@ from gridplayer.utils.drop_zone import DropIndicator from gridplayer.widgets.cell_chrome import dashed_frame from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_status import StatusControl from gridplayer.widgets.video_overlay import ( OverlayBlock, OverlayBlockFloating, @@ -439,7 +441,7 @@ def test_show_overlay_while_loading(mocker): VideoBlock.show_overlay(block) - block._sync_overlay_mode.assert_called_once() + block.status.sync_overlay_mode.assert_called_once() block.overlay.show.assert_called_once() @@ -455,7 +457,7 @@ def test_show_overlay_on_error(mocker): VideoBlock.show_overlay(block) - block._sync_overlay_mode.assert_called_once() + block.status.sync_overlay_mode.assert_called_once() block.overlay.show.assert_called_once() @@ -475,14 +477,15 @@ def test_overlay_mode_follows_the_cell( mocker, is_error, is_stopped, is_initialized, is_kept, is_status, mode ): block = mocker.Mock() - block._is_error = is_error + block.is_error = is_error block.is_stopped = is_stopped block.is_video_initialized = is_initialized block.is_loading = not is_initialized and not is_stopped and not is_error - block._is_status_kept_for_picture = is_kept block.video_status.isHidden.return_value = not is_status + status = StatusControl(block) + status.is_kept_for_picture = is_kept - assert VideoBlock._overlay_mode(block) is mode + assert status._overlay_mode() is mode def test_the_overlay_turns_along_with_the_status(mocker): @@ -492,14 +495,15 @@ def test_the_overlay_turns_along_with_the_status(mocker): block = mocker.Mock() order = [] - block._sync_overlay_mode.side_effect = lambda: order.append("mode") + status = StatusControl(block) + status.sync_overlay_mode = lambda: order.append("mode") block.overlay.lay_out_now.side_effect = lambda: order.append("laid out") block.repaint.side_effect = lambda: order.append("cell") block.overlay.isWindow.return_value = True block.overlay.isVisible.return_value = True block.overlay.repaint.side_effect = lambda: order.append("overlay") - VideoBlock.set_status(block, "processing") + status.show("processing") assert order == ["mode", "laid out", "cell", "overlay"] @@ -528,11 +532,12 @@ def test_the_overlay_laid_out_at_once(): def test_overlay_on_chrome_but_over_a_video(mocker): block = mocker.Mock() + status = StatusControl(block) for mode in OverlayMode: - block._overlay_mode.return_value = mode + status._overlay_mode = Mock(return_value=mode) - VideoBlock._sync_overlay_mode(block) + status.sync_overlay_mode() block.overlay.set_mode.assert_called_with(mode) block.overlay.set_is_on_chrome.assert_called_with(mode is not OverlayMode.VIDEO) @@ -555,7 +560,7 @@ def test_overlay_mode_kept_while_it_stays_hidden( VideoBlock.show_overlay(block) - block._sync_overlay_mode.assert_called_once() + block.status.sync_overlay_mode.assert_called_once() block.overlay.show.assert_not_called() @@ -566,7 +571,7 @@ def test_show_overlay_skipped_when_cell_hidden(mocker): block.is_overlay_fits = True block.is_loading = False block._is_error = False - block._is_status_kept_for_picture = False + block.status.is_kept_for_picture = False block.isVisible.return_value = False VideoBlock.show_overlay(block) @@ -581,7 +586,7 @@ def test_show_overlay_skipped_when_cell_too_small(mocker): block.is_overlay_fits = False block.is_loading = False block._is_error = False - block._is_status_kept_for_picture = False + block.status.is_kept_for_picture = False block.isVisible.return_value = True VideoBlock.show_overlay(block) diff --git a/tests/test_video_block_lists.py b/tests/test_video_block_lists.py index c4bec84b..8719f478 100644 --- a/tests/test_video_block_lists.py +++ b/tests/test_video_block_lists.py @@ -805,7 +805,7 @@ def test_on_past_a_long_stretch_of_links_gone(self, make_cell, lists): block = _long_link_list(make_cell, lists) for _ in range(MAX_FAILED + 1): - block._last_error = ShownError(title="Not found", is_retryable=False) + block.status.last_error = ShownError(title="Not found", is_retryable=False) block.error() assert block.recovery.is_skip_counting block.recovery.stop_skip() @@ -814,11 +814,11 @@ def test_not_on_and_on_with_no_network(self, make_cell, lists): block = _long_link_list(make_cell, lists) for _ in range(MAX_FAILED): - block._last_error = ShownError(title="No connection", is_network=True) + block.status.last_error = ShownError(title="No connection", is_network=True) block.error() block.recovery.stop_skip() - block._last_error = ShownError(title="No connection", is_network=True) + block.status.last_error = ShownError(title="No connection", is_network=True) block.error() assert not block.recovery.is_skip_counting @@ -831,11 +831,11 @@ def test_nor_with_a_site_turning_requests_away(self, make_cell, lists): block = _long_link_list(make_cell, lists) for _ in range(MAX_FAILED): - block._last_error = refused + block.status.last_error = refused block.error() block.recovery.stop_skip() - block._last_error = refused + block.status.last_error = refused block.error() assert not block.recovery.is_skip_counting @@ -848,7 +848,7 @@ def test_links_gone_in_between_count_to_the_stop(self, make_cell, lists): lost = ShownError(title="No connection", is_network=True) for n in range(2 * MAX_FAILED + 1): - block._last_error = gone if n % 2 else lost + block.status.last_error = gone if n % 2 else lost block.error() block.recovery.stop_skip() diff --git a/tests/test_video_block_network_retry.py b/tests/test_video_block_network_retry.py index 540ec908..bea9f8de 100644 --- a/tests/test_video_block_network_retry.py +++ b/tests/test_video_block_network_retry.py @@ -42,11 +42,11 @@ def _set_retries(mode, times=3): def _block(mocker, retries=0, countdown=0): block = mocker.Mock() block._log = logging.getLogger("test") - block._is_closing = False + block.is_closing = False block._has_item_played = True block.media_list = None block.video_params = Video(uri="http://example.com/a.mp4") - block._last_error = ShownError(title="Connection lost", is_network=True) + block.status.last_error = ShownError(title="Connection lost", is_network=True) block.recovery = RecoveryControl(block) block.recovery._retries = retries @@ -110,9 +110,10 @@ def test_the_error_is_shown_once_there_is_nothing_left_to_try(mocker): VideoBlock.network_error(block) assert block._is_error is True - assert block._error_tries == 2 + # how many times it was tried, for its Details + block.status.show_error_card.assert_called_once_with(2) assert block.recovery.retries == 0 - block.set_status.assert_called_once_with("network-error") + block.status.show.assert_called_once_with("network-error") def test_no_error_is_shown_while_a_retry_is_pending(mocker): @@ -122,7 +123,7 @@ def test_no_error_is_shown_while_a_retry_is_pending(mocker): VideoBlock.network_error(block) assert block._is_error is False - assert call("network-error") not in block.set_status.call_args_list + assert call("network-error") not in block.status.show.call_args_list block.recovery._retry_timer.start.assert_called_once() @@ -147,7 +148,7 @@ def test_the_countdown_is_shown_until_it_runs_out(mocker): def test_a_closing_block_is_not_reloaded(mocker): block = _block(mocker, retries=1, countdown=1) - block._is_closing = True + block.is_closing = True block.recovery._retry_tick() diff --git a/tests/test_video_block_quality_adapt.py b/tests/test_video_block_quality_adapt.py index c22240fb..268f44a1 100644 --- a/tests/test_video_block_quality_adapt.py +++ b/tests/test_video_block_quality_adapt.py @@ -61,8 +61,8 @@ def _timers(mocker): def _block(mocker, quality=STREAM_QUALITY_AUTO, playing="360p", pane_height=300): block = mocker.Mock() block._log = logging.getLogger("test") - block._is_closing = False - block._is_error = False + block.is_closing = False + block.is_error = False block._is_state_change_in_progress = False block._item_headers = {} # these streams name no language, so the ladder is all of them @@ -175,7 +175,7 @@ def test_a_pane_in_the_middle_of_loading_is_asked_again_later(mocker): def test_a_failing_pane_is_left_to_the_retry_timer(mocker): block = _adapting(mocker, playing="360p", pane_height=700) - block._is_error = True + block.is_error = True block.stream._adapt() @@ -185,7 +185,7 @@ def test_a_failing_pane_is_left_to_the_retry_timer(mocker): def test_a_closing_pane_does_not_start_loading_a_new_stream(mocker): block = _adapting(mocker, playing="360p", pane_height=700) - block._is_closing = True + block.is_closing = True block.stream._adapt() diff --git a/tests/test_video_block_repeat.py b/tests/test_video_block_repeat.py index 1a2254ed..d66d1c83 100644 --- a/tests/test_video_block_repeat.py +++ b/tests/test_video_block_repeat.py @@ -162,7 +162,7 @@ def test_stopped_overlay_uses_hide_timer(mocker): block.is_stopped = True block.is_loading = False block._is_error = False - block._is_status_kept_for_picture = False + block.status.is_kept_for_picture = False block.view.is_zoom_ui.return_value = False block._is_list_window_shown = False block.isVisible.return_value = True diff --git a/tests/test_video_errors.py b/tests/test_video_errors.py index 08c9802b..1ef5f8a1 100644 --- a/tests/test_video_errors.py +++ b/tests/test_video_errors.py @@ -829,7 +829,7 @@ def test_its_details_in_full(self, make_cell, mocker): shown = [] mocker.patch( - "gridplayer.widgets.video_block.ErrorDetailsDialog.exec_", + "gridplayer.widgets.video_block_status.ErrorDetailsDialog.exec_", lambda dialog: shown.append(dialog), ) @@ -855,7 +855,7 @@ def test_forgotten_once_it_plays(self, make_cell, tmp_path): block.reload() - assert block._last_error is None + assert block.status.last_error is None def test_held_on_the_item_while_its_details_are_read( self, make_cell, tmp_path, mocker @@ -863,11 +863,11 @@ def test_held_on_the_item_while_its_details_are_read( block, _ = _failed_list_item(make_cell, tmp_path) while_read = [] mocker.patch( - "gridplayer.widgets.video_block.ErrorDetailsDialog.exec_", + "gridplayer.widgets.video_block_status.ErrorDetailsDialog.exec_", side_effect=lambda: while_read.append(block.recovery.is_skip_counting), ) - block.show_error_details() + block.status.show_error_details() assert while_read == [False] # closed with nothing done in it: on through the list as before @@ -879,11 +879,11 @@ def test_held_on_the_item_while_its_details_are_read( def test_its_try_again_for_the_item_that_failed(self, make_cell, tmp_path, mocker): block, files = _failed_list_item(make_cell, tmp_path) mocker.patch( - "gridplayer.widgets.video_block.ErrorDetailsDialog.exec_", + "gridplayer.widgets.video_block_status.ErrorDetailsDialog.exec_", lambda dialog: dialog.try_again.emit(), ) - block.show_error_details() + block.status.show_error_details() assert block.video_params.uri == files[0] assert not block._is_error @@ -919,7 +919,7 @@ def test_not_named_by_how_far_it_loaded(self, make_cell, tmp_path): video = tmp_path / "a.mp4" video.touch() block = make_cell(video) - block._show_stage(LoadStage.VIDEO, "Opening…") + block.status.show_stage(LoadStage.VIDEO, "Opening…") video.unlink() # the only file of its folder gone, none to go on to @@ -933,12 +933,12 @@ def test_its_list_read_before_it_was_stopped_no_step_of_the_next_load( video = tmp_path / "a.mp4" video.touch() block = make_cell(video) - block._show_stage(LoadStage.LIST, "Reading the list…") + block.status.show_stage(LoadStage.LIST, "Reading the list…") block.stop_playback() block._start_load() - assert LoadStage.LIST not in block._load_steps + assert LoadStage.LIST not in block.status.load_steps def test_stopped_from_the_menu_it_reconnects_no_more(self, make_cell): """Stop and Stop All in the menu stop it as its own Stop does: the From 337336f14a3711c7f8a4e5e2c20a5c88b8a364ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:37:33 +0000 Subject: [PATCH 12/14] refactor: take the video block's way along its list or folder out into a NavigationControl Going to the next, previous or a random video along the cell's list or through its folder, taking up and leaving a list, its List window and the place on it said over the video, and going on from a file that is gone are block.navigation ("navigation.next", "navigation.leave_list", ...). Which list and item the cell is on, media_list and list_item, stay with the block. "playlist" already names the saved grid, hence not that. Every part is now made before the URL resolver, which is wired to them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- gridplayer/dialogs/media_list.py | 2 +- gridplayer/params/actions.py | 22 +- gridplayer/player/managers/active_block.py | 6 +- gridplayer/player/managers/add_videos.py | 12 +- gridplayer/player/managers/lists.py | 8 +- gridplayer/player/managers/video_blocks.py | 12 +- gridplayer/utils/web_lists.py | 2 +- gridplayer/widgets/video_block.py | 616 +------------------ gridplayer/widgets/video_block_navigation.py | 604 ++++++++++++++++++ gridplayer/widgets/video_block_recovery.py | 4 +- gridplayer/widgets/video_block_status.py | 2 +- tests/conftest.py | 2 + tests/test_add_media.py | 26 +- tests/test_bookmarks_dialog.py | 2 +- tests/test_close_teardown.py | 2 +- tests/test_folder_navigation.py | 95 +-- tests/test_list_window_beside.py | 2 + tests/test_make_list.py | 2 + tests/test_media_list_dialog.py | 2 + tests/test_overlay_stopped.py | 6 +- tests/test_video_block_bookmarks.py | 12 +- tests/test_video_block_lists.py | 99 +-- tests/test_video_block_navigation.py | 46 ++ tests/test_video_block_repeat.py | 25 +- tests/test_video_errors.py | 15 +- 25 files changed, 875 insertions(+), 751 deletions(-) create mode 100644 gridplayer/widgets/video_block_navigation.py create mode 100644 tests/test_video_block_navigation.py diff --git a/gridplayer/dialogs/media_list.py b/gridplayer/dialogs/media_list.py index 93fd20bb..98db8eec 100644 --- a/gridplayer/dialogs/media_list.py +++ b/gridplayer/dialogs/media_list.py @@ -3735,7 +3735,7 @@ def _play(self): place = self._model.place_at(index.row()) if self._block is not None: - self._block.play_list_item(place) + self._block.navigation.play_list_item(place) elif self._open_in_new_cell is not None: self._open_in_new_cell(self._list_id, place) diff --git a/gridplayer/params/actions.py b/gridplayer/params/actions.py index 2926bbdc..c2b8e252 100644 --- a/gridplayer/params/actions.py +++ b/gridplayer/params/actions.py @@ -63,26 +63,26 @@ "title": translate("Actions", "Play Previous File"), "key": "PgUp", "icon": "previous-video-file", - "func": ("active", "previous_video"), + "func": ("active", "navigation.previous"), "show_if": "is_active_navigable", }, "Play Next File": { "title": translate("Actions", "Play Next File"), "key": "PgDown", "icon": "next-video-file", - "func": ("active", "next_video"), + "func": ("active", "navigation.next"), "show_if": "is_active_navigable", }, "Play Random File": { "title": translate("Actions", "Play Random File"), "icon": "loop-dir-shuffle", - "func": ("active", "shuffle_video"), + "func": ("active", "navigation.shuffle"), "show_if": "is_active_navigable", }, "Include Subfolders": { "title": translate("Folder Navigation", "Include Subfolders"), "icon": "folder-tree", - "func": ("active", "toggle_dir_recursive"), + "func": ("active", "navigation.toggle_dir_recursive"), "check_if": ("is_active_param_set_to", "is_dir_recursive", True), # a list goes its own way, not through the folder "show_if": AND("is_active_local_file", NOT("is_active_list_cell")), @@ -426,13 +426,17 @@ "Leave the List": { "title": translate("Actions", "Leave the List"), "icon": "empty", - "func": ("active", "leave_list"), + "func": ("active", "navigation.leave_list"), "show_if": "is_active_list_cell", }, "List End Start Over": { "title": translate("Lists", "Start Over"), "icon": "loop-dir", - "func": ("active", "set_list_end_action", ListEndAction.START_OVER), + "func": ( + "active", + "navigation.set_list_end_action", + ListEndAction.START_OVER, + ), "check_if": ( "is_active_param_set_to", "list_end_action", @@ -444,7 +448,7 @@ "List End Pause": { "title": translate("Lists", "Pause At Start"), "icon": "pause", - "func": ("active", "set_list_end_action", ListEndAction.PAUSE), + "func": ("active", "navigation.set_list_end_action", ListEndAction.PAUSE), "check_if": ( "is_active_param_set_to", "list_end_action", @@ -456,7 +460,7 @@ "List End Stop": { "title": translate("Lists", "Stop"), "icon": "stop", - "func": ("active", "set_list_end_action", ListEndAction.STOP), + "func": ("active", "navigation.set_list_end_action", ListEndAction.STOP), "check_if": ( "is_active_param_set_to", "list_end_action", @@ -468,7 +472,7 @@ "List End Close": { "title": translate("Lists", "Close"), "icon": "close", - "func": ("active", "set_list_end_action", ListEndAction.CLOSE), + "func": ("active", "navigation.set_list_end_action", ListEndAction.CLOSE), "check_if": ( "is_active_param_set_to", "list_end_action", diff --git a/gridplayer/player/managers/active_block.py b/gridplayer/player/managers/active_block.py index 3590aaff..ddeecc8e 100644 --- a/gridplayer/player/managers/active_block.py +++ b/gridplayer/player/managers/active_block.py @@ -48,9 +48,9 @@ "reload", "stop_playback", "close", - "previous_video", - "next_video", - "shuffle_video", + "navigation.previous", + "navigation.next", + "navigation.shuffle", "recovery.set_retry_mode", "recovery.retry_times_dialog", "recovery.get_retry_times", diff --git a/gridplayer/player/managers/add_videos.py b/gridplayer/player/managers/add_videos.py index c4b29184..14665b9a 100644 --- a/gridplayer/player/managers/add_videos.py +++ b/gridplayer/player/managers/add_videos.py @@ -832,7 +832,7 @@ def _cell_read(self, video_id, expanded: Expanded): if isinstance(group, ListInput): is_played = self._play_read_list(block, group) else: - block.play_read(group.uri) + block.navigation.play_read(group.uri) is_played = True if is_played: @@ -858,8 +858,8 @@ def _play_read_list(self, block, read: ListInput) -> bool: ) self._tell_added(media_list) - block.take_list(media_list.id, first) - block.play_list_item(first) + block.navigation.take_list(media_list.id, first) + block.navigation.play_list_item(first) return True @@ -1065,7 +1065,7 @@ def _add_to_cell(self, block, groups): self._ctx.lists.put(media_list) self._ctx.lists.tell_added(media_list.id, [item.key for item in added]) - block.tell_added_to_list(len(added)) + block.navigation.tell_added_to_list(len(added)) return params = block.video_params @@ -1078,8 +1078,8 @@ def _add_to_cell(self, block, groups): ) self._tell_added(media_list) - block.take_list(media_list.id, 0) - block.tell_added_to_list(len(items)) + block.navigation.take_list(media_list.id, 0) + block.navigation.tell_added_to_list(len(items)) def _tell_added(self, media_list): """A list made of what was added: its items, for what is known of diff --git a/gridplayer/player/managers/lists.py b/gridplayer/player/managers/lists.py index 6346c6a9..b9201a5e 100644 --- a/gridplayer/player/managers/lists.py +++ b/gridplayer/player/managers/lists.py @@ -753,7 +753,7 @@ def cmd_manage_list(self): ) ) - block.take_list(media_list.id, 0) + block.navigation.take_list(media_list.id, 0) self.open_list_window(media_list.id, block) return @@ -816,7 +816,7 @@ def _folder_read(self, _what, folder, error): ) self._registry.tell_added(media_list.id, [item.key for item in items]) - block.take_list(media_list.id, index) + block.navigation.take_list(media_list.id, index) self.open_list_window(media_list.id, block) def list_window_for(self, block): @@ -862,7 +862,7 @@ def open_list_window(self, list_id, block=None, parent=None): if place is not None: dialog.restoreGeometry(place) - block.set_list_window_shown(True) + block.navigation.set_list_window_shown(True) dialog.show() @@ -975,7 +975,7 @@ def _list_window_closed(self, dialog): block = dialog.block if not block.is_closing: - block.set_list_window_shown(False) + block.navigation.set_list_window_shown(False) dialog.deleteLater() diff --git a/gridplayer/player/managers/video_blocks.py b/gridplayer/player/managers/video_blocks.py index 31bf6eed..18a48805 100644 --- a/gridplayer/player/managers/video_blocks.py +++ b/gridplayer/player/managers/video_blocks.py @@ -433,7 +433,7 @@ def set_disable_overlay(self, is_disabled): # up as long as the List window is open on it, the others once the # mouse is over them for vb in self._ctx.video_blocks: - if vb.is_list_window_shown: + if vb.navigation.is_list_window_shown: vb.show_overlay() def toggle_disable_overlay(self): @@ -870,8 +870,8 @@ def _add_video_block(self, video): (self.all_loop_chapter, vb.chapters.loop), (self.all_reset_loop, vb.loop.reset), (self.all_set_end_action, vb.set_end_action), - (self.all_set_dir_recursive, vb.set_dir_recursive), - (self.all_set_list_end_action, vb.set_list_end_action), + (self.all_set_dir_recursive, vb.navigation.set_dir_recursive), + (self.all_set_list_end_action, vb.navigation.set_list_end_action), (self.all_set_list_fail_action, vb.recovery.set_fail_action), (self.all_set_list_fail_wait, vb.recovery.set_fail_wait), (self.all_rate_increase, vb.rate_increase), @@ -903,9 +903,9 @@ def _add_video_block(self, video): (self.all_volume_increase, vb.audio.volume_increase), (self.all_volume_decrease, vb.audio.volume_decrease), (self.all_set_muted, vb.audio.set_muted), - (self.all_previous_video, vb.previous_video), - (self.all_next_video, vb.next_video), - (self.all_shuffle_video, vb.shuffle_video), + (self.all_previous_video, vb.navigation.previous), + (self.all_next_video, vb.navigation.next), + (self.all_shuffle_video, vb.navigation.shuffle), (self.hide_overlay, vb.hide_overlay), (self.show_overlay, vb.show_overlay), (self.set_drag_ui, vb.set_drag_ui), diff --git a/gridplayer/utils/web_lists.py b/gridplayer/utils/web_lists.py index 39f89b90..af2aafc5 100644 --- a/gridplayer/utils/web_lists.py +++ b/gridplayer/utils/web_lists.py @@ -78,7 +78,7 @@ def is_web_list_url(url: str) -> bool: What it leads to is only known once it is read, and a look that was wrong comes out as the one video it is. A list behind a link of any other look is found out once the link is played; see - VideoBlock._link_was_a_list. + NavigationControl.link_was_a_list. """ if not is_url(url): diff --git a/gridplayer/widgets/video_block.py b/gridplayer/widgets/video_block.py index 3ac7f0bb..0aa708b7 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -20,12 +20,8 @@ ) from gridplayer.models.bookmark import Bookmark from gridplayer.models.media_list import ( - ItemDetails, ListItem, - ListSource, MediaList, - list_step_action, - merged_details, ) from gridplayer.models.seek_mark import SeekMark, SeekMarkKind from gridplayer.models.stream import ( @@ -42,8 +38,6 @@ MIN_RATE, OVERLAY_ACTIVITY_EVENT, PLAYER_ID_LENGTH, - ListEndAction, - ListSourceKind, VideoAspect, VideoDeinterlace, VideoDeinterlaceMode, @@ -52,27 +46,19 @@ VideoTransform, ) from gridplayer.settings import Settings -from gridplayer.utils.bookmarks import file_key from gridplayer.utils.drop_zone import DropIndicator -from gridplayer.utils.folder_navigator import FolderNavigator, FolderStep from gridplayer.utils.libvlc_options_parser import ( get_vlc_options, is_deinterlace_reload_needed, is_reopen_needed, ) -from gridplayer.utils.list_errors import ReadErrorKind -from gridplayer.utils.list_navigator import ListNavigator -from gridplayer.utils.qt import MILLISECONDS, count_txt, qt_connect, translate +from gridplayer.utils.qt import MILLISECONDS, qt_connect, translate from gridplayer.utils.sponsorblock import ( segment_mark, ) from gridplayer.utils.step_sizes import seek_step_ms -from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo +from gridplayer.utils.url_resolve.static import ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver -from gridplayer.utils.video_errors import ( - ShownError, - file_error, -) from gridplayer.vlc_player.static import ( DISABLED_TRACK, SEEK_ARRIVAL_MS, @@ -93,12 +79,11 @@ from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_guards import ( only_initialized, - only_local_file, - only_navigable, only_seekable, only_with_video_tacks, ) from gridplayer.widgets.video_block_loop import LoopControl +from gridplayer.widgets.video_block_navigation import NavigationControl from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl @@ -133,9 +118,6 @@ # the time shows up where the seek could never have landed, it takes. SEEK_RETRIES = 2 -# the place, over the video, of one taken off its list while it played -NOT_ON_LIST_TXT = "\u2013" - # the end actions that take the video out of its cell: closed, or another # file put in its place LEAVING_END_ACTIONS = frozenset( @@ -256,12 +238,10 @@ def __init__(self, video_driver, context, **kwargs): # the seek before the last, when the last was made before it landed self._seek_behind: int | None = None - # first: the resolver says how far it got to it from the start - self.status = StatusControl(self) - - self.url_resolver = self.init_url_resolver() self.video_driver: VideoFrameVLC | None = None + # before the resolver, which is wired to them as it is made + self.status = StatusControl(self) self.bookmarker = Bookmarker(self) self.view = ViewControl(self) self.chapters = ChapterControl(self) @@ -272,54 +252,16 @@ def __init__(self, video_driver, context, **kwargs): self.screenshot = ScreenshotControl(self) self.recovery = RecoveryControl(self) self.stream = StreamControl(self) + self.navigation = NavigationControl(self) - # the next, previous or a random file of the folder, to put in this - # one's place - self._folder_navigator = FolderNavigator( - video=lambda: self.video_params, - switch=self.switch_video, - tell=self.info_change.emit, - tell_next=self._tell_once_loaded, - hold_end=self._hold_end_for_bookmarks, - open_elsewhere=self._files_open_elsewhere, - ) - # a word to say once the next file is on show, the overlay being - # hidden while it loads - self._notice_once_loaded: str | None = None - - # the item of its list the video was put in by, to know it by its - # key when the list is edited: the same file may be on it more than - # once; and by its link then, to know when its source gave it a new - # one - self._held_item: ListItem | None = None - - # the next, previous or a random item of the list, for a cell that - # plays one instead of going through its folder - self._list_navigator = ListNavigator( - video=lambda: self.video_params, - media_list=lambda: self.media_list, - item_key=lambda: None if self._held_item is None else self._held_item.key, - switch=self.play_list_item, - end_of_list=self._end_list, - open_elsewhere=self._uris_open_elsewhere, - ) - - # the List window is open on the cell, and plays in it - self._is_list_window_shown = False + self.url_resolver = self.init_url_resolver() # the item playing has played since it was put in the cell: a # reload may yet bring it back, whatever it failed with self._has_item_played = False - # shared with other cells, the list may change in any of them - self._ctx.lists.changed.connect(self._list_changed) self._ctx.lists.waiting_progress.connect(self.status.on_waiting_progress) - # its file gone as the playlist opened, waiting for its list to be - # read again to go on along it from there - self._is_waiting_for_list_read = False - self._ctx.lists.reading_changed.connect(self._on_list_reading) - self.overlay = self.init_overlay() self.ui_setup() @@ -443,7 +385,7 @@ def init_url_resolver(self): url_resolver = VideoURLResolver(parent=self) url_resolver.error.connect(self.network_error) url_resolver.url_resolved.connect(self.set_video_url) - url_resolver.list_resolved.connect(self._link_was_a_list) + url_resolver.list_resolved.connect(self.navigation.link_was_a_list) url_resolver.resolving.connect(self.status.on_resolving) url_resolver.reading.connect(self.status.on_link_list_reading) url_resolver.failed.connect(self.status.resolve_failed) @@ -502,7 +444,7 @@ def init_overlay(self): (overlay.mute_unmute_clicked, self.audio.mute_unmute), (overlay.bookmark_clicked, self.bookmarker.jump_to_marker), (overlay.bookmark_menu_requested, self.bookmarker.show_marker_menu), - (overlay.list_place_clicked, self.show_list_window), + (overlay.list_place_clicked, self.navigation.show_list_window), (overlay.stop_clicked, self.stop_playback), (overlay.retry_clicked, self.reload), (overlay.next_clicked, self.recovery.skip_now), @@ -572,7 +514,7 @@ def cleanup(self): self.sponsorblock.stop_listening() - self._folder_navigator.close() + self.navigation.close() self._log.debug(f"{self.id}: cleaning up driver ") self._destroy_video_driver() @@ -917,7 +859,7 @@ def list_item(self) -> ListItem | None: """The item of its list the video playing is, None where it is not on it, or there is no list.""" - place = self._list_navigator.place() + place = self.navigation.place() if place is None or not place.is_found: return None @@ -1224,7 +1166,7 @@ def hide_overlay(self): return # the List window plays in it: it stays in sight, marked - if self._is_list_window_shown: + if self.navigation.is_list_window_shown: return if not self._ctx.is_overlay_hide_on_timeout: @@ -1404,9 +1346,9 @@ def _step_at_end(self, end_action: VideoEndAction) -> bool: video does at its end; whether it went.""" step = { - VideoEndAction.NEXT_FILE: self.next_video, - VideoEndAction.PREVIOUS_FILE: self.previous_video, - VideoEndAction.SHUFFLE_FILE: self.shuffle_video, + VideoEndAction.NEXT_FILE: self.navigation.next, + VideoEndAction.PREVIOUS_FILE: self.navigation.previous, + VideoEndAction.SHUFFLE_FILE: self.navigation.shuffle, }.get(end_action) if step is None: @@ -1463,12 +1405,12 @@ def stop_playback(self): # it again by itself: a reconnect counting down, a link still being # resolved, a list being read, the next item of its list (below) self.recovery.drop_retries() - self._is_waiting_for_list_read = False + self.navigation.is_waiting_for_list_read = False if self.url_resolver is not None and self.url_resolver.is_busy: self.reset_url_resolver() - self._cancel_steps() + self.navigation.cancel_steps() # one that failed, stopped, is a stopped one like any other: played # from there, not left on Try Again with its reason gone @@ -1589,10 +1531,10 @@ def _apply_snapshot_of_another_file(self, snapshot: Video): on its list or in its folder.""" self.bookmarker.close_manager() - self._cancel_steps() + self.navigation.cancel_steps() self.view.forget_view_to_follow() - self._held_item = None + self.navigation.held_item = None self._title = None self._default_title = None @@ -1616,11 +1558,11 @@ def set_video(self, video_params: Video): if video_params.list_id is not None: self._ctx.lists.take_up(video_params.list_id) - self._show_list_place() + self.navigation.show_list_place() # known by its item from now on, the list read again at the latest # as the playlist opens - self._held_item = self.list_item + self.navigation.held_item = self.list_item # a file's bookmarks left from when they were shared, if this is the # first cell to open it since @@ -1712,10 +1654,10 @@ def _start_load(self): self._present_waiting() return - self._is_waiting_for_list_read = False + self.navigation.is_waiting_for_list_read = False - if self._is_list_file_gone: - self._go_on_from_gone_file() + if self.navigation.is_list_file_gone: + self.navigation.go_on_from_gone_file() return self._sync_cell_background() @@ -1787,7 +1729,7 @@ def set_video_url(self, video: ResolvedVideo): self._youtube_id = video.youtube_id self.sponsorblock.look_up() - self._learn_item_details(video.details) + self.navigation.learn_item_details(video.details) self.stream.load_quality(self.video_params.stream_quality) @@ -1812,7 +1754,7 @@ def load_video_finish(self): self._has_item_played = True - self._learn_item_length() + self.navigation.learn_item_length() self.audio.set_channel_mode(self.video_params.audio_channel_mode) self.audio.set_delay(self.video_params.audio_delay_ms, is_silent=True) @@ -1852,9 +1794,10 @@ def load_video_finish(self): self.time_change.emit(self.time, self.video_driver.length) self.show_overlay() - if self._notice_once_loaded is not None: - self.info_change.emit(self._notice_once_loaded) - self._notice_once_loaded = None + notice = self.navigation.take_notice() + + if notice is not None: + self.info_change.emit(notice) self.video_driver.adjust_view() @@ -1966,16 +1909,10 @@ def bookmarks(self) -> tuple[Bookmark, ...]: @only_seekable def set_end_action(self, end_action: VideoEndAction): - self._cancel_steps() + self.navigation.cancel_steps() self.video_params.end_action = end_action - def set_list_end_action(self, list_end_action: ListEndAction): - """What to do once the list has been played through. Kept for a - cell with no list too, for when it has one.""" - - self.video_params.list_end_action = list_end_action - @only_initialized @only_seekable def seek_shift_percent(self, shift_percent): @@ -2057,7 +1994,7 @@ def seek(self, seek_ms, is_viewers=True): # the viewer going back into the video stays in it, whatever file # its end was looking for if is_viewers: - self._cancel_steps() + self.navigation.cancel_steps() # Marked before the seek is made, not after: the single process # drivers call libVLC on this very thread, and it reports the new time @@ -2237,495 +2174,6 @@ def play_pause(self): self.sync_pause.emit(is_paused) - @property - def _navigator(self) -> FolderNavigator | ListNavigator: - """The way to the next video: along the list, for a cell that plays - one, or through the folder of the file.""" - - if self.media_list is not None: - return self._list_navigator - - return self._folder_navigator - - @only_navigable - def previous_video(self, is_end=False): - # where files come at random, the one before is the one played before - if self.video_params.end_action == VideoEndAction.SHUFFLE_FILE: - self._navigator.step(FolderStep.BACK, is_end=is_end) - else: - self._navigator.step(FolderStep.PREVIOUS, is_end=is_end) - - @only_navigable - def next_video(self, is_end=False): - if self.video_params.end_action == VideoEndAction.SHUFFLE_FILE: - self._navigator.step(FolderStep.RANDOM, is_end=is_end) - else: - self._navigator.step(FolderStep.NEXT, is_end=is_end) - - @only_navigable - def shuffle_video(self, is_end=False): - self._navigator.step(FolderStep.RANDOM, is_end=is_end) - - def _cancel_steps(self): - """Take no step for the end of the video still on its way: the - viewer did something else with it meanwhile.""" - - self._folder_navigator.cancel() - self.recovery.stop_skip() - - def _link_was_a_list(self, resolved: ResolvedList): - """The link leads to a list of videos rather than to one: the cell - plays the list from its first video, and an item of a list that is - one has the list's videos take its place on it. - - After the resolver is done telling: putting a video in the cell's - place starts the resolver over. - """ - - QTimer.singleShot(0, lambda: self._play_link_as_list(resolved)) - - def _play_link_as_list(self, resolved: ResolvedList): - if self._is_closing or self.video_params is None: - return - - media_list = self.media_list - item = self.list_item - - if media_list is not None and item is not None: - index = media_list.index_of_key(item.key) - - self._ctx.lists.put( - media_list.model_copy( - update={ - "items": media_list.items[:index] - + resolved.items - + media_list.items[index + 1 :] - } - ) - ) - - self.play_list_item(index) - return - - url = self.video_params.uri - - source = ListSource(kind=ListSourceKind.URL, location=url) - - new_list = self._ctx.lists.add( - MediaList.read_from( - self.video_params.title or resolved.title, resolved.items, source - ) - ) - - self.take_list(new_list.id, 0) - self.play_list_item(0) - - def play_read(self, uri: Path | str) -> None: - """Play the one file or link that what the cell waited for turned - out to be: a link to a stream rather than a list, or a folder with - one video in it.""" - - self._switch_to(uri) - - def take_list(self, list_id, index: int): - """Play from a list from now on, the video playing being the item - at that place on it. - - Its name goes with it onto the list, where a cell playing a list - keeps the names; and it goes along the list at its end, unless it is - set to already. - """ - - params = self.video_params - - self._cancel_steps() - - params.list_id = list_id - params.list_index = index - params.title = None - - params.end_action = list_step_action(params.end_action) - - media_list = self.media_list - item = None if media_list is None else media_list.items[index] - self._held_item = item - - self._ctx.lists.take_up(list_id) - - # An item that is not the video yet is put in its place next: its - # place is told then. Looked for now, the video would be looked for - # all along the list, which a channel list makes a long look. - if item is not None and file_key(item.uri) != file_key(params.uri): - return - - self._show_list_state() - - def play_list_item(self, index: int | None): - """Put an item of the cell's list in the cell, by its place on it.""" - - media_list = self.media_list - - if media_list is None: - return - - # none to play, every one around gone - if index is None or not 0 <= index < len(media_list.items): - self.status.last_error = ShownError( - title=translate("Lists", "Nothing on the list to play") - ) - self.error() - return - - item = media_list.items[index] - - self._held_item = item - self.video_params.list_index = index - - # the same file played again stays: its place is told here. Another - # put in its place tells its own, once it is the video - if file_key(item.uri) == file_key(self.video_params.uri): - self._show_list_place() - - self._switch_to(item.uri) - - def show_list_window(self): - """The List window on the cell's list, which item of it it plays - clicked over the video.""" - - if self.media_list is not None: - self._ctx.commands.list_window_for(self) - - @property - def is_list_window_shown(self) -> bool: - return self._is_list_window_shown - - def set_list_window_shown(self, is_shown: bool): - """The List window open on the cell, beside the player, playing what - is picked in it here: the overlay stays up while it is, saying so - under the title, for the cell to be found among the others; unless - there is to be none.""" - - self._is_list_window_shown = is_shown - - self.overlay.set_list_window_shown(is_shown) - - if is_shown: - self.overlay_hide_timer.stop() - - # up as long as the window is, and gone after the usual while once - # it is closed - self.show_overlay() - - def tell_added_to_list(self, count: int): - """Files dropped on the cell put on its list: said under its title - for a moment, nothing else showing that they were.""" - - self.show_overlay() - self.info_change.emit( - translate("Lists", "Added to the list: {COUNT}").format( - COUNT=count_txt(count) - ) - ) - - def leave_list(self): - """Play the item it is on as a video of its own from now on: Next - goes through its folder again, as for any file. - - The name the item had on the list stays the cell's. The list stays - for any other cell playing it, and goes when the playlist is saved - if none does. - """ - - params = self.video_params - - if params is None or params.list_id is None: - return - - item = self.list_item - - self._cancel_steps() - - params.title = None if item is None else item.title - params.list_id = None - params.list_index = None - self._held_item = None - - # and what the list said to fetch it with - if item is not None: - params.http_headers = item.http_headers - - self._show_list_state() - - def _learn_item_length(self): - """How long an item turned out to be, where its list did not say: - the list says from now on, for its window to show.""" - - item = self.list_item - - if item is None or item.duration_ms is not None or self.is_live: - return - - length = self.video_driver.length - - if not length or length <= 0: - return - - self._ctx.lists.update_item( - self.video_params.list_id, item.key, duration_ms=int(length) - ) - - def _learn_item_details(self, details: ItemDetails | None): - """What the site said of the item as it was looked up to be played: - its list says it from now on, when it was put up to the second - rather than as near as the list page said it, and its views as - they are now.""" - - item = self.list_item - - if item is None or details is None: - return - - learned = merged_details(item.details, details) - - if learned != item.details: - self._ctx.lists.update_item( - self.video_params.list_id, item.key, details=learned - ) - - def _show_list_place(self): - """Say over the video which item of its list the cell plays.""" - - self.list_place_change.emit(self.list_place_txt) - - @property - def list_place_txt(self) -> str: - """Which item of its list the cell plays, "3/12"; empty where it - plays no list.""" - - media_list = self.media_list - - if media_list is None: - return "" - - place = self._list_navigator.place() - count = len(media_list.items) - - # the video playing taken off the list meanwhile - if place is None or not place.is_found: - return f"{NOT_ON_LIST_TXT}/{count}" - - return f"{place.index + 1}/{count}" - - def _end_list(self, list_end_action: ListEndAction): - """The list has been played through, and this is what is done.""" - - if list_end_action == ListEndAction.PAUSE: - self._pause_at_start() - elif list_end_action == ListEndAction.STOP: - self.stop_playback() - elif list_end_action == ListEndAction.CLOSE: - QTimer.singleShot(0, self.close) - - def _list_changed(self, list_id): - """The list edited, in this cell or another one playing from it.""" - - if self.video_params is None or list_id != self.video_params.list_id: - return - - self._follow_new_link() - - place = self._list_navigator.place() - - if place is None: - return - - # where it is on the list now, for the playlist to say - if place.is_found: - self.video_params.list_index = place.index - self._held_item = self.media_list.items[place.index] - - self._show_list_state() - - def _show_list_state(self): - """Where the cell is on its list, and its name as the item is now - called: renamed on the list, maybe in another cell.""" - - self._show_list_place() - - if self._default_title is not None: - self.title = self._custom_title or self._default_title - - @property - def _is_list_file_gone(self) -> bool: - """Whether the cell plays a list and the file it is on is gone.""" - - params = self.video_params - - if params.list_id is None or not isinstance(params.uri, Path): - return False - - try: - return not params.uri.is_file() - except OSError: - return True - - def _go_on_from_gone_file(self): - """The file the cell is on gone, its drive not plugged in: the next - item there is on its list in its place. - - A linked list still to be read again as the playlist opens is - waited for, having only the items cells play until then; one that - could not be read is not gone along, and the cell says the file is - not there. - """ - - list_id = self.video_params.list_id - - if self._ctx.lists.is_read_expected(list_id): - self._is_waiting_for_list_read = True - self._present_waiting() - return - - if self._ctx.lists.is_as_saved(list_id): - self._present_gone_file() - return - - self._list_navigator.step(FolderStep.NEXT) - - def _on_list_reading(self, list_id): - """Its list read again as the playlist opened, or not: the cell - waiting for it goes on.""" - - if ( - not self._is_waiting_for_list_read - or self.video_params is None - or list_id != self.video_params.list_id - or self._ctx.lists.is_read_expected(list_id) - ): - return - - self._start_load() - - def _follow_new_link(self): - """Its item read again from the source with a new link, the same - item still: a channel provider gives out new links each time, a new - token in each. The stream playing goes on; a reload takes the new - link, and so does the playlist.""" - - held = self._held_item - - if held is None: - return - - item = self.media_list.item_by_key(held.key) - - if item is None or item.uri == held.uri: - return - - # gone on to another video meanwhile - if file_key(held.uri) != file_key(self.video_params.uri): - return - - self.video_params.uri = item.uri - self._held_item = item - - def _uris_open_elsewhere(self) -> list: - return [ - block.video_params.uri - for block in self._ctx.video_blocks - if block is not self and block.video_params is not None - ] - - @only_local_file - def toggle_dir_recursive(self): - self.set_dir_recursive(not self.video_params.is_dir_recursive) - - def set_dir_recursive(self, is_dir_recursive: bool): - """Look for the next, previous or a random file in the subfolders as - well, or only in the folder of the file playing. - - The subfolders are the ones under the folder of the file playing - now, and stay so until they are left out again. Kept for a video - stopped or still loading too, and for a URL, which has no folder - to go through. - """ - - params = self.video_params - - if is_dir_recursive == params.is_dir_recursive: - return - - self._cancel_steps() - - params.is_dir_recursive = is_dir_recursive - - if is_dir_recursive and params.is_local_file: - params.dir_root = params.uri.parent - else: - params.dir_root = None - - def _tell_once_loaded(self, notice: str): - self._notice_once_loaded = notice - - def _files_open_elsewhere(self) -> list[Path]: - return [ - block.video_params.uri - for block in self._ctx.video_blocks - if block is not self - and block.video_params is not None - and block.video_params.is_local_file - ] - - @only_local_file - def switch_video(self, new_video: Path): - # If single file in the dir and was removed, highly unlikely but still - if new_video is None: - self.status.last_error = file_error( - ReadErrorKind.MISSING, self.video_params.uri - ) - self.error() - return - - self._switch_to(new_video) - - def _switch_to(self, new_video: Path | str): - """Put another file or link in the cell's place, the one after - this one or before it, in the folder or on the list; the cell's - settings stay as they are.""" - - if new_video == self.video_params.uri: - if self.is_video_initialized: - self._seek_loop_start() - return - self._load_and_play() - return - - self.bookmarker.close_manager() - - self._cancel_steps() - - self.loop.reset() - self.video_params.current_position = 0 - self._forget_files_of_previous_video() - # its bookmarks, if it has any, come with the file: see bookmarks - self.video_params.uri = new_video - self.video_params.playback_state = VideoInitialState.PLAYING - self._title = None - self._default_title = None - - self.set_video(self.video_params) - - def _forget_files_of_previous_video(self): - """Leave the audio and subtitle files picked for the video that was - here behind with it. - - They were picked for that one; files of the next one's own are found - again where they are looked for by themselves. - """ - - self.audio.drop_external() - self.subtitles.drop_external() - def rename(self): cur_title = self.title diff --git a/gridplayer/widgets/video_block_navigation.py b/gridplayer/widgets/video_block_navigation.py new file mode 100644 index 00000000..d20ad7fa --- /dev/null +++ b/gridplayer/widgets/video_block_navigation.py @@ -0,0 +1,604 @@ +"""Where a video block goes from the video it plays: along the list it +plays from, or through the folder of its file; and the list itself as the +cell plays it, its place on it and its List window. + +Which list and item the cell is on is the block's to say (see +VideoBlock.media_list and VideoBlock.list_item); this is what is done with +them. +""" + +from pathlib import Path + +from PyQt5.QtCore import QTimer + +from gridplayer.models.media_list import ( + ItemDetails, + ListItem, + ListSource, + MediaList, + list_step_action, + merged_details, +) +from gridplayer.params.static import ( + ListEndAction, + ListSourceKind, + VideoEndAction, + VideoInitialState, +) +from gridplayer.utils.bookmarks import file_key +from gridplayer.utils.folder_navigator import FolderNavigator, FolderStep +from gridplayer.utils.list_errors import ReadErrorKind +from gridplayer.utils.list_navigator import ListNavigator +from gridplayer.utils.qt import count_txt, translate +from gridplayer.utils.url_resolve.static import ResolvedList +from gridplayer.utils.video_errors import ShownError, file_error +from gridplayer.widgets.video_block_guards import ( + VideoBlockPart, + only_local_file, + only_navigable, +) + +# the place, over the video, of one taken off its list while it played +NOT_ON_LIST_TXT = "\u2013" + + +class NavigationControl(VideoBlockPart): + def __init__(self, block): + super().__init__(block) + + # the next, previous or a random file of the folder, to put in this + # one's place + self._folder_navigator = FolderNavigator( + video=lambda: block.video_params, + switch=self.switch_video, + tell=block.info_change.emit, + tell_next=self._tell_once_loaded, + hold_end=block._hold_end_for_bookmarks, + open_elsewhere=self._files_open_elsewhere, + ) + # a word to say once the next file is on show, the overlay being + # hidden while it loads + self._notice_once_loaded: str | None = None + + # the item of its list the video was put in by, to know it by its + # key when the list is edited: the same file may be on it more than + # once; and by its link then, to know when its source gave it a new + # one + self.held_item: ListItem | None = None + + # the next, previous or a random item of the list, for a cell that + # plays one instead of going through its folder + self._list_navigator = ListNavigator( + video=lambda: block.video_params, + media_list=lambda: block.media_list, + item_key=lambda: None if self.held_item is None else self.held_item.key, + switch=self.play_list_item, + end_of_list=self._end_list, + open_elsewhere=self._uris_open_elsewhere, + ) + + # the List window is open on the cell, and plays in it + self._is_list_window_shown = False + + # its file gone as the playlist opened, waiting for its list to be + # read again to go on along it from there + self.is_waiting_for_list_read = False + + # shared with other cells, the list may change in any of them + block._ctx.lists.changed.connect(self._list_changed) + block._ctx.lists.reading_changed.connect(self._on_list_reading) + + def close(self): + self._folder_navigator.close() + + def place(self): + """Where the video playing is on the cell's list, None where it + plays none.""" + + return self._list_navigator.place() + + def take_notice(self) -> str | None: + """The word to say now that the next file is on show, once.""" + + notice = self._notice_once_loaded + self._notice_once_loaded = None + + return notice + + def set_list_end_action(self, list_end_action: ListEndAction): + """What to do once the list has been played through. Kept for a + cell with no list too, for when it has one.""" + + self._block.video_params.list_end_action = list_end_action + + @property + def _navigator(self) -> FolderNavigator | ListNavigator: + """The way to the next video: along the list, for a cell that plays + one, or through the folder of the file.""" + + if self._block.media_list is not None: + return self._list_navigator + + return self._folder_navigator + + @only_navigable + def previous(self, is_end=False): + # where files come at random, the one before is the one played before + if self._block.video_params.end_action == VideoEndAction.SHUFFLE_FILE: + self._navigator.step(FolderStep.BACK, is_end=is_end) + else: + self._navigator.step(FolderStep.PREVIOUS, is_end=is_end) + + @only_navigable + def next(self, is_end=False): + if self._block.video_params.end_action == VideoEndAction.SHUFFLE_FILE: + self._navigator.step(FolderStep.RANDOM, is_end=is_end) + else: + self._navigator.step(FolderStep.NEXT, is_end=is_end) + + @only_navigable + def shuffle(self, is_end=False): + self._navigator.step(FolderStep.RANDOM, is_end=is_end) + + def cancel_steps(self): + """Take no step for the end of the video still on its way: the + viewer did something else with it meanwhile.""" + + self._folder_navigator.cancel() + self._block.recovery.stop_skip() + + def link_was_a_list(self, resolved: ResolvedList): + """The link leads to a list of videos rather than to one: the cell + plays the list from its first video, and an item of a list that is + one has the list's videos take its place on it. + + After the resolver is done telling: putting a video in the cell's + place starts the resolver over. + """ + + QTimer.singleShot(0, lambda: self._play_link_as_list(resolved)) + + def _play_link_as_list(self, resolved: ResolvedList): + if self._block._is_closing or self._block.video_params is None: + return + + media_list = self._block.media_list + item = self._block.list_item + + if media_list is not None and item is not None: + index = media_list.index_of_key(item.key) + + self._block._ctx.lists.put( + media_list.model_copy( + update={ + "items": media_list.items[:index] + + resolved.items + + media_list.items[index + 1 :] + } + ) + ) + + self.play_list_item(index) + return + + url = self._block.video_params.uri + + source = ListSource(kind=ListSourceKind.URL, location=url) + + new_list = self._block._ctx.lists.add( + MediaList.read_from( + self._block.video_params.title or resolved.title, resolved.items, source + ) + ) + + self.take_list(new_list.id, 0) + self.play_list_item(0) + + def play_read(self, uri: Path | str) -> None: + """Play the one file or link that what the cell waited for turned + out to be: a link to a stream rather than a list, or a folder with + one video in it.""" + + self.switch_to(uri) + + def take_list(self, list_id, index: int): + """Play from a list from now on, the video playing being the item + at that place on it. + + Its name goes with it onto the list, where a cell playing a list + keeps the names; and it goes along the list at its end, unless it is + set to already. + """ + + params = self._block.video_params + + self.cancel_steps() + + params.list_id = list_id + params.list_index = index + params.title = None + + params.end_action = list_step_action(params.end_action) + + media_list = self._block.media_list + item = None if media_list is None else media_list.items[index] + self.held_item = item + + self._block._ctx.lists.take_up(list_id) + + # An item that is not the video yet is put in its place next: its + # place is told then. Looked for now, the video would be looked for + # all along the list, which a channel list makes a long look. + if item is not None and file_key(item.uri) != file_key(params.uri): + return + + self._show_list_state() + + def play_list_item(self, index: int | None): + """Put an item of the cell's list in the cell, by its place on it.""" + + media_list = self._block.media_list + + if media_list is None: + return + + # none to play, every one around gone + if index is None or not 0 <= index < len(media_list.items): + self._block.status.last_error = ShownError( + title=translate("Lists", "Nothing on the list to play") + ) + self._block.error() + return + + item = media_list.items[index] + + self.held_item = item + self._block.video_params.list_index = index + + # the same file played again stays: its place is told here. Another + # put in its place tells its own, once it is the video + if file_key(item.uri) == file_key(self._block.video_params.uri): + self.show_list_place() + + self.switch_to(item.uri) + + def show_list_window(self): + """The List window on the cell's list, which item of it it plays + clicked over the video.""" + + if self._block.media_list is not None: + self._block._ctx.commands.list_window_for(self._block) + + @property + def is_list_window_shown(self) -> bool: + return self._is_list_window_shown + + def set_list_window_shown(self, is_shown: bool): + """The List window open on the cell, beside the player, playing what + is picked in it here: the overlay stays up while it is, saying so + under the title, for the cell to be found among the others; unless + there is to be none.""" + + self._is_list_window_shown = is_shown + + self._block.overlay.set_list_window_shown(is_shown) + + if is_shown: + self._block.overlay_hide_timer.stop() + + # up as long as the window is, and gone after the usual while once + # it is closed + self._block.show_overlay() + + def tell_added_to_list(self, count: int): + """Files dropped on the cell put on its list: said under its title + for a moment, nothing else showing that they were.""" + + self._block.show_overlay() + self._block.info_change.emit( + translate("Lists", "Added to the list: {COUNT}").format( + COUNT=count_txt(count) + ) + ) + + def leave_list(self): + """Play the item it is on as a video of its own from now on: Next + goes through its folder again, as for any file. + + The name the item had on the list stays the cell's. The list stays + for any other cell playing it, and goes when the playlist is saved + if none does. + """ + + params = self._block.video_params + + if params is None or params.list_id is None: + return + + item = self._block.list_item + + self.cancel_steps() + + params.title = None if item is None else item.title + params.list_id = None + params.list_index = None + self.held_item = None + + # and what the list said to fetch it with + if item is not None: + params.http_headers = item.http_headers + + self._show_list_state() + + def learn_item_length(self): + """How long an item turned out to be, where its list did not say: + the list says from now on, for its window to show.""" + + item = self._block.list_item + + if item is None or item.duration_ms is not None or self._block.is_live: + return + + length = self._block.video_driver.length + + if not length or length <= 0: + return + + self._block._ctx.lists.update_item( + self._block.video_params.list_id, item.key, duration_ms=int(length) + ) + + def learn_item_details(self, details: ItemDetails | None): + """What the site said of the item as it was looked up to be played: + its list says it from now on, when it was put up to the second + rather than as near as the list page said it, and its views as + they are now.""" + + item = self._block.list_item + + if item is None or details is None: + return + + learned = merged_details(item.details, details) + + if learned != item.details: + self._block._ctx.lists.update_item( + self._block.video_params.list_id, item.key, details=learned + ) + + def show_list_place(self): + """Say over the video which item of its list the cell plays.""" + + self._block.list_place_change.emit(self.list_place_txt) + + @property + def list_place_txt(self) -> str: + """Which item of its list the cell plays, "3/12"; empty where it + plays no list.""" + + media_list = self._block.media_list + + if media_list is None: + return "" + + place = self._list_navigator.place() + count = len(media_list.items) + + # the video playing taken off the list meanwhile + if place is None or not place.is_found: + return f"{NOT_ON_LIST_TXT}/{count}" + + return f"{place.index + 1}/{count}" + + def _end_list(self, list_end_action: ListEndAction): + """The list has been played through, and this is what is done.""" + + if list_end_action == ListEndAction.PAUSE: + self._block._pause_at_start() + elif list_end_action == ListEndAction.STOP: + self._block.stop_playback() + elif list_end_action == ListEndAction.CLOSE: + QTimer.singleShot(0, self._block.close) + + def _list_changed(self, list_id): + """The list edited, in this cell or another one playing from it.""" + + if ( + self._block.video_params is None + or list_id != self._block.video_params.list_id + ): + return + + self._follow_new_link() + + place = self._list_navigator.place() + + if place is None: + return + + # where it is on the list now, for the playlist to say + if place.is_found: + self._block.video_params.list_index = place.index + self.held_item = self._block.media_list.items[place.index] + + self._show_list_state() + + def _show_list_state(self): + """Where the cell is on its list, and its name as the item is now + called: renamed on the list, maybe in another cell.""" + + self.show_list_place() + + if self._block._default_title is not None: + self._block.title = self._block._custom_title or self._block._default_title + + @property + def is_list_file_gone(self) -> bool: + """Whether the cell plays a list and the file it is on is gone.""" + + params = self._block.video_params + + if params.list_id is None or not isinstance(params.uri, Path): + return False + + try: + return not params.uri.is_file() + except OSError: + return True + + def go_on_from_gone_file(self): + """The file the cell is on gone, its drive not plugged in: the next + item there is on its list in its place. + + A linked list still to be read again as the playlist opens is + waited for, having only the items cells play until then; one that + could not be read is not gone along, and the cell says the file is + not there. + """ + + list_id = self._block.video_params.list_id + + if self._block._ctx.lists.is_read_expected(list_id): + self.is_waiting_for_list_read = True + self._block._present_waiting() + return + + if self._block._ctx.lists.is_as_saved(list_id): + self._block._present_gone_file() + return + + self._list_navigator.step(FolderStep.NEXT) + + def _on_list_reading(self, list_id): + """Its list read again as the playlist opened, or not: the cell + waiting for it goes on.""" + + if ( + not self.is_waiting_for_list_read + or self._block.video_params is None + or list_id != self._block.video_params.list_id + or self._block._ctx.lists.is_read_expected(list_id) + ): + return + + self._block._start_load() + + def _follow_new_link(self): + """Its item read again from the source with a new link, the same + item still: a channel provider gives out new links each time, a new + token in each. The stream playing goes on; a reload takes the new + link, and so does the playlist.""" + + held = self.held_item + + if held is None: + return + + item = self._block.media_list.item_by_key(held.key) + + if item is None or item.uri == held.uri: + return + + # gone on to another video meanwhile + if file_key(held.uri) != file_key(self._block.video_params.uri): + return + + self._block.video_params.uri = item.uri + self.held_item = item + + def _uris_open_elsewhere(self) -> list: + return [ + block.video_params.uri + for block in self._block._ctx.video_blocks + if block is not self._block and block.video_params is not None + ] + + @only_local_file + def toggle_dir_recursive(self): + self.set_dir_recursive(not self._block.video_params.is_dir_recursive) + + def set_dir_recursive(self, is_dir_recursive: bool): + """Look for the next, previous or a random file in the subfolders as + well, or only in the folder of the file playing. + + The subfolders are the ones under the folder of the file playing + now, and stay so until they are left out again. Kept for a video + stopped or still loading too, and for a URL, which has no folder + to go through. + """ + + params = self._block.video_params + + if is_dir_recursive == params.is_dir_recursive: + return + + self.cancel_steps() + + params.is_dir_recursive = is_dir_recursive + + if is_dir_recursive and params.is_local_file: + params.dir_root = params.uri.parent + else: + params.dir_root = None + + def _tell_once_loaded(self, notice: str): + self._notice_once_loaded = notice + + def _files_open_elsewhere(self) -> list[Path]: + return [ + block.video_params.uri + for block in self._block._ctx.video_blocks + if block is not self._block + and block.video_params is not None + and block.video_params.is_local_file + ] + + @only_local_file + def switch_video(self, new_video: Path): + # If single file in the dir and was removed, highly unlikely but still + if new_video is None: + self._block.status.last_error = file_error( + ReadErrorKind.MISSING, self._block.video_params.uri + ) + self._block.error() + return + + self.switch_to(new_video) + + def switch_to(self, new_video: Path | str): + """Put another file or link in the cell's place, the one after + this one or before it, in the folder or on the list; the cell's + settings stay as they are.""" + + if new_video == self._block.video_params.uri: + if self._block.is_video_initialized: + self._block._seek_loop_start() + return + self._block._load_and_play() + return + + self._block.bookmarker.close_manager() + + self.cancel_steps() + + self._block.loop.reset() + self._block.video_params.current_position = 0 + self._forget_files_of_previous_video() + # its bookmarks, if it has any, come with the file: see bookmarks + self._block.video_params.uri = new_video + self._block.video_params.playback_state = VideoInitialState.PLAYING + self._block._title = None + self._block._default_title = None + + self._block.set_video(self._block.video_params) + + def _forget_files_of_previous_video(self): + """Leave the audio and subtitle files picked for the video that was + here behind with it. + + They were picked for that one; files of the next one's own are found + again where they are looked for by themselves. + """ + + self._block.audio.drop_external() + self._block.subtitles.drop_external() diff --git a/gridplayer/widgets/video_block_recovery.py b/gridplayer/widgets/video_block_recovery.py index f2195d69..6e6e471d 100644 --- a/gridplayer/widgets/video_block_recovery.py +++ b/gridplayer/widgets/video_block_recovery.py @@ -245,7 +245,7 @@ def skip_now(self): return if not self._block._step_at_end(self._block.video_params.end_action): - self._block.next_video() + self._block.navigation.next() def set_retry_mode(self, mode): self._block.video_params.network_retry_mode = mode @@ -438,4 +438,4 @@ def _skip(self): # along the list the way it goes by itself; on to the next where it # doesn't if not self._block._step_at_end(self._block.video_params.end_action): - self._block.next_video(is_end=True) + self._block.navigation.next(is_end=True) diff --git a/gridplayer/widgets/video_block_status.py b/gridplayer/widgets/video_block_status.py index 84ab927a..2d648ed0 100644 --- a/gridplayer/widgets/video_block_status.py +++ b/gridplayer/widgets/video_block_status.py @@ -130,7 +130,7 @@ def _error_rows(self, error: ShownError) -> DetailRows: if media_list is not None: name = media_list.name or translate("Error Details", "List") - place = self._block._list_navigator.place() + place = self._block.navigation.place() if place is not None and place.is_found: name = translate("Error Details", "{LIST}, {PLACE} of {COUNT}").format( diff --git a/tests/conftest.py b/tests/conftest.py index 84115897..f77a422a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ def _block_parts(): from gridplayer.widgets.video_block_bookmarker import Bookmarker from gridplayer.widgets.video_block_chapters import ChapterControl from gridplayer.widgets.video_block_loop import LoopControl + from gridplayer.widgets.video_block_navigation import NavigationControl from gridplayer.widgets.video_block_recovery import RecoveryControl from gridplayer.widgets.video_block_screenshot import ScreenshotControl from gridplayer.widgets.video_block_sponsorblock import SponsorBlockControl @@ -69,6 +70,7 @@ def _block_parts(): "bookmarker": Bookmarker, "chapters": ChapterControl, "loop": LoopControl, + "navigation": NavigationControl, "recovery": RecoveryControl, "screenshot": ScreenshotControl, "sponsorblock": SponsorBlockControl, diff --git a/tests/test_add_media.py b/tests/test_add_media.py index b35de04d..3a4ad07a 100644 --- a/tests/test_add_media.py +++ b/tests/test_add_media.py @@ -72,6 +72,8 @@ def __init__(self, video, lists): self.video_params = video self.is_closing = False self._lists = lists + # the cell's navigation part, done by the fake itself + self.navigation = self def take_list(self, list_id, index): self.video_params.list_id = list_id @@ -860,7 +862,7 @@ def test_what_is_added_to_a_cells_list(self, manager, tmp_path, told): cell = SimpleNamespace( video_params=Video(uri=a, list_id=media_list.id), media_list=media_list, - tell_added_to_list=MagicMock(), + navigation=SimpleNamespace(tell_added_to_list=MagicMock()), ) _add(manager, [b], drop=(cell, DropZone.CENTER, False)) @@ -871,9 +873,13 @@ def test_what_is_added_to_a_cells_list(self, manager, tmp_path, told): class TestAddedToACell: def _cell(self, manager, video, media_list=None): - cell = SimpleNamespace(video_params=video, media_list=media_list) - cell.take_list = MagicMock() - cell.tell_added_to_list = MagicMock() + cell = SimpleNamespace( + video_params=video, + media_list=media_list, + navigation=SimpleNamespace( + take_list=MagicMock(), tell_added_to_list=MagicMock() + ), + ) return cell @@ -903,8 +909,8 @@ def test_one_with_no_list_gets_one_its_video_first(self, manager, tmp_path, mock (media_list,) = manager._ctx.lists.lists() assert [item.uri for item in media_list.items] == [a, b] assert media_list.items[0].title == "Mine" - cell.take_list.assert_called_once_with(media_list.id, 0) - cell.tell_added_to_list.assert_called_once_with(1) + cell.navigation.take_list.assert_called_once_with(media_list.id, 0) + cell.navigation.tell_added_to_list.assert_called_once_with(1) class TestAListOnASite: @@ -1553,7 +1559,7 @@ def cell(self, manager, tmp_path): return SimpleNamespace( video_params=Video(uri=a, list_id=media_list.id), media_list=media_list, - tell_added_to_list=MagicMock(), + navigation=SimpleNamespace(tell_added_to_list=MagicMock()), ) def _items(self, manager, cell): @@ -1572,7 +1578,7 @@ def test_on_its_list_unasked_however_the_playlist_adds( asked.assert_not_called() assert self._items(manager, cell)[1:] == [b, c] manager._ctx.commands.layout_drop.assert_not_called() - cell.tell_added_to_list.assert_called_once_with(2) + cell.navigation.tell_added_to_list.assert_called_once_with(2) def test_a_folder_too_with_no_cell_put_in_to_wait_for_it( self, manager, tmp_path, cell @@ -1584,7 +1590,7 @@ def test_a_folder_too_with_no_cell_put_in_to_wait_for_it( assert self._items(manager, cell)[1:] == list(files) manager._ctx.commands.layout_drop.assert_not_called() - cell.tell_added_to_list.assert_called_once_with(2) + cell.navigation.tell_added_to_list.assert_called_once_with(2) @pytest.mark.parametrize("zone", [DropZone.BEFORE, DropZone.BELOW]) def test_at_its_edge_in_a_cell_of_its_own(self, manager, tmp_path, cell, zone): @@ -1598,7 +1604,7 @@ def test_at_its_edge_in_a_cell_of_its_own(self, manager, tmp_path, cell, zone): assert [video.uri for video in videos] == [b] assert (at, in_zone, is_replace) == (cell, zone, False) assert len(self._items(manager, cell)) == 1 - cell.tell_added_to_list.assert_not_called() + cell.navigation.tell_added_to_list.assert_not_called() def test_at_its_edge_asked_its_list_is_not_offered( self, manager, tmp_path, cell, mocker diff --git a/tests/test_bookmarks_dialog.py b/tests/test_bookmarks_dialog.py index 9381ecfb..701f4c61 100644 --- a/tests/test_bookmarks_dialog.py +++ b/tests/test_bookmarks_dialog.py @@ -840,7 +840,7 @@ def remove_one_and_close(dialog): def test_another_file_in_its_place_takes_it_too(self, block, tmp_path): (tmp_path / "other.mkv").touch() - opened = _manage_and(block, lambda _dialog: block.next_video()) + opened = _manage_and(block, lambda _dialog: block.navigation.next()) assert not opened["left_open"] assert opened["dialog"].result() == QDialog.Accepted diff --git a/tests/test_close_teardown.py b/tests/test_close_teardown.py index 8ec863af..49389320 100644 --- a/tests/test_close_teardown.py +++ b/tests/test_close_teardown.py @@ -381,7 +381,7 @@ def test_a_load_begun_again_says_so_while_the_old_player_is_waited_on( if how == "reload": block.reload() else: - block._switch_to(other) + block.navigation.switch_to(other) assert seen == [(False, "Opening…")] diff --git a/tests/test_folder_navigation.py b/tests/test_folder_navigation.py index cba32502..a9d987df 100644 --- a/tests/test_folder_navigation.py +++ b/tests/test_folder_navigation.py @@ -128,14 +128,14 @@ class TestOneFolder: def test_next_stays_in_the_folder(self, make_block, tree): block = make_block(tree / "b.mp4") - block.next_video() + block.navigation.next() assert _uri(block) == tree / "a.mp4" def test_previous_stays_in_the_folder(self, make_block, tree): block = make_block(tree / "s1" / "c.mp4") - block.previous_video() + block.navigation.previous() assert _uri(block) == tree / "s1" / "c.mp4" @@ -147,7 +147,7 @@ def test_next_goes_through_every_subfolder_and_round(self, make_block, tree): seen = [] for _ in range(5): - block.next_video() + block.navigation.next() seen.append(_uri(block).relative_to(tree).as_posix()) assert seen == ["b.mp4", "s1/c.mp4", "s1/deep/d.mp4", "s2/e.mp4", "a.mp4"] @@ -155,7 +155,7 @@ def test_next_goes_through_every_subfolder_and_round(self, make_block, tree): def test_previous_goes_back_up(self, make_block, tree): block = make_block(tree / "s1" / "c.mp4", is_dir_recursive=True, dir_root=tree) - block.previous_video() + block.navigation.previous() assert _uri(block) == tree / "b.mp4" @@ -164,40 +164,40 @@ def test_turned_on_it_is_the_folder_playing_that_is_gone_through( ): block = make_block(tree / "s1" / "c.mp4") - block.set_dir_recursive(True) + block.navigation.set_dir_recursive(True) assert block.video_params.dir_root == tree / "s1" - block.next_video() - block.next_video() + block.navigation.next() + block.navigation.next() # round within s1, never up to the files beside it assert _uri(block) == tree / "s1" / "c.mp4" def test_the_root_stays_while_in_a_subfolder(self, make_block, tree): block = make_block(tree / "b.mp4") - block.set_dir_recursive(True) + block.navigation.set_dir_recursive(True) - block.next_video() - block.next_video() + block.navigation.next() + block.navigation.next() assert _uri(block) == tree / "s1" / "deep" / "d.mp4" assert block.video_params.dir_root == tree def test_turned_off_the_root_is_forgotten(self, make_block, tree): block = make_block(tree / "b.mp4") - block.set_dir_recursive(True) + block.navigation.set_dir_recursive(True) - block.set_dir_recursive(False) + block.navigation.set_dir_recursive(False) assert block.video_params.dir_root is None def test_turned_on_again_it_is_not_moved(self, make_block, tree): block = make_block(tree / "b.mp4") - block.set_dir_recursive(True) - block.next_video() + block.navigation.set_dir_recursive(True) + block.navigation.next() - block.set_dir_recursive(True) + block.navigation.set_dir_recursive(True) assert block.video_params.dir_root == tree @@ -206,7 +206,7 @@ def test_on_by_default_it_starts_in_the_folder_it_was_added_from( ): block = make_block(tree / "b.mp4", is_dir_recursive=True) - block.next_video() + block.navigation.next() assert block.video_params.dir_root == tree assert _uri(block) == tree / "s1" / "c.mp4" @@ -218,7 +218,7 @@ def test_a_root_the_file_is_not_under_is_moved_to_its_folder( tree / "s2" / "e.mp4", is_dir_recursive=True, dir_root=tmp_path / "other" ) - block.next_video() + block.navigation.next() assert block.video_params.dir_root == tree / "s2" @@ -226,10 +226,10 @@ def test_a_file_gone_since_the_tree_was_looked_through_is_passed_over( self, make_block, tree ): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video() + block.navigation.next() (tree / "s1" / "c.mp4").unlink() - block.next_video() + block.navigation.next() assert _uri(block) == tree / "s1" / "deep" / "d.mp4" @@ -238,15 +238,15 @@ def test_too_many_files_is_said_once_the_next_one_shows(self, make_block, tree): PlaylistSettings().set("playlist/folder_max_entries", 4) block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video() - block.next_video() + block.navigation.next() + block.navigation.next() assert block.notices == ["Stopped looking after 4 files and folders"] assert _uri(block) == tree / "a.mp4" def test_a_tree_gone_through_since_is_looked_through_again(self, make_block, tree): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video() + block.navigation.next() for name in ("a.mp4", "s1/c.mp4", "s1/deep/d.mp4", "s2/e.mp4"): (tree / name).unlink() @@ -254,7 +254,7 @@ def test_a_tree_gone_through_since_is_looked_through_again(self, make_block, tre with pytest.MonkeyPatch.context() as patch: patch.setattr("gridplayer.utils.next_file.MAX_MISSING_FILES", 2) - block.next_video() + block.navigation.next() assert _uri(block) == fresh @@ -288,7 +288,7 @@ class TestWhileTheTreeIsLookedThrough: def test_it_says_so(self, make_block, tree, held_scans): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video() + block.navigation.next() assert block.notices == ["Scanning folders…"] @@ -302,7 +302,8 @@ def test_it_says_so(self, make_block, tree, held_scans): id="end action", ), pytest.param( - lambda block: block.set_dir_recursive(False), id="subfolders off" + lambda block: block.navigation.set_dir_recursive(False), + id="subfolders off", ), ], ) @@ -310,7 +311,7 @@ def test_the_viewer_doing_something_else_takes_no_step( self, make_block, tree, held_scans, action ): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video(is_end=True) + block.navigation.next(is_end=True) action(block) held_scans.finish() @@ -321,7 +322,7 @@ def test_bookmarks_edited_since_the_end_hold_the_video( self, make_block, tree, held_scans ): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video(is_end=True) + block.navigation.next(is_end=True) block.bookmarker._is_editing_one = True held_scans.finish() @@ -334,7 +335,7 @@ def test_a_step_the_viewer_asked_for_is_taken_all_the_same( self, make_block, tree, held_scans ): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video() + block.navigation.next() block.bookmarker._is_editing_one = True held_scans.finish() @@ -346,13 +347,13 @@ def test_a_tree_thrown_away_as_it_came_is_still_waited_for( self, make_block, tree, held_scans ): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video() + block.navigation.next() (key,) = held_scans.keys folder_index().forget(key) folder_index().scanned.emit(key) - assert block._folder_navigator.is_waiting + assert block.navigation._folder_navigator.is_waiting def test_a_tree_just_looked_through_is_not_looked_through_twice( self, make_block, tmp_path, held_scans @@ -360,7 +361,7 @@ def test_a_tree_just_looked_through_is_not_looked_through_twice( (only,) = _touch(tmp_path, "root/a.mp4") block = make_block(only, is_dir_recursive=True) - block.next_video() + block.navigation.next() only.unlink() held_scans.finish() @@ -374,7 +375,7 @@ def test_the_step_is_taken_once_the_tree_has_been_looked_through( ): block = make_block(tree / "b.mp4", is_dir_recursive=True) - block.next_video() + block.navigation.next() deadline = time.monotonic() + 5 while _uri(block) == tree / "b.mp4" and time.monotonic() < deadline: @@ -385,8 +386,8 @@ def test_the_step_is_taken_once_the_tree_has_been_looked_through( def test_a_file_that_came_meanwhile_is_left_alone(self, make_block, tree): block = make_block(tree / "a.mp4", is_dir_recursive=True) - block.next_video() - block.switch_video(tree / "s2" / "e.mp4") + block.navigation.next() + block.navigation.switch_video(tree / "s2" / "e.mp4") deadline = time.monotonic() + 1 while time.monotonic() < deadline: @@ -406,7 +407,7 @@ def test_every_file_once_before_any_comes_again(self, make_block, tree): played = [] for _ in range(4): - block.shuffle_video() + block.navigation.shuffle() played.append(_uri(block)) assert sorted(played) == sorted( @@ -421,21 +422,21 @@ def test_every_file_once_before_any_comes_again(self, make_block, tree): def test_play_previous_goes_back_through_those_played(self, make_block, tree): block = make_block(tree / "a.mp4", end_action=VideoEndAction.SHUFFLE_FILE) - block.next_video() + block.navigation.next() first = _uri(block) - block.previous_video() + block.navigation.previous() assert _uri(block) == tree / "a.mp4" - block.next_video() + block.navigation.next() assert _uri(block) == first def test_play_random_file_while_looping_the_file(self, make_block, tree): block = make_block(tree / "a.mp4", end_action=VideoEndAction.LOOP_FILE) - block.shuffle_video() + block.navigation.shuffle() first = _uri(block) - block.shuffle_video() + block.navigation.shuffle() assert first == tree / "b.mp4" assert _uri(block) == tree / "a.mp4" @@ -444,7 +445,7 @@ def test_play_random_file_while_looping_the_file(self, make_block, tree): def test_with_none_played_before_previous_starts_it_over(self, make_block, tree): block = make_block(tree / "a.mp4", end_action=VideoEndAction.SHUFFLE_FILE) - block.previous_video() + block.navigation.previous() assert _uri(block) == tree / "a.mp4" @@ -454,12 +455,12 @@ def test_previous_passes_over_a_file_gone_since_it_played( _touch(tmp_path, "a.mp4", "b.mp4", "c.mp4", "d.mp4") block = make_block(tmp_path / "a.mp4", end_action=VideoEndAction.SHUFFLE_FILE) - block.next_video() + block.navigation.next() gone = _uri(block) - block.next_video() + block.navigation.next() gone.unlink() - block.previous_video() + block.navigation.previous() assert _uri(block) == tmp_path / "a.mp4" @@ -474,7 +475,7 @@ def test_a_file_open_in_another_cell_is_put_off(self, make_block, tmp_path): blocks.extend([block, other]) for _ in range(10): - block.shuffle_video() + block.navigation.shuffle() assert _uri(block) != b def test_with_avoiding_off_any_file_may_come(self, make_block, tmp_path): @@ -486,7 +487,7 @@ def test_with_avoiding_off_any_file_may_come(self, make_block, tmp_path): other = make_block(b, context=context, end_action=VideoEndAction.LOOP_FILE) blocks.extend([block, other]) - block.shuffle_video() + block.navigation.shuffle() assert _uri(block) == b @@ -502,7 +503,7 @@ def test_the_audio_file_picked_for_the_last_one_stays_with_it( audio_selection=AudioExternal(file=commentary), ) - block.next_video() + block.navigation.next() assert block.video_params.external_audio == [] assert not isinstance(block.video_params.audio_selection, AudioExternal) diff --git a/tests/test_list_window_beside.py b/tests/test_list_window_beside.py index c0a436d1..f4971e31 100644 --- a/tests/test_list_window_beside.py +++ b/tests/test_list_window_beside.py @@ -40,6 +40,8 @@ def __init__(self, registry, media_list): self.video_params = SimpleNamespace(list_id=media_list.id) self.is_closing = False self.marks = [] + # the cell's navigation part, done by the fake itself + self.navigation = self @property def media_list(self): diff --git a/tests/test_make_list.py b/tests/test_make_list.py index 96865a4a..39cb0412 100644 --- a/tests/test_make_list.py +++ b/tests/test_make_list.py @@ -85,6 +85,8 @@ def __init__(self, uri, title=None): self.media_list = None self.is_closing = False self.taken = [] + # the cell's navigation part, done by the fake itself + self.navigation = self def take_list(self, list_id, index): self.taken.append((list_id, index)) diff --git a/tests/test_media_list_dialog.py b/tests/test_media_list_dialog.py index 8a7f346d..d6267cff 100644 --- a/tests/test_media_list_dialog.py +++ b/tests/test_media_list_dialog.py @@ -89,6 +89,8 @@ def __init__(self, registry, list_id, key): self.video_params = SimpleNamespace(list_id=list_id) self.key = key self.played = [] + # the cell's navigation part, done by the fake itself + self.navigation = self @property def list_item(self): diff --git a/tests/test_overlay_stopped.py b/tests/test_overlay_stopped.py index 61db1f79..bc61fa1b 100644 --- a/tests/test_overlay_stopped.py +++ b/tests/test_overlay_stopped.py @@ -322,9 +322,9 @@ def test_cmd_active_next_video_when_stopped(mocker): block.is_playable = True manager = _active_manager(block) - manager.cmd_active("next_video") + manager.cmd_active("navigation.next") - block.next_video.assert_called_once() + block.navigation.next.assert_called_once() def test_is_active_local_file_when_stopped(mocker): @@ -645,7 +645,7 @@ def test_start_load_keeps_overlay(mocker): ) block.size_tuple = (100, 100) block._ctx.lists.is_waiting.return_value = False - block._is_list_file_gone = False + block.navigation.is_list_file_gone = False mocker.patch("gridplayer.widgets.video_block.MediaInput") VideoBlock._start_load(block) diff --git a/tests/test_video_block_bookmarks.py b/tests/test_video_block_bookmarks.py index 897df0f2..edb66236 100644 --- a/tests/test_video_block_bookmarks.py +++ b/tests/test_video_block_bookmarks.py @@ -545,7 +545,7 @@ def test_the_next_file_in_the_folder_has_its_own(self, block, tmp_path): other.touch() block._ctx.bookmarks.set(other, None, [Bookmark(time_ms=5000)]) - block.next_video() + block.navigation.next() assert block.video_params.uri == other assert _times(block) == [5000] @@ -554,8 +554,8 @@ def test_the_next_file_in_the_folder_has_its_own(self, block, tmp_path): def test_coming_back_to_the_file_brings_them_back(self, block, tmp_path): (tmp_path / "other.mkv").touch() - block.next_video() - block.previous_video() + block.navigation.next() + block.navigation.previous() block.video_driver._fake_player_timer.stop() assert block.bookmarks == BOOKMARKS @@ -867,7 +867,7 @@ def test_left_open_while_another_file_came_it_leaves_that_one_be( (tmp_path / "other.mkv").touch() menu = block.bookmarker.marker_menu((30000,)) - block.next_video() + block.navigation.next() block.video_driver._fake_player_timer.stop() theirs = (Bookmark(time_ms=5000), Bookmark(time_ms=20000)) block.bookmarker.replace(theirs) @@ -886,7 +886,7 @@ def test_a_name_typed_while_another_file_came_is_dropped( theirs = (Bookmark(time_ms=5000), Bookmark(time_ms=20000)) def typed(**_kwargs): - block.next_video() + block.navigation.next() block.bookmarker.replace(theirs) return "Win", "#ff3d3d" @@ -1127,7 +1127,7 @@ def test_left_open_while_another_file_came_it_leaves_that_one_be( (tmp_path / "other.mkv").touch() menu = self._menu(block) - block.next_video() + block.navigation.next() block.video_driver._fake_player_timer.stop() theirs = (Bookmark(time_ms=5000), Bookmark(time_ms=30000)) block.bookmarker.replace(theirs) diff --git a/tests/test_video_block_lists.py b/tests/test_video_block_lists.py index 8719f478..6585a1bf 100644 --- a/tests/test_video_block_lists.py +++ b/tests/test_video_block_lists.py @@ -35,7 +35,8 @@ from gridplayer.utils.url_resolve.static import ResolvedList, ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver from gridplayer.utils.video_errors import ShownError -from gridplayer.widgets.video_block import NOT_ON_LIST_TXT, VideoBlock +from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_navigation import NOT_ON_LIST_TXT from gridplayer.widgets.video_block_recovery import ( MAX_NETWORK_FAILURES_IN_A_ROW as MAX_FAILED, ) @@ -172,7 +173,7 @@ def test_next_is_the_next_on_the_list_not_in_the_folder( media_list = lists.add(MediaList(items=[ListItem(uri=a), ListItem(uri=c)])) block = make_cell(media_list) - block.next_video() + block.navigation.next() assert _uri(block) == c assert block.video_params.list_index == 1 @@ -180,7 +181,7 @@ def test_next_is_the_next_on_the_list_not_in_the_folder( def test_previous_goes_round_to_the_last(self, make_cell, file_list, files): block = make_cell(file_list) - block.previous_video() + block.navigation.previous() assert _uri(block) == files[2] @@ -190,7 +191,7 @@ def test_links_are_gone_along_as_well(self, make_cell, lists, no_resolving): ) block = make_cell(channels) - block.next_video() + block.navigation.next() assert _uri(block) == URL.format(1) no_resolving.assert_called_with(URL.format(1), {}) @@ -202,7 +203,7 @@ def test_a_channel_that_does_not_answer_can_be_left(self, make_cell, lists): block = make_cell(channels) block.network_error() - block.next_video() + block.navigation.next() assert _uri(block) == URL.format(1) @@ -214,7 +215,7 @@ def test_a_channel_left_takes_its_reload_with_it(self, make_cell, lists): block.network_error() block.network_error() - block.next_video() + block.navigation.next() assert not block.recovery._retry_timer.isActive() @@ -225,7 +226,7 @@ def test_the_next_channel_reloaded_as_many_times_as_any(self, make_cell, lists): block = make_cell(channels) block.network_error() block.network_error() - block.next_video() + block.navigation.next() block.network_error() @@ -262,7 +263,7 @@ def test_the_same_file_twice_on_the_list_is_two_places(self, make_cell, lists): ) block = make_cell(channels, index=2) - block.next_video() + block.navigation.next() assert block.video_params.list_index == 0 @@ -303,19 +304,19 @@ def _listened(self, block, said): def test_from_the_start(self, make_cell, file_list): block = make_cell(file_list, index=1) - assert block.list_place_txt == "2/3" + assert block.navigation.list_place_txt == "2/3" def test_and_as_it_goes_along(self, make_cell, file_list, said): block = self._listened(make_cell(file_list), said) - block.next_video() + block.navigation.next() assert said[-1] == "2/3" def test_round_to_the_first(self, make_cell, file_list, said): block = self._listened(make_cell(file_list, index=2), said) - block.next_video() + block.navigation.next() assert said[-1] == "1/3" @@ -345,7 +346,7 @@ def test_nothing_for_a_cell_with_no_list(self, make_cell, file_list, files, said block.set_video(Video(uri=files[0])) assert said[-1] == "" - assert block.list_place_txt == "" + assert block.navigation.list_place_txt == "" def test_the_overlay_shows_it_only_for_a_list(self): overlay = OverlayBlock() @@ -380,8 +381,8 @@ def test_clicked_for_the_list_window(self, make_cell, file_list): def test_no_window_once_the_cell_has_left_the_list(self, make_cell, file_list): block = make_cell(file_list) - block.leave_list() - block.show_list_window() + block.navigation.leave_list() + block.navigation.show_list_window() block._ctx.commands.list_window_for.assert_not_called() @@ -400,7 +401,7 @@ def picked(self, lists, files): def test_the_cell_plays_no_list(self, make_cell, picked, files): block = make_cell(picked) - block.leave_list() + block.navigation.leave_list() assert block.media_list is None assert block.video_params.list_index is None @@ -409,15 +410,15 @@ def test_the_cell_plays_no_list(self, make_cell, picked, files): def test_next_goes_through_the_folder_again(self, make_cell, picked, files): block = make_cell(picked) - block.leave_list() - block.next_video() + block.navigation.leave_list() + block.navigation.next() assert _uri(block) == files[1] def test_the_name_it_had_on_the_list_stays(self, make_cell, picked): block = make_cell(picked) - block.leave_list() + block.navigation.leave_list() assert block.video_params.title == "Pilot" @@ -426,23 +427,23 @@ def test_nothing_said_over_the_video(self, make_cell, picked): said = [] block.list_place_change.connect(said.append) - block.leave_list() + block.navigation.leave_list() assert said == [""] def test_the_list_stays_for_the_playlist(self, make_cell, lists, picked): block = make_cell(picked) - block.leave_list() + block.navigation.leave_list() assert lists.get(picked.id) is not None def test_a_cell_with_no_list_is_left_as_it_is(self, make_cell, picked, files): block = make_cell(picked) - block.leave_list() + block.navigation.leave_list() block.video_params.title = "Mine" - block.leave_list() + block.navigation.leave_list() assert block.video_params.title == "Mine" @@ -453,7 +454,7 @@ def test_the_overlay_stays_up(self, make_cell, file_list): block._ctx.is_overlay_hide_on_timeout = True block.overlay.show() - block.set_list_window_shown(True) + block.navigation.set_list_window_shown(True) block.hide_overlay() assert not block.overlay.isHidden() @@ -462,7 +463,7 @@ def test_the_overlay_stays_up(self, make_cell, file_list): def test_gone_once_the_overlay_is_turned_off(self, make_cell, file_list): block = make_cell(file_list) block.overlay.show() - block.set_list_window_shown(True) + block.navigation.set_list_window_shown(True) block._ctx.is_disable_overlay = True block.hide_overlay() @@ -472,19 +473,21 @@ def test_gone_once_the_overlay_is_turned_off(self, make_cell, file_list): def test_not_brought_back_by_closing_it(self, make_cell, file_list): block = make_cell(file_list) block.overlay.show() - block.set_list_window_shown(True) + block.navigation.set_list_window_shown(True) block._ctx.is_disable_overlay = True block.hide_overlay() - block.set_list_window_shown(False) + block.navigation.set_list_window_shown(False) assert block.overlay.isHidden() def test_back_once_the_overlay_is_turned_on(self, mocker): manager = mocker.Mock() manager._ctx.is_overlay_hide_on_timeout = True - marked = mocker.Mock(is_list_window_shown=True) - other = mocker.Mock(is_list_window_shown=False) + marked = mocker.Mock() + marked.navigation.is_list_window_shown = True + other = mocker.Mock() + other.navigation.is_list_window_shown = False manager._ctx.video_blocks = [marked, other] VideoBlocksManager.set_disable_overlay(manager, False) @@ -496,8 +499,8 @@ def test_and_goes_as_ever_once_it_is_closed(self, make_cell, file_list): block = make_cell(file_list) block._ctx.is_overlay_hide_on_timeout = True - block.set_list_window_shown(True) - block.set_list_window_shown(False) + block.navigation.set_list_window_shown(True) + block.navigation.set_list_window_shown(False) block.overlay.show() block.hide_overlay() @@ -653,7 +656,7 @@ def test_before_the_end_the_list_goes_on(self, make_cell, file_list, files): def test_is_set_for_the_cell(self, make_cell, file_list): block = make_cell(file_list) - block.set_list_end_action(ListEndAction.CLOSE) + block.navigation.set_list_end_action(ListEndAction.CLOSE) assert block.video_params.list_end_action is ListEndAction.CLOSE @@ -864,7 +867,7 @@ def test_one_that_plays_starts_the_count_over(self, make_cell, file_list): block.error() block.recovery.stop_skip() - block.next_video() + block.navigation.next() block.error() assert block.recovery.is_skip_counting @@ -956,7 +959,7 @@ def test_the_name_on_the_list_is_the_cells_title(self, make_cell, lists, files): ) block = make_cell(media_list) - block.next_video() + block.navigation.next() assert block.title == "Two" @@ -1012,7 +1015,7 @@ def test_the_link_is_resolved_with_them(self, make_cell, channels, no_resolving) def test_the_next_one_with_its_own(self, make_cell, channels, no_resolving): block = make_cell(channels) - block.next_video() + block.navigation.next() no_resolving.assert_called_with(URL.format(1), {}) @@ -1068,7 +1071,7 @@ def test_kept_once_the_cell_leaves_the_list( ): block = make_cell(channels) - block.leave_list() + block.navigation.leave_list() block.reload() no_resolving.assert_called_with(URL.format(0), self.HEADERS) @@ -1080,7 +1083,7 @@ def test_puts_that_one_back(self, make_cell, file_list, files, monkeypatch): snapshot = block.video_params.model_copy() snapshot.current_position = 30000 - block.next_video() + block.navigation.next() # where it is started from: the dummy player starts every one at 0 started_at = [] @@ -1106,7 +1109,7 @@ def _renamed(self, block, name): def test_the_one_it_had_then(self, make_cell, file_list): block = make_cell(file_list) - block.leave_list() + block.navigation.leave_list() self._renamed(block, "Cam 1") snapshot = block.video_params.model_copy() self._renamed(block, "Cam A") @@ -1139,7 +1142,7 @@ def test_plays_from_it_from_where_it_is(self, make_cell, lists, files): ) ) - block.take_list(grown.id, 0) + block.navigation.take_list(grown.id, 0) assert block.media_list is grown assert block.video_params.list_index == 0 @@ -1149,7 +1152,7 @@ def test_plays_from_it_from_where_it_is(self, make_cell, lists, files): # and it goes along the list at its end assert block.video_params.end_action is VideoEndAction.NEXT_FILE - block.next_video() + block.navigation.next() assert _uri(block) == files[1] @@ -1157,7 +1160,7 @@ def test_going_along_a_list_already_it_goes_on_so(self, make_cell, lists, files) media_list = lists.add(MediaList(items=[ListItem(uri=files[0])])) block = make_cell(media_list, end_action=VideoEndAction.SHUFFLE_FILE) - block.take_list(media_list.id, 0) + block.navigation.take_list(media_list.id, 0) assert block.video_params.end_action is VideoEndAction.SHUFFLE_FILE @@ -1186,7 +1189,7 @@ def test_the_cell_stays_on_its_channel(self, make_cell, lists, channels): self._read_again(lists, channels, "b") - assert block.list_place_txt == "2/3" + assert block.navigation.list_place_txt == "2/3" assert block.list_item.title == "Channel 1" def test_and_has_its_new_link(self, make_cell, lists, channels): @@ -1219,7 +1222,7 @@ def test_going_on_from_it_goes_to_the_next(self, make_cell, lists, channels): block = make_cell(channels, index=1) self._read_again(lists, channels, "b") - block.next_video() + block.navigation.next() assert _uri(block) == self.LINK.format(2, "b") @@ -1380,7 +1383,7 @@ def test_a_link_read_as_one_stream_plays_it(self, cell, no_resolving): cell.set_video(Video(uri=url, read_as_list=True)) cell.video_params.read_as_list = None - cell.play_read(url) + cell.navigation.play_read(url) assert cell.video_params.uri == url no_resolving.assert_called_once() @@ -1389,7 +1392,7 @@ def test_a_folder_read_as_one_file_plays_it(self, cell, tmp_path, files): cell.set_video(Video(uri=str(tmp_path), read_as_list=True)) cell.video_params.read_as_list = None - cell.play_read(files[0]) + cell.navigation.play_read(files[0]) assert cell.video_params.uri == files[0] @@ -1404,7 +1407,7 @@ def test_the_cell_plays_the_list(self, make_cell, file_list): block = make_cell(file_list) block.set_video(Video(uri="https://site/list", title=None)) - block._link_was_a_list(self.SITE_LIST) + block.navigation.link_was_a_list(self.SITE_LIST) QTest.qWait(0) media_list = block.media_list @@ -1425,7 +1428,7 @@ def test_on_a_list_its_videos_take_its_place(self, make_cell, lists): ) block = make_cell(media_list, index=1) - block._link_was_a_list(self.SITE_LIST) + block.navigation.link_was_a_list(self.SITE_LIST) QTest.qWait(0) assert [item.uri for item in block.media_list.items] == [ @@ -1439,7 +1442,7 @@ def test_on_a_list_its_videos_take_its_place(self, make_cell, lists): def test_not_once_the_cell_is_closing(self, make_cell, file_list, files): block = make_cell(file_list) - block._link_was_a_list(self.SITE_LIST) + block.navigation.link_was_a_list(self.SITE_LIST) block._is_closing = True QTest.qWait(0) @@ -1473,6 +1476,6 @@ def test_says_how_many_under_its_title(self, make_cell, file_list): said = [] block.info_change.connect(said.append) - block.tell_added_to_list(3) + block.navigation.tell_added_to_list(3) assert said == ["Added to the list: 3"] diff --git a/tests/test_video_block_navigation.py b/tests/test_video_block_navigation.py new file mode 100644 index 00000000..fa402f55 --- /dev/null +++ b/tests/test_video_block_navigation.py @@ -0,0 +1,46 @@ +"""The navigation part of a video block goes by the cell it is part of.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +from gridplayer.widgets.video_block_navigation import NavigationControl + + +def _cells(*uris): + cells = [] + + for uri in uris: + cell = Mock() + cell.video_params = SimpleNamespace(uri=uri, is_local_file=True) + cell._ctx.video_blocks = cells + cells.append(cell) + + return cells + + +def test_the_files_open_elsewhere_leave_out_its_own(): + """Its own is not one to skip on the way to the next file.""" + + own, _ = _cells(Path("/videos/a.mp4"), Path("/videos/b.mp4")) + + navigation = NavigationControl(own) + + assert navigation._files_open_elsewhere() == [Path("/videos/b.mp4")] + + +def test_the_links_open_elsewhere_leave_out_its_own(): + own, _ = _cells("https://site.example/a", "https://site.example/b") + + navigation = NavigationControl(own) + + assert navigation._uris_open_elsewhere() == ["https://site.example/b"] + + +def test_the_list_window_is_opened_on_the_cell(): + (own,) = _cells(Path("/videos/a.mp4")) + own.media_list = Mock() + + NavigationControl(own).show_list_window() + + own._ctx.commands.list_window_for.assert_called_once_with(own) diff --git a/tests/test_video_block_repeat.py b/tests/test_video_block_repeat.py index d66d1c83..c6e446cc 100644 --- a/tests/test_video_block_repeat.py +++ b/tests/test_video_block_repeat.py @@ -17,6 +17,7 @@ seek_landing, ) from gridplayer.widgets.video_block import VideoBlock +from gridplayer.widgets.video_block_navigation import NavigationControl @pytest.fixture(scope="module", autouse=True) @@ -47,7 +48,7 @@ def test_loop_end_stop_without_explicit_loop(mocker): block.stop_playback.assert_called_once() block._loop_to_start.assert_not_called() - block.next_video.assert_not_called() + block.navigation.next.assert_not_called() def test_loop_end_close_schedules_close(mocker): @@ -75,7 +76,7 @@ def test_loop_end_next_file(mocker): VideoBlock.loop_end_action(block) - block.next_video.assert_called_once() + block.navigation.next.assert_called_once() block._loop_to_start.assert_not_called() @@ -84,8 +85,8 @@ def test_loop_end_previous_file(mocker): VideoBlock.loop_end_action(block) - block.previous_video.assert_called_once() - block.next_video.assert_not_called() + block.navigation.previous.assert_called_once() + block.navigation.next.assert_not_called() def test_loop_end_shuffle_file(mocker): @@ -93,7 +94,7 @@ def test_loop_end_shuffle_file(mocker): VideoBlock.loop_end_action(block) - block.shuffle_video.assert_called_once() + block.navigation.shuffle.assert_called_once() def test_loop_end_pause_at_start(mocker): @@ -164,7 +165,7 @@ def test_stopped_overlay_uses_hide_timer(mocker): block._is_error = False block.status.is_kept_for_picture = False block.view.is_zoom_ui.return_value = False - block._is_list_window_shown = False + block.navigation.is_list_window_shown = False block.isVisible.return_value = True VideoBlock.show_overlay(block) @@ -317,9 +318,9 @@ def test_switch_video_stopped_same_file_loads(mocker): block.video_params = mocker.Mock(uri=uri) block.is_video_initialized = False - block._switch_to = partial(VideoBlock._switch_to, block) + block.navigation = NavigationControl(block) - VideoBlock.switch_video(block, uri) + block.navigation.switch_video(uri) block._load_and_play.assert_called_once() block.seek.assert_not_called() @@ -334,9 +335,9 @@ def test_switch_video_initialized_same_file_seeks(mocker): block.is_video_initialized = True block.loop_start = 0 - block._switch_to = partial(VideoBlock._switch_to, block) + block.navigation = NavigationControl(block) - VideoBlock.switch_video(block, uri) + block.navigation.switch_video(uri) block._seek_loop_start.assert_called_once_with() block._load_and_play.assert_not_called() @@ -348,9 +349,9 @@ def test_switch_video_stopped_other_file_starts_playback(mocker): block.video_params = mocker.Mock(uri=Path("/tmp/a.mp4")) block.is_video_initialized = False - block._switch_to = partial(VideoBlock._switch_to, block) + block.navigation = NavigationControl(block) - VideoBlock.switch_video(block, Path("/tmp/b.mp4")) + block.navigation.switch_video(Path("/tmp/b.mp4")) assert block.video_params.uri == Path("/tmp/b.mp4") assert block.video_params.playback_state is VideoInitialState.PLAYING diff --git a/tests/test_video_errors.py b/tests/test_video_errors.py index 1ef5f8a1..5c217b41 100644 --- a/tests/test_video_errors.py +++ b/tests/test_video_errors.py @@ -897,7 +897,9 @@ def test_next_goes_the_way_the_list_goes_by_itself( ) stepped = [] mocker.patch.object( - block, "previous_video", lambda is_end: stepped.append(("back", is_end)) + block.navigation, + "previous", + lambda is_end: stepped.append(("back", is_end)), ) block.overlay.next_clicked.emit() @@ -923,7 +925,7 @@ def test_not_named_by_how_far_it_loaded(self, make_cell, tmp_path): video.unlink() # the only file of its folder gone, none to go on to - block.switch_video(None) + block.navigation.switch_video(None) assert block.video_status.error.title == "File not found" @@ -985,11 +987,11 @@ def test_stopped_while_its_list_is_read_it_waits_for_it_no_more( self, make_cell, tmp_path, mocker ): block, _ = _failed_list_item(make_cell, tmp_path) - block._is_waiting_for_list_read = True + block.navigation.is_waiting_for_list_read = True block.stop_playback() start_load = mocker.patch.object(block, "_start_load") - block._on_list_reading(block.video_params.list_id) + block.navigation._on_list_reading(block.video_params.list_id) start_load.assert_not_called() @@ -1061,7 +1063,8 @@ def test_the_menu_stops_its_list_going_on_once_it_failed(self, make_cell, tmp_pa assert block.overlay.mode is OverlayMode.STOPPED @pytest.mark.parametrize( - ("command", "index"), [("next_video", 1), ("previous_video", 2)] + ("command", "index"), + [("navigation.next", 1), ("navigation.previous", 2)], ) def test_the_menu_steps_along_its_list_while_it_loads( self, make_cell, command, index @@ -1121,7 +1124,7 @@ def test_reloaded_once_it_played(self, make_cell): def test_the_next_one_is_not_taken_to_have_played(self, make_cell): block = _failed_link(make_cell, None) block._has_item_played = True - block.next_video() + block.navigation.next() _fail(block, _http_error(403)) From 082d52efdc372e30e7d7daaa96bf8bfe7be2d52f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:10:51 +0000 Subject: [PATCH 13/14] test: check that every command named for a video block resolves; document the block parts A misspelt "part.method" is no error until its menu item is chosen, and then the item just does nothing. The test reads the commands named for the active block, the ones let through while loading, the ones sent to all blocks and the signals that carry those to each block, and checks each one is there. AGENTS.md now says how the video block is split into parts and what to watch for when moving code into one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- AGENTS.md | 31 ++++++++ tests/test_dotted_commands.py | 133 +++++++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e24fd572..52c2b32a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,37 @@ from gridplayer.settings import Settings Settings().set("playlist/unsaved_changes", UnsavedChangesMode.DISCARD) ``` +## Video block parts + +`VideoBlock` (`gridplayer/widgets/video_block.py`) keeps the core: loading, +playback, seeking, rate and its Qt events. Everything else lives in a part, a +`VideoBlockPart` subclass in its own `video_block_.py`, kept on the +block as an attribute: `status`, `bookmarker`, `view`, `chapters`, +`sponsorblock`, `subtitles`, `audio`, `loop`, `screenshot`, `recovery`, +`stream` and `navigation`. + +* A part reaches its block through `self._block`. When moving a method into + a part, every `self.x` that is the block's must become `self._block.x`. A + bare `self` passed on (to a dialog, a signal, `ctx.commands`) has to become + `self._block` too; a test with a `Mock` block will not notice. +* The `only_initialized` / `only_seekable` / ... guards in + `video_block_guards.py` work on part methods as well; they ask the block. +* Commands name part methods with a dot: `("active", "navigation.next")`, + `LOADING_COMMANDS` entries, `manual_seek("bookmarker.seek", i)`. An + `("all", "x")` command emits `VideoBlocksManager.all_x`, which + `VideoBlocksManager` connects to `vb..` for each new block. + `tests/test_dotted_commands.py` checks that every named command resolves. +* Parts are made in `VideoBlock.__init__` before `init_url_resolver()`, + which connects to them. A new part goes there too, and into + `_block_parts()` in `tests/conftest.py`. +* In tests, `mock_block_parts(block)` gives a `Mock(spec=VideoBlock)` its + parts, and `is_block_command("part.method")` checks a command name. A part + that makes a `QTimer(block)` cannot take a `Mock` as its parent: patch + `gridplayer.widgets.video_block_.QTimer`, or use a `QObject` + stand-in. +* An exception raised inside a Qt slot aborts the whole test run ("Fatal + Python error: Aborted"), not just the test. + ## Translatable strings `pylupdate5` only extracts a string when it sees a literal context and a diff --git a/tests/test_dotted_commands.py b/tests/test_dotted_commands.py index f6c38b20..a71ff4ab 100644 --- a/tests/test_dotted_commands.py +++ b/tests/test_dotted_commands.py @@ -1,10 +1,141 @@ """A command may name a method of a part of the video block, "part.method", as well as one of the block itself.""" +import ast +from collections import defaultdict +from pathlib import Path from types import SimpleNamespace -from gridplayer.player.managers.active_block import ActiveBlockManager +import pytest + +import gridplayer +from gridplayer.player.managers.active_block import ( + LOADING_COMMANDS, + QUIETLY_DROPPED_COMMANDS, + ActiveBlockManager, +) +from gridplayer.player.managers.video_blocks import VideoBlocksManager from gridplayer.widgets.video_block import VideoBlock +from tests.conftest import is_block_command + +PACKAGE = Path(gridplayer.__file__).parent + +# these run another command of the block, named in their first argument +FORWARDING = frozenset({"manual_seek", "manual_view", "manual_rate"}) + + +def _sources(): + for path in sorted(PACKAGE.rglob("*.py")): + # compiled resources, nothing in them names a command + if "resources" in path.relative_to(PACKAGE).parts: + continue + + yield path.relative_to(PACKAGE), ast.parse(path.read_text("utf-8")) + + +def _literal_str(node): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + + return None + + +def _named_commands(): + """Each command a menu, a key or a dialog names for a video block, as + ("active", name), ("all", name) or block.manual_seek(name), with where + it was named. + + A misspelt one is no error until it is chosen, and then it is only a + menu item doing nothing. + """ + + active, everyone = defaultdict(list), defaultdict(list) + + for path, tree in _sources(): + for node in ast.walk(tree): + if isinstance(node, ast.Tuple) and len(node.elts) >= 2: + kind, name = map(_literal_str, node.elts[:2]) + + if name is None: + continue + + if kind == "active": + active[name].append(f"{path}:{node.lineno}") + + forwarded = node.elts[2:3] + target = _literal_str(forwarded[0]) if forwarded else None + if name in FORWARDING and target is not None: + active[target].append(f"{path}:{node.lineno}") + + elif kind == "all": + everyone[name].append(f"{path}:{node.lineno}") + + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in FORWARDING + and node.args + ): + target = _literal_str(node.args[0]) + if target is not None: + active[target].append(f"{path}:{node.lineno}") + + return dict(sorted(active.items())), dict(sorted(everyone.items())) + + +def _all_signal_wiring(): + """(signal, block command) as VideoBlocksManager connects each new block + to the commands it sends to all of them: (self.all_x, vb.part.method).""" + + source = PACKAGE / "player" / "managers" / "video_blocks.py" + tree = ast.parse(source.read_text("utf-8")) + + wiring = [] + for node in ast.walk(tree): + if not (isinstance(node, ast.Tuple) and len(node.elts) == 2): + continue + + signal, slot = map(ast.unparse, node.elts) + if signal.startswith("self.all_") and slot.startswith("vb."): + wiring.append((signal.removeprefix("self."), slot.removeprefix("vb."))) + + return wiring + + +ACTIVE_COMMANDS, ALL_COMMANDS = _named_commands() +ALL_SIGNAL_WIRING = _all_signal_wiring() + + +def test_the_named_commands_are_found_at_all(): + # a walk that finds nothing passes every test below + assert len(ACTIVE_COMMANDS) > 50 + assert len(ALL_COMMANDS) > 30 + assert len(ALL_SIGNAL_WIRING) > 30 + + +@pytest.mark.parametrize("command", ACTIVE_COMMANDS) +def test_a_command_named_for_the_active_block_is_there(command): + assert is_block_command(command), f"named at {ACTIVE_COMMANDS[command]}" + + +@pytest.mark.parametrize("command", sorted(LOADING_COMMANDS | QUIETLY_DROPPED_COMMANDS)) +def test_a_command_let_through_while_loading_is_there(command): + # one that is not would leave its menu item dropped while loading + assert is_block_command(command) + + +@pytest.mark.parametrize("command", ALL_COMMANDS) +def test_a_command_named_for_all_blocks_has_a_signal(command): + assert hasattr(VideoBlocksManager, f"all_{command}"), ( + f"named at {ALL_COMMANDS[command]}" + ) + + +@pytest.mark.parametrize( + ("signal", "command"), ALL_SIGNAL_WIRING, ids=[s for s, _ in ALL_SIGNAL_WIRING] +) +def test_a_signal_to_all_blocks_reaches_a_block_command(signal, command): + assert is_block_command(command) def _active_manager(block): From 2ac0feb3c70d818ea3ab09329519994ac466ce68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:48:40 +0000 Subject: [PATCH 14/14] test: drive the real app through its menus, in scenarios a test or a debug run can use tests/real_app starts the player offscreen with VLC, in a process of its own, and runs a scenario against it: open media, choose menu items, wait for the state to follow, check it, take screenshots. ffmpeg makes the media into build/test-media on first use (clips, a list, a file with chapters and audio and subtitle tracks and files beside it, a stream in three sizes and a dubbed one), and HLS is served from the run itself. The scenarios in tests/real_app/scenarios cover playback, folder and list stepping, stream sizes and a failing link, dubs, tracks, and chapters, bookmarks and SponsorBlock (answered locally). One shows a known bug, VLC moving the video back on a track change while an audio file plays beside it, and is expected to fail until it is fixed. They run with `just test-real-app` (pytest -m real_app) and as a CI job of their own; a plain pytest leaves them out. A one-off scenario file runs with `python -m tests.real_app.run`, which AGENTS.md describes along with what the driver takes care of. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MTS355x5fJ6RENK1HLYqmX --- .github/workflows/test.yml | 37 ++ AGENTS.md | 63 ++- justfile | 3 + pyproject.toml | 7 + tests/real_app/__init__.py | 0 tests/real_app/driver.py | 436 ++++++++++++++++++ tests/real_app/media.py | 242 ++++++++++ tests/real_app/run.py | 97 ++++ tests/real_app/scenarios/__init__.py | 0 tests/real_app/scenarios/dubs.py | 43 ++ .../real_app/scenarios/external_audio_jump.py | 26 ++ tests/real_app/scenarios/marks.py | 97 ++++ tests/real_app/scenarios/navigation.py | 41 ++ tests/real_app/scenarios/playback.py | 56 +++ tests/real_app/scenarios/streams.py | 49 ++ tests/real_app/scenarios/tracks.py | 75 +++ tests/real_app/test_scenarios.py | 82 ++++ 17 files changed, 1342 insertions(+), 12 deletions(-) create mode 100644 tests/real_app/__init__.py create mode 100644 tests/real_app/driver.py create mode 100644 tests/real_app/media.py create mode 100644 tests/real_app/run.py create mode 100644 tests/real_app/scenarios/__init__.py create mode 100644 tests/real_app/scenarios/dubs.py create mode 100644 tests/real_app/scenarios/external_audio_jump.py create mode 100644 tests/real_app/scenarios/marks.py create mode 100644 tests/real_app/scenarios/navigation.py create mode 100644 tests/real_app/scenarios/playback.py create mode 100644 tests/real_app/scenarios/streams.py create mode 100644 tests/real_app/scenarios/tracks.py create mode 100644 tests/real_app/test_scenarios.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f4d09810..7d02357c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,3 +54,40 @@ jobs: - name: Run tests run: uv run --locked pytest + + # the real app driven through its menus, see tests/real_app + real-app: + runs-on: ubuntu-latest + env: + UV_PYTHON: "3.14" + steps: + - uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + # VLC decodes and draws in software, ffmpeg makes the media + - name: Install requirements + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libvlc5 vlc-plugin-base ffmpeg \ + libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + + - name: Run the real app + run: uv run --locked pytest -m real_app --basetemp=build/real-app + + # each run's report.json, app.log and screenshots + - name: Keep what the runs left + if: failure() + uses: actions/upload-artifact@v7 + with: + name: real-app-runs + path: | + build/real-app + !build/real-app/**/home diff --git a/AGENTS.md b/AGENTS.md index 52c2b32a..2c6f85c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,18 +29,57 @@ Files matching `*_ui.py` are generated from Qt Designer `.ui` sources. Never edi just generate-ui ``` -## Test harnesses driving the real app - -Closing the player asks "do you want to save?" by default, and a harness has -nobody to answer it, so it hangs forever after the main window closes. Set the -playlist to discard before building the player: - -```python -from gridplayer.params.static import UnsavedChangesMode -from gridplayer.settings import Settings - -Settings().set("playlist/unsaved_changes", UnsavedChangesMode.DISCARD) -``` +## Driving the real app + +`tests/real_app` runs the real player offscreen, with VLC, driven through its +menus. Use it to see a change working or to chase a bug in the app, rather +than writing a harness of your own. + +* Write a scenario (in your scratchpad for a one-off) and run it: + + ```bash + uv run python -m tests.real_app.run path/to/scenario.py --out some/dir + ``` + + It prints each step as a line of JSON and leaves `report.json`, `app.log` + and the screenshots in the folder. A scenario is `def scenario(d)`, with + optional `SETTINGS`; the docstring of `run.py` has an example, and + `driver.py` what `d` does: `open`, `wait_for_cell`, `act` (an action of + `params/actions.py` by name), `menu` and `choose` (a generated menu), + `wait_until`, `check`, `note`, `shot`, `through_modal`, `serve`, + `fake_sponsorblock`. +* The media are made by ffmpeg into `build/test-media/` the first time; the + docstring of `tests/real_app/media.py` says what there is: a folder and a + list of clips, a file with chapters, two audio and two subtitle tracks and + files to find beside it, a stream in three sizes and a dubbed one (serve + those with `d.serve`). +* The scenarios in `tests/real_app/scenarios` are tests too: `just + test-real-app` (`pytest -m real_app`), and a job of their own in CI. A plain + `uv run pytest` leaves them out. A bug one shows goes in `KNOWN_BUGS` in + `test_scenarios.py` until it is fixed. +* What the driver already takes care of, should you go around it: + * The app picks the xcb platform itself, whatever `QT_QPA_PLATFORM` says; + set `gridplayer.params.env.QT_PLATFORM`. + * Hardware video needs an X11 window: offscreen takes the `vlc_sw_sp` + video driver. + * Closing the player asks "do you want to save?", which nobody answers + (set `playlist/unsaved_changes` to `discard`), and ends the process with + `os._exit`. + * The managers' context is `player._context`. +* What to know when writing a scenario: + * Wait for state, not for time. Tracks, sizes and languages are listed + only once a video has loaded, and choosing an audio file, a size or a + language reloads it. + * A seek takes a moment to land, and `time` is the old one until it has + (`chapters.playing` already goes by where it is arriving): wait for the + time before judging from it. + * Streamlink reads only the default audio of an HLS stream; for dubs, set + `streaming/resolver_priority` to `yt_dlp`. + * Audio and subtitle files beside a video are offered only after "Detect + Audio Files" / "Detect Subtitle Files". + * VLC's clock steps back a few milliseconds on a track change. + * `pkill -f` with a pattern that is also in your own command line kills + your shell. ## Video block parts diff --git a/justfile b/justfile index b1d0799b..ec8a3dc3 100644 --- a/justfile +++ b/justfile @@ -67,6 +67,9 @@ generate-ui: generate-resources: ./scripts/qt_resources/build_resources.sh +test-real-app: + uv run pytest -m real_app + translations-build-main: ./scripts/translations/build_ts.sh diff --git a/pyproject.toml b/pyproject.toml index e5b30e3d..0ed2cb03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,13 @@ dev = [ [tool.uv.build-backend] module-root = "" +[tool.pytest.ini_options] +markers = [ + "real_app: drives the real app with VLC and ffmpeg, see tests/real_app; run with -m real_app", +] +# a later -m on the command line takes over from this one +addopts = "-m 'not real_app'" + [tool.ruff] extend-exclude = ["gridplayer/vlc_player/vlc.py"] force-exclude = true diff --git a/tests/real_app/__init__.py b/tests/real_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/real_app/driver.py b/tests/real_app/driver.py new file mode 100644 index 00000000..9810bec3 --- /dev/null +++ b/tests/real_app/driver.py @@ -0,0 +1,436 @@ +"""The real player, driven the way a viewer drives it: through its menus. + +A scenario is a function that takes a Driver; tests/real_app/run.py starts +the app in a process of its own and hands it one. Everything here runs in +that process. See AGENTS.md, "Driving the real app". +""" + +import json +import logging +import os +import sys +import threading +import time +import traceback +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from tests.real_app.media import Media + +_log = logging.getLogger("real_app") + +# The app's own defaults that a driven run cannot live with +DEFAULT_SETTINGS = { + # nobody is there to answer "save the playlist?" + "playlist/unsaved_changes": "discard", + # Hardware video draws into an X11 window, which offscreen has none of + "player/video_driver": "vlc_sw_sp", + "player/one_instance": False, +} + + +class Driver: + def __init__(self, app, player, media: Media, out: Path): + self.app = app + self.player = player + self.ctx = player._context + self.media = media + self.out = out + self.steps: list[dict] = [] + self.failures: list[str] = [] + self.errors: list[str] = [] + self._servers: list[ThreadingHTTPServer] = [] + + # -- time + + def pump(self, seconds: float = 0.05) -> None: + """Let the app run for a while: its timers, VLC's callbacks, paints.""" + + from PyQt5.QtCore import QEventLoop + + deadline = time.monotonic() + seconds + while True: + self.app.processEvents(QEventLoop.AllEvents, 20) + left = deadline - time.monotonic() + if left <= 0: + return + time.sleep(min(left, 0.01)) + + wait = pump + + def wait_until(self, predicate, what: str, timeout: float = 20) -> bool: + """Run the app until predicate() is true; a failure if it never is. + + An exception from the predicate counts as not yet: the cell it asks + about may not be there, or not have loaded. + """ + + deadline = time.monotonic() + timeout + started = time.monotonic() + + while time.monotonic() < deadline: + try: + if predicate(): + self._step(f"waited {time.monotonic() - started:.1f}s: {what}") + return True + except Exception: + pass + self.pump(0.05) + + self.fail(f"gave up after {timeout}s waiting until {what}") + return False + + # -- cells + + def open(self, *uris) -> None: + """Open each as a cell of its own, as from the command line.""" + + self._step(f"open {', '.join(map(str, uris))}") + self.player.process_arguments([str(uri) for uri in uris]) + + @property + def cells(self) -> list: + return list(self.ctx.video_blocks) + + def cell(self, name: str): + """The first cell playing something with this in its link or path, + or reading a list with it in its path.""" + + for block in self.cells: + if name in str(block.video_params.uri) or name in str(block.media_list): + return block + + raise LookupError(f"no cell has {name!r}") + + def wait_for_cell(self, name: str, timeout: float = 30): + """The cell once it is playing.""" + + self.wait_until(lambda: self.cell(name).is_playable, f"{name} plays", timeout) + return self.cell(name) + + # -- doing things + + def act(self, block, action: str) -> None: + """Choose a menu item or press its key: an action of params/actions.py, + by its name there, for this cell.""" + + self.ctx.active_block = block + item = self.ctx.actions[action] + self._step(f"action {action!r}", enabled=item.is_enabled) + item.trigger() + self.pump() + + def run(self, block, *command): + """Run a command as the menus name it: ("active", "loop.reset").""" + + self.ctx.active_block = block + self._step(f"command {command!r}") + result = self.ctx.commands.resolve(command)() + self.pump() + return result + + def menu(self, block, generator: str) -> list[str]: + """The titles of a menu made as it is opened, as the viewer sees it: + "menu_generator_audio_track" and the like in ActiveBlockManager.""" + + titles = [title for title, _ in self._menu(block, generator)] + self._step(f"menu {generator}", items=titles) + return titles + + def choose(self, block, generator: str, title: str, timeout: float = 10) -> bool: + """Click the item of such a menu with this title, or else the first + with it in its title. + + Waits for it to be there: tracks, sizes and languages are listed + only once the video has loaded, and a choice of some of them + reloads it. + """ + + deadline = time.monotonic() + timeout + + while True: + entries = [(t, item) for t, item in self._menu(block, generator) if item] + chosen = next((item for t, item in entries if t == title), None) or next( + (item for t, item in entries if title in t), None + ) + if chosen is not None or time.monotonic() > deadline: + break + self.pump(0.1) + + if chosen is None: + self.fail(f"no {title!r} in {generator}: {[t for t, _ in entries]}") + return False + + self.ctx.active_block = block + self._step(f"choose {title!r} in {generator}") + self.ctx.commands.resolve(chosen["func"])() + self.pump() + return True + + def _menu(self, block, generator): + self.ctx.active_block = block + + for template in self.ctx.commands.resolve(generator)(): + if not isinstance(template, dict): + yield str(template), None + continue + title = template.get("title") + if isinstance(title, tuple): + title = title[0] + yield str(title), template if template.get("func") else None + + def windows(self) -> list: + """The windows open besides the player.""" + + from PyQt5.QtWidgets import QApplication + + return [ + w + for w in QApplication.topLevelWidgets() + if w.isVisible() and w is not self.player and w.isWindow() + ] + + def through_modal(self, open_it, name: str, inspect=None) -> None: + """Open a window that holds everything until it is closed, such as an + error's Details: look at it (a screenshot, then inspect(window)) and + close it from inside, since open_it only returns once it is.""" + + from PyQt5.QtCore import QTimer + + seen = [] + + def look(): + windows = self.windows() + if not windows: + QTimer.singleShot(100, look) + return + window = windows[-1] + seen.append(type(window).__name__) + self.shot(name, window) + if inspect is not None: + try: + inspect(window) + except Exception: + self.error(*sys.exc_info()) + window.close() + + QTimer.singleShot(500, look) + open_it() + # one that does not hold everything has returned already + self.wait_until(lambda: seen, f"{name} is open", timeout=10) + self._step(f"window {name}", windows=seen) + + # -- looking + + def state(self, block) -> dict: + """What a viewer could tell of a cell, and a little more.""" + + return {key: _read(block, read) for key, read in STATE.items()} + + def note(self, label: str, block=None) -> dict: + """Write down a cell's state, or every cell's, as a step.""" + + if block is not None: + state = self.state(block) + self._step(label, state=state) + return state + + cells = [self.state(b) for b in self.cells] + self._step(label, cells=cells) + return cells + + def shot(self, name: str, widget=None) -> Path: + path = self.out / f"{name}.png" + (widget or self.player).grab().save(str(path)) + self._step(f"screenshot {path.name}") + return path + + # -- judging + + def check(self, what: str, ok, detail=None) -> bool: + """A soft assert: the run goes on, and fails at the end.""" + + self._step(f"check {what}", ok=bool(ok), detail=detail) + if not ok: + self.fail(f"{what}" + (f": {detail}" if detail is not None else "")) + return bool(ok) + + def fail(self, message: str) -> None: + self.failures.append(message) + self._step(f"FAILED {message}") + + def error(self, kind, value, tb) -> None: + text = "".join(traceback.format_exception(kind, value, tb)) + self.errors.append(text) + self._step(f"ERROR {kind.__name__}: {value}") + + # -- what a run needs from outside + + def serve(self, directory: Path) -> str: + """Serve a folder over HTTP on a port of its own; the base URL.""" + + handler = partial(_QuietHandler, directory=str(directory)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + self._servers.append(server) + + return f"http://127.0.0.1:{server.server_address[1]}" + + def fake_sponsorblock(self, block, segments, video_id="aaaaaaaaaaa") -> None: + """Have SponsorBlock answer for this cell, with these segments. + + Asking it takes a YouTube video and the internet; this makes the + cell's video one, with a made-up id, and answers the lookup here. + """ + + import gridplayer.utils.sponsorblock as sponsorblock + + sponsorblock.fetch_segments = lambda _id, _length: tuple(segments) + + block._youtube_id = video_id + block._site_length_ms = block.video_driver.length + self._step(f"fake SponsorBlock: {len(segments)} segment(s)") + block.sponsorblock.look_up() + self.pump() + + # -- the record + + def _step(self, label: str, **details) -> None: + entry = {"t": round(time.monotonic() - _STARTED, 2), "step": label, **details} + self.steps.append(entry) + print(json.dumps(entry, default=str), flush=True) + + def report(self) -> dict: + return { + "ok": not self.failures and not self.errors, + "failures": self.failures, + "errors": self.errors, + "steps": self.steps, + } + + def close(self) -> None: + for server in self._servers: + server.shutdown() + + +class _QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + +def _read(block, read): + try: + value = read(block) + except Exception as e: + return f"<{type(e).__name__}: {e}>" + + if isinstance(value, (bool, int, float, str, type(None))): + return value + if isinstance(value, Path): + return value.name + if isinstance(value, (list, tuple)) and all(isinstance(v, Path) for v in value): + return [v.name for v in value] + return str(value) + + +def _title_of(error): + return None if error is None else error.title + + +STATE = { + "uri": lambda b: str(b.video_params.uri), + "title": lambda b: b.title, + "playable": lambda b: b.is_playable, + "paused": lambda b: b.video_params.is_paused, + "time": lambda b: b.time, + "length": lambda b: b.video_driver.length, + "error": lambda b: b.is_error, + "last_error": lambda b: _title_of(b.status.last_error), + "stage": lambda b: b.status.load_stage, + "retries": lambda b: b.recovery.retries, + "loop": lambda b: (b.loop_start, b.loop_end), + "list_place": lambda b: b.navigation.list_place_txt, + "muted": lambda b: b.video_params.is_muted, + "volume": lambda b: b.video_params.volume, + "scale": lambda b: b.video_params.scale, + "chapter": lambda b: b.chapters.playing, + "audio_playing": lambda b: b.audio.track_playing, + "audio_delay_ms": lambda b: b.video_params.audio_delay_ms, + "external_audio": lambda b: list(b.video_params.external_audio), + "subtitle_showing": lambda b: b.subtitles.track_showing, + "subtitle_delay_ms": lambda b: b.video_params.subtitle_delay_ms, + "streams": lambda b: len(b.streams), + "quality": lambda b: b.stream.quality_playing, + "languages": lambda b: b.stream.audio_language_options, + "language": lambda b: b.stream.audio_language_playing, +} + +_STARTED = time.monotonic() + + +def start(media: Media, out: Path, settings: dict) -> Driver: + """The app, as run.py starts it: offscreen, with settings and data of its + own under out, and these settings over the defaults above.""" + + home = out / "home" + os.environ["XDG_DATA_HOME"] = str(home / "data") + os.environ["XDG_CONFIG_HOME"] = str(home / "config") + os.environ["XDG_CACHE_HOME"] = str(home / "cache") + os.environ["XDG_RUNTIME_DIR"] = str(home / "runtime") + Path(os.environ["XDG_RUNTIME_DIR"]).mkdir(parents=True, mode=0o700) + # no desktop to talk to: no media keys, no dark mode portal waits + os.environ["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=/nonexistent/gridplayer-run" + + logging.basicConfig( + filename=out / "app.log", + level=logging.DEBUG, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + from gridplayer.main.init_app import init_app + from gridplayer.main.init_app_env import init_app_env + from gridplayer.params import env + from gridplayer.settings import Settings + from gridplayer.utils.libvlc import init_vlc + + # the app picks xcb itself whenever there is no Wayland, whatever + # QT_QPA_PLATFORM says + env.QT_PLATFORM = "offscreen" + + init_app_env() + app = init_app() + env.VLC_VERSION, env.VLC_PYTHON_VERSION = init_vlc() + + store = Settings() + for key, value in { + **DEFAULT_SETTINGS, + "screenshots/dir": str(out / "screenshots"), + **settings, + }.items(): + store.set(key, _setting_value(key, value)) + + from gridplayer.player import Player + + player = Player() + app.installEventFilter(player) + player.resize(1280, 720) + player.show() + + return Driver(app, player, media, out) + + +def _setting_value(key, value): + """A setting as the app keeps it: an enum's member from its value, so a + scenario can say "discard" rather than import UnsavedChangesMode.""" + + from enum import Enum + + from gridplayer.settings import _default_settings + + default = _default_settings.get(key) + + if isinstance(default, Enum) and not isinstance(value, Enum): + return type(default)(value) + + return value diff --git a/tests/real_app/media.py b/tests/real_app/media.py new file mode 100644 index 00000000..a556217f --- /dev/null +++ b/tests/real_app/media.py @@ -0,0 +1,242 @@ +"""Media to drive the real app with, made by ffmpeg rather than kept in git. + +Made once into build/test-media// and taken from there +until this file changes. Run it to make them and see where they are: + + uv run python -m tests.real_app.media + +What there is (every video a test pattern with a tone, small and short): + + folder/a.mp4 b.mp4 c.mp4 20s each, a folder to step through + list.m3u the three, as a list of "Clip A" to "Clip C" + feature/feature.mkv 60s: chapters Intro, Part One, Part Two and + Credits at 0, 15, 30 and 45s; English and + Spanish audio; English and French subtitles + ("English line 1" every 5s) + feature/feature.de.srt German subtitles to discover beside it + feature/feature.commentary.m4a + audio to discover beside it + hls/master.m3u8 30s HLS in 360p, 180p and 90p + dubs/master.m3u8 30s HLS in 360p and 180p, dubbed into English + (the default) and Spanish as audio renditions + +The HLS ones are for serving over HTTP; see Driver.serve. +""" + +import hashlib +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +ROOT = REPO / "build" / "test-media" + +CHAPTERS = ( + (0, 15_000, "Intro"), + (15_000, 30_000, "Part One"), + (30_000, 45_000, "Part Two"), + (45_000, 60_000, "Credits"), +) + + +@dataclass(frozen=True) +class Media: + root: Path + + @property + def folder(self) -> Path: + return self.root / "folder" + + @property + def clips(self) -> tuple[Path, Path, Path]: + return tuple(self.folder / f"{n}.mp4" for n in "abc") + + @property + def list_m3u(self) -> Path: + return self.root / "list.m3u" + + @property + def feature(self) -> Path: + return self.root / "feature" / "feature.mkv" + + @property + def hls(self) -> Path: + return self.root / "hls" + + @property + def dubs(self) -> Path: + return self.root / "dubs" + + +def is_ffmpeg_installed() -> bool: + return shutil.which("ffmpeg") is not None + + +def ensure() -> Media: + """The media, made first if this version of the recipe has not been.""" + + digest = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12] + media = Media(ROOT / digest) + + if media.root.is_dir(): + return media + + # made aside and moved in whole, so a run cut short leaves nothing that + # looks finished + building = ROOT / f".building-{digest}-{os.getpid()}" + shutil.rmtree(building, ignore_errors=True) + _make(Media(building)) + + try: + building.rename(media.root) + except OSError: + # another run made it first + shutil.rmtree(building, ignore_errors=True) + + return media + + +def _ffmpeg(*args, cwd: Path) -> None: + subprocess.run( + ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", *map(str, args)], + cwd=cwd, + check=True, + ) + + +def _tone(frequency: int, seconds: int) -> list[str]: + return ["-f", "lavfi", "-i", f"sine=frequency={frequency}:duration={seconds}"] + + +def _pattern(seconds: int, size="320x180", source="testsrc") -> list[str]: + return ["-f", "lavfi", "-i", f"{source}=size={size}:rate=25:duration={seconds}"] + + +def _srt(text: str, seconds: int) -> str: + cues = [] + for i in range(seconds // 5): + start = i * 5 + cues.append( + f"{i + 1}\n00:00:{start:02d},000 --> 00:00:{start + 4:02d},500\n" + f"{text} line {i + 1}\n" + ) + return "\n".join(cues) + + +def _make(media: Media) -> None: + media.folder.mkdir(parents=True) + + for name, frequency in zip("abc", (440, 550, 660)): + _ffmpeg( + *_pattern(20), + *_tone(frequency, 20), + "-metadata", + f"title=Clip {name.upper()}", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-c:a", + "aac", + "-shortest", + f"{name}.mp4", + cwd=media.folder, + ) + + media.list_m3u.write_text( + "#EXTM3U\n" + + "".join(f"#EXTINF:20,Clip {n.upper()}\nfolder/{n}.mp4\n" for n in "abc"), + encoding="utf-8", + ) + + _make_feature(media.feature.parent) + _make_hls(media.hls) + _make_dubs(media.dubs) + + +def _make_feature(where: Path) -> None: + where.mkdir() + + (where / "en.srt").write_text(_srt("English", 60), encoding="utf-8") + (where / "fr.srt").write_text(_srt("Francais", 60), encoding="utf-8") + (where / "feature.de.srt").write_text(_srt("Deutsch", 60), encoding="utf-8") + + chapters = [";FFMETADATA1", "title=Feature"] + for start, end, title in CHAPTERS: + chapters += ["[CHAPTER]", "TIMEBASE=1/1000", f"START={start}", f"END={end}"] + chapters.append(f"title={title}") + (where / "chapters.txt").write_text("\n".join(chapters) + "\n", encoding="utf-8") + + _ffmpeg( + *_pattern(60), + *_tone(440, 60), + *_tone(660, 60), + "-i", + "en.srt", + "-i", + "fr.srt", + "-i", + "chapters.txt", + *("-map", "0:v", "-map", "1:a", "-map", "2:a", "-map", "3", "-map", "4"), + *("-map_metadata", "5", "-map_chapters", "5"), + *("-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-c:s", "srt"), + *("-metadata:s:a:0", "language=eng", "-metadata:s:a:0", "title=English"), + *("-metadata:s:a:1", "language=spa", "-metadata:s:a:1", "title=Spanish"), + *("-metadata:s:s:0", "language=eng", "-metadata:s:s:1", "language=fre"), + "feature.mkv", + cwd=where, + ) + _ffmpeg(*_tone(880, 60), "-c:a", "aac", "feature.commentary.m4a", cwd=where) + + for scratch in ("en.srt", "fr.srt", "chapters.txt"): + (where / scratch).unlink() + + +def _make_hls(where: Path) -> None: + where.mkdir() + + _ffmpeg( + *_pattern(30, size="640x360", source="testsrc2"), + *_tone(330, 30), + "-filter_complex", + "[0:v]split=3[a][b][c];[a]scale=640:360[o0];[b]scale=320:180[o1];" + "[c]scale=160:90[o2]", + *("-map", "[o0]", "-map", "[o1]", "-map", "[o2]"), + *("-map", "1:a", "-map", "1:a", "-map", "1:a"), + *("-c:v", "libx264", "-preset", "ultrafast", "-g", "25", "-c:a", "aac"), + *("-b:v:0", "800k", "-b:v:1", "300k", "-b:v:2", "100k"), + *("-f", "hls", "-hls_time", "2", "-hls_playlist_type", "vod"), + *("-master_pl_name", "master.m3u8"), + *("-var_stream_map", "v:0,a:0 v:1,a:1 v:2,a:2"), + "v%v/index.m3u8", + cwd=where, + ) + + +def _make_dubs(where: Path) -> None: + where.mkdir() + + _ffmpeg( + *_pattern(30, size="640x360", source="testsrc2"), + *_tone(330, 30), + *_tone(550, 30), + "-filter_complex", + "[0:v]split=2[a][b];[a]scale=640:360[o0];[b]scale=320:180[o1]", + *("-map", "[o0]", "-map", "[o1]", "-map", "1:a", "-map", "2:a"), + *("-c:v", "libx264", "-preset", "ultrafast", "-g", "25", "-c:a", "aac"), + *("-b:v:0", "800k", "-b:v:1", "300k"), + *("-f", "hls", "-hls_time", "2", "-hls_playlist_type", "vod"), + *("-master_pl_name", "master.m3u8"), + "-var_stream_map", + "v:0,agroup:aud v:1,agroup:aud" + " a:0,agroup:aud,language:en,default:yes,name:english" + " a:1,agroup:aud,language:es,name:spanish", + "v%v/index.m3u8", + cwd=where, + ) + + +if __name__ == "__main__": + print(ensure().root) diff --git a/tests/real_app/run.py b/tests/real_app/run.py new file mode 100644 index 00000000..740d76af --- /dev/null +++ b/tests/real_app/run.py @@ -0,0 +1,97 @@ +"""Run a scenario against the real app. + + uv run python -m tests.real_app.run SCENARIO [--out DIR] + +SCENARIO is one of tests/real_app/scenarios by name ("playback"), or the +path of a .py file anywhere, such as one written for a single debug run. +It defines + + SETTINGS = {"video_defaults/network_retry_times": 2} (optional) + + def scenario(d): # d is a tests.real_app.driver.Driver + d.open(d.media.feature) + block = d.wait_for_cell("feature.mkv") + d.act(block, "Next Chapter") + d.wait_until(lambda: block.chapters.playing == 1, "in Part One") + +and gets the app offscreen, with settings and data of its own, and the +media of tests/real_app/media.py made if they are not yet. Every step is +printed as a line of JSON as it happens. + +What it leaves in DIR (a new temporary folder by default): report.json, +app.log, the screenshots the scenario took and, in screenshots/, those the +app saved. Exits 0 when every check passed and nothing raised, 1 when not, +2 when the run could not start. +""" + +import argparse +import importlib +import importlib.util +import json +import os +import sys +import tempfile +from pathlib import Path + +from tests.real_app import media + + +def _load(scenario: str): + if scenario.endswith(".py"): + spec = importlib.util.spec_from_file_location("scenario", scenario) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + return importlib.import_module(f"tests.real_app.scenarios.{scenario}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("scenario") + parser.add_argument("--out", type=Path) + args = parser.parse_args() + + out = args.out or Path(tempfile.mkdtemp(prefix="gridplayer-run-")) + out.mkdir(parents=True, exist_ok=True) + + if not media.is_ffmpeg_installed(): + print("ffmpeg is needed to make the media", file=sys.stderr) + return 2 + + module = _load(args.scenario) + + from tests.real_app.driver import start + + driver = start(media.ensure(), out, getattr(module, "SETTINGS", {})) + + # An exception in a Qt slot goes here; with Python's own hook PyQt + # aborts the process instead + sys.excepthook = driver.error + + try: + module.scenario(driver) + except Exception: + driver.error(*sys.exc_info()) + + report = driver.report() + (out / "report.json").write_text(json.dumps(report, indent=1, default=str)) + + print(f"\n{'PASSED' if report['ok'] else 'FAILED'}: {out}") + for line in report["failures"]: + print(f" failed: {line}") + for text in report["errors"]: + print(f" error: {text}") + + driver.close() + + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + code = main() + sys.stdout.flush() + sys.stderr.flush() + # The player tears down VLC and its threads as it quits, which a run + # has no use for and can hang on; it ends itself this way too + os._exit(code) diff --git a/tests/real_app/scenarios/__init__.py b/tests/real_app/scenarios/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/real_app/scenarios/dubs.py b/tests/real_app/scenarios/dubs.py new file mode 100644 index 00000000..4e6905de --- /dev/null +++ b/tests/real_app/scenarios/dubs.py @@ -0,0 +1,43 @@ +"""A stream dubbed into two languages: switching between them, and keeping +the one chosen through a change of size.""" + +SETTINGS = { + # Streamlink takes only the default audio of an HLS stream; yt-dlp + # reads each rendition and its language + "streaming/resolver_priority": "yt_dlp", +} + +NAMES = {"en": "English", "es": "Spanish"} + + +def scenario(d): + d.open(f"{d.serve(d.media.dubs)}/master.m3u8") + dubs = d.wait_for_cell("master.m3u8") + + d.check( + "both languages", + set(dubs.stream.audio_language_options) == {"en", "es"}, + dubs.stream.audio_language_options, + ) + playing = dubs.stream.audio_language_playing + other = "en" if playing == "es" else "es" + d.menu(dubs, "menu_generator_audio_track") + + d.choose(dubs, "menu_generator_audio_track", NAMES[other]) + d.wait_until( + lambda: dubs.stream.audio_language_playing == other and dubs.is_playable, + f"{NAMES[other]} plays", + ) + + d.choose(dubs, "menu_generator_stream_quality", "180p") + d.wait_until( + lambda: dubs.stream.quality_playing.startswith("180p") and dubs.is_playable, + "180p plays", + ) + d.check( + "in the language chosen", + dubs.stream.audio_language_playing == other, + dubs.stream.audio_language_playing, + ) + + d.note("end", dubs) diff --git a/tests/real_app/scenarios/external_audio_jump.py b/tests/real_app/scenarios/external_audio_jump.py new file mode 100644 index 00000000..7fee6651 --- /dev/null +++ b/tests/real_app/scenarios/external_audio_jump.py @@ -0,0 +1,26 @@ +"""A known bug: with an audio file playing beside the video, choosing a +subtitle track moves the video back to about where the audio file was +added. GridPlayer only asks VLC for the track; VLC does the moving, and +only while it has the audio file as a slave. + +Expected to fail (see test_scenarios.py) until it is fixed. +""" + + +def scenario(d): + d.open(d.media.feature) + b = d.wait_for_cell("feature.mkv") + + d.wait(1) + d.choose(b, "menu_generator_audio_track", "Detect Audio Files") + d.wait_until(lambda: b.audio.discovered_files, "the commentary is found") + d.choose(b, "menu_generator_audio_track", "feature.commentary.m4a") + d.wait_until(lambda: b.video_params.external_audio, "the commentary plays") + + d.wait(4) + before = b.time + d.choose(b, "menu_generator_subtitle_track", "French") + d.wait(1.5) + + # VLC's clock steps back a little on any track change; this is seconds + d.check("it plays on from where it was", b.time > before - 500, (before, b.time)) diff --git a/tests/real_app/scenarios/marks.py b/tests/real_app/scenarios/marks.py new file mode 100644 index 00000000..83fb7cda --- /dev/null +++ b/tests/real_app/scenarios/marks.py @@ -0,0 +1,97 @@ +"""What marks the seek bar: chapters, bookmarks and SponsorBlock segments.""" + +from gridplayer.models.sponsor_segment import SponsorSegment + +SETTINGS = { + "sponsorblock/enabled": True, + "sponsorblock/sponsor": "skip", + "sponsorblock/intro": "show", +} + +SEGMENTS = ( + SponsorSegment(start_ms=2_000, end_ms=5_000, category="intro"), + SponsorSegment(start_ms=40_000, end_ms=44_000, category="sponsor"), +) + + +def scenario(d): + d.open(d.media.feature) + b = d.wait_for_cell("feature.mkv") + + chapters(d, b) + bookmarks(d, b) + sponsorblock(d, b) + + d.note("end", b) + + +def chapters(d, b): + d.check( + "the chapters, named and timed", + d.menu(b, "menu_generator_chapters") + == ["Intro\t00:00", "Part One\t00:15", "Part Two\t00:30", "Credits\t00:45"], + ) + + d.act(b, "Next Chapter") + # the chapter goes by where the seek is arriving, the time once it has + d.wait_until( + lambda: b.chapters.playing == 1 and 15_000 <= b.time < 17_500, + "at the start of Part One", + ) + d.act(b, "Next Chapter") + d.wait_until(lambda: b.chapters.playing == 2, "in Part Two") + d.act(b, "Previous Chapter") + d.wait_until(lambda: b.chapters.playing == 1, "back in Part One") + + d.choose(b, "menu_generator_chapters", "Credits") + d.wait_until(lambda: b.chapters.playing == 3, "in the Credits") + + d.choose(b, "menu_generator_chapters", "Part Two") + d.wait_until(lambda: b.chapters.playing == 2, "in Part Two again") + d.act(b, "Loop Chapter") + d.check("the loop is the chapter", (b.loop_start, b.loop_end) == (30_000, 45_000)) + d.act(b, "Loop Reset") + + +def bookmarks(d, b): + for at, near in (("10%", 6_000), ("50%", 30_000)): + d.act(b, at) + d.wait_until(lambda near=near: abs(b.time - near) < 1_500, f"near {near}ms") + d.act(b, "Add Bookmark") + + d.check("two bookmarks", len(d.menu(b, "menu_generator_bookmarks")) == 2) + + d.act(b, "0%") + d.wait_until(lambda: b.time < 2_000, "at the start") + for action, near in ( + ("Next Bookmark", 6_000), + ("Next Bookmark", 30_000), + ("Previous Bookmark", 6_000), + ): + d.act(b, action) + d.wait_until( + lambda near=near: abs(b.time - near) < 2_500, f"{action} lands near {near}" + ) + + +def sponsorblock(d, b): + d.fake_sponsorblock(b, SEGMENTS) + d.check("both are marked", len(b.sponsorblock.shown_segments) == 2) + + # 2s before the sponsor, then watch it play past + d.run(b, "active", "manual_seek", "seek", 38_000) + d.wait_until(lambda: 37_500 <= b.time < 40_000, "2s before the sponsor") + seen = [] + passed = d.wait_until( + lambda: seen.append(b.time) or b.time >= 44_000, "past the sponsor", timeout=15 + ) + inside = [t for t in seen if 40_500 <= t <= 43_500] + d.check("the sponsor is skipped, not played", passed and not inside, seen[-20:]) + + # an intro is only marked, and plays through + d.act(b, "0%") + d.wait_until(lambda: b.time < 1_500, "back at the start") + seen.clear() + d.wait_until(lambda: seen.append(b.time) or b.time >= 5_500, "past the intro") + inside = [t for t in seen if 2_500 <= t <= 4_500] + d.check("the intro is played", inside, seen[-20:]) diff --git a/tests/real_app/scenarios/navigation.py b/tests/real_app/scenarios/navigation.py new file mode 100644 index 00000000..62b3bec6 --- /dev/null +++ b/tests/real_app/scenarios/navigation.py @@ -0,0 +1,41 @@ +"""Stepping along a folder and along a list, and the List window.""" + + +def scenario(d): + d.open(d.media.clips[0], d.media.list_m3u) + d.wait_until( + lambda: len(d.cells) == 2 and all(c.is_playable for c in d.cells), + "both play", + ) + # both start on a.mp4; the list's is the one reading a list + folder = next(c for c in d.cells if c.media_list is None) + listed = next(c for c in d.cells if c.media_list is not None) + + d.act(folder, "Play Next File") + d.wait_until(lambda: folder.video_params.uri.name == "b.mp4", "the folder's next") + d.wait_until(lambda: folder.is_playable, "b.mp4 plays") + d.act(folder, "Play Previous File") + d.wait_until(lambda: folder.video_params.uri.name == "a.mp4", "and back") + + d.check("the list starts at 1/3", listed.navigation.list_place_txt == "1/3") + d.check("titled from the list", listed.title == "Clip A", listed.title) + d.act(listed, "Play Next File") + d.wait_until(lambda: listed.navigation.list_place_txt == "2/3", "the list's next") + d.wait_until(lambda: listed.title == "Clip B", "titled from the list") + d.check("on b.mp4", listed.video_params.uri.name == "b.mp4") + + d.act(listed, "List") + d.wait_until( + lambda: [w for w in d.windows() if type(w).__name__ == "MediaListDialog"], + "the List window is open", + ) + window = d.windows()[-1] + d.shot("list_window", window) + window.close() + d.pump() + + d.run(folder, "all", "next_video") + d.wait_until(lambda: folder.video_params.uri.name == "b.mp4", "all stepped on") + d.wait_until(lambda: listed.navigation.list_place_txt == "3/3", "the list too") + + d.note("end") diff --git a/tests/real_app/scenarios/playback.py b/tests/real_app/scenarios/playback.py new file mode 100644 index 00000000..68a562ec --- /dev/null +++ b/tests/real_app/scenarios/playback.py @@ -0,0 +1,56 @@ +"""Playing a file: pause, seek, the loop, sound, zoom and a screenshot, and +the same commands sent to every cell at once.""" + + +def scenario(d): + clip, other = d.media.clips[0], d.media.clips[1] + d.open(clip, other) + a = d.wait_for_cell("a.mp4") + b = d.wait_for_cell("b.mp4") + + d.act(a, "Play / Pause") + d.wait_until(lambda: a.video_params.is_paused, "a is paused") + d.check("b plays on", not b.video_params.is_paused) + d.act(a, "Play / Pause") + d.wait_until(lambda: not a.video_params.is_paused, "a plays again") + + d.act(a, "50%") + d.wait_until(lambda: 9_500 <= a.time <= 12_500, "a is halfway") + + d.act(a, "Set Loop Start") + start = a.loop_start + d.check("the loop starts where it was set", 9_500 <= start <= 12_500, start) + d.wait(1) + d.act(a, "Set Loop End") + d.check("the loop ends after it starts", a.loop_end > start, a.loop_end) + d.note("a with a loop", a) + d.act(a, "Loop Reset") + d.check("the loop is reset", a.loop_start == 0, (a.loop_start, a.loop_end)) + + volume = a.video_params.volume + d.act(a, "Audio Volume - Decrease") + d.check("quieter", a.video_params.volume < volume, a.video_params.volume) + muted = a.video_params.is_muted + d.act(a, "Audio Volume - Mute / Unmute") + d.check("mute toggled", a.video_params.is_muted is not muted) + d.act(a, "Audio Volume - Mute / Unmute") + d.check("mute toggled back", a.video_params.is_muted is muted) + + d.act(a, "Zoom In") + d.check("zoomed in", a.video_params.scale > 1, a.video_params.scale) + d.shot("zoomed") + d.act(a, "Zoom Reset") + d.check("zoom reset", a.video_params.scale == 1, a.video_params.scale) + + d.act(a, "Take Screenshot") + saved = d.out / "screenshots" + d.wait_until(lambda: any(saved.glob("a_*")), "the screenshot is saved") + + d.run(a, "all", "scale_increase") + d.check("all zoomed", all(c.video_params.scale > 1 for c in d.cells)) + d.run(a, "all", "scale_reset") + d.run(a, "all", "set_muted", True) + d.check("all muted", all(c.video_params.is_muted for c in d.cells)) + + d.note("end") + d.shot("end") diff --git a/tests/real_app/scenarios/streams.py b/tests/real_app/scenarios/streams.py new file mode 100644 index 00000000..8adb6e44 --- /dev/null +++ b/tests/real_app/scenarios/streams.py @@ -0,0 +1,49 @@ +"""A stream in three sizes, switched between; and a link that fails, tried +again, shown with its Details and reloaded.""" + +from PyQt5.QtWidgets import QLabel + +SETTINGS = { + "video_defaults/network_retry_mode": "times", + "video_defaults/network_retry_times": 2, +} + +# nothing listens on the discard port +DEAD = "http://127.0.0.1:9/dead.mp4" + + +def scenario(d): + hls_url = f"{d.serve(d.media.hls)}/master.m3u8" + d.open(hls_url, DEAD) + + hls = d.wait_for_cell("master.m3u8") + sizes = d.menu(hls, "menu_generator_stream_quality") + d.check("three sizes", {"360p", "180p", "90p"} <= set(sizes), sizes) + d.check("the biggest plays", hls.stream.quality_playing == "360p") + + d.choose(hls, "menu_generator_stream_quality", "180p") + d.wait_until( + lambda: hls.stream.quality_playing == "180p" and hls.is_playable, + "180p plays", + ) + + dead = d.cell("dead.mp4") + d.wait_until(lambda: dead.is_error, "the dead link gives up", timeout=40) + d.check( + "it says why", + dead.status.last_error.title == "Connection refused", + dead.status.last_error.title, + ) + d.shot("dead_link") + + def tries_shown(window): + texts = [label.text() for label in window.findChildren(QLabel)] + d.check("Details count the tries", "2 of 2" in texts, texts) + + d.through_modal(dead.status.show_error_details, "details", tries_shown) + + d.act(dead, "Reload") + d.wait_until(lambda: not dead.is_error, "a reload tries again") + d.wait_until(lambda: dead.is_error, "and gives up again", timeout=40) + + d.note("end") diff --git a/tests/real_app/scenarios/tracks.py b/tests/real_app/scenarios/tracks.py new file mode 100644 index 00000000..59d7b153 --- /dev/null +++ b/tests/real_app/scenarios/tracks.py @@ -0,0 +1,75 @@ +"""Audio and subtitle tracks: the file's own, files found beside it, and +their delays.""" + + +def scenario(d): + d.open(d.media.feature) + b = d.wait_for_cell("feature.mkv") + + def audio_language(): + return b.audio.tracks[b.audio.track_playing].language + + def subtitle_language(): + return b.subtitles.tracks[b.subtitles.track_showing].language + + # audio + + d.menu(b, "menu_generator_audio_track") + d.choose(b, "menu_generator_audio_track", "Spanish") + d.wait_until(lambda: audio_language() == "spa", "Spanish plays") + + d.choose(b, "menu_generator_audio_track", "Detect Audio Files") + d.wait_until(lambda: b.audio.discovered_files, "the commentary is found") + # Attached within a moment of the switch above, under load VLC now and + # then never plays it; nobody clicks that fast + d.wait(1) + d.choose(b, "menu_generator_audio_track", "feature.commentary.m4a") + d.wait_until( + lambda: b.video_params.external_audio and b.audio.track_playing not in (1, 2), + "the commentary plays", + ) + + d.act(b, "Audio Delay Later") + d.check("audio later", b.video_params.audio_delay_ms > 0) + d.act(b, "Audio Delay Reset") + d.check("audio on time", b.video_params.audio_delay_ms == 0) + + d.choose(b, "menu_generator_audio_track", "English") + d.wait_until(lambda: audio_language() == "eng", "English plays") + # it moves the video on a track change; see external_audio_jump + at = b.time + d.choose(b, "menu_generator_audio_track", "Remove External Audio") + d.wait_until(lambda: not b.video_params.external_audio, "the commentary is gone") + # which takes a reload, back to where it was + d.wait_until(lambda: b.is_playable and b.time >= at, "it plays on after the reload") + + # subtitles + + d.wait(3) + before = b.time + d.choose(b, "menu_generator_subtitle_track", "French") + d.wait_until(lambda: subtitle_language() == "fre", "French shows") + # VLC's clock steps back a little on a track change + d.check("it plays on from where it was", b.time > before - 500, (before, b.time)) + + d.act(b, "Subtitle Delay Later") + d.check("subtitles later", b.video_params.subtitle_delay_ms > 0) + d.act(b, "Subtitle Delay Reset") + d.check("subtitles on time", b.video_params.subtitle_delay_ms == 0) + + d.choose(b, "menu_generator_subtitle_track", "Detect Subtitle Files") + d.wait_until(lambda: b.subtitles.discovered_files, "German is found") + d.choose(b, "menu_generator_subtitle_track", "German") + d.wait_until( + lambda: b.subtitles.track_showing not in (-1, 3, 4), "the German file shows" + ) + d.wait(1) + d.shot("german_subtitles") + + d.choose(b, "menu_generator_subtitle_track", "Off") + d.wait_until(lambda: b.subtitles.track_showing == -1, "no subtitles") + d.choose(b, "menu_generator_subtitle_track", "Remove External Subtitles") + # found beside it still, and offered again, but no longer attached + d.wait_until(lambda: not b.video_params.external_subtitles, "German is detached") + + d.note("end", b) diff --git a/tests/real_app/test_scenarios.py b/tests/real_app/test_scenarios.py new file mode 100644 index 00000000..0c7b2838 --- /dev/null +++ b/tests/real_app/test_scenarios.py @@ -0,0 +1,82 @@ +"""Each scenario of tests/real_app/scenarios against the real app, in a +process of its own (see run.py). + +Not part of a plain `pytest` run: they take VLC, ffmpeg and a minute or +two. Run them with + + uv run pytest -m real_app + +What a run left behind (report.json, app.log, screenshots) is under the +test's temporary folder, named in a failure. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.real_app import media + +SCENARIOS_DIR = Path(__file__).parent / "scenarios" +SCENARIOS = sorted( + path.stem for path in SCENARIOS_DIR.glob("*.py") if not path.stem.startswith("_") +) + +# bugs a scenario shows, until they are fixed: a fix makes the run pass, +# which fails the test, so that it is taken off this list +KNOWN_BUGS = { + "external_audio_jump": "VLC moves the video back on a track change while " + "an audio file plays beside it", +} + +RUN_TIMEOUT_SEC = 300 + +pytestmark = [ + pytest.mark.real_app, + pytest.mark.skipif(not media.is_ffmpeg_installed(), reason="needs ffmpeg"), +] + + +@pytest.fixture(scope="module", autouse=True) +def _media(): + # made once up front, so that no one scenario's run pays for it + return media.ensure() + + +@pytest.mark.parametrize( + "scenario", + [ + pytest.param( + name, + marks=pytest.mark.xfail(reason=KNOWN_BUGS[name], strict=True) + if name in KNOWN_BUGS + else (), + ) + for name in SCENARIOS + ], +) +def test_scenario(scenario, tmp_path): + out = tmp_path / scenario + + run = subprocess.run( + [sys.executable, "-m", "tests.real_app.run", scenario, "--out", str(out)], + cwd=media.REPO, + capture_output=True, + text=True, + timeout=RUN_TIMEOUT_SEC, + check=False, + ) + + report_file = out / "report.json" + if not report_file.exists(): + pytest.fail( + f"the run ended without a report:\n{run.stdout[-3000:]}{run.stderr[-3000:]}" + ) + + report = json.loads(report_file.read_text()) + + assert report["ok"], "\n".join( + [f"see {out}", *report["failures"], *report["errors"]] + )