Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c415bbd
Add optional legacy playback controls and direct hidden-HUD seeking
stefan-ovezea Oct 22, 2026
b6f9452
Preserve the play queue across item navigation and cover simulator tr…
stefan-ovezea Oct 22, 2026
b60f917
Refresh retained play queue windows when changing items
stefan-ovezea Oct 22, 2026
0b3d4e8
Keep hidden seeks dismissed through post-seek priming
stefan-ovezea Oct 22, 2026
9ee5b39
Clarify the queue helper module responsibility
stefan-ovezea Oct 22, 2026
9b6b4ed
Bound GC test pipe cleanup after killing a process group
stefan-ovezea Oct 22, 2026
4cd8631
Pin the feed slice regression fixture to a fixed pacing reference
stefan-ovezea Oct 22, 2026
421f83a
Register the retained play queue window request
stefan-ovezea Oct 22, 2026
d09855d
Allow the pipe-holder fixture the same startup budget as its companion
stefan-ovezea Oct 22, 2026
0272b81
Merge upstream main and share its HUD title fitting with legacy controls
stefan-ovezea Oct 22, 2026
0bf62fb
Route legacy actions through the upstream player session owner
stefan-ovezea Oct 22, 2026
71f6e2a
Rename optional playback controls from Legacy to Classic
stefan-ovezea Oct 22, 2026
eec0e92
Accumulate classic tap-seek targets across early keyframe landings
stefan-ovezea Oct 22, 2026
3de7b64
Refresh seek regression evidence and screen-shape replay anchors
stefan-ovezea Oct 22, 2026
2afcd98
Allow either adjacent tap pair to share the regression keyframe
stefan-ovezea Oct 22, 2026
f25a297
Separate playback options and unify episode navigation controls
stefan-ovezea Oct 25, 2026
dd1165c
Merge branch 'main' of github.com:GLinnik21/plx-native into codex/leg…
stefan-ovezea Oct 25, 2026
05aa02d
Preserve FF/REW input identity and refresh playback regression evidence
stefan-ovezea Oct 25, 2026
4eb388f
Show Previous/Next without a setting; tidy leftover naming
claude Oct 25, 2026
97cb8dc
Merge remote-tracking branch 'origin/main' into pr921-final
claude Oct 25, 2026
0a6d418
Simplify the playback-navigation code and fix the PR's CI failures
claude Oct 25, 2026
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
1 change: 1 addition & 0 deletions assets/icons/next.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/previous.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions ci/plex-requests.ini
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ class = whole
reason = The subtitle agent's candidates for one item, read whole; the endpoint takes no window.
evidence = rust-modules/plex/src/plex/subtitles.rs:129

[queue_window.rs::play_queue_window]
class = bounded
parameter = window
reason = Reads the existing queue around a target item; each handoff moves the window so navigation continues beyond the first response.
evidence = rust-modules/plex/src/plex/queue_window.rs:16

[timeline.rs::create_play_queue]
class = whole
reason = The play queue the server opens with continuous=1, kept whole; the server picks its window.
Expand Down
26 changes: 19 additions & 7 deletions ci/test_build_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,29 @@ def run_guarded(argv, cwd, env, timeout=HANG_GUARD_SECS):
the script's stages run in `$(...)` and python children that inherit those pipes: a hung grandchild
keeps them open and the "timeout" blocks forever in `communicate()`. The child is therefore started
as its own session and process-group leader, and on timeout the whole group - which this call
created, so the pgid is the pid it started - is killed before `TimeoutExpired` is raised."""
created, so the pgid is the pid it started - is killed before `TimeoutExpired` is raised.
A shell can fork during the first group kill; retry with a bounded pipe drain instead of
replacing the original timeout with an unbounded `communicate()`."""
proc = subprocess.Popen(argv, cwd=cwd, env=env, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, start_new_session=True)
try:
out, err = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except OSError:
pass
proc.communicate()
for _ in range(10):
try:
os.killpg(proc.pid, signal.SIGKILL)
except OSError:
pass
try:
proc.communicate(timeout=1)
break
except subprocess.TimeoutExpired:
continue
else:
# A pipe held outside the owned group must still not hang the entire test runner.
proc.stdout.close()
proc.stderr.close()
proc.wait(timeout=1)
raise
return subprocess.CompletedProcess(argv, proc.returncode, out, err)

Expand Down Expand Up @@ -198,7 +210,7 @@ def test_a_run_whose_grandchild_holds_the_pipes_is_still_ended_by_the_guard(self
started = time.monotonic()
with self.assertRaises(subprocess.TimeoutExpired):
run_guarded(["sh", "-c", "sleep 3600 & echo $! > " + shlex.quote(str(pidfile)) + "; wait"],
self.root, os.environ.copy(), timeout=2)
self.root, os.environ.copy(), timeout=5)
self.assertLess(time.monotonic() - started, 60)
pid = int(pidfile.read_text())
deadline = time.monotonic() + 10
Expand Down
1 change: 1 addition & 0 deletions docs/parity-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,7 @@ player, transport and tracks auditors, and is counted once in the themes above.
*Verified:* CONFIRMED verbatim. rust-modules/src/app.rs:1405 handles `vis && hud_nav.focus == 1 && !ctrl.is_discs()`; app.rs:1409 is an empty `} else if vis && hud_nav.focus == 1 {` whose block closes on line 1410, so app.rs:1410-1413 (`track_menu::open_tab` + `route = Route::Player { overlay: Overlay::Menu }`) is unreachable for the ONLY condition that can still reach it (the discs). `git diff rust-modules/src/app.rs` confirms provenance: the pre-branch code was a single `if vis && hud_nav.focus == 1 {`, and the uncommitted skip-pill/Up-Next work split it into the guarded arm plus the stray empty one. Co

- **No play/pause transport button and no previous/next-item buttons — the entire CENTER cluster is missing** — `major` / `medium`
*Fork update (2026-10-25):* Previous/Next now share the default disc row for an episode with a neighbour in its PlayQueue; there is no setting for them. Instant D-pad seeking is a separate opt-in setting, Off by default; dedicated FF/REW taps jump without it. Queue rows survive item handoffs, preserving Previous. The comparison below predates this work. See [Playback navigation](playback-navigation.md).
Plex HTPC's centre cluster is |◀ / a large white FILLED circular play-pause (the only filled control on the HUD) / ▶|. We draw none of them. Play/pause exists only as an unlabelled fallthrough — OK when HUD focus is not on a control row (app.rs:1422-1430) or a pointer click on empty video (app.rs:1697-1705) — and the only play-state affordance drawn is a small NON-interactive Pause glyph beside the elapsed clock, documented at its call site as 'a state read-out, not an action toggle' (player_hud.rs:446-464). There is therefore no focusable, hit-testable play/pause anywhere, and no way to jump to the next or previous episode from the transport: Up Next only appears once the playhead is inside a credits marker AND a successor is queued (player_hud.rs:252-264), so mid-episode there is no next-item control at all, and previous-item does not exist in any form.
*Where:* ui/player_hud.rs:333-486 for the drawn cluster + a hit-test beside `icon_hit` (player_hud.rs:289-297); ui/widgets.rs `CircleButton` already gives the disc look (focus-driven `ControlStyle::Accent` — the always-filled `Primary` style was retired in the 2026-08-13 palette sync). Next-item can reuse `route::up_next()` + `app.rs::play_up_next` (app.rs:824-845) unchanged. Previous-item needs `plex::Client::create_play_queue` (plex/timeline.rs:52-70) to also return the row BEFORE the selected one.
*Device:* Play/pause and next-item are cheap: both already work through paths the auto-advance chain exercises (`player::pause`/`resume` and the stop+Load ritual in app.rs:824-845). Previous-item is the expensive one — starting a different item is a full engine teardown + fresh Starfish `Load` on the buffer-feed pipeline, and `next_after` deliberately DRAINS the queue rows rather than cloning them because 'a `Metadata` row carries the whole Media/Part/Stream/Role tree and this runs on the resolve worker on a 32-bit TV' (plex/timeline.rs:71-74), so retaining the predecessor row costs real heap on the ARM target.
Expand Down
88 changes: 88 additions & 0 deletions docs/playback-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Playback navigation

**Previous/Next** are always part of the default control row for an episode with a neighbor
in its existing PlayQueue; there is no setting for them. Movies, extras and a queue without
either neighbor omit both, which leaves the three existing discs at exactly the same positions.

Settings → Video & playback has one per-profile option, **Instant D-pad seeking**, Off by
default. Off preserves reveal → target → OK for Left/Right. On makes a fresh Left/Right press
jump immediately by the configured Skip interval when the controls are hidden. With controls
visible, it still previews and waits for OK. Holding the D-pad in this optional
hidden-controls path does not start a scrub.

Dedicated **rewind/fast-forward** keys work independently of that option. A tap commits one
Skip interval jump on release, without opening the controls or requiring OK. Waiting for the
release lets a hold use the existing shared held-key mechanism without first seeking. A hold
raises the scrubber, moves its pending target continuously and waits for OK; Back cancels it.

Immediate jumps preserve pause. Fresh taps no more than 1.5 seconds apart accumulate from the
last clamped requested target, including when reversing direction. An earlier keyframe landing
therefore cannot erase the requested travel. After a longer gap, the next tap starts from the
current intended playhead. Another control, pointer interaction, a held scrub or a new item
ends the burst; a same-item seek reload preserves it. A dismissed immediate seek stays hidden
through the first new picture; ordinary rebuffering and failures retain their diagnostic read-out.

The default control row is **Previous, Next**, a wider gap, then **Subtitles, Audio, More**.
Previous restarts at five seconds or later and shows the restart arrow. Before five seconds it
plays the preceding queue item. Unavailable buttons are dimmed, have no pointer stop and are
skipped by keyboard focus. Entering the row focuses Next when enabled, otherwise its first
enabled button. Marker offers and Up Next retain their precedence over this row.

## Queue continuity and maintenance

The feature reuses the existing player screen, focus engine, transport widget, seek request and
stop/resolve/start transaction. It does not copy a player renderer or change native decoding.
Its main files are:

| File under `rust-modules/` | Responsibility |
| --- | --- |
| `screens/src/player/seek_jump.rs` | FF/REW tap policy and optional D-pad jumps |
| `appkit/src/player_hud/seek_jump.rs` | Short-burst requested-target accumulation |
| `appkit/src/player_hud.rs` | One default disc row, availability, icons and shared HStack geometry |
| `media/src/route/queue_navigation.rs` | Queue neighbors, continuation and five-second rule |
| `plex/src/plex/session/playback_options.rs` | Independent preference types and defaults |
| `plex/src/plex/queue_window.rs` | Read the existing PlayQueue around a target item |
| `src/app/playback/queue_transport.rs` | Queue handoff shared with Up Next |

Queue handoffs preserve the PlayQueue ID, selected item ID and known rows through the shared
resolve environment. Creating a replacement queue from its remaining tail would lose Previous.
Each handoff reads the existing window around the target item ID using
`GET /playQueues/{playQueueId}` with `center`, `window`, `includeBefore`, `includeAfter` and
`own=0`, as specified in `docs/plex-openapi.json`. Expired, unavailable or mismatched windows
retain the known snapshot. The queue-window behavior is shared with automatic advance.

The D-pad option serves remotes without dedicated FF/REW keys. It can also make frequent jumps
through filler sections in anime more convenient while preserving the ordinary D-pad default.

## Synthetic regression

Use a fresh evidence directory for each run. No household account is needed:

```sh
make sim-macos
python3 tests/fixtures/make_fixtures.py --only mockverify --out /tmp/navigation-media
python3 tests/playback_navigation_sim.py --sim rust-modules/target-sim/debug/plxnative-sim \
--media /tmp/navigation-media --out /tmp/navigation-evidence
```

This exercises queue continuity, restart after five seconds, an immediate hidden-HUD FF tap,
and visible-HUD D-pad confirmation. Add `--instant-dpad` to exercise the optional D-pad path.
For a repeated-keyframe regression, make a copy with 12-second keyframes (24 fps):

```sh
mkdir -p /tmp/navigation-long-gop
ffmpeg -y -i /tmp/navigation-media/mockverify-v1.mkv -t 60 -map 0 \
-c:v libx264 -preset ultrafast -b:v 8M -minrate 8M -maxrate 8M -bufsize 16M \
-x264-params nal-hrd=cbr -g 288 -keyint_min 288 -sc_threshold 0 \
-c:a copy -c:s copy /tmp/navigation-long-gop/mockverify-v1.mkv
cp /tmp/navigation-media/mockverify-v2.mkv /tmp/navigation-media/mockverify-v2.eng.srt /tmp/navigation-long-gop/
python3 tests/playback_navigation_sim.py --sim rust-modules/target-sim/debug/plxnative-sim \
--media /tmp/navigation-long-gop --out /tmp/navigation-burst --rapid-seeks
```

The burst regression uses FF taps with the D-pad option Off. Three requested targets advance by
exactly ten seconds while two adjacent taps land on the same keyframe and the sequence reaches
a later keyframe. Host tests also cover REW reversal, intervals, bounds, clock wrap, expiry,
reset, tap versus hold, disabled focus, row spacing and title fitting. Simulator checks do not
establish LG decoding, text rasterization or hardware video-plane behavior; use `tv-session`
for those before shipping.
6 changes: 6 additions & 0 deletions locales/be/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Імгненны пераход стрэлкамі",
"settings.playback.instant_dpad_seeking.copy": "Калі кіраванне схавана, Улева/Управа перамотвае на зададзены інтэрвал. Калі бачна — пацвердзіце кнопкай OK. Калі выключана, спачатку паказваецца кіраванне.",
"settings.playback.instant_dpad_seeking.off": "Выключана",
"settings.playback.instant_dpad_seeking.on": "Уключана"
}
6 changes: 6 additions & 0 deletions locales/de/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Direktsprung per Steuerkreuz",
"settings.playback.instant_dpad_seeking.copy": "Bei ausgeblendeten Bedienelementen springt Links/Rechts um das gewählte Intervall. Sonst bestätigt OK. Aus blendet zuerst die Bedienelemente ein.",
"settings.playback.instant_dpad_seeking.off": "Aus",
"settings.playback.instant_dpad_seeking.on": "Ein"
}
18 changes: 18 additions & 0 deletions locales/en/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"settings.playback.instant_dpad_seeking": {
"value": "Instant D-pad seeking",
"description": "Playback setting title. Immediate D-pad jumps while controls are hidden; default Off."
},
"settings.playback.instant_dpad_seeking.copy": {
"value": "With controls hidden, Left/Right jumps by the skip interval. With controls visible, OK confirms. Off keeps reveal-first navigation.",
"description": "Explanation of optional D-pad seeking. Dedicated rewind and fast-forward work independently."
},
"settings.playback.instant_dpad_seeking.off": {
"value": "Off",
"description": "Choice for the Instant D-pad seeking setting."
},
"settings.playback.instant_dpad_seeking.on": {
"value": "On",
"description": "Choice for the Instant D-pad seeking setting."
}
}
6 changes: 6 additions & 0 deletions locales/es/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Salto inmediato con cruceta",
"settings.playback.instant_dpad_seeking.copy": "Con los controles ocultos, Izquierda/Derecha salta el intervalo elegido. Con los controles visibles, confirma con OK. Desactivado muestra primero los controles.",
"settings.playback.instant_dpad_seeking.off": "Desactivados",
"settings.playback.instant_dpad_seeking.on": "Activados"
}
6 changes: 6 additions & 0 deletions locales/fr/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Saut direct avec la croix",
"settings.playback.instant_dpad_seeking.copy": "Commandes masquées, Gauche/Droite saute de l’intervalle choisi. Commandes visibles, OK confirme. Désactivé, le premier appui affiche les commandes.",
"settings.playback.instant_dpad_seeking.off": "Désactivées",
"settings.playback.instant_dpad_seeking.on": "Activées"
}
6 changes: 6 additions & 0 deletions locales/hu/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Azonnali ugrás iránygombbal",
"settings.playback.instant_dpad_seeking.copy": "Rejtett vezérlőknél a Balra/Jobbra a beállított idővel ugrik. Látható vezérlőknél az OK erősít meg. Kikapcsolva előbb megjelennek a vezérlők.",
"settings.playback.instant_dpad_seeking.off": "Ki",
"settings.playback.instant_dpad_seeking.on": "Be"
}
6 changes: 6 additions & 0 deletions locales/it/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Salto diretto con frecce",
"settings.playback.instant_dpad_seeking.copy": "A controlli nascosti, Sinistra/Destra salta dell’intervallo scelto. A controlli visibili, conferma con OK. Se disattivato, mostra prima i controlli.",
"settings.playback.instant_dpad_seeking.off": "Disattivati",
"settings.playback.instant_dpad_seeking.on": "Attivati"
}
6 changes: 6 additions & 0 deletions locales/pt/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Salto direto com direcional",
"settings.playback.instant_dpad_seeking.copy": "Com os controlos ocultos, Esquerda/Direita salta o intervalo escolhido. Com os controlos visíveis, confirma com OK. Desativado mostra primeiro os controlos.",
"settings.playback.instant_dpad_seeking.off": "Desativados",
"settings.playback.instant_dpad_seeking.on": "Ativados"
}
6 changes: 6 additions & 0 deletions locales/ru/playback_navigation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings.playback.instant_dpad_seeking": "Мгновенная перемотка стрелками",
"settings.playback.instant_dpad_seeking.copy": "При скрытом управлении стрелки перематывают на выбранный интервал. При видимом — подтвердите кнопкой OK. Если выключено, сначала показывается управление.",
"settings.playback.instant_dpad_seeking.off": "Выкл.",
"settings.playback.instant_dpad_seeking.on": "Вкл."
}
Loading
Loading