diff --git a/VERIFY.md b/VERIFY.md
index e8c8090..6577246 100644
--- a/VERIFY.md
+++ b/VERIFY.md
@@ -73,6 +73,7 @@ changes, complete these live checks before saying the update is done:
| Nested page Back (web + Android) | Visit Movie A -> Related B -> Cast -> Related C. Visible Back, browser Back, Escape/Backspace, and Android/TV hardware Back unwind C -> Cast -> B -> A -> the originating browse page, one page per press. A deep-focused person filmography must leave the person page immediately; a direct deep link with no prior in-app page uses the safe origin fallback. |
| Web Live TV | Channel starts in Triboon's web player, retunes cleanly, and shows live-specific errors instead of a generic external-player panel. |
| Android ExoPlayer VOD | Movie or episode opens the native branded loader and ExoPlayer surface, not the web video shell; seek does not show the full startup loader. |
+| Continue Watching source recovery (web + Android) | Resume an episode on a source made blocked/unresponsive after the watch point was saved. A blocked health verdict advances promptly; an unknown stall retries the same source once, then selects a different ranked release. Playback remains on the same episode and absolute timestamp, never visits show details, and never invokes next episode unless EOF is reached. |
| Android ExoPlayer Live TV | Native Live TV uses ExoPlayer, survives at least 20 Up/Down zaps, and logcat has no fatal/provider-loop markers. |
| Windows native VOD | On Windows 10/11 x64, an H.264 1080p title and HEVC Main10 4K title open the dedicated libmpv surface. Start, pause/resume, seeks/skips, fullscreen, close, direct/remux/transcode fallback, and a simulated sustained stall remain responsive. Diagnostics name the actual decoder; claim GPU only when `hwdec-current` is hardware-backed while frames advance. |
| Windows episode/resume | Saved resume opens at the correct absolute position. Manual next, autoplay at EOF, and episode-strip selection reuse the native surface without revealing details. Close/error/final checkpoint reaches Continue Watching promptly and stale token callbacks cannot change the replacement episode. |
@@ -689,8 +690,9 @@ Manual checks:
- A season-pack RAR/ZIP mounts and reuses only the requested episode.
- A stalled top candidate gets one 800ms hedge; a ready understudy waits at
most 250ms for higher ranks and prevents additional source launches.
-- A sustained web stall retries the same source/kind/timestamp before release
- failover.
+- A sustained web/native stall retries the same source/kind/timestamp once
+ before release failover; a confirmed blocked health verdict advances without
+ waiting, and neither path changes episode or loses the resume timestamp.
### Subtitles / CC / P11
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 1b477d0..9400e0d 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -27,8 +27,8 @@ android {
applicationId = "app.triboon.tv"
minSdk = 23 // Media3 1.10.x requires API 23+; Shield/onn/Chromecast TV targets clear this
targetSdk = 36
- versionCode = 305
- versionName = "2.8.0"
+ versionCode = 306
+ versionName = "2.8.1"
}
signingConfigs {
diff --git a/android/app/src/main/java/app/triboon/tv/MainActivity.java b/android/app/src/main/java/app/triboon/tv/MainActivity.java
index 332ecd3..13a9eaf 100644
--- a/android/app/src/main/java/app/triboon/tv/MainActivity.java
+++ b/android/app/src/main/java/app/triboon/tv/MainActivity.java
@@ -324,7 +324,8 @@ public class MainActivity extends Activity {
private static final long NATIVE_VIDEO_STARTUP_STALL_MS = 7000L;
private static final long NATIVE_VIDEO_HEAVY_STARTUP_STALL_MS = 12000L;
private static final long NATIVE_VIDEO_REBUFFER_TRIM_MS = 15000L;
- private static final long NATIVE_VIDEO_REBUFFER_RECOVERY_MS = 45000L;
+ private static final long NATIVE_VIDEO_REBUFFER_RECOVERY_MS = 30000L;
+ private static final long NATIVE_VIDEO_HEAVY_REBUFFER_RECOVERY_MS = 45000L;
private static final long NATIVE_LIVE_STALL_RECOVERY_MS = 45000L;
private static final long NATIVE_LIVE_STARTUP_STALL_RECOVERY_MS = 12000L;
private static final long NATIVE_LIVE_RECOVERY_COOLDOWN_MS = 15000L;
@@ -4087,8 +4088,9 @@ && isNativeRecoverableIoError(error) && nativeAllowReconnectResume()) {
updateNativeChrome();
if (state == Player.STATE_READY) {
if ("video".equals(nativeMode)) {
- nativeVideoStarted = true;
- widenNativeReadTimeoutAfterFirstFrame();
+ // READY only proves that ExoPlayer can begin; it can still be waiting on
+ // the resumed byte window with no rendered/advancing frame. PLAYING below
+ // owns the established boundary used by stall recovery.
applyNativeStartSeekIfReady();
if (!nativePercentResumePending) {
rememberNativeVideoPosition();
@@ -4102,7 +4104,8 @@ && isNativeRecoverableIoError(error) && nativeAllowReconnectResume()) {
}
}
if (state == Player.STATE_READY && nativeLoading != null
- && nativeLoading.getVisibility() == View.VISIBLE && !nativePercentResumePending) {
+ && nativeLoading.getVisibility() == View.VISIBLE && !nativePercentResumePending
+ && (!"video".equals(nativeMode) || nativeVideoStarted || !nativePlayer.getPlayWhenReady())) {
nativeVideoUnhealthySinceMs = 0L;
hideNativeLoading();
if (!nativeGuideMode) showNativeChrome(true);
@@ -4149,6 +4152,12 @@ && isNativeRecoverableIoError(error) && nativeAllowReconnectResume()) {
nativeVideoStarted = true;
widenNativeReadTimeoutAfterFirstFrame();
nativeVideoUnhealthySinceMs = 0L;
+ nativeVideoMemoryTrimmedDuringBuffer = false;
+ if (nativeLoading != null && nativeLoading.getVisibility() == View.VISIBLE
+ && !nativePercentResumePending) {
+ hideNativeLoading();
+ if (!nativeGuideMode) showNativeChrome(true);
+ }
if (!nativePercentResumePending) {
rememberNativeVideoPosition();
web.evaluateJavascript("window.__tvNativeVideoPlaying && __tvNativeVideoPlaying("
@@ -4507,11 +4516,22 @@ private void resolveNativePercentStartIfKnown() {
private void completeNativePercentResume() {
if (!nativePercentResumePending) return;
nativePercentResumePending = false;
- if (nativePlayer != null && !nativePlayer.getPlayWhenReady()) nativePlayer.play();
- if (nativeLoading != null && nativeLoading.getVisibility() == View.VISIBLE) {
+ if (nativePlayer != null && nativePlayer.isPlaying()) {
+ // Direct percent-resume may already be playing at the resolved target when the 1s
+ // progress tick clears the pending flag. onIsPlayingChanged(true) will not fire twice,
+ // so commit that real-playing boundary here instead of leaving the loader/JS state stuck.
+ nativeVideoStarted = true;
+ widenNativeReadTimeoutAfterFirstFrame();
nativeVideoUnhealthySinceMs = 0L;
+ nativeVideoMemoryTrimmedDuringBuffer = false;
hideNativeLoading();
if (!nativeGuideMode) showNativeChrome(true);
+ rememberNativeVideoPosition();
+ web.evaluateJavascript("window.__tvNativeVideoPlaying && __tvNativeVideoPlaying("
+ + nativePosSeconds() + "," + nativeDurSeconds() + "," + nativePlaybackToken + ")", null);
+ } else if (nativePlayer != null && !nativePlayer.getPlayWhenReady()) {
+ // The eventual onIsPlayingChanged(true) callback owns the boundary in this path.
+ nativePlayer.play();
}
}
@@ -6907,17 +6927,18 @@ private void updateNativeLiveWatchdog() {
private void updateNativeVideoWatchdog() {
if (!"video".equals(nativeMode) || nativePlayer == null) return;
int state = nativePlayer.getPlaybackState();
- if (state == Player.STATE_READY) {
- nativeVideoStarted = true;
- widenNativeReadTimeoutAfterFirstFrame();
- rememberNativeVideoPosition();
+ boolean wantsPlayback = nativePlayer.getPlayWhenReady()
+ && nativePlayer.getPlaybackSuppressionReason() == Player.PLAYBACK_SUPPRESSION_REASON_NONE;
+ boolean readyButNotPlaying = state == Player.STATE_READY && wantsPlayback && !nativePlayer.isPlaying();
+ if (state == Player.STATE_READY && !readyButNotPlaying) {
+ if (nativeVideoStarted) rememberNativeVideoPosition();
nativeVideoUnhealthySinceMs = 0L;
nativeVideoMemoryTrimmedDuringBuffer = false;
return;
}
if (nativeVideoStarted) {
boolean waitingForData = state == Player.STATE_BUFFERING
- || (nativePlayer.getPlayWhenReady() && !nativePlayer.isPlaying() && nativePlayer.isLoading());
+ || readyButNotPlaying;
boolean unhealthy = state == Player.STATE_IDLE || waitingForData;
if (!unhealthy) {
nativeVideoUnhealthySinceMs = 0L;
@@ -6935,15 +6956,17 @@ private void updateNativeVideoWatchdog() {
Log.w(TAG, "Native VOD rebuffer still waiting after " + elapsed + "ms; trimming UI caches");
trimAndroidMemoryCaches(false);
}
- if (elapsed >= NATIVE_VIDEO_REBUFFER_RECOVERY_MS) {
+ long recoveryMs = nativeLikelyHeavyVod()
+ ? NATIVE_VIDEO_HEAVY_REBUFFER_RECOVERY_MS
+ : NATIVE_VIDEO_REBUFFER_RECOVERY_MS;
+ if (elapsed >= recoveryMs) {
Log.w(TAG, "Native VOD rebuffer stalled after " + elapsed + "ms; retrying same source");
notifyNativeVideoError(state == Player.STATE_IDLE ? "native player idle" : "native rebuffer stalled",
nativePosSeconds(), nativeDurSeconds());
}
return;
}
- boolean unhealthy = state == Player.STATE_IDLE || state == Player.STATE_BUFFERING
- || (nativePlayer.getPlayWhenReady() && !nativePlayer.isPlaying());
+ boolean unhealthy = state == Player.STATE_IDLE || state == Player.STATE_BUFFERING || readyButNotPlaying;
if (!unhealthy) {
nativeVideoUnhealthySinceMs = 0L;
return;
diff --git a/clients/windows-px8/README.md b/clients/windows-px8/README.md
index cbe25c6..d843fe2 100644
--- a/clients/windows-px8/README.md
+++ b/clients/windows-px8/README.md
@@ -102,8 +102,8 @@ pre-bundle hash.
powershell -ExecutionPolicy Bypass -File .\clients\windows-px8\scripts\build-package.ps1
# A release build additionally creates the versioned alias and rejects a tag
-# that does not match package/Cargo/Tauri version 2.8.0.
-powershell -ExecutionPolicy Bypass -File .\clients\windows-px8\scripts\build-package.ps1 -Tag v2.8.0
+# that does not match package/Cargo/Tauri version 2.8.1.
+powershell -ExecutionPolicy Bypass -File .\clients\windows-px8\scripts\build-package.ps1 -Tag v2.8.1
```
The default output is `dist\windows-client\Triboon-Windows-Client.exe`; a tag
diff --git a/clients/windows-px8/package-lock.json b/clients/windows-px8/package-lock.json
index c145f64..ee3f7d1 100644
--- a/clients/windows-px8/package-lock.json
+++ b/clients/windows-px8/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "triboon-windows-client",
- "version": "2.8.0",
+ "version": "2.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "triboon-windows-client",
- "version": "2.8.0",
+ "version": "2.8.1",
"dependencies": {
"@tauri-apps/api": "2.11.1"
},
diff --git a/clients/windows-px8/package.json b/clients/windows-px8/package.json
index efbc00c..e7daf3c 100644
--- a/clients/windows-px8/package.json
+++ b/clients/windows-px8/package.json
@@ -1,7 +1,7 @@
{
"name": "triboon-windows-client",
"private": true,
- "version": "2.8.0",
+ "version": "2.8.1",
"description": "Triboon native Windows GPU client (Tauri + libmpv).",
"scripts": {
"tauri": "tauri",
diff --git a/clients/windows-px8/src-tauri/Cargo.lock b/clients/windows-px8/src-tauri/Cargo.lock
index e7530c0..095eed8 100644
--- a/clients/windows-px8/src-tauri/Cargo.lock
+++ b/clients/windows-px8/src-tauri/Cargo.lock
@@ -3494,7 +3494,7 @@ dependencies = [
[[package]]
name = "triboon-px8"
-version = "2.8.0"
+version = "2.8.1"
dependencies = [
"libmpv2",
"serde",
diff --git a/clients/windows-px8/src-tauri/Cargo.toml b/clients/windows-px8/src-tauri/Cargo.toml
index a2fe7cd..6f04ae7 100644
--- a/clients/windows-px8/src-tauri/Cargo.toml
+++ b/clients/windows-px8/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "triboon-px8"
-version = "2.8.0"
+version = "2.8.1"
description = "Triboon native Windows GPU client"
edition = "2021"
rust-version = "1.85"
diff --git a/clients/windows-px8/src-tauri/tauri.conf.json b/clients/windows-px8/src-tauri/tauri.conf.json
index 391ae15..ba955a5 100644
--- a/clients/windows-px8/src-tauri/tauri.conf.json
+++ b/clients/windows-px8/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Triboon",
- "version": "2.8.0",
+ "version": "2.8.1",
"identifier": "app.triboon.windows",
"build": {
"frontendDist": "../ui"
diff --git a/docs-architecture.md b/docs-architecture.md
index b2009dc..10d7633 100644
--- a/docs-architecture.md
+++ b/docs-architecture.md
@@ -168,8 +168,9 @@ Rules that must not drift:
WEB-sized/remux-to-AAC path. Low-power Android TV and older Chromecast-class
devices also prefer AVC/H.264 for 1080p auto-picks when an AVC source is
available, while HEVC/AV1 remain available as fallback/manual sources.
-- After ExoPlayer reaches READY, normal buffering must not remount or restart
- a movie from the beginning.
+- ExoPlayer `STATE_READY` alone is not a rendered-playback boundary. A READY
+ player that wants playback but produces no frames remains under the bounded
+ startup watchdog; recovery always preserves the requested timestamp.
- Continue Watching follows `docs-continue-watching.md`: one canonical Home card
per movie/show, active progress beats next-up, and the saved 4K/1080p source
class carries into remaining TV episodes.
@@ -268,9 +269,12 @@ Required behavior:
seeks stay fast for other users.
- Health checks keep the 500ms upfront gate. Background triage is lower priority
and must never starve the segment the player is actively waiting on.
-- After web VOD has genuinely started, a waiting state with no meaningful
- position progress for 45 seconds retries the same source, playback kind, and
- timestamp first. Recovery must not silently advance to a different release.
+- Web and native VOD distinguish readiness from real playback. Startup without
+ a first frame is bounded at 10/18 seconds on web and 7/12 seconds on Android
+ (normal/heavy). Established stalls retry the same source, playback kind, and
+ timestamp once after 30/45 seconds. A blocked live health verdict or a second
+ sustained stall advances to the next ranked release at the same timestamp;
+ it never changes episodes. A missing server session re-mounts the same title.
- Historical local-only Easynews benchmark evidence supports the original
fast-start assumptions, but it is not part of a clean clone and is not a fixed
runtime rule. Do not reintroduce hardcoded "16 warm connections" or "8-12
@@ -499,9 +503,10 @@ When changing persistence, update:
path uses a `SurfaceView` player surface, decoder fallback, closest-sync
seeks, seeded bandwidth, byte-bounded VOD buffers, short back buffers, live
target-offset tuning, conservative-device HLS caps, and audio offload where
- Android supports it. Sustained post-start VOD stalls trim UI caches and retry
- the same source at the last trustworthy timestamp instead of silently
- switching release or quality.
+ Android supports it. `STATE_READY` does not count as started until ExoPlayer
+ actually plays. Sustained post-start VOD stalls trim UI caches and retry the
+ same source at the last trustworthy timestamp once; a confirmed dead source
+ or repeated stall advances releases without changing episode or position.
- Sends native capability claims to the web UI/server before source selection,
including HDMI/ARC/eARC audio-output passthrough flags for AC3, E-AC3, E-AC3
JOC, DTS, DTS-HD, and TrueHD. Conservative/budget device detection is allowed
diff --git a/docs-continue-watching.md b/docs-continue-watching.md
index c467103..9a11c3f 100644
--- a/docs-continue-watching.md
+++ b/docs-continue-watching.md
@@ -89,7 +89,18 @@ flowchart LR
seeks once ExoPlayer knows duration; remux/transcode performs one
token-guarded server-seek remount at the computed absolute timestamp.
- Native progress, READY state, and the loading surface must not report a false
- zero position before that resume handoff completes.
+ zero position before that resume handoff completes. READY alone is not proof
+ that resumed frames are advancing; real playback owns that boundary.
+
+## Resume Source Recovery
+
+- Web and native playback re-check the live mount after opening. A confirmed
+ blocked source advances immediately to the next ranked release.
+- Startup without a real first frame uses the bounded player fallback ladder.
+ An established playback stall retries the same source/kind/timestamp once;
+ a second sustained stall changes release, never episode.
+- Source replacement retains the requested Continue Watching point even when
+ it occurs before the native player reports its first position callback.
## Focus Rules
@@ -122,4 +133,6 @@ When changing Continue Watching, verify:
9. Pause or stop web, native direct/remux/transcode, Cast, and Multiview VOD;
the latest position is immediately visible in Continue Watching, including
after backgrounding or closing the app.
-10. `npm.cmd test` passes after behavior changes.
+10. Resume an episode whose saved source is now blocked: playback advances to a
+ healthy release at the same episode and timestamp without visiting details.
+11. `npm.cmd test` passes after behavior changes.
diff --git a/docs-player-regression-map.md b/docs-player-regression-map.md
index 48c3d16..ee72cac 100644
--- a/docs-player-regression-map.md
+++ b/docs-player-regression-map.md
@@ -185,7 +185,7 @@ before revealing that shell's Up Next UI.
| P2 | Live TV D-pad is channel-first: Up goes to next channel, Down goes to previous channel, and VOD-style seeking is hidden. Native Live TV must stamp web player state as `type: 'live'` before playback starts, or the zap callback has no safe channel list to use. Native Live TV chrome shows the channel title once in the top-left player metadata cluster with one `LIVE` badge beside it, and the clock alone in the top-right; it must not duplicate the source/title in the bottom controls or show another `LIVE` label in the seek row. | `MainActivity.java` `zapNativeLiveChannel`, `startNativePlayback`, `updateNativeChrome`, `web/index.html` `setNativeLivePlaybackState`, `tryNativeLivePlayer`, `__tvNativeLiveZap`, `zapChannel` | `test/phase4.test.js`, Live TV smoke |
| P3 | Native VOD timeline always has stable duration behavior. If ExoPlayer has not reported duration yet, the native player uses the web-side known duration until Exo catches up. Native remux/transcode handoff must declare `video/mp4` so ExoPlayer does not waste startup time sniffing fMP4 streams. Android playback capability claims must come from the native Exo/MediaCodec bridge and be merged into `/api/play` caps, not inferred from WebView alone. Those caps include RAM/device class, Dolby Vision support, and real HDMI/ARC/eARC audio-output encodings (`ac3`, `eac3`, `eac3Joc`, `dts`, `dtsHd`, `truehd`, `passthrough`, `audioOutput`). Onn-class/low-memory Android TV boxes stay conservative even if a decoder is exposed; Shield-class/eARC-capable devices may direct-play compatible MKV TrueHD/Atmos/DTS-HD sources when the sink reports passthrough support. | `web/index.html` `clientCaps`, `nativePlaybackCaps`, `tryNativeVideoPlayer`, `nativeMimeForKind`; `MainActivity.java` `nativePlaybackCaps`, `buildNativePlaybackCaps`, `nativeAudioSinkCaps`, `nativePassthroughAudioDevice`, `nativeTotalRamMb`, `nativeConservativePlaybackDevice`, `nativeKnownDurationMs`, `nativeDurationMs`, `updateNativeChrome`, `buildNativeMediaItem`; `server/index.js` `parseCaps`, `budgetAndroidTvCaps`, `playbackPolicyFor`; `server/transcode.js` `decidePlayback`, `releaseLosslessAudioDirectOk`, `audioCopyOk` | `test/phase4.test.js`, Android native seek/audio-passthrough smoke |
| P4 | Finished playback returns to the right detail page: movies return to movie details, episodes show Up Next when available, and final episodes return to show details. TV episode players show the show title with season/episode metadata as a separate subline. On web and Android native playback, that title and episode subline belong in the top-left player metadata cluster beside the Back button; the bottom controls stay reserved for seek, playback buttons, episode strip, and sheets. Up Next must appear before the episode ends on both web and native playback; native uses `__tvNativeVideoProgress` rather than waiting for ExoPlayer `STATE_ENDED`. Once Up Next appears, autoplay gives a 10-second choice window in the final ten seconds. Reaching EOF completes that existing choice window: autoplay advances immediately unless dismissed and must never start a second post-EOF countdown. Manual Play Next, automatic next, and episode-strip selection are direct player-to-player replacements: the old frame or native loading surface remains topmost before local lookup/search/mount awaits, and the show detail page must never become visible between episodes. That popup must not start earlier than the 10-second choice window, because a 10-second countdown shown at 45 seconds remaining skips the end of the current episode. For D-pad episode playback, Down from the control row opens an animated current-season thumbnail strip with the current episode selected; cards show a larger borderless, rounded 16:9 still first and the episode name below it, not over the image. Left/Right changes episode focus, OK plays through the normal episode play path, Up/Back returns to controls, and Android native ExoPlayer receives the same episode choices through the web bridge. | `web/index.html` `beginPlaybackTransition`, `openPlayer`, `episodePlayerMeta`, `updatePlayerMeta`, `getPlayerEpisodeContext`, `prepPlayerSeasonEpisodes`, `openPlayerEpisodes`, `activatePlayerEpisode`, `tryNativeVideoPlayer`, `__tvNativeEpisodeSelect`, `__tvNativeVideoProgress`, `__tvNativeVideoEnded`, `finishEpisodeToNext`, `maybeShowUpNext`, `showUpNext`, `closePlayer`, `playbackFinishedDetailTarget`, `prepNextEpisode`; `android/app/src/main/java/app/triboon/tv/MainActivity.java` `nativePlaybackToken`, `nativePlaybackSubline`, `nativeChromeTitle`, `nativeChromeSubline`, `nativePlayerSubline`, `playNativeNextEpisode`, `updateNativeEpisodeChoices`, `renderNativeEpisodeStrip`, `animateNativeEpisodeStripIn`, `animateNativeEpisodeStripOut`, `handleNativeEpisodeStripKey`, `startNativeProgress` | `test/phase4.test.js`, end-of-file smoke, Android manual/autoplay no-detail-flash smoke, Android D-pad episode-strip smoke |
-| P5 | Resume is saved from every non-live entry point and honored by the player that opens: detail Play, Continue Watching, Sources, local library, native player, web player, quality switch, close, error, and ended. Android native direct playback must keep the requested start time pending until ExoPlayer reports the saved position. Android native remux/transcode playback must use the server-side `start=` URL and carry a display offset so watch saving, elapsed time, seeking, and finish handling stay in absolute movie time; remote seeks on those restarted streams remount the same native kind with a new `start=` instead of seeking inside the segment. Native VOD startup watchdogs are startup-only: once ExoPlayer has reached `STATE_READY`, normal short mid-play buffering must not trigger fallback/remount/advance. Sustained post-start VOD stalls trim UI caches, then retry the same source and same playback kind at the current timestamp; they must not silently walk direct -> remux -> transcode -> next release in the middle of a movie or episode. If Exo reports `0` during an error/reset after real playback has progressed, the bridge must keep the last trustworthy movie position so recovery starts where the viewer was, not at the beginning. Trakt-linked users must export progress through `/scrobble/stop` with the Trakt app `/scrobble` permission enabled; failed exports are queued and retried by the normal sync tick before imports run. | `web/index.html` `resolvePlaybackResume`, `play`, `openSources`, `playLocal`, `saveWatch`, `stopActivePlaybackForReplacement`, `tryNativeVideoPlayer`, `applyNativeVideoProgress`, `__tvNativeVideoReady`, `__tvNativeVideoSeek`, `__tvNativeVideoError`, `recoverSamePlaybackSource`, `failover`, `autoAdvance`; `android/app/src/main/java/app/triboon/tv/MainActivity.java` `nativePendingStartMs`, `nativeStartSeekIssuedAtMs`, `nativeStartOffsetMs`, `nativeDisplayPositionMs`, `nativeSeekToDisplayPosition`, `requestNativeVideoSeek`, `applyNativeStartSeekIfReady`, `updateNativeVideoWatchdog`, `safeNativeVideoPosSeconds`, `nativeLoadControlForMode`; `server/index.js` `/api/watch`, `/api/trakt/sync`, `/api/remux`, `/api/transcode`; `server/trakt.js` `scrobble`, `flushOutbox`, `_requestForOp` | `test/phase4.test.js`, `test/security.test.js`, Android resume/rebuffer smoke |
+| P5 | Resume is saved from every non-live entry point and honored by the player that opens: detail Play, Continue Watching, Sources, local library, native player, web player, quality switch, close, error, and ended. Android native direct playback must keep the requested start time pending until ExoPlayer reports the saved position. Android native remux/transcode playback must use the server-side `start=` URL and carry a display offset so watch saving, elapsed time, seeking, and finish handling stay in absolute movie time; remote seeks on those restarted streams remount the same native kind with a new `start=` instead of seeking inside the segment. `STATE_READY` is readiness, not proof of rendered playback: only real `isPlaying`/position progress crosses the established boundary. A frozen native startup recovers in 7 seconds for normal VOD or 12 seconds for heavy/4K VOD; web startup uses 10/18 seconds. Established stalls wait 30/45 seconds, trim Android UI caches when useful, and retry the same source/kind/timestamp once. A confirmed blocked health verdict or a second sustained stall advances to the next ranked release while preserving the same movie/episode and timestamp; it must never enter the next-episode path. A lost server session (`404`) re-mounts the same title instead. If Exo reports `0` during an error/reset after real playback has progressed, the bridge must keep the last trustworthy movie position so recovery starts where the viewer was, not at the beginning. Trakt-linked users must export progress through `/scrobble/stop` with the Trakt app `/scrobble` permission enabled; failed exports are queued and retried by the normal sync tick before imports run. | `web/index.html` `resolvePlaybackResume`, `play`, `openSources`, `playLocal`, `saveWatch`, `stopActivePlaybackForReplacement`, `startHealthPoll`, `tryNativeVideoPlayer`, `applyNativeVideoProgress`, `__tvNativeVideoReady`, `__tvNativeVideoPlaying`, `__tvNativeVideoSeek`, `__tvNativeVideoError`, `recoverSamePlaybackSource`, `failover`, `autoAdvance`; `android/app/src/main/java/app/triboon/tv/MainActivity.java` `nativePendingStartMs`, `nativeStartSeekIssuedAtMs`, `nativeStartOffsetMs`, `nativeDisplayPositionMs`, `nativeSeekToDisplayPosition`, `requestNativeVideoSeek`, `applyNativeStartSeekIfReady`, `updateNativeVideoWatchdog`, `safeNativeVideoPosSeconds`, `nativeLoadControlForMode`; `server/pipeline.js` `Pipeline.advance`; `server/index.js` `/api/watch`, `/api/health`, `/api/advance`, `/api/trakt/sync`, `/api/remux`, `/api/transcode`; `server/trakt.js` `scrobble`, `flushOutbox`, `_requestForOp` | `test/phase2.test.js`, `test/phase4.test.js`, `test/security.test.js`, Android resume/rebuffer smoke |
| P6 | Source selection picks the best correct release under the user cap, and Sources manual picks mount the exact selected release. Playback selection must not visibly trial-and-error unknown release containers: unknown inner filenames should start with the server remux path when ffmpeg is available. If Android ExoPlayer rejects a server-selected remux because the device cannot decode that codec, the same release may fall through to the server transcode URL before Triboon advances to a different release; it must not fall back to raw direct for that remux-selected source. Source scoring also receives the TMDB original language plus the user's preferred audio language: English/default titles demote foreign-only/dubbed releases, while non-English originals are allowed to prefer original-language or dual/multi-audio releases instead of forcing an English-only dub. Onn-class/low-memory Android TV 4K playback should prefer WEB-sized UHD sources and AAC/EAC3-friendly audio over huge remux/HD-audio defaults; the large remux remains available in Sources when the user explicitly chooses it. Low-power Android TV and older Chromecast-class HD playback should prefer 1080p AVC/H.264 auto-picks when available, because exposed HEVC/AV1 decode support does not always mean fast startup, stable seeks, or reliable recovery on those boxes; HEVC/AV1 must still remain in fallback/manual source lists. Atmos/TrueHD/DTS-HD scoring is device-aware: lossless/Atmos remuxes get a meaningful boost only when the current native client reports matching passthrough caps, while browsers and budget devices prefer safer WEB-sized DDP sources. | `server/pipeline.js` `releaseMatches`, `Pipeline.search`, `Pipeline.play`; `server/scoring.js` `normalizeLanguageCode`, `releaseLanguageTag`, `scoreRelease`; `server/index.js` `playbackPolicyFor`, `budgetAndroidTvCaps`, `sourceDrawerCandidates`; `server/transcode.js` `decidePlayback`; `web/index.html` `sourceSearchQuery`, `play`, `nativePlaybackOrder` | `test/phase2.test.js`, `test/phase4.test.js`, `test/security.test.js`, Android source-quality stress |
| P7 | Startup must expose menu and home shell in under 1 second on Android TV. Watch state, TMDB catalog rows, libraries, watchlist, Live TV, and local indexing hydrate after first focus. Home must render a focusable placeholder before `/api/watch` can block first paint, prepare Continue Watching next-up entries during the watch-state publish path with a short deadline, coalesce unchanged row updates, preserve the current D-pad focus during background row refreshes, and defer catalog/enrichment repaint while the TV focus model or recent D-pad input is still settling. Continue Watching sorts the mixed resume/next-episode row by the last watched activity timestamp; next-up cards inherit the timestamp of the watched episode that produced them and never jump ahead only because they are next episodes. Android must buffer early D-pad keys until the web focus model reports ready. Empty home rows still need a focusable target so the remote never lands on a dead body focus. | `web/index.html` `enterAppShell`, `hydrateAppShellData`, `loadRows`, `prepareHomeTvNext`, `buildCwItems`, `compareContinueWatchingItems`, `publishHomeRows`, `homeRowsSignature`, `homeBackgroundRefreshReady`, `refreshHomeWhenSettled`, `homeRowsFromWatch`, `renderRows`, `restoreHomeFocus`, `signalTvReady`, `loadLibraries`, `enrichHome`; `server/index.js` `nextWatchEpisodes`; `android/app/src/main/java/app/triboon/tv/MainActivity.java` `pageTvReady`, `pendingTvKeys`, `appReady`, `jsKey`; `bench/android-tv-smoke.ps1` | `test/phase4.test.js`, `test/security.test.js`, authenticated UI smoke with boot timing |
| P8 | Live TV guide categories and rows must keep the focused item visible during fast D-pad repeats. Category columns are their own D-pad lane: Up/Down clamps inside categories, applies the highlighted category, and never spills into channels at the bottom; Right is the only category-to-channel handoff. In-player guide/PIP must use the same category-lane contract and open in a staged way: render/measure/sync the PiP slot first, then reveal the guide and native PiP without visible jumping. Opening the PiP guide from native movie/episode or Live TV playback must wake and clear the app screensaver before the WebView guide background is visible. Android native playback opens the native guide through the `TriboonTV.openGuide()` bridge instead of layering a web guide over ExoPlayer. If the WebView guide renderer crashes while ExoPlayer is in PiP, Android must promote playback back to fullscreen; a later normal Live TV selection must clear stale guide state and never inherit an old PiP layout. | `web/index.html` `renderLiveTvBody`, `focusLiveCategory`, `focusPlayerGuideCategory`, `renderPlayerGuideTimeline`, `togglePlayerGuide`, `scheduleNativeGuidePipSync`, `wakeScreensaverForPlayerSurface`, `revealNativeGuideShell`, `openNativeLiveGuideShell`, `tryNativeLivePlayer`, `playChannel`, guide key handler; `MainActivity.java` `openGuide`, `recoverWebRenderer`, `startNativePlayback`, `enterNativeGuideMode`, `enterNativeFullscreenMode`, `applyNativeGuidePipRect` | `test/phase4.test.js`, Live TV fast-scroll smoke, Android PiP guide smoke, `bench/android-tv-stress.ps1` |
@@ -256,7 +256,9 @@ them when the table is reorganized:
every losing startup consumer before warmup; shared work is aborted only when
no other joined play still needs it. Missing-probe and mount-deadline exits
also abort the underlying BODY, and a later Play cannot join an already
- aborted prepare record. Auto-advance stays serial. Code:
+ aborted prepare record. Recovery auto-advance uses a narrower two-candidate
+ delayed hedge, so one stalled replacement cannot consume another full mount
+ deadline. Code:
`server/pipeline.js` `RACE_HEDGE_MS`, `RACE_COMMIT_GRACE_MS`,
`Pipeline._tryCandidate`, `Pipeline._advance`; `server/vfs.js`
`NzbFileStream._fetchSegment`. Verification: the understudy, rank-grace,
@@ -334,7 +336,8 @@ For any future player fix:
- Rapid Live TV selection is last-intent-wins independently for the main player,
split panes, and Multiview panes.
- A season-pack request must never mount or reuse a different episode.
-- Sustained web VOD stalls must retry the same source before release failover.
+- Sustained web/native VOD stalls must retry the same source once before release
+ failover; a blocked live health verdict may skip directly to release failover.
- Native subtitle size and three-cue bounds must match the saved preference.
- Attached local libraries must hydrate after the shell is usable, auto-load a bounded first page from the rail, and request additional bounded pages so Android TV D-pad movement stays responsive in large folders.
- Movies, TV Shows, and attached-library pages must treat the first TV Back as "open this section menu," not "jump Home."
diff --git a/docs-streaming-performance.md b/docs-streaming-performance.md
index f38535f..18dbe62 100644
--- a/docs-streaming-performance.md
+++ b/docs-streaming-performance.md
@@ -288,11 +288,16 @@ partial range. VOD stream sockets also get a longer per-route timeout than the
general API timeout so a provider hiccup becomes buffering/retry behavior, not
an immediate truncated response.
-After web VOD has started, a `waiting` state with no meaningful position
-progress for 45 seconds triggers same-source recovery. The first recovery keeps
-the selected release, playback kind, and current timestamp; it must not silently
-advance releases because a temporary buffer drained. Playback, position
-progress, seek, source replacement, and close all cancel the pending watchdog.
+Web and Android VOD treat readiness and rendered playback separately. A startup
+that produces no first frame is bounded at 10/18 seconds on web and 7/12 seconds
+on Android for normal/heavy VOD. After playback has genuinely started, no
+meaningful position progress for 30/45 seconds triggers same-source recovery.
+That first recovery keeps the selected release, playback kind, and current
+timestamp so a temporary buffer drain does not change sources. A confirmed
+`blocked` live health verdict skips the media timeout; a second sustained stall
+advances to the next ranked release at the same timestamp. It never advances the
+episode. Playback/position progress, seek, replacement, and close cancel pending
+watchdogs, and a server-session `404` re-mounts the same title instead.
Playback windows are applied when a mount is selected and rebalanced again when
stream routes are touched or housekeeping removes mounts. That lets existing
@@ -391,9 +396,12 @@ losing hedge detaches from shared prepare state and its startup controller is
aborted when no other play still consumes it. This releases queued/running
startup NNTP work instead of waiting for the earlier candidate's full mount
deadline; a concurrent play joined to that same mount remains protected. After
-an actual candidate failure the bounded walk may fill its existing race window,
-while auto-advance remains serial. Fast missing-probe and mount-deadline exits
-likewise abort their underlying startup BODY before the failure is returned.
+an actual candidate failure the bounded walk may fill its existing race window.
+Recovery auto-advance uses a narrower two-candidate delayed hedge: the usual
+healthy next release remains a single grab, while a replacement still pending
+after 800ms gets one understudy instead of another full mount-deadline wait. Fast
+missing-probe and mount-deadline exits likewise abort their underlying startup
+BODY before the failure is returned.
## Recommendation Flow
@@ -513,7 +521,9 @@ Before changing performance behavior, check:
payload; its probe targets that payload, and a ready hedge cancels unused
startup work without interrupting another joined consumer. Probe/deadline
exits leave no startup BODY behind.
-9. Web recovery retries the same source/kind before release failover.
+9. Web/native recovery retries the same source/kind once before release
+ failover, except a confirmed blocked health verdict may advance immediately;
+ both paths retain the title/episode and timestamp.
10. Live TV tune epochs, stable channel ids, and XMLTV worker parsing stay
responsive during rapid zaps.
11. Docs remain aligned: this file, `docs-architecture.md`,
diff --git a/package.json b/package.json
index 8c5cc82..bf0b630 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "triboon",
- "version": "2.8.0",
+ "version": "2.8.1",
"description": "Press play on anything. Triboon mounts the best healthy NZB from your usenet provider and streams it instantly.",
"main": "server/index.js",
"scripts": {
diff --git a/server/pipeline.js b/server/pipeline.js
index c5df5ac..ca3e1ae 100644
--- a/server/pipeline.js
+++ b/server/pipeline.js
@@ -310,8 +310,12 @@ function mountHasActivePlayback(mount, now = Date.now()) {
// HEALTHY one (startup win #2). Measured: a cold start is dominated by walking PAST dead/incomplete
// top picks one-at-a-time — racing collapses that serial tail to the fastest healthy of the top N.
// Kept small so startup never floods the provider pool (the startup reserve covers a few parallel
-// mounts). Auto-advance stays serial (width 1) — the active source already died, no tail to race.
+// mounts). Recovery uses a narrower delayed hedge below because an active source has already died.
const PLAY_RACE_WIDTH = 5;
+// Recovery keeps the common healthy-next-source path single-grab, but launches one delayed hedge
+// when that replacement stalls. This avoids another full 30-second mount wait after the player has
+// already declared its active release unhealthy.
+const RECOVERY_RACE_WIDTH = 2;
// Hedge delay before speculatively mounting the next candidate in parallel. A healthy/fast top
// pick (usually prefetched → NZB cached → mounts in well under this) commits before the hedge
// fires, so the common case costs ZERO extra indexer grabs; only a STALLING top pick gets a
@@ -1591,8 +1595,8 @@ class Pipeline {
}
// Mount the next viable candidate in the session. width > 1 races the top N candidates'
- // fetch+mount+health concurrently and commits the first HEALTHY one (cold-start win); width 1
- // is the original one-at-a-time walk (auto-advance, and explicit Sources picks).
+ // fetch+mount+health concurrently and commits the first HEALTHY one (cold-start and bounded
+ // recovery hedge); width 1 is the original one-at-a-time walk used by explicit Sources picks.
async _advance(session, mountOpts = {}, { width = 1 } = {}) {
const attempts = [];
const started = Date.now();
@@ -1773,7 +1777,8 @@ class Pipeline {
// episode (session.query carries season/ep). Without this, advance() dropped it and a pack advanced
// to the largest file (E01). Movies/single-ep are unaffected — their largest video IS the content.
const _we = wantedEpisodeOf(session.query);
- return this._advance(session, _we ? { ...rest, wantedEpisode: _we } : rest);
+ return this._advance(session, _we ? { ...rest, wantedEpisode: _we } : rest,
+ { width: RECOVERY_RACE_WIDTH });
}
}
diff --git a/test/phase2.test.js b/test/phase2.test.js
index 6f511e7..28e7740 100644
--- a/test/phase2.test.js
+++ b/test/phase2.test.js
@@ -245,8 +245,8 @@ test('pipeline: a wanted episode matches a season PACK or covering RANGE (season
]) assert.ok(!releaseMatches(bad, w), `rejects ${bad}`);
// advance() (auto-advance on source rot) must re-thread the wanted episode, or a season pack advances to E01.
const pipelineSrc = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'pipeline.js'), 'utf8');
- assert.match(pipelineSrc, /async advance\(sessionId[\s\S]+wantedEpisodeOf\(session\.query\)[\s\S]+_advance\(session, _we \? \{ \.\.\.rest, wantedEpisode: _we \} : rest\)/,
- 'advance() re-threads the wanted episode into mountOpts so a pack keeps mounting the requested episode');
+ assert.match(pipelineSrc, /async advance\(sessionId[\s\S]+wantedEpisodeOf\(session\.query\)[\s\S]+_advance\(session, _we \? \{ \.\.\.rest, wantedEpisode: _we \} : rest,[\s\S]+\{ width: RECOVERY_RACE_WIDTH \}\)/,
+ 'advance() keeps the requested pack episode while using the bounded source-recovery hedge');
// Loose-file season-pack NZB: mount the WANTED episode file, not the largest. (E01 is bigger here.)
const q = (name, bytes) => `