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 e24fd572..2c6f85c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,18 +29,88 @@ 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 + +`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 diff --git a/gridplayer/dialogs/audio_delay.py b/gridplayer/dialogs/audio_delay.py index 81d47b37..f928d12b 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. @@ -93,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() @@ -109,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/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/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/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/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/dialogs/subtitle_delay.py b/gridplayer/dialogs/subtitle_delay.py index 7d17c583..81cc5bb1 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. @@ -93,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() @@ -109,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 e54646d5..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")), @@ -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": { @@ -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,13 +332,13 @@ "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": { "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,27 +346,27 @@ "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": { "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": { @@ -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", @@ -480,7 +484,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 +496,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 +509,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", ( @@ -577,14 +581,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 +601,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 +621,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 +724,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 +738,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 +746,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 +754,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 +762,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 +770,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 +778,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 +786,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 +799,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", @@ -1087,7 +1101,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)": { @@ -1105,7 +1119,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": { @@ -1136,28 +1150,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": { @@ -1170,8 +1184,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": { @@ -1180,27 +1194,27 @@ # 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": { "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", @@ -1212,7 +1226,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", @@ -1224,7 +1238,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", @@ -1236,7 +1250,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", @@ -1248,7 +1262,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", @@ -1260,7 +1274,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", @@ -1272,7 +1286,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", @@ -1284,7 +1298,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", @@ -1296,13 +1310,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": ( @@ -1312,7 +1326,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"), @@ -1367,14 +1381,14 @@ "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": { "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", @@ -1386,7 +1400,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", @@ -1398,7 +1412,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", @@ -1411,8 +1425,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 709ca7cf..ddeecc8e 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 @@ -43,43 +44,43 @@ # the reload that the next choice would otherwise land in. LOADING_COMMANDS = frozenset( { - "switch_stream_quality", + "stream.switch_quality", "reload", "stop_playback", "close", - "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", - "set_audio_track", - "set_audio_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", - "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", + "navigation.previous", + "navigation.next", + "navigation.shuffle", + "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", + "stream.set_audio_language", + "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", + "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", } ) @@ -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 @@ -266,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(): @@ -303,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 @@ -327,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: @@ -348,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) @@ -369,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. @@ -387,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 @@ -418,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.""" @@ -432,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.""" @@ -446,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): @@ -468,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.""" @@ -476,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 @@ -496,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), @@ -511,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 = [ @@ -581,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})" @@ -589,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", @@ -642,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 {} @@ -655,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", @@ -677,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 {} @@ -688,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", @@ -712,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 [ @@ -721,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", @@ -748,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", } ] @@ -768,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", } ) @@ -779,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": ( @@ -805,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 ] @@ -826,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", @@ -838,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", } ] @@ -855,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 @@ -880,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 @@ -898,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", @@ -917,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 ] @@ -949,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 {} @@ -975,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", @@ -1002,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", @@ -1019,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 @@ -1038,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 ] @@ -1070,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", } ] @@ -1092,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", } ) @@ -1103,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": ( @@ -1130,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() @@ -1143,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 @@ -1156,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", @@ -1187,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", @@ -1284,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", @@ -1301,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", @@ -1319,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", } @@ -1331,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", @@ -1344,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", } @@ -1364,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", } @@ -1486,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", @@ -1509,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/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/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 142c6b09..18a48805 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( @@ -432,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): @@ -554,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 @@ -565,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 @@ -618,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. @@ -628,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) @@ -649,17 +650,17 @@ 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) 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 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. @@ -859,52 +860,52 @@ 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_bookmark, vb.next_bookmark), - (self.all_previous_bookmark, vb.previous_bookmark), - (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_reset_loop, vb.reset_loop), + (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.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.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_list_fail_action, vb.set_list_fail_action), - (self.all_set_list_fail_wait, vb.set_list_fail_wait), + (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), (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), - (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_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_volume_increase, vb.volume_increase), - (self.all_volume_decrease, vb.volume_decrease), - (self.all_set_muted, vb.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_take_screenshot, vb.screenshot.take), + (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), + (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), + (self.all_subtitle_delay_reset, vb.subtitles.delay_reset), + (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.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/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 6548879c..9224b32f 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 ViewControl.synced.""" + + 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/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/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..0aa708b7 100644 --- a/gridplayer/widgets/video_block.py +++ b/gridplayer/widgets/video_block.py @@ -1,221 +1,107 @@ -import contextlib -import dataclasses import logging import random import secrets -import time -from datetime import datetime, timezone from functools import partial +from operator import attrgetter 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.bookmark_rename_dialog import QBookmarkRenameDialog -from gridplayer.dialogs.bookmarks import BookmarksDialog, held_end_action_txt -from gridplayer.dialogs.error_details import ( - DetailRows, - ErrorDetailsDialog, - code_rows, -) +from PyQt5.QtCore import QSize, Qt, QTimer, pyqtSignal +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QStackedLayout, QWidget + from gridplayer.dialogs.input_dialog import ( - QCustomSpinboxInput, QCustomSpinboxTimeInput, - QCustomTextInput, ) from gridplayer.dialogs.rename_dialog import QVideoRenameDialog -from gridplayer.dialogs.subtitle_delay import SetSubtitleDelayDialog -from gridplayer.dialogs.subtitle_encoding import SetSubtitleEncodingDialog 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, bookmark_color -from gridplayer.models.chapter import Chapter +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.sponsor_segment import SponsorSegment from gridplayer.models.stream import ( - STANDING_QUALITIES, - STREAM_QUALITY_AUTO, - HashableDict, - StreamOrigin, 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.sponsorblock import ( - CATEGORY_COLORS, - HIGHLIGHT_CATEGORY, - category_name, -) 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, - NetworkRetryMode, - SponsorBlockMode, - VideoAnchor, VideoAspect, - VideoCrop, VideoDeinterlace, VideoDeinterlaceMode, VideoEndAction, VideoInitialState, - VideoShift, VideoTransform, - ViewParams, ) from gridplayer.settings import Settings -from gridplayer.utils.aspect_calc import SharedView -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.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, - 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 -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.libvlc_options_parser import ( + get_vlc_options, + is_deinterlace_reload_needed, + is_reopen_needed, ) +from gridplayer.utils.qt import MILLISECONDS, qt_connect, 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, + segment_mark, ) -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.step_sizes import seek_step_ms +from gridplayer.utils.url_resolve.static import ResolvedVideo from gridplayer.utils.url_resolve.url_resolve import VideoURLResolver -from gridplayer.utils.video_errors import ( - ProxyFailure, - ShownError, - file_error, - player_error, - proxy_error, -) 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, - 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_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 ( + only_initialized, + 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 +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 from gridplayer.widgets.video_frame_vlc_base import VideoFrameVLC from gridplayer.widgets.video_overlay import ( OverlayBlock, OverlayBlockChild, OverlayBlockFloating, OverlayFakeInvisible, - OverlayMode, is_layered_child_supported, ) from gridplayer.widgets.video_status import ( LoadStage, - StepState, VideoStatus, - step_label, ) IN_PROGRESS_THRESHOLD_MS = 500 @@ -232,24 +118,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 - -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" - # the end actions that take the video out of its cell: closed, or another # file put in its place LEAVING_END_ACTIONS = frozenset( @@ -262,229 +130,6 @@ ) -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)) - - audio = translate("Dialog - Add external audio", "Audio", "File formats") - every = translate("Dialog - Add external audio", "All", "File formats") - - 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") - - 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() - - -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) @@ -542,16 +187,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 @@ -560,51 +195,24 @@ 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 - # 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, ...] = () - # 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) @@ -616,56 +224,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) - 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 - 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 - 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 - - # 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 - - # 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 - - # 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) - self._seek_settle_timer = QTimer(self) self._seek_settle_timer.setSingleShot(True) self._seek_settle_timer.setInterval(SEEK_SETTLE_MS) @@ -680,76 +238,29 @@ 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 - - # 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 - sponsorblock_fetcher().fetched.connect(self._sponsor_segments_fetched) - - # 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, - ) - - # 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 + # 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) + self.sponsorblock = SponsorBlockControl(self) + self.subtitles = SubtitleControl(self) + self.audio = AudioControl(self) + self.loop = LoopControl(self) + self.screenshot = ScreenshotControl(self) + self.recovery = RecoveryControl(self) + self.stream = StreamControl(self) + self.navigation = NavigationControl(self) - # 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) + 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._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._ctx.lists.waiting_progress.connect(self.status.on_waiting_progress) self.overlay = self.init_overlay() @@ -773,18 +284,18 @@ 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.tracks_changed, self.tracks_changed), - (video_driver.chapters_changed, self._update_chapters), + (video_driver.view_followed, self.view.remember_followed), + (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), (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. - (video_driver.screenshot_taken, self._screenshot_taken), + (video_driver.screenshot_taken, self.screenshot.taken), (self.load_video, video_driver.load_video), ) @@ -828,9 +339,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 @@ -840,13 +349,13 @@ 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: 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() @@ -876,10 +385,10 @@ 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.resolving.connect(self._on_resolving) - url_resolver.reading.connect(self._on_link_list_reading) - url_resolver.failed.connect(self._on_resolve_failed) + 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) return url_resolver @@ -903,13 +412,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() @@ -929,17 +438,17 @@ 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.bookmark_clicked, self.jump_to_bookmark_at), - (overlay.bookmark_menu_requested, self.show_bookmark_menu), - (overlay.list_place_clicked, self.show_list_window), + (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.navigation.show_list_window), (overlay.stop_clicked, self.stop_playback), (overlay.retry_clicked, self.reload), - (overlay.next_clicked, self.skip_failed_item_now), - (overlay.details_clicked, self.show_error_details), + (overlay.next_clicked, self.recovery.skip_now), + (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), @@ -950,7 +459,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), @@ -997,18 +506,15 @@ 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.stream.stop_adapting() + self.recovery.stop() 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() + self.navigation.close() self._log.debug(f"{self.id}: cleaning up driver ") self._destroy_video_driver() @@ -1020,620 +526,181 @@ 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._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 - self._error_tries = 0 - self.set_status("error") + self.status.show("error") self.cleanup() - self._schedule_skip_failed_item() - self._show_error_card() + self.recovery.schedule_skip() + self.status.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 + 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._schedule_skip_failed_item() - 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.""" + self.recovery.schedule_skip() + self.status.show_error_card(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 + def reload(self): + self.recovery.stop_retry_countdown() + self.stream.stop_adapting() - self.video_status.show_error(error, self._skip_note()) - self._sync_overlay_mode() + self.is_live = False + self._is_error = False + self.streams = Streams() + self._title = None + self._default_title = None - def _skip_note(self) -> str: - if not self._skip_failed_timer.isActive(): - return self._given_up_txt() + self.reset() - if self._skip_failed_timer.interval() == 0: - return "" + video_params = self.video_params + self.video_params = None - seconds = max(1, -(-self._skip_failed_timer.remainingTime() // 1000)) + self.set_video(video_params) - if self._is_list_going_back: - return translate("Video Status", "Previous file in {SECONDS} s").format( - SECONDS=seconds - ) + def show_reloading(self): + """Say it loads again, over the video, as a cell opening it afresh + does at first, its player about to be let go for all of them to be + opened again (VideoBlocksManager.reload_videos).""" - return translate("Video Status", "Next file in {SECONDS} s").format( - SECONDS=seconds - ) + if self.video_driver is None: + return - def _refresh_skip_note(self): - if not self._skip_failed_timer.isActive(): - self._skip_note_timer.stop() + self._is_error = False + self.status.show_load_start() + self.status.show("processing") - if self._is_error and self._last_error is not None: - self.video_status.show_error(self._last_error, self._skip_note()) + def release_player(self): + """Let its player go, waiting until it has.""" - def show_error_details(self): - """Everything known of why it failed, in a window of its own.""" + self._destroy_video_driver() - error = self._last_error + def close_silently(self): + self.close(notify=False) - if error is None: + def close(self, notify=True): + if self._is_closing: 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._skip_failed_timer.isActive() - self._skip_failed_timer.stop() - self._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._start_skip_count() - self._refresh_skip_note() + self._is_closing = True - def _error_rows(self, error: ShownError) -> DetailRows: - """What its Details window says of the video besides.""" + self.bookmarker.close_manager() - uri = self.video_params.uri + self.closing.emit() - # a link named by itself is said once, as the link - rows = [] + # what the cell looked like, for the bookmarks it leaves behind, if + # each cell keeps its own, to be told apart by; and the list it + # waited for no longer read for it + if self.video_params is not None: + self._ctx.lists.stop_waiting(self.video_params.id) + self._ctx.bookmarks.remember_cell( + self.video_params.id, + self.title or self.video_params.uri_name, + self.video_params.color.as_hex(), + ) - if self.title and self.title != str(uri): - rows.append((translate("Error Details", "Video"), self.title)) + self._log.debug(f"Closing video block {self.id}") - media_list = self.media_list + if notify: + self.about_to_close.emit(self.id) - if media_list is not None: - name = media_list.name or translate("Error Details", "List") - place = self._list_navigator.place() + # off screen, and the window painted without it, before its player is + # waited on (closeEvent): a hardware video's cell, once its player + # lets go, shows what was painted there before its picture came, the + # loading status, until the window is painted again + if self.isVisible(): + self.hide() - 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) - ) + if self.parentWidget() is not None: + self.parentWidget().repaint() - rows.append((translate("Error Details", "List"), name)) + super().close() - if self.video_params.is_local_file: - rows.append((translate("Error Details", "File"), str(uri))) - else: - rows.append((translate("Error Details", "Link"), str(uri))) + def closeEvent(self, event) -> None: + self.cleanup() + event.accept() - 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) + def wheelEvent(self, event): + if self._ctx.is_disable_mouse_wheel_events: + event.ignore() + return - if step: - rows.append((translate("Error Details", "Step"), step)) + if self._dispatch_mouse_action(event): + # Accept so the event does not bubble to Player and re-trigger + # the same mouse shortcut (e.g. open Settings twice). + event.accept() + return - rows.extend(code_rows(error)) + event.ignore() - if self._error_tries: - limit = self._network_retry_limit + def mousePressEvent(self, event) -> None: + if Settings().get("internal/fake_overlay_invisibility"): + self.window().raise_() - if limit > 0: - tries = translate("Error Details", "{COUNT} of {LIMIT}").format( - COUNT=self._error_tries, LIMIT=limit - ) - else: - tries = str(self._error_tries) + super().mousePressEvent(event) - rows.append((translate("Error Details", "Tries"), tries)) + def mouseMoveEvent(self, event) -> None: + # Overlay hide-on-timeout hides the overlay widget, so hover activity + # never comes from overlay children. Restart the overlay from the cell + # itself — including stopped tiles with no VLC widget. + self.show_overlay() + event.ignore() - rows.append( - ( - translate("Error Details", "When"), - datetime.fromtimestamp(error.at, tz=timezone.utc) - .astimezone() - .strftime("%H:%M:%S"), - ) - ) + def mouseReleaseEvent(self, event) -> None: + if self._ctx.is_disable_mouse_click_events: + event.ignore() + return - return tuple(rows) + if self._dispatch_mouse_action(event): + event.accept() + return - def _schedule_network_retry(self) -> bool: - """Try the video again later instead of giving up on it. + event.ignore() - 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. - """ + def mouseDoubleClickEvent(self, event) -> None: + if self._ctx.is_disable_mouse_click_events: + event.ignore() + return - retry_limit = self._network_retry_limit + if self._dispatch_mouse_action(event): + event.accept() + return - if retry_limit == 0: - return False + event.ignore() - if retry_limit > 0 and self._network_retries >= retry_limit: - self._log.debug(f"Giving up after {self._network_retries} retries") + def _dispatch_mouse_action(self, event) -> bool: + try: + actions_manager = self._ctx.actions_manager + except KeyError: return False + return actions_manager.handle_mouse_event(event) - self._network_retries += 1 + def hideEvent(self, event): + # The overlay of a hardware video is a window of its own, floating + # or a child one, so hiding this cell does not hide it as far as Qt + # knows. Always unmap it here — hide_overlay() is chrome-timeout + # policy and is a no-op when overlays stay visible. + self.overlay_hide_timer.stop() + self.overlay.hide() - 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 - - 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._can_skip_failed_item, - is_going_back=self._is_list_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 - - 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._video_detail(), - percent, - ) - return - - self._show_opening() - - def _show_opening(self): - self._show_stage( - LoadStage.VIDEO, - translate("Video Status", "Opening…"), - self._video_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._network_retry_timer.stop() - self._quality_adapt_timer.stop() - - self.is_live = False - self._is_error = False - self.streams = Streams() - self._title = None - self._default_title = None - - self.reset() - - video_params = self.video_params - self.video_params = None - - self.set_video(video_params) - - def show_reloading(self): - """Say it loads again, over the video, as a cell opening it afresh - does at first, its player about to be let go for all of them to be - opened again (VideoBlocksManager.reload_videos).""" - - if self.video_driver is None: - 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() - - def release_player(self): - """Let its player go, waiting until it has.""" - - self._destroy_video_driver() - - def close_silently(self): - self.close(notify=False) - - def close(self, notify=True): - if self._is_closing: - return - - self._is_closing = True - - self._close_bookmarks_manager() - - self.closing.emit() - - # what the cell looked like, for the bookmarks it leaves behind, if - # each cell keeps its own, to be told apart by; and the list it - # waited for no longer read for it - if self.video_params is not None: - self._ctx.lists.stop_waiting(self.video_params.id) - self._ctx.bookmarks.remember_cell( - self.video_params.id, - self.title or self.video_params.uri_name, - self.video_params.color.as_hex(), - ) - - self._log.debug(f"Closing video block {self.id}") - - if notify: - self.about_to_close.emit(self.id) - - # off screen, and the window painted without it, before its player is - # waited on (closeEvent): a hardware video's cell, once its player - # lets go, shows what was painted there before its picture came, the - # loading status, until the window is painted again - if self.isVisible(): - self.hide() - - if self.parentWidget() is not None: - self.parentWidget().repaint() - - super().close() - - def closeEvent(self, event) -> None: - self.cleanup() - event.accept() - - def wheelEvent(self, event): - if self._ctx.is_disable_mouse_wheel_events: - event.ignore() - return - - if self._dispatch_mouse_action(event): - # Accept so the event does not bubble to Player and re-trigger - # the same mouse shortcut (e.g. open Settings twice). - event.accept() - return - - event.ignore() - - def mousePressEvent(self, event) -> None: - if Settings().get("internal/fake_overlay_invisibility"): - self.window().raise_() - - super().mousePressEvent(event) - - def mouseMoveEvent(self, event) -> None: - # Overlay hide-on-timeout hides the overlay widget, so hover activity - # never comes from overlay children. Restart the overlay from the cell - # itself — including stopped tiles with no VLC widget. - self.show_overlay() - event.ignore() - - def mouseReleaseEvent(self, event) -> None: - if self._ctx.is_disable_mouse_click_events: - event.ignore() - return - - if self._dispatch_mouse_action(event): - event.accept() - return - - event.ignore() - - def mouseDoubleClickEvent(self, event) -> None: - if self._ctx.is_disable_mouse_click_events: - event.ignore() - return - - if self._dispatch_mouse_action(event): - event.accept() - return - - event.ignore() - - def _dispatch_mouse_action(self, event) -> bool: - try: - actions_manager = self._ctx.actions_manager - except KeyError: - return False - return actions_manager.handle_mouse_event(event) - - def hideEvent(self, event): - # The overlay of a hardware video is a window of its own, floating - # or a child one, so hiding this cell does not hide it as far as Qt - # knows. Always unmap it here — hide_overlay() is chrome-timeout - # policy and is a no-op when overlays stay visible. - self.overlay_hide_timer.stop() - self.overlay.hide() - - def paintEvent(self, event): - super().paintEvent(event) - self._paint_stopped_chrome() + def paintEvent(self, event): + super().paintEvent(event) + self._paint_stopped_chrome() def _paint_stopped_chrome(self): if not self.is_stopped: @@ -1648,7 +715,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: @@ -1677,7 +744,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 @@ -1715,132 +782,27 @@ 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 + def is_under_cursor(self): + return self.rect().contains(self.mapFromGlobal(QCursor.pos())) - if not self._network_retry_timer.isActive(): - return + @property + def is_active(self): + return self._is_active - if self._network_retry_limit != 0: - return + @is_active.setter + def is_active(self, is_active): + self._is_active = is_active - # the reload this video was counting down to has just been called off - self._network_retry_timer.stop() - self.network_error() + self._update_chrome() - 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, - ) + if is_active and not self._ctx.is_show_overlay_border: + return - self.video_params.network_retry_times = times + self.is_active_change.emit(is_active) - 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( - 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())) - - @property - def is_active(self): - return self._is_active - - @is_active.setter - def is_active(self, is_active): - self._is_active = is_active - - self._update_chrome() - - if is_active and not self._ctx.is_show_overlay_border: - return - - self.is_active_change.emit(is_active) - - def refresh_overlay_border(self): - self.is_active_change.emit(self.is_active_shown) - self._update_chrome() + def refresh_overlay_border(self): + self.is_active_change.emit(self.is_active_shown) + self._update_chrome() @property def is_active_shown(self) -> bool: @@ -1897,13 +859,19 @@ 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 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 @@ -1939,4680 +907,1272 @@ def youtube_id(self) -> str | None: return self._youtube_id @property - def video_tracks(self): - if self.video_driver is None or not self.is_video_initialized: - return {} - return self.video_driver.video_tracks + 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.""" - @property - def audio_tracks(self): - if self.video_driver is None or not self.is_video_initialized: - return {} - return self.video_driver.audio_tracks + return self._site_length_ms @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. - """ - + def video_tracks(self): 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}" - ) + return {} + return self.video_driver.video_tracks - if track_id == DISABLED_TRACK and self._is_video_track_off: + @only_initialized + def set_video_track(self, track_id): + # only -1 says the sound was switched off; None says it has not been + # read back, which is no reason to keep the picture + if track_id == DISABLED_TRACK and isinstance( + self.video_params.audio_selection, AudioDisabled + ): 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) + self.video_params.video_track_id = track_id + self.video_driver.set_video_track(track_id) - def _audio_selection_for(self, track_id): - """How to remember this track, by the steadiest name it answers to. + def set_video_decoding_stopped(self, is_stopped: bool) -> None: + """Switch the picture off while the player is in the tray, and back on. - 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. + The sound plays on, the picture is not decoded. Only the player is + told: the video's own track stays as picked, and is what is saved. + A paused video decodes nothing anyway and is left alone, as is one + with no sound to play instead. """ - 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) + if not self.is_video_initialized or not self.video_tracks: + self._is_video_stopped_in_tray = False + return - return AudioTrackId(id=track_id) + if is_stopped: + if self._is_video_stopped_in_tray or self.video_params.is_paused: + return - def set_audio_device(self, device) -> None: - """Send this video's sound another way out of the machine. + if not self.audio.tracks or isinstance( + self.video_params.audio_selection, AudioDisabled + ): + return - 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 - """ + if self.video_params.video_track_id == DISABLED_TRACK: + return - self._log.debug(f"Set audio device {device}") + self._is_video_stopped_in_tray = True + self.video_driver.set_video_track(DISABLED_TRACK) + return - self.video_params.audio_device = device + if not self._is_video_stopped_in_tray: + return - # 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) + self._is_video_stopped_in_tray = False - def restore_audio_selection(self, selection) -> None: - """Put a remembered choice of sound back in force, as a snapshot does.""" + track_id = self.video_params.video_track_id + if track_id is None: + track_id = min(self.video_tracks) - self.video_params.audio_selection = selection + self.video_driver.set_video_track(track_id) - self._apply_wanted_audio_track() + # Switched back on, the picture is decoded from wherever the stream + # is, between key frames, and D3D11 hardware decoding of 10-bit HEVC + # mostly never recovers from that ("hardware acceleration picture + # allocation failed", black from then on). The same spot asked for + # again starts it at a key frame. Paused while away, a video gets + # the picture it has no other way to show that way too. + if not self.is_live and self.time: + self.video_driver.set_time(self.time) @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 + def video_id(self) -> str: + return str(self.video_params.id) - return self.video_driver.cur_video_track_id == DISABLED_TRACK + @property + def title(self): + return self._title - def _track_language_key(self, track_id) -> str | None: - """The language to remember this track by, where one will do. + @title.setter + def title(self, title): + self._title = title + self.label_change.emit(self._title) - 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. - """ + @property + def color(self): + return self._color - track = self.audio_tracks.get(track_id) + @color.setter + def color(self, color): + self._color = color + self.color_change.emit(color) - if track is None or not track.language: - return None + @property + def time(self): + return self.video_params.current_position - namesakes = [ - other - for other in self.audio_tracks.values() - if normalize(other.language) == normalize(track.language) - ] + @time.setter + @only_initialized + def time(self, time): + if not self.is_live: + self.video_params.current_position = time - return track.language if len(namesakes) == 1 else None + self.time_change.emit(time, self.video_driver.length) @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 + def position(self): + if not self.time or not self.video_driver.length: + return 0 - return self.video_driver.cur_audio_track_id + return self.time / self.video_driver.length @property - def audio_language_playing(self) -> str | None: - """Which of the languages on offer the rung on screen was loaded for.""" + def loop_start(self): + if self.video_params.loop_start is None: + return 0 - return self._audio_language_playing + return self.video_params.loop_start @property - def preferred_audio_track_id(self) -> int | None: - """The track this file would open with, were nothing picked by hand.""" + def loop_end(self): + if self.video_params.loop_end is None: + length = 0 + if self.video_driver is not None: + length = self.video_driver.length + # No margin before the actual end: VLC wraps the input around on + # its own, and a finished pass is caught after the fact + return length - return pick_track(self.video_params.audio_languages, self.audio_tracks) + return self.video_params.loop_end - def apply_audio_default(self): - """Leave the sound to the video's own file, preference and all. + @property + def is_end_a_loop(self) -> bool: + """Whether reaching the end here only starts the video over again. - A dubbed video's own is the language it was made in, which may be - another one than the dub on screen. + A segment is a loop whatever the end action says, and so is the end + action that loops the file. Everything else leaves the video behind. """ - self.video_params.audio_selection = AudioDefault() - - self._follow_audio_language() + is_segment = ( + self.video_params.loop_start is not None + or self.video_params.loop_end is not None + ) - def apply_audio_preference(self): - """Follow the preferred languages again, whichever way this video dubs.""" + return is_segment or self.video_params.end_action == VideoEndAction.LOOP_FILE - self.video_params.audio_selection = AudioPreferred() + def set_drop_indicator(self, indicator: DropIndicator): + self._drop_indicator = indicator + opaque_drag = self._ctx.is_drag_ui and getattr(self.overlay, "is_opaque", False) + # Unmap before clearing Shape; setMask on a mapped opaque window + # flashes Window fill (default white) over the whole cell. + if indicator == DropIndicator.NONE and opaque_drag: + self.overlay.hide() + self._sync_drop_chrome(indicator) + self.overlay.set_drop_indicator(indicator) + if self.is_stopped: + self.update() + if indicator != DropIndicator.NONE: + self.overlay.show() + self.overlay_hide_timer.stop() - self._follow_audio_language() + def _sync_drop_chrome(self, indicator: DropIndicator): + """Over a cell with no picture, the drop target is the cell's own + disc, as the stopped and the empty cells draw theirs, in place of + what the cell says.""" - def _apply_wanted_audio_track(self): - """Put the settings into effect on the video that is already playing.""" + self.overlay.drop_indicator.set_chrome_colors( + chrome_color_on_cell(self), self.palette().color(self.backgroundRole()) + ) + self.video_status.set_is_content_shown(indicator == DropIndicator.NONE) - if self.video_driver is None or not self.is_video_initialized: - # part way through a reload, which will settle this on its own + def set_drag_ui(self, is_drag_ui: bool): + if is_drag_ui and getattr(self.overlay, "is_opaque", False): + if self._drop_indicator == DropIndicator.NONE: + self.overlay.hide() + self.overlay.set_is_chrome_visible(not is_drag_ui) + if self.is_stopped: + self.update() + if is_drag_ui: + self.overlay_hide_timer.stop() 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: + self.set_drop_indicator(DropIndicator.NONE) + if self._ctx.is_disable_overlay: + self.overlay_hide_timer.stop() + self.overlay.hide() return + if self._ctx.is_overlay_hide_on_timeout: + self.overlay_hide_timer.stop() + self.overlay.hide() + return + self.show_overlay() - 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 + @property + def is_stopped(self) -> bool: + return bool(self.video_params and self.video_params.is_stopped) - if isinstance(selection, AudioDefault): - return self.default_audio_track_id + def _sync_cell_background(self) -> None: + # HW vout is a native child. Filling this widget on expose paints + # QPalette.Window (white) over the whole cell. + fill = self.is_stopped + self.setAttribute(Qt.WA_StyledBackground, fill) + self.setAutoFillBackground(fill) - if isinstance(selection, AudioExternal): - if selection.file == self.attached_audio_file: - picked = track_of_file(selection, self.external_audio_track_ids) + @property + def is_playable(self) -> bool: + return self.is_video_initialized or self.is_stopped - if picked is not None: - return picked + @property + def is_loading(self) -> bool: + return ( + not self.is_video_initialized and not self.is_stopped and not self._is_error + ) - return wanted_audio_track_id(self.video_params, self.audio_tracks) + @property + def is_stoppable(self) -> bool: + """Playing, or on its way to: loading, reconnecting, or failed and + about to go on to the next item of its list.""" - @only_initialized - def audio_languages_dialog(self): - """Edit the languages this video would rather be heard in.""" + return self.video_params is not None and not self.is_stopped - 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: + def show_overlay(self): + # shown while loading too, and once it failed: for what it shows. + # 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.status.sync_overlay_mode() + if self._ctx.is_drag_ui or self._ctx.is_disable_overlay: return - - self.video_params.audio_languages = languages - - if not isinstance(self.video_params.audio_selection, AudioPreferred): + # Hardware overlays are windows of their own; do not remap them while + # this cell is hidden (single-mode background, minimized, etc.). + if not self.isVisible(): + return + if not self.is_overlay_fits: 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") + self.overlay.show() + if self._ctx.is_overlay_hide_on_timeout: + self.overlay_hide_timer.start(1000 * self._ctx.overlay_timeout) - @property - def discovered_audio_files(self) -> list[Path]: - """Audio files named after this video that nobody has picked yet. + def hide_overlay(self): + if self._ctx.is_drag_ui: + return - 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. - """ + # it shows a selection to zoom to, or how to make one + if self.view.is_zoom_ui(): + return - if ( - not self.is_local_file - or not self.video_params.is_external_audio_autodiscover - ): - return [] + if self._ctx.is_disable_overlay: + self.overlay_hide_timer.stop() + self.overlay.hide() + return - attached = set(self.video_params.external_audio) + # the List window plays in it: it stays in sight, marked + if self.navigation.is_list_window_shown: + return - return [ - found - for found in discover_audio_files(self.video_params.uri) - if found not in attached - ] + if not self._ctx.is_overlay_hide_on_timeout: + return - @property - def external_audio_track_ids(self) -> tuple[int, ...]: - """The tracks that came out of a file, as the player saw them arrive. + self.overlay.hide() - 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. - """ + @only_initialized + def time_changed(self, new_time): + is_wrapped = self._is_loop_wrapped(new_time) - if self.video_driver is None or not self.is_video_initialized: - return () + self.time = new_time - return self.video_driver.external_audio_ids + is_seek_on_its_way = self._follow_seek(new_time) - @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.is_live: + return - if self.video_driver is None or not self.is_video_initialized: - return None + if self.is_stopped: + return - from_file = set(self.external_audio_track_ids) + # Where a seek has not got to yet says nothing about the loop. On a + # stream it lands seconds short, as far back as a short video's wrap + # would go, and short of a loop's start, from where it would be sent + # back to land short again, over and over. + if is_seek_on_its_way: + return - own_tracks = [ - track_id for track_id in self.audio_tracks if track_id not in from_file - ] + if is_wrapped: + self._loop_wrapped() + return - opened_on = self.video_driver.default_audio_track_id + # 100ms headspace for slow callbacks + if self.time < self.loop_start - 100: + self._seek_loop_start() - if opened_on in own_tracks: - return opened_on + elif self.time > self.loop_end: + self.loop_end_action() - return own_tracks[0] if own_tracks else None + else: + self.sponsorblock.skip_if_played_into() - @property - def attached_audio_file(self) -> Path | None: - """The external audio libVLC was really handed, if any. + def _is_loop_wrapped(self, new_time) -> bool: + """Has the video begun another pass since the last time update?""" - 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. + if self._seek_settle_timer.isActive(): + return False - 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. - """ + last_time, self._last_time = self._last_time, new_time - return self._attached_audio_file + return is_loop_wrapped(last_time, new_time, self.video_driver.length) - @property - def _audio_file_to_attach(self) -> Path | None: - """The file the next load is to open this video with. + def _follow_seek(self, new_time) -> bool: + """Keep up with the last seek, True while the time is its doing. - 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. + That is while it lands, and while it is made again after the stream + turned it down, the time then being where the one before it went. """ - 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 + target = self._seek_target - @property - def offered_audio_files(self) -> list[Path]: - """Files this video can be played with, other than the one it is on.""" + if target is None: + return False - attached = self.attached_audio_file + rate = self.video_params.rate - 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() - ] + if self._seek_behind is not None and new_time != target: + behind, self._seek_behind = self._seek_behind, None - return known + self.discovered_audio_files + # the one before it repeated back, which says nothing yet + if new_time == behind: + self._seek_behind = behind + return True - @property - def external_audio_tracks(self) -> dict[int, Path]: - """Which file each external track came out of, where there is one.""" + # The first word since, and it is the one before landing. How far + # that is from this one's aim cannot tell them apart on a short + # video: every time there is near enough to be this one landing. + is_behinds = seek_landing(new_time, behind, None, rate) is not ( + SeekLanding.ASTRAY + ) + is_this_ones = seek_landing(new_time, target, None, rate) is ( + SeekLanding.ARRIVED + ) - file_path = self.attached_audio_file + if is_behinds and not is_this_ones: + return self._seek_again(new_time) - if file_path is None: - return {} + landing = seek_landing(new_time, target, self._seek_landed_at, rate) - return dict.fromkeys(self.external_audio_track_ids, file_path) + if landing is SeekLanding.LANDING: + if new_time != target and self._seek_landed_at is None: + self._seek_landed_at = new_time + return True - @property - def selected_audio_slave_uri(self) -> str | None: - """The file the video is to be played with, for the player to find.""" + if landing is SeekLanding.ASTRAY and self._seek_retries > 0: + return self._seek_again(new_time) - wanted = self._audio_file_to_attach + self._seek_target = None + return False - return None if wanted is None else wanted.as_uri() + def _seek_again(self, new_time) -> bool: + """Make the last seek again, the stream having turned it down.""" - @only_local_file - def add_external_audio_dialog(self) -> None: - """Pick audio files to play this video with.""" + self._seek_retries -= 1 + self._seek_landed_at = None - 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._log.debug( + f"Seek to {self._seek_target} did not take, at {new_time} instead" ) - 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. - """ + # not from in here: the driver would report the time from inside the + # call, back into this one before it is done + QTimer.singleShot(0, self._retry_seek) - 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 - ] + return True - if not new_files: + def _retry_seek(self): + if self._seek_target is None or not self.is_video_initialized: 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. + self._last_time = self._seek_target + self._seek_settle_timer.start() - 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. - """ + self.video_driver.set_time(self._seek_target) - selection = self.video_params.audio_selection + def _loop_wrapped(self): + """The video wrapped around on its own, mind whatever is left to do.""" - if not isinstance(selection, AudioExternal): - return + is_plain_loop = ( + self.video_params.end_action == VideoEndAction.LOOP_FILE + and self.video_params.loop_start is None + and not self.video_params.is_start_random + ) - if self._audio_file_to_attach is not None: + # VLC has already looped it seamlessly, which is the whole point + if is_plain_loop: return - self._log.debug(f"{selection.file.name} is not there any more") - - self.video_params.audio_selection = default_audio_selection() + self.loop_end_action() - def _put_external_audio_into_effect(self) -> None: - """Play the file that was picked, the cheapest way that is certain. + def playback_status_changed(self, is_paused): + self._is_state_change_in_progress = False + self._in_progress_timer.stop() - 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. - """ + self._set_playback_state( + VideoInitialState.PAUSED if is_paused else VideoInitialState.PLAYING + ) - if not self.is_video_initialized: - # part way through a load, which will open it with the video + def loop_end_action(self): + # A loop start without an end is still a segment: start → EOF. + if ( + self.video_params.loop_end is not None + or self.video_params.loop_start is not None + ): + self._loop_to_start() return - if self._attached_audio_file is not None: - self._log.debug("A file is on already, loading the video again") + end_action = self.video_params.end_action - self.reload() + if self._hold_end_for_bookmarks(): return - wanted = self._audio_file_to_attach + if self._step_at_end(end_action): + return - if wanted is not None: - self._attached_audio_file = wanted + if end_action == VideoEndAction.LOOP_FILE: + self._loop_to_start() + elif end_action == VideoEndAction.PAUSE: + self._pause_at_start() + elif end_action == VideoEndAction.STOP: + self.stop_playback() + elif end_action == VideoEndAction.CLOSE: + QTimer.singleShot(0, self.close) - self.video_driver.add_audio_slave(wanted.as_uri()) + def _step_at_end(self, end_action: VideoEndAction) -> bool: + """On to another file, or item of the list, if that is what the + video does at its end; whether it went.""" - def _warn_about_awkward_audio(self, file_paths) -> None: - """Say so where libVLC will not make a sound of what was picked. + step = { + VideoEndAction.NEXT_FILE: self.navigation.next, + VideoEndAction.PREVIOUS_FILE: self.navigation.previous, + VideoEndAction.SHUFFLE_FILE: self.navigation.shuffle, + }.get(end_action) - 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. - """ + if step is None: + return False - unplayable = unplayable_as_audio_slave(file_paths) - silenced = silenced_by_seeking(file_paths) + step(is_end=True) - warnings = [] + return True - if unplayable: - warnings.append( - translate( - "Warning", - "VLC cannot play {FILES} alongside a video.", - ).format(FILES=_file_names(unplayable)) - ) + def _loop_to_start(self): + if self.video_params.is_start_random: + self.seek_random(is_viewers=False) + else: + self._seek_loop_start() - if silenced: - warnings.append( - translate( - "Warning", - "{FILES} will fall silent once the video is seeked.", - ).format(FILES=_file_names(silenced)) - ) + def _seek_loop_start(self): + """Back to where a pass starts. - if not warnings: - return + Where a loop starts is the viewer's choice, a sponsor or not. Where + the video starts is nobody's, and an intro there is skipped on every + pass as it was on the first. + """ - warnings.append( - translate("Warning", "Convert to MP3, M4A, FLAC or AC3 to play it.") - ) + self.seek(self.loop_start, is_viewers=self.video_params.loop_start is not None) - self._ctx.commands.warning("\n\n".join(warnings)) + def _pause_at_start(self): + self._seek_loop_start() + self.set_pause(True) - def play_external_audio(self, file_name: str) -> None: - """Hear this file, whether it has been attached yet or not.""" + def _hold_end_for_bookmarks(self) -> bool: + """Pause at the start in place of an end action that would take the + video out of its cell, while its bookmarks are being edited, and say + whether it did. - file_path = Path(file_name) + The bookmarks manager, or a bookmark's name being typed or its + colour picked, holds the video in its cell: closed, or another file + put in its place, it would take the bookmarks being edited away with + it. The end action is the same again on the next pass. + """ - 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]) + end_action = self.video_params.end_action - return + if not self.bookmarker.is_editing or end_action not in LEAVING_END_ACTIONS: + return False - if file_path not in self.video_params.external_audio: - self.add_external_audio([file_path]) - return + self._pause_at_start() - self.video_params.audio_selection = AudioExternal(file=file_path) + self.bookmarker.tell_end_held(end_action) - self._put_external_audio_into_effect() + return True - def remove_external_audio(self) -> None: - """Play the video on its own sound again. + 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.recovery.drop_retries() + self.navigation.is_waiting_for_list_read = False - 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 self.url_resolver is not None and self.url_resolver.is_busy: + self.reset_url_resolver() - if not self.video_params.external_audio: - return + self.navigation.cancel_steps() - self._drop_external_audio() + # one that failed, stopped, is a stopped one like any other: played + # from there, not left on Try Again with its reason gone + self._is_error = False - self.reload() + # nor as far as it got loading: played again, its steps start afresh + self.status.load_stage = None - def _drop_external_audio(self) -> None: - self.video_params.external_audio = [] + if self.video_params is not None: + self._ctx.lists.stop_waiting(self.video_params.id) - # 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() + self.video_params.current_position = 0 + self.video_params.loop_start = None + self.video_params.loop_end = None + self.loop_start_change.emit(0) + self.loop_end_change.emit(100.0) - def set_external_audio_autodiscover(self, is_on: bool) -> None: - """Whether to offer the audio files kept beside this video.""" + self._set_playback_state(VideoInitialState.STOPPED) - self.video_params.is_external_audio_autodiscover = is_on + # gone before the cell is painted as its player is let go + self.video_status.hide() + self._destroy_video_driver() + self.show_overlay() - 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) - - # 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 - # read back, which is no reason to keep the picture - if track_id == DISABLED_TRACK and isinstance( - self.video_params.audio_selection, AudioDisabled - ): - self._ctx.commands.warning( - translate("Warning", "Cannot disable both video & audio tracks") - ) - return - - self.video_params.video_track_id = track_id - self.video_driver.set_video_track(track_id) - - def set_video_decoding_stopped(self, is_stopped: bool) -> None: - """Switch the picture off while the player is in the tray, and back on. - - The sound plays on, the picture is not decoded. Only the player is - told: the video's own track stays as picked, and is what is saved. - A paused video decodes nothing anyway and is left alone, as is one - with no sound to play instead. - """ - - if not self.is_video_initialized or not self.video_tracks: - self._is_video_stopped_in_tray = False - return - - if is_stopped: - if self._is_video_stopped_in_tray or self.video_params.is_paused: - return - - if not self.audio_tracks or isinstance( - self.video_params.audio_selection, AudioDisabled - ): - return - - if self.video_params.video_track_id == DISABLED_TRACK: - return - - self._is_video_stopped_in_tray = True - self.video_driver.set_video_track(DISABLED_TRACK) - return - - if not self._is_video_stopped_in_tray: - return - - self._is_video_stopped_in_tray = False - - track_id = self.video_params.video_track_id - if track_id is None: - track_id = min(self.video_tracks) - - self.video_driver.set_video_track(track_id) - - # Switched back on, the picture is decoded from wherever the stream - # is, between key frames, and D3D11 hardware decoding of 10-bit HEVC - # mostly never recovers from that ("hardware acceleration picture - # allocation failed", black from then on). The same spot asked for - # again starts it at a key frame. Paused while away, a video gets - # the picture it has no other way to show that way too. - 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) - - @property - def title(self): - return self._title - - @title.setter - def title(self, title): - self._title = title - self.label_change.emit(self._title) - - @property - def color(self): - return self._color - - @color.setter - def color(self, color): - self._color = color - self.color_change.emit(color) - - @property - def time(self): - return self.video_params.current_position - - @time.setter - @only_initialized - def time(self, time): - if not self.is_live: - self.video_params.current_position = time - - self.time_change.emit(time, self.video_driver.length) - - @property - def position(self): - if not self.time or not self.video_driver.length: - return 0 - - return self.time / self.video_driver.length - - @property - def loop_start(self): - if self.video_params.loop_start is None: - return 0 - - return self.video_params.loop_start - - @property - def loop_end(self): - if self.video_params.loop_end is None: - length = 0 - if self.video_driver is not None: - length = self.video_driver.length - # No margin before the actual end: VLC wraps the input around on - # its own, and a finished pass is caught after the fact - return length - - return self.video_params.loop_end - - @property - def is_end_a_loop(self) -> bool: - """Whether reaching the end here only starts the video over again. - - A segment is a loop whatever the end action says, and so is the end - action that loops the file. Everything else leaves the video behind. - """ - - is_segment = ( - self.video_params.loop_start is not None - or self.video_params.loop_end is not None - ) - - return is_segment or self.video_params.end_action == VideoEndAction.LOOP_FILE - - def set_drop_indicator(self, indicator: DropIndicator): - self._drop_indicator = indicator - opaque_drag = self._ctx.is_drag_ui and getattr(self.overlay, "is_opaque", False) - # Unmap before clearing Shape; setMask on a mapped opaque window - # flashes Window fill (default white) over the whole cell. - if indicator == DropIndicator.NONE and opaque_drag: - self.overlay.hide() - self._sync_drop_chrome(indicator) - self.overlay.set_drop_indicator(indicator) - if self.is_stopped: - self.update() - if indicator != DropIndicator.NONE: - self.overlay.show() - self.overlay_hide_timer.stop() - - def _sync_drop_chrome(self, indicator: DropIndicator): - """Over a cell with no picture, the drop target is the cell's own - disc, as the stopped and the empty cells draw theirs, in place of - what the cell says.""" - - self.overlay.drop_indicator.set_chrome_colors( - chrome_color_on_cell(self), self.palette().color(self.backgroundRole()) - ) - self.video_status.set_is_content_shown(indicator == DropIndicator.NONE) - - def set_drag_ui(self, is_drag_ui: bool): - if is_drag_ui and getattr(self.overlay, "is_opaque", False): - if self._drop_indicator == DropIndicator.NONE: - self.overlay.hide() - self.overlay.set_is_chrome_visible(not is_drag_ui) - if self.is_stopped: - self.update() - if is_drag_ui: - self.overlay_hide_timer.stop() - return - - self.set_drop_indicator(DropIndicator.NONE) - if self._ctx.is_disable_overlay: - self.overlay_hide_timer.stop() - self.overlay.hide() - return - if self._ctx.is_overlay_hide_on_timeout: - self.overlay_hide_timer.stop() - self.overlay.hide() - return - self.show_overlay() - - @property - def is_stopped(self) -> bool: - return bool(self.video_params and self.video_params.is_stopped) - - def _sync_cell_background(self) -> None: - # HW vout is a native child. Filling this widget on expose paints - # QPalette.Window (white) over the whole cell. - fill = self.is_stopped - self.setAttribute(Qt.WA_StyledBackground, fill) - self.setAutoFillBackground(fill) - - @property - def is_playable(self) -> bool: - return self.is_video_initialized or self.is_stopped - - @property - def is_loading(self) -> bool: - return ( - not self.is_video_initialized and not self.is_stopped and not self._is_error - ) - - @property - def is_stoppable(self) -> bool: - """Playing, or on its way to: loading, reconnecting, or failed and - about to go on to the next item of its list.""" - - return self.video_params is not None and not self.is_stopped - - def show_overlay(self): - # shown while loading too, and once it failed: for what it shows. - # 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() - 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 - # this cell is hidden (single-mode background, minimized, etc.). - if not self.isVisible(): - return - if not self.is_overlay_fits: - return - - self.overlay.show() - if self._ctx.is_overlay_hide_on_timeout: - self.overlay_hide_timer.start(1000 * self._ctx.overlay_timeout) - - def hide_overlay(self): - if self._ctx.is_drag_ui: - return - - # it shows a selection to zoom to, or how to make one - if self._is_zoom_ui(): - return - - if self._ctx.is_disable_overlay: - self.overlay_hide_timer.stop() - self.overlay.hide() - return - - # the List window plays in it: it stays in sight, marked - if self._is_list_window_shown: - return - - if not self._ctx.is_overlay_hide_on_timeout: - return - - self.overlay.hide() - - @only_initialized - def time_changed(self, new_time): - is_wrapped = self._is_loop_wrapped(new_time) - - self.time = new_time - - is_seek_on_its_way = self._follow_seek(new_time) - - if self.is_live: - return - - if self.is_stopped: - return - - # Where a seek has not got to yet says nothing about the loop. On a - # stream it lands seconds short, as far back as a short video's wrap - # would go, and short of a loop's start, from where it would be sent - # back to land short again, over and over. - if is_seek_on_its_way: - return - - if is_wrapped: - self._loop_wrapped() - return - - # 100ms headspace for slow callbacks - if self.time < self.loop_start - 100: - self._seek_loop_start() - - elif self.time > self.loop_end: - 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 - - def _is_loop_wrapped(self, new_time) -> bool: - """Has the video begun another pass since the last time update?""" - - if self._seek_settle_timer.isActive(): - return False - - last_time, self._last_time = self._last_time, new_time - - return is_loop_wrapped(last_time, new_time, self.video_driver.length) - - def _follow_seek(self, new_time) -> bool: - """Keep up with the last seek, True while the time is its doing. - - That is while it lands, and while it is made again after the stream - turned it down, the time then being where the one before it went. - """ - - target = self._seek_target - - if target is None: - return False - - rate = self.video_params.rate - - if self._seek_behind is not None and new_time != target: - behind, self._seek_behind = self._seek_behind, None - - # the one before it repeated back, which says nothing yet - if new_time == behind: - self._seek_behind = behind - return True - - # The first word since, and it is the one before landing. How far - # that is from this one's aim cannot tell them apart on a short - # video: every time there is near enough to be this one landing. - is_behinds = seek_landing(new_time, behind, None, rate) is not ( - SeekLanding.ASTRAY - ) - is_this_ones = seek_landing(new_time, target, None, rate) is ( - SeekLanding.ARRIVED - ) - - if is_behinds and not is_this_ones: - return self._seek_again(new_time) - - landing = seek_landing(new_time, target, self._seek_landed_at, rate) - - if landing is SeekLanding.LANDING: - if new_time != target and self._seek_landed_at is None: - self._seek_landed_at = new_time - return True - - if landing is SeekLanding.ASTRAY and self._seek_retries > 0: - return self._seek_again(new_time) - - self._seek_target = None - return False - - def _seek_again(self, new_time) -> bool: - """Make the last seek again, the stream having turned it down.""" - - self._seek_retries -= 1 - self._seek_landed_at = None - - self._log.debug( - f"Seek to {self._seek_target} did not take, at {new_time} instead" - ) - - # not from in here: the driver would report the time from inside the - # call, back into this one before it is done - QTimer.singleShot(0, self._retry_seek) - - return True - - def _retry_seek(self): - if self._seek_target is None or not self.is_video_initialized: - return - - self._last_time = self._seek_target - self._seek_settle_timer.start() - - self.video_driver.set_time(self._seek_target) - - def _loop_wrapped(self): - """The video wrapped around on its own, mind whatever is left to do.""" - - is_plain_loop = ( - self.video_params.end_action == VideoEndAction.LOOP_FILE - and self.video_params.loop_start is None - and not self.video_params.is_start_random - ) - - # VLC has already looped it seamlessly, which is the whole point - if is_plain_loop: - return - - self.loop_end_action() - - def playback_status_changed(self, is_paused): - self._is_state_change_in_progress = False - self._in_progress_timer.stop() - - self._set_playback_state( - VideoInitialState.PAUSED if is_paused else VideoInitialState.PLAYING - ) - - def loop_end_action(self): - # A loop start without an end is still a segment: start → EOF. - if ( - self.video_params.loop_end is not None - or self.video_params.loop_start is not None - ): - self._loop_to_start() - return - - end_action = self.video_params.end_action - - if self._hold_end_for_bookmarks(): - return - - if self._step_at_end(end_action): - return - - if end_action == VideoEndAction.LOOP_FILE: - self._loop_to_start() - elif end_action == VideoEndAction.PAUSE: - self._pause_at_start() - elif end_action == VideoEndAction.STOP: - self.stop_playback() - elif end_action == VideoEndAction.CLOSE: - QTimer.singleShot(0, self.close) - - def _step_at_end(self, end_action: VideoEndAction) -> bool: - """On to another file, or item of the list, if that is what the - 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, - }.get(end_action) - - if step is None: - return False - - step(is_end=True) - - return True - - def _loop_to_start(self): - if self.video_params.is_start_random: - self.seek_random(is_viewers=False) - else: - self._seek_loop_start() - - def _seek_loop_start(self): - """Back to where a pass starts. - - Where a loop starts is the viewer's choice, a sponsor or not. Where - the video starts is nobody's, and an intro there is skipped on every - pass as it was on the first. - """ - - self.seek(self.loop_start, is_viewers=self.video_params.loop_start is not None) - - def _pause_at_start(self): - self._seek_loop_start() - self.set_pause(True) - - def _hold_end_for_bookmarks(self) -> bool: - """Pause at the start in place of an end action that would take the - video out of its cell, while its bookmarks are being edited, and say - whether it did. - - The bookmarks manager, or a bookmark's name being typed or its - colour picked, holds the video in its cell: closed, or another file - put in its place, it would take the bookmarks being edited away with - it. The end action is the same again on the next pass. - """ - - end_action = self.video_params.end_action - - if not self._is_editing_bookmarks 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 - - # 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) - ) - ) - - 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._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() - - # one that failed, stopped, is a stopped one like any other: played - # from there, not left on Try Again with its reason gone - self._is_error = False - - # nor as far as it got loading: played again, its steps start afresh - self._load_stage = None - - if self.video_params is not None: - self._ctx.lists.stop_waiting(self.video_params.id) - - self.video_params.current_position = 0 - self.video_params.loop_start = None - self.video_params.loop_end = None - self.loop_start_change.emit(0) - self.loop_end_change.emit(100.0) - - self._set_playback_state(VideoInitialState.STOPPED) - - # gone before the cell is painted as its player is let go - self.video_status.hide() - self._destroy_video_driver() - self.show_overlay() - - def _set_playback_state(self, state: VideoInitialState): - if self.video_params is None: - return - if self.video_params.playback_state == state: - # VLC's status may already match the model (the initial PLAYING - # state comes from session defaults before any VLC callback, and - # snapshots can overwrite the model before the callback lands). - # Still sync the overlay so the play/pause button isn't stuck. - self.is_paused_change.emit(self.video_params.is_paused) - if state is VideoInitialState.STOPPED: - self.is_stopped_change.emit(True) - self.show_overlay() - return - - self.video_params.playback_state = state - self.is_paused_change.emit(self.video_params.is_paused) - self.is_stopped_change.emit(self.is_stopped) - was_filled = self.autoFillBackground() - self._sync_cell_background() - if self.is_stopped or was_filled: - self.update() - self.show_overlay() - - def apply_snapshot(self, snapshot: Video): - if snapshot.uri != self.video_params.uri: - self._apply_snapshot_of_another_file(snapshot) - return - - # the snapshot's view is the one to show - self._view_to_follow = None - - self.color = snapshot.color.as_hex() - - if snapshot.is_stopped: - self._take_snapshot_params(snapshot) - self._destroy_video_driver() - self._present_stopped() - return - - if not self.is_video_initialized: - self._take_snapshot_params(snapshot) - self._start_load() - return - - if self.video_tracks and _is_deinterlace_reload_needed( - self.video_params, snapshot.deinterlace, snapshot.deinterlace_mode - ): - self._take_snapshot_params(snapshot) - self.reload() - return - - self.set_video_track(snapshot.video_track_id) - self.restore_audio_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.restore_subtitle_selection(snapshot.subtitle_selection) - self.set_subtitle_delay(snapshot.subtitle_delay_ms, is_silent=True) - - self.set_aspect(snapshot.aspect_mode) - 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.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.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._take_snapshot_params(snapshot) - - def _take_snapshot_params(self, snapshot: Video): - """The snapshot's video in place of the cell's, and named as it - was then.""" - - self.video_params = snapshot.model_copy() - - self.title = self._custom_title or self._default_title - - def _apply_snapshot_of_another_file(self, snapshot: Video): - """Put the file the snapshot was taken of back in the cell, as the - 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._cancel_steps() - - self._view_to_follow = None - self._held_item = None - self._title = None - self._default_title = None - - self.set_video(snapshot.model_copy()) - - 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) - - # 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: - self._ctx.lists.stop_waiting(self.video_params.id) - - # a list the playlist does not have, as one edited by hand would - if self._ctx.lists.get(video_params.list_id) is None: - video_params.list_id = None - video_params.list_index = None - - self.video_params = video_params - - if video_params.list_id is not None: - self._ctx.lists.take_up(video_params.list_id) - - self._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 - - # a file's bookmarks left from when they were shared, if this is the - # first cell to open it since - self._ctx.bookmarks.claim(self.video_params.uri, self.video_params.id) - - # another video in the cell's place, or this one given to it again: - # a reload of the one before still to come is not for it, and it - # 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._has_item_played = False - - # Shut down current video - if not is_first_video or is_reopen_needed: - self.reset() - - if self.video_params.is_stopped and not self.is_video_initialized: - self._present_stopped() - return - - self._start_load() - - def _present_waiting(self): - """The cell there at once, saying so while the list it is to play - is read: a channel list from a provider can take a minute.""" - - 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…")) - - def _present_gone_file(self): - """The file the cell is on gone, and its list not read again to go - on along: the cell says so.""" - - self._is_error = True - 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) - - if self._default_title is None: - self._default_title = self.video_params.uri_name - - 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 - - if self.title is None: - self.title = self._custom_title or self._default_title - - self.color = self.video_params.color.as_hex() - self.is_audio_present_change.emit(False) - self.is_paused_change.emit(True) - self.is_stopped_change.emit(True) - self.video_status.hide() - self._sync_cell_background() - self.update() - self.show_overlay() - - def _start_load(self): - # what went wrong before is behind it, but while it is tried again - if self._network_retries == 0: - self._last_error = None - - self._skip_note_timer.stop() - - # named from the start, on the overlay shown while it loads: a link - # by its file name until its page gives it a title - if self._default_title is None: - self._default_title = self.video_params.uri_name - - if self.title is None: - self.title = self._custom_title or self._default_title - - # a list still being read for it: nothing to load until it is - if self._ctx.lists.is_waiting(self.video_params.id): - self._present_waiting() - return - - # saved with the playlist as it waited: read again - if self.video_params.read_as_list: - self._ctx.commands.read_for_cell(self.video_params) - self._present_waiting() - return - - self._is_waiting_for_list_read = False - - if self._is_list_file_gone: - self._go_on_from_gone_file() - return - - self._sync_cell_background() - 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 = ( - *((LoadStage.LIST,) if was_listed else ()), - *((LoadStage.LINK,) if self.video_params.is_http_url else ()), - LoadStage.VIDEO, - ) - - 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 - - # 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._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 - - # a file is never adaptive; a URL says so once its stream is picked - self._is_adaptive = False - - if self.video_params.is_http_url: - self._show_resolving_link() - self.url_resolver.resolve(self.video_params.uri, self._item_headers) - else: - self._show_opening() - self.load_video.emit( - MediaInput( - uri=str(self.video_params.uri), - is_live=False, - is_audio_only=False, - size=self.size_tuple, - video=self.video_params, - selected_audio_slave=self.selected_audio_slave_uri, - selected_subtitle_slaves=self.selected_subtitle_slave_uris, - ) - ) - - def _load_and_play(self): - if self._is_error or self._is_state_change_in_progress: - return - - self._is_state_change_in_progress = True - self._in_progress_timer.start() - self._set_playback_state(VideoInitialState.PLAYING) - self.set_status("processing") - self._start_load() - - def set_video_url(self, video: ResolvedVideo): - self._default_title = video.title - - if self._custom_title is None: - self.title = video.title - - self.streams = video.streams - self.is_live = video.is_live - - # kept, like the streams, through a switch of quality or of dub - self._site_chapters = video.chapters - self._site_length_ms = video.duration_ms - - self._youtube_id = video.youtube_id - self._look_up_sponsor_segments() - - 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._apply_wanted_audio_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 _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. - - 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) - - def load_video_finish(self): - # the video is playing, so whatever went wrong before is behind us - self._network_retries = 0 - self._last_error = None - - # a fresh player has no pass behind it to compare times against - self._last_time = None - - # final verdict belongs to VLC - self.is_live = self.video_driver.is_live - - if self._default_title is None: - self._default_title = self.video_params.uri_name - - if self.title is None: - self.title = self._custom_title or self._default_title - - 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() - - 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.set_volume(self.video_params.volume) - self.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.set_rate(self.video_params.rate, is_silent=True) - - self.set_auto_reload_timer(self.video_params.auto_reload_timer_min) - - self.video_params.video_track_id = self.video_driver.cur_video_track_id - - # a fresh player shows the picture, loaded in the tray it is not to - self._is_video_stopped_in_tray = False - if getattr(self._ctx, "is_video_decoding_stopped", False): - self.set_video_decoding_stopped(True) - - self._update_chapters() - self._update_sponsor_segments() - - self.is_audio_present_change.emit(bool(self.audio_tracks)) - - self._adopt_audio_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.video_status.hide() - - self._follow_synced_view() - - self.video_driver.show() - self.is_stopped_change.emit(self.is_stopped) - 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 - - self.video_driver.adjust_view() - - def _picture_shown(self): - """Take the loading status off the video, now there is a picture. - - On Windows the status is a native window over the frame's, as made - (seen in the window tree), so it keeps VLC's windows and their black - out of sight until then. Unless it has been given something else to - show since. - - The overlay is held back until then too: shown on the load, it sat - 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: - return - - self._is_status_kept_for_picture = False - - self.video_status.hide() - self.show_overlay() - - @only_with_video_tacks - def set_aspect(self, aspect: VideoAspect): - self.video_params.aspect_mode = aspect - - self.video_driver.set_aspect_ratio(self.video_params.aspect_mode) - - @only_with_video_tacks - def set_transform(self, transform: VideoTransform): - self.video_params.transform = transform - - self.reload() - - @only_with_video_tacks - def set_deinterlace(self, deinterlace: VideoDeinterlace): - self.set_deinterlace_params(deinterlace, self.video_params.deinterlace_mode) - - @only_with_video_tacks - def set_deinterlace_mode(self, mode: VideoDeinterlaceMode): - # picking a mode is asking for it, as it is in VLC's own menu - deinterlace = self.video_params.deinterlace - if deinterlace == VideoDeinterlace.OFF: - deinterlace = VideoDeinterlace.ON - - self.set_deinterlace_params(deinterlace, mode) - - @only_with_video_tacks - def set_deinterlace_params( - self, deinterlace: VideoDeinterlace, mode: VideoDeinterlaceMode - ): - is_reload_needed = _is_deinterlace_reload_needed( - self.video_params, deinterlace, mode - ) - - self.video_params.deinterlace = deinterlace - self.video_params.deinterlace_mode = mode - - if is_reload_needed: - self.reload() - 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) - - # 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.""" - - return self._seek_marks - - def _update_seek_marks(self): - """Tell the seek bar what to mark: chapters, SponsorBlock's, and the - viewer's bookmarks.""" - - chapters = ( - SeekMark( - time_ms=chapter.start_ms, - label=self.chapter_name(index), - kind=SeekMarkKind.CHAPTER, - ) - for index, chapter in enumerate(self._chapters) - ) - - segments = (_segment_mark(segment) for segment in self._shown_segments) - - bookmarks = () - if self.is_video_initialized and not self.is_live: - bookmarks = self.bookmark_marks(self.video_driver.length) - - marks = (*chapters, *segments, *bookmarks) - - if marks == self._seek_marks: - return - - self._seek_marks = marks - - 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) - - # 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. - - @property - def bookmarks(self) -> tuple[Bookmark, ...]: - if self.video_params is None: - return () - - 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() - - 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 - - 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): - seek_ms = int(shift_percent / 100 * self.video_driver.length) - - self.seek_shift_ms(seek_ms) - - @only_initialized - @only_seekable - def seek_shift_ms(self, seek_ms): - seek_stretch = self.loop_end - self.loop_start - - if seek_ms > 0 and self.time + seek_ms > self.loop_end: - # Seeking past the end is the end arriving early, and a video that - # is meant to move on when it gets there should move on here too. - # Only where the end just starts the video over does the seek - # carry on into the next pass, which is what keeps wheeling - # through a looping video continuous. - if not self.is_end_a_loop: - self.loop_end_action() - return - - rest = self.loop_end - self.time - seek_set = seek_ms - rest - seek_set = seek_set - (seek_set // seek_stretch) * seek_stretch - - new_time = self.loop_start + seek_set - - elif seek_ms < 0 and self.time + seek_ms < self.loop_start: - seek_ms *= -1 - - rest = self.time - self.loop_start - seek_set = seek_ms - rest - seek_set = seek_set - (seek_set // seek_stretch) * seek_stretch - - new_time = self.loop_end - seek_set - - else: - new_time = self.time + seek_ms - - self.seek(new_time) - - @only_initialized - @only_seekable - def seek_step(self, slot, direction): - """Seek by one of the playlist's steps, "short", "medium" or "long", - forwards (direction 1) or back (-1).""" - - self.seek_shift_ms(direction * seek_step_ms(slot)) - - @only_initialized - @only_seekable - def seek_random(self, is_viewers=True): - random_ms = random.randint(self.loop_start, self.loop_end) - - self.seek(random_ms, is_viewers=is_viewers) - - @only_initialized - @only_seekable - def seek_percent(self, percent): - seek_ms = int(percent * self.video_driver.length) - - self.seek(seek_ms) - - @only_initialized - @only_seekable - def seek(self, seek_ms, is_viewers=True): - """Go to a time, for the viewer or for the player itself. - - A stretch to be skipped that a seek of the viewer's lands in is left - to play, the viewer having gone there. One the player lands in, going - round to the start or past another, is skipped as it would be if it - were played into. - """ - - if seek_ms < self.loop_start or seek_ms > self.loop_end: - seek_ms = self.loop_start - - # the viewer going back into the video stays in it, whatever file - # its end was looking for - if is_viewers: - self._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 - # from inside that call. Marked afterwards, a seek that moves the time - # backwards -- wheeling past the end lands back at the start -- reads - # as a finished pass, and the end action then stops the player from - # inside libVLC, which deadlocks against the seek it is still making. - self._last_time = seek_ms - self._seek_settle_timer.start() - - # made before the one before it has landed, while the stream is still - # restarting for that one, it is likely to be turned down - is_behind = self._seek_target is not None and self._seek_landed_at is None - behind = self._seek_target - - self._seek_target = seek_ms - self._seek_landed_at = None - - # one that close to the end can run into it before it is seen to - # 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 - ) - 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.time = seek_ms - self.video_driver.set_time(seek_ms) - - @property - 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 - was aimed for a second or so: a jump to a chapter reads as still in - the one before, so a press of next jumps to the same one again. - """ - - return arriving_time(self.time, self._seek_target) - - @property - def is_seek_on_its_way(self) -> bool: - """Whether the last seek has yet to be seen to land.""" - - return self._seek_target is not None - - @only_with_video_tacks - @only_seekable - @only_initialized - def next_frame(self): - self.step_frame(1) - - @only_with_video_tacks - @only_seekable - @only_initialized - def previous_frame(self): - self.step_frame(-1) - - @only_with_video_tacks - @only_seekable - @only_initialized - def step_frame(self, frames): - if not self.video_params.is_paused: - self.set_pause(True) - return - - ms_per_frame = self.video_driver.get_ms_per_frame() - - # Counted from the frame the video is on rather than added to the - # time it reports, so that steps rounded to whole milliseconds cannot - # pile up their leftovers until a press lands back on the frame it - # started from. The seek aims at the middle of the frame it wants, - # since a whole millisecond lands on either side of a frame's edge. - frame_no = int(self.time // ms_per_frame) - new_time = round((frame_no + frames + 0.5) * ms_per_frame) - - 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: + def _set_playback_state(self, state: VideoInitialState): + if self.video_params 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: + if self.video_params.playback_state == state: + # VLC's status may already match the model (the initial PLAYING + # state comes from session defaults before any VLC callback, and + # snapshots can overwrite the model before the callback lands). + # Still sync the overlay so the play/pause button isn't stuck. + self.is_paused_change.emit(self.video_params.is_paused) + if state is VideoInitialState.STOPPED: + self.is_stopped_change.emit(True) + self.show_overlay() 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.""" + self.video_params.playback_state = state + self.is_paused_change.emit(self.video_params.is_paused) + self.is_stopped_change.emit(self.is_stopped) + was_filled = self.autoFillBackground() + self._sync_cell_background() + if self.is_stopped or was_filled: + self.update() + self.show_overlay() - if self._is_zoom_ui() == was_zoom_ui: + def apply_snapshot(self, snapshot: Video): + if snapshot.uri != self.video_params.uri: + self._apply_snapshot_of_another_file(snapshot) return - if self._is_zoom_ui(): - self.overlay_hide_timer.stop() - self.overlay.set_is_chrome_visible(False) - self.overlay.show() - return + # the snapshot's view is the one to show + self.view.forget_view_to_follow() - self.overlay.set_is_chrome_visible(not self._ctx.is_drag_ui) + self.color = snapshot.color.as_hex() - if self._ctx.is_disable_overlay or not self.is_overlay_fits: - self.overlay_hide_timer.stop() - self.overlay.hide() + if snapshot.is_stopped: + self._take_snapshot_params(snapshot) + self._destroy_video_driver() + self._present_stopped() 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). - """ - - result = getattr(self, command)(*args) - - synced = self.synced_view() - - 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: + self._take_snapshot_params(snapshot) + self._start_load() 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).""" - - getattr(self, command)() - - if self.is_video_initialized and not self.is_live: - self.sync_rate.emit(self.video_params.rate) - - def follow_rate(self, rate: float): - """Play at another video's speed, quietly. One that is not playing - yet plays at it once it is.""" - - if self.is_live: + if self.video_tracks and is_deinterlace_reload_needed( + self.video_params, snapshot.deinterlace, snapshot.deinterlace_mode + ): + self._take_snapshot_params(snapshot) + self.reload() return - self.video_params.rate = rate - - self.set_rate(rate, is_silent=True) - - @only_initialized - @only_seekable - def rate_increase(self): - self.video_params.rate += 0.1 - self.video_params.rate = min(round(self.video_params.rate, 1), MAX_RATE) - - self.set_rate(self.video_params.rate) - - @only_initialized - @only_seekable - def rate_decrease(self): - self.video_params.rate -= 0.1 - self.video_params.rate = max(round(self.video_params.rate, 1), MIN_RATE) - - self.set_rate(self.video_params.rate) + self.set_video_track(snapshot.video_track_id) + self.audio.restore_selection(snapshot.audio_selection) - @only_initialized - @only_seekable - def rate_reset(self): - self.set_rate(1.0) + self.audio.set_channel_mode(snapshot.audio_channel_mode) + self.audio.set_delay(snapshot.audio_delay_ms, is_silent=True) - @only_initialized - @only_seekable - def set_rate(self, rate, is_silent=False): - self.video_params.rate = rate + self.subtitles.restore_selection(snapshot.subtitle_selection) + self.subtitles.set_delay(snapshot.subtitle_delay_ms, is_silent=True) - self.video_driver.set_playback_rate(rate) + self.set_aspect(snapshot.aspect_mode) + self.set_deinterlace_params(snapshot.deinterlace, snapshot.deinterlace_mode) + 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) + 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.audio.set_volume(snapshot.volume) - if not is_silent: - self.info_change.emit(f"Speed: {rate}") + self.seek(snapshot.current_position) + self.loop.set_start_time(snapshot.loop_start) + self.loop.set_end_time(snapshot.loop_end) + self.set_rate(snapshot.rate, is_silent=True) - def set_pause(self, paused): - if self._is_state_change_in_progress: - return + self.stream.switch_quality(snapshot.stream_quality) + self.stream.set_reload_timer(snapshot.auto_reload_timer_min) - if not self.is_video_initialized: - if not paused: - self._load_and_play() - return + self._take_snapshot_params(snapshot) - if self.video_params.is_paused == paused: - return + def _take_snapshot_params(self, snapshot: Video): + """The snapshot's video in place of the cell's, and named as it + was then.""" - self._is_state_change_in_progress = True - self._in_progress_timer.start() + self.video_params = snapshot.model_copy() - self.video_driver.set_pause(paused) + self.title = self._custom_title or self._default_title - def mute_unmute(self): - self.set_muted(not self.video_params.is_muted) + def _apply_snapshot_of_another_file(self, snapshot: Video): + """Put the file the snapshot was taken of back in the cell, as the + snapshot has it: the cell went on to another file since, the next + on its list or in its folder.""" - @only_initialized - def set_muted(self, muted): - self.video_params.is_muted = muted + self.bookmarker.close_manager() + self.navigation.cancel_steps() - self.video_driver.audio_set_mute(self.video_params.is_muted) + self.view.forget_view_to_follow() + self.navigation.held_item = None + self._title = None + self._default_title = None - self.is_muted_change.emit(self.video_params.is_muted) + self.set_video(snapshot.model_copy()) - @only_initialized - def set_volume(self, percent): - self.video_params.volume = round(percent, 2) + def set_video(self, video_params: Video): + is_first_video = self.video_params is None + is_reopen = is_reopen_needed(self.video_params, video_params) - self.video_driver.audio_set_volume(self.video_params.volume) + # 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: + self._ctx.lists.stop_waiting(self.video_params.id) - self.volume_change.emit(percent) + # a list the playlist does not have, as one edited by hand would + if self._ctx.lists.get(video_params.list_id) is None: + video_params.list_id = None + video_params.list_index = None - @only_initialized - def volume_increase(self): - self.set_muted(False) + self.video_params = video_params - self.video_params.volume += 0.05 - self.video_params.volume = min(round(self.video_params.volume, 2), 1.0) + if video_params.list_id is not None: + self._ctx.lists.take_up(video_params.list_id) - self.set_volume(self.video_params.volume) + self.navigation.show_list_place() - @only_initialized - def volume_decrease(self): - self.set_muted(False) + # known by its item from now on, the list read again at the latest + # as the playlist opens + self.navigation.held_item = self.list_item - self.video_params.volume -= 0.05 - self.video_params.volume = max(round(self.video_params.volume, 2), 0) + # a file's bookmarks left from when they were shared, if this is the + # first cell to open it since + self._ctx.bookmarks.claim(self.video_params.uri, self.video_params.id) - self.set_volume(self.video_params.volume) + # another video in the cell's place, or this one given to it again: + # a reload of the one before still to come is not for it, and it + # 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.recovery.drop_retries() + self._has_item_played = False - def play_pause(self): - """Play or pause as the viewer asked, the others following where - playing and pausing is synced (see VideoBlocksManager).""" + # Shut down current video + if not is_first_video or is_reopen: + self.reset() - if self._is_state_change_in_progress: + if self.video_params.is_stopped and not self.is_video_initialized: + self._present_stopped() return - is_paused = not self.video_params.is_paused - - self.set_pause(is_paused) + self._start_load() - self.sync_pause.emit(is_paused) + def _present_waiting(self): + """The cell there at once, saying so while the list it is to play + is read: a channel list from a provider can take a minute.""" - @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.""" + self._present_unloaded("processing", translate("Lists", "Reading the list…")) - if self.media_list is not None: - return self._list_navigator + self.status.load_steps = (LoadStage.LIST, LoadStage.VIDEO) + self.status.show_stage(LoadStage.LIST, translate("Lists", "Reading the list…")) - return self._folder_navigator + def _present_gone_file(self): + """The file the cell is on gone, and its list not read again to go + on along: the cell says so.""" - @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) + self._is_error = True + self._present_unloaded("error", translate("Lists", "File not found")) - @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) + def _present_unloaded(self, status: str, text: str): + self.status.show(status) + self.status.set_text(text) - @only_navigable - def shuffle_video(self, is_end=False): - self._navigator.step(FolderStep.RANDOM, is_end=is_end) + if self._default_title is None: + self._default_title = self.video_params.uri_name - 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.title = self._custom_title or self._default_title + self.color = self.video_params.color.as_hex() - self._folder_navigator.cancel() - self._skip_failed_timer.stop() + def _present_stopped(self): + if self._default_title is None: + self._default_title = self.video_params.uri_name - 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. + if self.title is None: + self.title = self._custom_title or self._default_title - After the resolver is done telling: putting a video in the cell's - place starts the resolver over. - """ + self.color = self.video_params.color.as_hex() + self.is_audio_present_change.emit(False) + self.is_paused_change.emit(True) + self.is_stopped_change.emit(True) + self.video_status.hide() + self._sync_cell_background() + self.update() + self.show_overlay() - QTimer.singleShot(0, lambda: self._play_link_as_list(resolved)) + def _start_load(self): + # what went wrong before is behind it, but while it is tried again + if self.recovery.retries == 0: + self.status.last_error = None - def _play_link_as_list(self, resolved: ResolvedList): - if self._is_closing or self.video_params is None: - return + self.recovery.stop_skip_note() - media_list = self.media_list - item = self.list_item + # named from the start, on the overlay shown while it loads: a link + # by its file name until its page gives it a title + if self._default_title is None: + self._default_title = self.video_params.uri_name - if media_list is not None and item is not None: - index = media_list.index_of_key(item.key) + if self.title is None: + self.title = self._custom_title or self._default_title - self._ctx.lists.put( - media_list.model_copy( - update={ - "items": media_list.items[:index] - + resolved.items - + media_list.items[index + 1 :] - } - ) - ) + # a list still being read for it: nothing to load until it is + if self._ctx.lists.is_waiting(self.video_params.id): + self._present_waiting() + return - self.play_list_item(index) + # saved with the playlist as it waited: read again + if self.video_params.read_as_list: + self._ctx.commands.read_for_cell(self.video_params) + self._present_waiting() return - url = self.video_params.uri + self.navigation.is_waiting_for_list_read = False - source = ListSource(kind=ListSourceKind.URL, location=url) + if self.navigation.is_list_file_gone: + self.navigation.go_on_from_gone_file() + return - new_list = self._ctx.lists.add( - MediaList.read_from( - self.video_params.title or resolved.title, resolved.items, source - ) - ) + self._sync_cell_background() + self._ensure_video_driver() - self.take_list(new_list.id, 0) - self.play_list_item(0) + # the list read for it, if it was, then the link, then the video + 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, + ) - 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.audio.take_file_for_load() + self.subtitles.take_files_for_load() - self._switch_to(uri) + # 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.chapters.set_site_chapters(()) + self._site_length_ms = 0 - 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. + # and SponsorBlock is asked about it again, if it is one to ask about + self._youtube_id = None + self.sponsorblock.forget() - 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. - """ + # a file is never adaptive; a URL says so once its stream is picked + self.stream.is_adaptive = False - params = self.video_params + if self.video_params.is_http_url: + self.status.show_resolving_link() + self.url_resolver.resolve(self.video_params.uri, self._item_headers) + else: + self.status.show_opening() + self.load_video.emit( + MediaInput( + uri=str(self.video_params.uri), + is_live=False, + is_audio_only=False, + size=self.size_tuple, + video=self.video_params, + selected_audio_slave=self.audio.selected_slave_uri, + selected_subtitle_slaves=self.subtitles.selected_slave_uris, + ) + ) - self._cancel_steps() + def _load_and_play(self): + if self._is_error or self._is_state_change_in_progress: + return - params.list_id = list_id - params.list_index = index - params.title = None + self._is_state_change_in_progress = True + self._in_progress_timer.start() + self._set_playback_state(VideoInitialState.PLAYING) + self.status.show("processing") + self._start_load() - params.end_action = list_step_action(params.end_action) + def set_video_url(self, video: ResolvedVideo): + self._default_title = video.title - media_list = self.media_list - item = None if media_list is None else media_list.items[index] - self._held_item = item + if self._custom_title is None: + self.title = video.title - self._ctx.lists.take_up(list_id) + self.streams = video.streams + self.is_live = video.is_live - # 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 + # kept, like the streams, through a switch of quality or of dub + self.chapters.set_site_chapters(video.chapters) + self._site_length_ms = video.duration_ms - self._show_list_state() + self._youtube_id = video.youtube_id + self.sponsorblock.look_up() - def play_list_item(self, index: int | None): - """Put an item of the cell's list in the cell, by its place on it.""" + self.navigation.learn_item_details(video.details) - media_list = self.media_list + self.stream.load_quality(self.video_params.stream_quality) - if media_list is None: - return + def load_video_finish(self): + # the video is playing, so whatever went wrong before is behind us + self.recovery.note_played() + self.status.last_error = None - # none to play, every one around gone - if index is None or not 0 <= index < len(media_list.items): - self._last_error = ShownError( - title=translate("Lists", "Nothing on the list to play") - ) - self.error() - return + # a fresh player has no pass behind it to compare times against + self._last_time = None - item = media_list.items[index] + # final verdict belongs to VLC + self.is_live = self.video_driver.is_live - self._held_item = item - self.video_params.list_index = index + if self._default_title is None: + self._default_title = self.video_params.uri_name - # 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() + if self.title is None: + self.title = self._custom_title or self._default_title - self._switch_to(item.uri) + self.color = self.video_params.color.as_hex() - def show_list_window(self): - """The List window on the cell's list, which item of it it plays - clicked over the video.""" + self._has_item_played = True - if self.media_list is not None: - self._ctx.commands.list_window_for(self) + self.navigation.learn_item_length() - @property - def is_list_window_shown(self) -> bool: - return self._is_list_window_shown + self.audio.set_channel_mode(self.video_params.audio_channel_mode) + self.audio.set_delay(self.video_params.audio_delay_ms, is_silent=True) - 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.audio.set_volume(self.video_params.volume) + self.audio.set_muted(self.video_params.is_muted) - self._is_list_window_shown = is_shown + 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.overlay.set_list_window_shown(is_shown) + self.stream.set_reload_timer(self.video_params.auto_reload_timer_min) - if is_shown: - self.overlay_hide_timer.stop() + self.video_params.video_track_id = self.video_driver.cur_video_track_id - # up as long as the window is, and gone after the usual while once - # it is closed - self.show_overlay() + # a fresh player shows the picture, loaded in the tray it is not to + self._is_video_stopped_in_tray = False + if getattr(self._ctx, "is_video_decoding_stopped", False): + self.set_video_decoding_stopped(True) - 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.chapters.update() + self.sponsorblock.update() - self.show_overlay() - self.info_change.emit( - translate("Lists", "Added to the list: {COUNT}").format( - COUNT=count_txt(count) - ) - ) + self.is_audio_present_change.emit(bool(self.audio.tracks)) - 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. + self.audio.adopt_if_silent() - 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. - """ + # over the video until there is a picture of it to show instead + self.status.is_kept_for_picture = self.video_driver.is_picture_pending + if not self.status.is_kept_for_picture: + self.video_status.hide() - params = self.video_params + self.view.follow_synced() - if params is None or params.list_id is None: - return + self.video_driver.show() + self.is_stopped_change.emit(self.is_stopped) + self.time_change.emit(self.time, self.video_driver.length) + self.show_overlay() - item = self.list_item + notice = self.navigation.take_notice() - self._cancel_steps() + if notice is not None: + self.info_change.emit(notice) - params.title = None if item is None else item.title - params.list_id = None - params.list_index = None - self._held_item = None + self.video_driver.adjust_view() - # and what the list said to fetch it with - if item is not None: - params.http_headers = item.http_headers + def _picture_shown(self): + """Take the loading status off the video, now there is a picture. - self._show_list_state() + On Windows the status is a native window over the frame's, as made + (seen in the window tree), so it keeps VLC's windows and their black + out of sight until then. Unless it has been given something else to + show since. - 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.""" + The overlay is held back until then too: shown on the load, it sat + 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.status.is_kept_for_picture: + return - item = self.list_item + self.status.is_kept_for_picture = False - if item is None or item.duration_ms is not None or self.is_live: - return + self.video_status.hide() + self.show_overlay() - length = self.video_driver.length + @only_with_video_tacks + def set_aspect(self, aspect: VideoAspect): + self.video_params.aspect_mode = aspect - if not length or length <= 0: - return + self.video_driver.set_aspect_ratio(self.video_params.aspect_mode) - self._ctx.lists.update_item( - self.video_params.list_id, item.key, duration_ms=int(length) - ) + @only_with_video_tacks + def set_transform(self, transform: VideoTransform): + self.video_params.transform = transform - 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.""" + self.reload() - item = self.list_item + @only_with_video_tacks + def set_deinterlace(self, deinterlace: VideoDeinterlace): + self.set_deinterlace_params(deinterlace, self.video_params.deinterlace_mode) - if item is None or details is None: - return + @only_with_video_tacks + def set_deinterlace_mode(self, mode: VideoDeinterlaceMode): + # picking a mode is asking for it, as it is in VLC's own menu + deinterlace = self.video_params.deinterlace + if deinterlace == VideoDeinterlace.OFF: + deinterlace = VideoDeinterlace.ON - learned = merged_details(item.details, details) + self.set_deinterlace_params(deinterlace, mode) - if learned != item.details: - self._ctx.lists.update_item( - self.video_params.list_id, item.key, details=learned - ) + @only_with_video_tacks + def set_deinterlace_params( + self, deinterlace: VideoDeinterlace, mode: VideoDeinterlaceMode + ): + is_reload_needed = is_deinterlace_reload_needed( + self.video_params, deinterlace, mode + ) - def _show_list_place(self): - """Say over the video which item of its list the cell plays.""" + self.video_params.deinterlace = deinterlace + self.video_params.deinterlace_mode = mode - self.list_place_change.emit(self.list_place_txt) + if is_reload_needed: + self.reload() + else: + self.video_driver.set_deinterlace(deinterlace, mode) @property - def list_place_txt(self) -> str: - """Which item of its list the cell plays, "3/12"; empty where it - plays no list.""" + def seek_marks(self) -> tuple[SeekMark, ...]: + """What the seek bar marks, as last told by seek_marks_change.""" - media_list = self.media_list + return self._seek_marks - if media_list is None: - return "" + def update_seek_marks(self): + """Tell the seek bar what to mark: chapters, SponsorBlock's, and the + viewer's bookmarks.""" - place = self._list_navigator.place() - count = len(media_list.items) + chapters = ( + SeekMark( + time_ms=chapter.start_ms, + label=self.chapters.name(index), + kind=SeekMarkKind.CHAPTER, + ) + for index, chapter in enumerate(self.chapters.all) + ) - # the video playing taken off the list meanwhile - if place is None or not place.is_found: - return f"{NOT_ON_LIST_TXT}/{count}" + segments = ( + segment_mark(segment) for segment in self.sponsorblock.shown_segments + ) - return f"{place.index + 1}/{count}" + bookmarks = () + if self.is_video_initialized and not self.is_live: + bookmarks = self.bookmarker.marks(self.video_driver.length) - def _end_list(self, list_end_action: ListEndAction): - """The list has been played through, and this is what is done.""" + marks = (*chapters, *segments, *bookmarks) - 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) + if marks == self._seek_marks: + return - def _list_changed(self, list_id): - """The list edited, in this cell or another one playing from it.""" + self._seek_marks = marks - if self.video_params is None or list_id != self.video_params.list_id: - return + self.seek_marks_change.emit(marks) - self._follow_new_link() + # the viewer's bookmarks; what is done with them is the Bookmarker's - place = self._list_navigator.place() + @property + def bookmarks(self) -> tuple[Bookmark, ...]: + if self.video_params is None: + return () - if place is None: - return + return self._ctx.bookmarks.get(self.video_params.uri, self.video_params.id) - # 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] + @only_seekable + def set_end_action(self, end_action: VideoEndAction): + self.navigation.cancel_steps() - self._show_list_state() + self.video_params.end_action = end_action - 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.""" + @only_initialized + @only_seekable + def seek_shift_percent(self, shift_percent): + seek_ms = int(shift_percent / 100 * self.video_driver.length) - self._show_list_place() + self.seek_shift_ms(seek_ms) - if self._default_title is not None: - self.title = self._custom_title or self._default_title + @only_initialized + @only_seekable + def seek_shift_ms(self, seek_ms): + seek_stretch = self.loop_end - self.loop_start - @property - def _is_list_file_gone(self) -> bool: - """Whether the cell plays a list and the file it is on is gone.""" + if seek_ms > 0 and self.time + seek_ms > self.loop_end: + # Seeking past the end is the end arriving early, and a video that + # is meant to move on when it gets there should move on here too. + # Only where the end just starts the video over does the seek + # carry on into the next pass, which is what keeps wheeling + # through a looping video continuous. + if not self.is_end_a_loop: + self.loop_end_action() + return - params = self.video_params + rest = self.loop_end - self.time + seek_set = seek_ms - rest + seek_set = seek_set - (seek_set // seek_stretch) * seek_stretch - if params.list_id is None or not isinstance(params.uri, Path): - return False + new_time = self.loop_start + seek_set - try: - return not params.uri.is_file() - except OSError: - return True + elif seek_ms < 0 and self.time + seek_ms < self.loop_start: + seek_ms *= -1 - 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. + rest = self.time - self.loop_start + seek_set = seek_ms - rest + seek_set = seek_set - (seek_set // seek_stretch) * seek_stretch - 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. - """ + new_time = self.loop_end - seek_set - list_id = self.video_params.list_id + else: + new_time = self.time + seek_ms - if self._ctx.lists.is_read_expected(list_id): - self._is_waiting_for_list_read = True - self._present_waiting() - return + self.seek(new_time) - if self._ctx.lists.is_as_saved(list_id): - self._present_gone_file() - return + @only_initialized + @only_seekable + def seek_step(self, slot, direction): + """Seek by one of the playlist's steps, "short", "medium" or "long", + forwards (direction 1) or back (-1).""" - self._list_navigator.step(FolderStep.NEXT) + self.seek_shift_ms(direction * seek_step_ms(slot)) - def _on_list_reading(self, list_id): - """Its list read again as the playlist opened, or not: the cell - waiting for it goes on.""" + @only_initialized + @only_seekable + def seek_random(self, is_viewers=True): + random_ms = random.randint(self.loop_start, self.loop_end) - 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.seek(random_ms, is_viewers=is_viewers) - self._start_load() + @only_initialized + @only_seekable + def seek_percent(self, percent): + seek_ms = int(percent * self.video_driver.length) - 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.""" + self.seek(seek_ms) - held = self._held_item + @only_initialized + @only_seekable + def seek(self, seek_ms, is_viewers=True): + """Go to a time, for the viewer or for the player itself. - if held is None: - return + A stretch to be skipped that a seek of the viewer's lands in is left + to play, the viewer having gone there. One the player lands in, going + round to the start or past another, is skipped as it would be if it + were played into. + """ - item = self.media_list.item_by_key(held.key) + if seek_ms < self.loop_start or seek_ms > self.loop_end: + seek_ms = self.loop_start - if item is None or item.uri == held.uri: - return + # the viewer going back into the video stays in it, whatever file + # its end was looking for + if is_viewers: + self.navigation.cancel_steps() - # gone on to another video meanwhile - if file_key(held.uri) != file_key(self.video_params.uri): - return + # Marked before the seek is made, not after: the single process + # drivers call libVLC on this very thread, and it reports the new time + # from inside that call. Marked afterwards, a seek that moves the time + # backwards -- wheeling past the end lands back at the start -- reads + # as a finished pass, and the end action then stops the player from + # inside libVLC, which deadlocks against the seek it is still making. + self._last_time = seek_ms + self._seek_settle_timer.start() - self.video_params.uri = item.uri - self._held_item = item + # made before the one before it has landed, while the stream is still + # restarting for that one, it is likely to be turned down + is_behind = self._seek_target is not None and self._seek_landed_at is None + behind = self._seek_target - 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. + self._seek_target = seek_ms + self._seek_landed_at = None - 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. - """ + # one that close to the end can run into it before it is seen to + # 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.stream.is_adaptive and not is_near_end else 0 + ) + self._seek_behind = behind if is_behind and self._seek_retries else None - if self.media_list is None or self._is_closing: - return + self.sponsorblock.note_seek(seek_ms, is_viewers) - self._list_failures_in_a_row += 1 + self.time = seek_ms + self.video_driver.set_time(seek_ms) + + @property + def arriving_time(self) -> int: + """Where the video is, a seek still on its way counting as there already. - if not self._is_item_dead: - self._network_failures_in_a_row += 1 + Read off the time alone, a seek on a stream sits short of where it + was aimed for a second or so: a jump to a chapter reads as still in + the one before, so a press of next jumps to the same one again. + """ - if self._is_failed_skipped: - self._start_skip_count() + return arriving_time(self.time, self._seek_target) @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.""" + def is_seek_on_its_way(self) -> bool: + """Whether the last seek has yet to be seen to land.""" - if self.video_params.is_local_file: - return True + return self._seek_target is not None - error = self._last_error + @only_with_video_tacks + @only_seekable + @only_initialized + def next_frame(self): + self.step_frame(1) - return error is not None and not error.is_retryable and not error.is_refused + @only_with_video_tacks + @only_seekable + @only_initialized + def previous_frame(self): + self.step_frame(-1) - def _given_up_txt(self) -> str: - """Why the list no longer goes on past the item failed, if it - doesn't.""" + @only_with_video_tacks + @only_seekable + @only_initialized + def step_frame(self, frames): + if not self.video_params.is_paused: + self.set_pause(True) + return - media_list = self.media_list + ms_per_frame = self.video_driver.get_ms_per_frame() - 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 - ) + # Counted from the frame the video is on rather than added to the + # time it reports, so that steps rounded to whole milliseconds cannot + # pile up their leftovers until a press lands back on the frame it + # started from. The seek aims at the middle of the frame it wants, + # since a whole millisecond lands on either side of a frame's edge. + frame_no = int(self.time // ms_per_frame) + new_time = round((frame_no + frames + 0.5) * ms_per_frame) - if self._list_failures_in_a_row > len(media_list.items): - return translate("Video Status", "Stopped: nothing on the list plays") + self.seek_shift_ms(new_time - self.time) - return "" + 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). - @property - def _is_failed_skipped(self) -> bool: - if self.media_list is None or self._is_closing: - return False + 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 ViewControl.pan_by). + """ - if self.video_params.list_fail_action != ListFailAction.PLAY_NEXT: - return False + result = attrgetter(command)(self)(*args) - return not self._given_up_txt() + synced = self.view.synced() - @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.""" + if synced is not None: + self.sync_view.emit(synced) - error = self._last_error + return result - return ( - error is not None - and not error.is_retryable - and not self._has_item_played - and self._is_failed_skipped - ) + def manual_rate(self, command): + """Change the speed as the viewer asked, for the others to follow + where speeds are synced (see VideoBlocksManager).""" - def _start_skip_count(self): - self._skip_failed_timer.start(self.video_params.list_fail_wait_sec * 1000) + attrgetter(command)(self)() - if self._skip_failed_timer.interval() > 0: - self._skip_note_timer.start() + if self.is_video_initialized and not self.is_live: + self.sync_rate.emit(self.video_params.rate) - 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.""" + def follow_rate(self, rate: float): + """Play at another video's speed, quietly. One that is not playing + yet plays at it once it is.""" - if not self._is_error: + if self.is_live: return - if self._is_failed_skipped: - self._start_skip_count() - else: - self._skip_failed_timer.stop() + self.video_params.rate = rate - self._refresh_skip_note() + self.set_rate(rate, is_silent=True) - def _skip_failed_item(self): - if self._is_closing or self.media_list is None: - return + @only_initialized + @only_seekable + def rate_increase(self): + self.video_params.rate += 0.1 + self.video_params.rate = min(round(self.video_params.rate, 1), MAX_RATE) - # 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 - 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. - """ + self.set_rate(self.video_params.rate) + + @only_initialized + @only_seekable + def rate_decrease(self): + self.video_params.rate -= 0.1 + self.video_params.rate = max(round(self.video_params.rate, 1), MIN_RATE) - params = self.video_params + self.set_rate(self.video_params.rate) - if is_dir_recursive == params.is_dir_recursive: - return + @only_initialized + @only_seekable + def rate_reset(self): + self.set_rate(1.0) - self._cancel_steps() + @only_initialized + @only_seekable + def set_rate(self, rate, is_silent=False): + self.video_params.rate = rate - params.is_dir_recursive = is_dir_recursive + self.video_driver.set_playback_rate(rate) - 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._last_error = file_error(ReadErrorKind.MISSING, self.video_params.uri) - self.error() - return + if not is_silent: + self.info_change.emit(f"Speed: {rate}") - self._switch_to(new_video) + def set_pause(self, paused): + if self._is_state_change_in_progress: + return - 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 not self.is_video_initialized: + if not paused: + self._load_and_play() + return - if new_video == self.video_params.uri: - if self.is_video_initialized: - self._seek_loop_start() - return - self._load_and_play() + if self.video_params.is_paused == paused: return - self._close_bookmarks_manager() + self._is_state_change_in_progress = True + self._in_progress_timer.start() - self._cancel_steps() + self.video_driver.set_pause(paused) - self.reset_loop() - 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 + def play_pause(self): + """Play or pause as the viewer asked, the others following where + playing and pausing is synced (see VideoBlocksManager).""" - self.set_video(self.video_params) + if self._is_state_change_in_progress: + return - def _forget_files_of_previous_video(self): - """Leave the audio and subtitle files picked for the video that was - here behind with it. + is_paused = not self.video_params.is_paused - They were picked for that one; files of the next one's own are found - again where they are looked for by themselves. - """ + self.set_pause(is_paused) - self._drop_external_audio() - self._drop_external_subtitles() + self.sync_pause.emit(is_paused) def rename(self): cur_title = self.title @@ -6649,68 +2209,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 new file mode 100644 index 00000000..a683dd1e --- /dev/null +++ b/gridplayer/widgets/video_block_audio.py @@ -0,0 +1,763 @@ +"""The sound of a video block: its tracks, where they go out of the machine, +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 +stream part's to follow (see StreamControl.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.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.stream.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) + + 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: + 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/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_chapters.py b/gridplayer/widgets/video_block_chapters.py new file mode 100644 index 00000000..3270d7c1 --- /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.loop.reset() + + 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.loop.set_end_time(end_ms) diff --git a/gridplayer/widgets/video_block_guards.py b/gridplayer/widgets/video_block_guards.py new file mode 100644 index 00000000..cb8dc2ac --- /dev/null +++ b/gridplayer/widgets/video_block_guards.py @@ -0,0 +1,90 @@ +"""Commands of a VideoBlock that only make sense for some videos, left +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): + block = _block_of(args[0]) + if not block.is_video_initialized: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_seekable(func): + def wrapper(*args, **kwargs): + block = _block_of(args[0]) + if block.is_live: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_with_video_tacks(func): + def wrapper(*args, **kwargs): + block = _block_of(args[0]) + if not block.video_tracks: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_live(func): + def wrapper(*args, **kwargs): + block = _block_of(args[0]) + if not block.is_live: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_local_file(func): + def wrapper(*args, **kwargs): + block = _block_of(args[0]) + if not block.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): + block = _block_of(args[0]) + if not block.is_navigable: + return None + return func(*args, **kwargs) + + return wrapper + + +def only_streamable(func): + def wrapper(*args, **kwargs): + block = _block_of(args[0]) + if not block.streams: + return None + return func(*args, **kwargs) + + return wrapper 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_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 new file mode 100644 index 00000000..6e6e471d --- /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.status.last_error is not None: + self._block.video_status.show_error( + self._block.status.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.status.load_stage = LoadStage.RECONNECT + self._block.status.show("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.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) + 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.navigation.next() + + 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.status.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.status.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.navigation.next(is_end=True) 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/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/gridplayer/widgets/video_block_status.py b/gridplayer/widgets/video_block_status.py new file mode 100644 index 00000000..2d648ed0 --- /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.navigation.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 new file mode 100644 index 00000000..0f9c68b9 --- /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.status.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/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/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/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/conftest.py b/tests/conftest.py index cee6adb1..f77a422a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,63 @@ COOKIE_LIFETIME_SEC = 10000 +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_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 + 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 + + return { + "audio": AudioControl, + "bookmarker": Bookmarker, + "chapters": ChapterControl, + "loop": LoopControl, + "navigation": NavigationControl, + "recovery": RecoveryControl, + "screenshot": ScreenshotControl, + "sponsorblock": SponsorBlockControl, + "status": StatusControl, + "stream": StreamControl, + "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 block + + class FakeSettings: """A page of settings, answering for itself. 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"]] + ) 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_audio_commands_while_loading.py b/tests/test_audio_commands_while_loading.py index 5dbc2397..37cd90be 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", - "set_audio_language", - "apply_audio_preference", - "audio_languages_dialog", - "get_audio_languages", + "audio.set_track", + "stream.set_audio_language", + "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..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,7 +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) @@ -121,29 +122,35 @@ 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") - 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.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 @@ -151,7 +158,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 +169,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 +189,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 +202,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) @@ -212,19 +218,18 @@ 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" - VideoBlock.apply_audio_preference(block) + 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): 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 +240,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 +250,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 +258,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 +268,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 +299,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 +311,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 +345,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_bookmarks_dialog.py b/tests/test_bookmarks_dialog.py index cc857120..701f4c61 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() @@ -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() @@ -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 @@ -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() @@ -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 @@ -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 @@ -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)) @@ -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_close_teardown.py b/tests/test_close_teardown.py index e2e6adbc..49389320 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() @@ -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_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 09391ed6..c5178411 100644 --- a/tests/test_deinterlace.py +++ b/tests/test_deinterlace.py @@ -21,10 +21,15 @@ 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 +from tests.conftest import mock_block_parts URI = "http://example.com/a.mp4" @@ -182,7 +187,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 +200,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): @@ -265,6 +270,7 @@ def _snapshot_block(self, current, wanted): block = _block(*current) block.is_video_initialized = True block._default_title = "a" + mock_block_parts(block) 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 new file mode 100644 index 00000000..a71ff4ab --- /dev/null +++ b/tests/test_dotted_commands.py @@ -0,0 +1,232 @@ +"""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 + +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): + 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.bookmarker.seek.return_value = "sought" + + result = _active_manager(block).cmd_active("bookmarker.seek", 3) + + block.bookmarker.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("bookmarker.next") + + block.bookmarker.next.assert_not_called() + + +def test_runtime_param_reaches_a_part_of_the_block(mocker): + block = _playing_block(mocker) + block.bookmarker.count = 2 + manager = _active_manager(block) + + 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, "bookmarker.seek", 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.bookmarker.seek.return_value = False + + VideoBlock.manual_seek(block, "bookmarker.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.view.synced.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) 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_folder_navigation.py b/tests/test_folder_navigation.py index 07d2e0b6..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,11 +322,11 @@ 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._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") @@ -334,11 +335,11 @@ 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._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" @@ -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_menu_audio_click.py b/tests/test_menu_audio_click.py index 8f565132..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 ( @@ -28,6 +29,8 @@ 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) @@ -95,46 +98,20 @@ def set_audio_track(self, track_id): self.cur_audio_track_id = self.tracks_manager.audio_track -class _Block: - """A block with the real audio methods 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 - 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 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) @@ -142,10 +119,14 @@ 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.stream = StreamControl(self) + self.stream.load_quality = self.load_stream_quality + self.load_stream_quality("best") @property @@ -155,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 @@ -288,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(): @@ -332,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") @@ -378,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_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..556f2e2a 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,25 @@ def _manager( ) block = SimpleNamespace( - audio_tracks=audio_tracks, - 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 + 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), + ), + stream=SimpleNamespace( + audio_language_options=languages, + audio_language=playing_language, + preferred_audio_language=preferred_language, + audio_language_playing=playing_language, ), - attached_audio_file=next(iter(external_tracks.values()), None), + is_local_file=is_local_file, video_params=SimpleNamespace( audio_selection=AudioPreferred() if selection is None else selection, external_audio=external, @@ -165,8 +170,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 +187,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 +217,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 +226,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 +320,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 +482,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 +560,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_menu_bookmarks.py b/tests/test_menu_bookmarks.py index 1cbfe6ce..77b14c54 100644 --- a/tests/test_menu_bookmarks.py +++ b/tests/test_menu_bookmarks.py @@ -13,7 +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 tests.conftest import is_block_command BOOKMARK_ACTIONS = ( "Add Bookmark", @@ -99,18 +99,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_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_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_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_overlay_stopped.py b/tests/test_overlay_stopped.py index aba0ee24..bc61fa1b 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, @@ -320,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): @@ -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) @@ -640,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_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_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_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_subtitle_encoding.py b/tests/test_subtitle_encoding.py index ebb6486b..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 @@ -28,12 +27,14 @@ ) 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 +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 @@ -222,19 +223,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("") @@ -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_sync.py b/tests/test_sync.py index 3824a30b..370c90cd 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -32,9 +32,10 @@ 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_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_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 1f1c382b..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 @@ -8,7 +7,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 +15,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_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..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,7 +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) @@ -50,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): @@ -143,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" @@ -182,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() @@ -194,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): @@ -227,25 +236,23 @@ 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" - VideoBlock.apply_audio_default(block) + 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" - 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): @@ -301,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"} @@ -312,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"} @@ -322,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 @@ -331,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_bookmarks.py b/tests/test_video_block_bookmarks.py index 36e28688..edb66236 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,15 +404,15 @@ 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 == [] 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.seek_bookmark(1) + block.bookmarker.seek(1) _reported(block, 30000) _reported(block, 30400) _reported(block, 31000) @@ -431,44 +432,42 @@ 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 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() @@ -546,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] @@ -555,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 @@ -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) @@ -674,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)] @@ -716,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) @@ -727,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,)) @@ -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] @@ -795,33 +796,33 @@ 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.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.navigation.next() 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() @@ -885,16 +886,16 @@ 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.set_bookmarks(theirs) + block.navigation.next() + 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,)) @@ -994,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] @@ -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() @@ -1126,29 +1127,29 @@ 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.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_chapters.py b/tests/test_video_block_chapters.py index 83565e6c..f32b3736 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,37 +473,37 @@ 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()) - block.switch_stream_quality("360p") + block.stream.switch_quality("360p") assert block.is_video_initialized - assert block.stream_quality_playing == "360p" - assert block.chapters == SITE_CHAPTERS + 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( 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_external_audio.py b/tests/test_video_block_external_audio.py index c61c5253..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 ( @@ -17,6 +17,8 @@ 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 +from gridplayer.widgets.video_block_stream import StreamControl @pytest.fixture(scope="module", autouse=True) @@ -69,37 +71,12 @@ def set_audio_track(self, track_id): self.tracks_set.append(track_id) -class _Block: - """A block with the real external audio methods, 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 - 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 def __init__( self, @@ -109,17 +86,21 @@ def __init__( autodiscover=True, external_id=None, ): + super().__init__() + self._log = logging.getLogger("test-block") self.video_params = Video(uri=video_path) 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 +109,22 @@ 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) + self.stream = StreamControl(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 +138,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 +153,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 +164,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 +172,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 +181,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 +194,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 +202,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 +216,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 +225,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 +233,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 +242,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 +292,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 +312,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 +327,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 +335,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 +351,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 +361,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 +372,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 +380,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 +390,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 +407,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 +418,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 +443,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 +454,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 +468,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 +479,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 +487,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 +497,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 +518,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 +536,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 +552,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 +571,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 +591,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 +601,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 +610,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) 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_lists.py b/tests/test_video_block_lists.py index 61bc6778..6585a1bf 100644 --- a/tests/test_video_block_lists.py +++ b/tests/test_video_block_lists.py @@ -35,10 +35,11 @@ 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 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, ) -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 @@ -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,9 +215,9 @@ 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._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( @@ -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() @@ -535,7 +538,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) @@ -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 @@ -665,9 +668,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 +678,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 +686,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 +695,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 +725,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 +736,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 +777,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,30 +801,30 @@ 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) 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._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) 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._skip_failed_timer.stop() + 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._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): @@ -831,14 +834,14 @@ 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._skip_failed_timer.stop() + block.recovery.stop_skip() - block._last_error = refused + block.status.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.""" @@ -848,11 +851,11 @@ 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._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 +865,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.navigation.next() 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 +883,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 +896,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): @@ -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), {}) @@ -1037,7 +1040,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 +1048,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 @@ -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_network_retry.py b/tests/test_video_block_network_retry.py index fae733b6..bea9f8de 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.is_closing = 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.status.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,66 @@ 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 - block.set_status.assert_called_once_with("network-error") + # 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.status.show.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.status.show.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 + 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 +172,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_block_quality_adapt.py b/tests/test_video_block_quality_adapt.py index 40dda5ff..268f44a1 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_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._is_error = True + 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._is_closing = True + 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_repeat.py b/tests/test_video_block_repeat.py index 3eb4419c..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) @@ -34,7 +35,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 @@ -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): @@ -162,9 +163,9 @@ 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._is_zoom_ui.return_value = False - block._is_list_window_shown = False + block.status.is_kept_for_picture = False + block.view.is_zoom_ui.return_value = 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 @@ -585,7 +586,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 +609,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..a1f1f397 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): @@ -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) @@ -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) @@ -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 @@ -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 27ea51e7..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") @@ -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) @@ -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_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_errors.py b/tests/test_video_errors.py index 896fce4d..5c217b41 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") @@ -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( @@ -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,31 +863,31 @@ 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_", - side_effect=lambda: while_read.append(block._skip_failed_timer.isActive()), + "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 - 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) 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 - 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 @@ -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() @@ -919,11 +921,11 @@ 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 - block.switch_video(None) + block.navigation.switch_video(None) assert block.video_status.error.title == "File not found" @@ -933,12 +935,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 @@ -953,12 +955,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 +970,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): @@ -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() @@ -1045,7 +1047,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,12 +1058,13 @@ 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 @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 @@ -1086,26 +1089,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,16 +1119,16 @@ 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) block._has_item_played = True - block.next_video() + block.navigation.next() _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 +1139,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 +1204,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 diff --git a/tests/test_video_position.py b/tests/test_video_position.py index a6abfeea..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 @@ -38,10 +37,13 @@ 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_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 URI = "http://example.com/a.mp4" NO_SHIFT = VideoShift(0, 0) @@ -232,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): +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 @@ -243,7 +246,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, method or command) assert hasattr(VideoBlocksManager, f"all_{command}") assert action["check_if"] == ("is_all_videos_param_set_to", param, value) @@ -277,8 +280,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 +301,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 +315,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 +345,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 +360,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 +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") + _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.""" @@ -413,16 +422,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 +430,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 +438,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 +448,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 +459,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 +470,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 +481,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 +490,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 +499,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 +515,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 +524,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 +535,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 +558,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 +567,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 +579,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 +587,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 +596,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 +604,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 +615,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 +624,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 +646,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 +655,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 +667,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 +675,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 +697,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 +717,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 +735,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() @@ -813,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 @@ -837,10 +822,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 +879,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" + mock_block_parts(block) snapshot = Video( uri=URI, @@ -903,9 +889,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 +900,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" + mock_block_parts(block) 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)