Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
94 changes: 82 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<part>.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.<part>.<method>` 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_<part>.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

Expand Down
17 changes: 14 additions & 3 deletions gridplayer/dialogs/audio_delay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()

Expand All @@ -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)
40 changes: 22 additions & 18 deletions gridplayer/dialogs/bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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])
Expand All @@ -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)
Expand All @@ -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)])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions gridplayer/dialogs/crop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion gridplayer/dialogs/media_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading