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) => `${name}@x`; @@ -2154,6 +2154,39 @@ test('pipeline: committing a healthy hedge cancels the stalled loser and release assert.strictEqual(pipeline.prepareInflight.size, 0, 'the cancelled loser is removed from shared prepare state'); }); +test('pipeline: source recovery hedges a stalled next release instead of waiting serially', async () => { + const pipeline = new Pipeline({ + pool: () => null, + verdicts: { get: () => null, set: () => {} }, + mounts: new Map(), + }); + const calls = []; + pipeline._tryCandidate = (candidate) => { + calls.push(candidate.name); + if (candidate.name === 'stalled-recovery-source') return new Promise(() => {}); + return Promise.resolve({ vf: { id: `${candidate.name}-vf`, streamable: true, size: 0 } }); + }; + const session = { + id: 'recovery-hedge', query: {}, cursor: 0, history: [], + candidates: [ + { name: 'stalled-recovery-source', nzbUrl: 'https://indexer.test/recovery-stalled.nzb' }, + { name: 'healthy-recovery-source', nzbUrl: 'https://indexer.test/recovery-healthy.nzb' }, + { name: 'must-not-launch', nzbUrl: 'https://indexer.test/recovery-unused.nzb' }, + ], + }; + pipeline.sessions.set(session.id, session); + + const t0 = Date.now(); + const result = await pipeline.advance(session.id, { resumeFrac: 0.55 }); + const elapsed = Date.now() - t0; + assert.strictEqual(result.candidate.name, 'healthy-recovery-source'); + assert.deepStrictEqual(calls, ['stalled-recovery-source', 'healthy-recovery-source'], + 'recovery launches one bounded hedge and stops once a healthy replacement is ready'); + assert.ok(elapsed >= 700 && elapsed < 2200, + `recovery should commit the healthy hedge in about one second, not wait for the 30s mount deadline (${elapsed}ms)`); + assert.strictEqual(session.query.resumeFrac, 0.55, 'the replacement still warms the live resume window'); +}); + test('pipeline: cancelling one hedge consumer preserves another play joined to the same mount', async () => { const pipeline = new Pipeline({ pool: () => null, diff --git a/test/phase4.test.js b/test/phase4.test.js index 56f98a1..73e3a1b 100644 --- a/test/phase4.test.js +++ b/test/phase4.test.js @@ -285,6 +285,8 @@ test('Trakt percent-only native resume reaches direct and server-seek Android pa 'the web remount rejects stale tokens and does not publish computed progress before replacement'); assert.match(android, /private void startNativeProgress\(\) \{[\s\S]+applyNativeStartSeekIfReady\(\);[\s\S]+if \(!nativePercentResumePending\) \{[\s\S]+__tvNativeVideoProgress/, 'the 1s progress driver completes a direct cached seek even without a second READY transition, while suppressing false zero progress'); + assert.match(android, /private void completeNativePercentResume\(\) \{[\s\S]+nativePercentResumePending = false;[\s\S]+nativePlayer\.isPlaying\(\)[\s\S]+nativeVideoStarted = true;[\s\S]+hideNativeLoading\(\);[\s\S]+__tvNativeVideoPlaying/, + 'a direct percent resume already playing at its target must commit the real-playing boundary and clear the loader'); }); test('quality toggle is a source-selection preference that survives Continue Watching', () => { @@ -1967,13 +1969,16 @@ test('web VOD rebuffer and subtitle handoff stay bounded, fast, and mobile-safe' const helperStart = ui.indexOf('function clearWebRebufferRecovery()'); const helperEnd = ui.indexOf('function stopActivePlaybackForReplacement', helperStart); assert.ok(helperStart >= 0 && helperEnd > helperStart, 'web rebuffer helpers should be present'); - const helperSource = ui.slice(helperStart, helperEnd).replace('}, 45000);', '}, 5);'); + const helperSource = ui.slice(helperStart, helperEnd) + .replace('if (!established) return webVodHeavy(p) ? 18000 : 10000;', 'if (!established) return 5;') + .replace('return webVodHeavy(p) ? 45000 : 30000;', 'return 5;'); const S = { view: 'player' }; const recovered = []; - const helpers = new Function('S', 'vodPlaybackStarted', 'appMs', 'recoverSamePlaybackSource', + const startupFailovers = []; + const helpers = new Function('S', 'vodPlaybackStarted', 'appMs', 'recoverSamePlaybackSource', 'failover', `${helperSource}\nreturn { armWebRebufferRecovery, clearWebRebufferRecovery };`)( - S, () => true, () => 10000, (reason) => recovered.push(reason)); + S, (p) => !!p.started, () => 10000, (reason) => recovered.push(reason), () => startupFailovers.push('failover')); const playing = { started: true, usingNative: false, item: { type: 'movie' } }; const video = { paused: false, seeking: false, readyState: 2, currentTime: 42 }; S.playing = playing; @@ -1990,8 +1995,17 @@ test('web VOD rebuffer and subtitle handoff stay bounded, fast, and mobile-safe' helpers.armWebRebufferRecovery(video, playing); assert.strictEqual(S._webRebufferT, null, 'paused playback does not arm a rebuffer restart'); - assert.match(ui, /v\.onwaiting = \(\) => \{[\s\S]+pWait && pWait\.started\) armWebRebufferRecovery\(v, pWait\)/, - 'the watchdog arms only after VOD has rendered a real frame'); + video.paused = false; + const starting = { started: false, usingNative: false, item: { type: 'episode' } }; + S.playing = starting; + video.currentTime = 0; + helpers.armWebRebufferRecovery(video, starting); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepStrictEqual(startupFailovers, ['failover'], + 'a browser episode which never produces a first frame enters the startup fallback ladder'); + + assert.match(ui, /v\.onwaiting = \(\) => \{[\s\S]+if \(pWait\) armWebRebufferRecovery\(v, pWait\)/, + 'the watchdog covers both startup-without-first-frame and established rebuffer stalls'); assert.match(ui, /v\.onplaying = \(\) => \{[\s\S]+S\.playing !== playingRef[\s\S]+clearWebRebufferRecovery\(\);[\s\S]+const rebufferWatch = S\._webRebufferWatch;[\s\S]+rebufferWatch\.playing === playingRef[\s\S]+Math\.abs\(v\.currentTime - rebufferWatch\.position\) >= 0\.5\) clearWebRebufferRecovery\(\)/, 'playing and meaningful time progress cancel the watchdog'); assert.match(ui, /function startSource\([\s\S]+clearWebRebufferRecovery\(\);[\s\S]+function seekTo\(seconds\) \{[\s\S]+clearWebRebufferRecovery\(\);/, @@ -2004,6 +2018,60 @@ test('web VOD rebuffer and subtitle handoff stay bounded, fast, and mobile-safe' 'web captions respect phone display cutouts and bottom safe areas'); }); +test('Continue Watching: a blocked live source health check advances the same playback promptly', async () => { + const ui = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8'); + const start = ui.indexOf('function startHealthPoll(id)'); + const end = ui.indexOf('function showPlayerPlayPrompt', start); + assert.ok(start >= 0 && end > start, 'source-health recovery helper should be present'); + + const S = { + view: 'player', + playing: { mountId: 'dead-resume-mount', usingNative: true, item: { type: 'episode', resume: 1842 } }, + }; + const scheduled = []; + const cleared = []; + const saves = []; + const advances = []; + const toasts = []; + const healthNode = { innerHTML: '' }; + let nextTimer = 1; + const fakeSetTimeout = (fn, ms) => { const rec = { id: nextTimer++, fn, ms, kind: 'timeout' }; scheduled.push(rec); return rec.id; }; + const fakeSetInterval = (fn, ms) => { const rec = { id: nextTimer++, fn, ms, kind: 'interval' }; scheduled.push(rec); return rec.id; }; + const clearTimer = (id) => cleared.push(id); + const startHealth = new Function('S', 'api', '$', 'saveWatch', 'toast', 'autoAdvance', + 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', + `${ui.slice(start, end)}\nreturn startHealthPoll;`)( + S, + async (url) => { + assert.strictEqual(url, '/api/health/dead-resume-mount'); + return { verdict: 'blocked' }; + }, + () => healthNode, + (...args) => saves.push(args), + (msg) => toasts.push(msg), + (opts) => advances.push(opts), + fakeSetTimeout, + fakeSetInterval, + clearTimer, + clearTimer, + ); + + startHealth('dead-resume-mount'); + assert.deepStrictEqual(scheduled.map((t) => [t.kind, t.ms]), [['timeout', 1200], ['interval', 20000]], + 'health triage gets one prompt kick and retains the bounded background interval'); + await scheduled[0].fn(); + assert.deepStrictEqual(saves, [[true]], 'the latest Continue Watching position is checkpointed before replacement'); + assert.deepStrictEqual(advances, [{ + allowMidstreamAdvance: true, + nativePreferred: true, + reason: 'source health blocked', + }], 'confirmed source rot advances releases while retaining native playback preference'); + assert.strictEqual(S.healthTimer, null); + assert.strictEqual(S._healthKickT, null); + assert.ok(cleared.length >= 2, 'both scheduled health timers are cancelled before source replacement'); + assert.match(toasts[0], /switching releases/); +}); + test('Android native player: direct source and native chrome stay out of the web player', () => { const ui = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8'); const server = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8'); @@ -2642,8 +2710,8 @@ test('Android native player: direct source and native chrome stay out of the web nativeStartCursor = nativeStartFlow.indexOf(step, nativeStartCursor + 1); return nativeStartCursor >= 0; }), 'Android native movie playback should stay ordered, cancelable, and never reveal the web player shell when ExoPlayer is available'); - assert.match(ui, /function startNativePlayerHousekeeping\(it\) \{[\s\S]+stopWebVideoElement\(\);[\s\S]+if \(S\.healthTimer\) clearInterval\(S\.healthTimer\);[\s\S]+S\.watchTimer = setInterval\(saveWatch, 10000\);[\s\S]+loadTracks\(\);[\s\S]+prepNextEpisode\(it\);[\s\S]+\}/, - 'native playback should silence the hidden web video while still probing duration metadata'); + assert.match(ui, /function startNativePlayerHousekeeping\(it\) \{[\s\S]+stopWebVideoElement\(\);[\s\S]+startHealthPoll\(S\.playing && S\.playing\.mountId\);[\s\S]+S\.watchTimer = setInterval\(saveWatch, 10000\);[\s\S]+loadTracks\(\);[\s\S]+prepNextEpisode\(it\);[\s\S]+\}/, + 'native playback should silence the hidden web video while retaining source health, watch, and metadata housekeeping'); assert.match(ui, /function homeRowsFromWatch\(cw, loading = false\) \{[\s\S]+if \(!rows\.length && loading\) \{[\s\S]+name: 'Loading home'[\s\S]+emptyLabel: 'Loading\.\.\.'[\s\S]+async function loadRows\(opts = \{\}\) \{[\s\S]+!opts\.catalogOnly && !opts\.watchReady && !hasFreshWatch[\s\S]+publishHomeRows\(homeRowsFromWatch\(cachedWatchRowsForHome\(\), true\), opts\); \/\/ Internal first paint: focus target under the splash before \/api\/watch returns\./, 'home should render a hidden focus target before the watch-state request can freeze Android TV D-pad input'); assert.match(ui, /function perfMark\(name, extra = \{\}\) \{[\s\S]+S\.perfMarks[\s\S]+function signalTvReady\(reason\) \{[\s\S]+TriboonTV\.appReady[\s\S]+function signalTvReadyOnce\(reason\)/, @@ -2707,27 +2775,40 @@ test('Android native player: direct source and native chrome stay out of the web 'Android key bridge should not drop early D-pad keydown events during first app paint'); assert.match(ui, /function startWebPlayerHousekeeping\(mount, it\) \{[\s\S]+v\.onerror = \(\) => \{[\s\S]+failover\(\);[\s\S]+\};[\s\S]+startHealthPoll\(mount\.id\);[\s\S]+loadTracks\(\);[\s\S]+subtitleCatalogAvailable\(it\)[\s\S]+fetch\(`\/api\/ossubs\/\$\{mount\.id\}\?\$\{subtitleRequestParams\(it, code2, mount\.streamToken\)\.toString\(\)\}`\)/, 'web-only probes and subtitle prefetch should stay in the web playback branch and carry catalog ids; the video error handler still fails over'); + assert.match(ui, /function startNativePlayerHousekeeping\(it\) \{[\s\S]+startHealthPoll\(S\.playing && S\.playing\.mountId\);/, + 'native Continue Watching must run the same live source-health recheck as web playback'); + assert.match(ui, /function startHealthPoll\(id\) \{[\s\S]+const poll = async \(\) => \{[\s\S]+p\.mountId !== id[\s\S]+h\.verdict === 'blocked'[\s\S]+autoAdvance\(\{ allowMidstreamAdvance: true, nativePreferred: !!p\.usingNative, reason: 'source health blocked' \}\)[\s\S]+S\._healthKickT = setTimeout\(poll, 1200\);[\s\S]+S\.healthTimer = setInterval\(poll, 20000\);/, + 'blocked resume sources should advance promptly on both web and native without waiting for a media timeout'); assert.match(ui, /async function loadTracks\(\) \{[\s\S]+if \(p\.usingNative && canUseNativeVideoPlayer\(\)\) \{[\s\S]+p\.nativeDuration = p\.duration \|\| p\.nativeDuration \|\| 0;[\s\S]+refreshNativeSubtitleChoices\(\);[\s\S]+return;[\s\S]+\}/, 'track probing should feed native duration and subtitle choices without starting web playback'); assert.match(ui, /function startSource\(kind, atSeconds, opts = \{\}\) \{[\s\S]+if \(p && p\.usingNative && canUseNativeVideoPlayer\(\)\) return false;/, 'web source swaps should not run underneath native playback'); - assert.match(ui, /function markVodPlaybackStarted\(p\) \{[\s\S]+p\.started = true;[\s\S]+function vodPlaybackStarted\(p\) \{[\s\S]+p\.nativeReady[\s\S]+function recoverSamePlaybackSource\(reason = ''\) \{[\s\S]+tryNativeVideoPlayer\(kind, at, \{ quietSeek: true \}\)[\s\S]+startSource\(kind, at, \{ quietSeek: true \}\)/, - 'VOD playback should record the post-start boundary and recover the same source/kind after a mid-stream interruption'); + assert.match(ui, /function markVodPlaybackStarted\(p\) \{[\s\S]+p\.started = true;[\s\S]+function vodPlaybackStarted\(p\) \{[\s\S]+p\.started \|\| p\.startedAt[\s\S]+function recoverSamePlaybackSource\(reason = ''\) \{[\s\S]+tryNativeVideoPlayer\(kind, at, \{ quietSeek: true \}\)[\s\S]+startSource\(kind, at, \{ quietSeek: true \}\)/, + 'VOD playback should cross the post-start boundary only after real playback and retry the same source/kind once'); + assert.doesNotMatch(ui, /function vodPlaybackStarted\(p\) \{[^}]+nativeReady/, + 'native STATE_READY alone must not misclassify a frozen Continue Watching resume as established playback'); assert.match(ui, /function failover\(\) \{[\s\S]+if \(vodPlaybackStarted\(p\)\) \{[\s\S]+recoverSamePlaybackSource\('playback interrupted'\);[\s\S]+return;[\s\S]+if \(!p\.usingRemux && !p\.usingTranscode/, 'web media errors after a real VOD frame must not silently switch to remux/transcode or another release'); // When same-source resume fails because the mount is gone (server restarted/updated/swept), re-mount // the SAME title on a fresh mount and resume at the current position, with backoff to ride out the // restart — instead of giving up. Failure-path only, so healthy playback is untouched. - assert.match(ui, /if \(!p\._reMounting\) reMountAndResume\(reason\);/, - 'a gone mount should escalate to a re-mount instead of immediately showing interrupted'); + assert.match(ui, /p\._sameSourceRecoveryKey === key[\s\S]+autoAdvance\(\{ allowMidstreamAdvance: true, nativePreferred: !!p\.usingNative, reason/, + 'a second sustained same-source failure should advance to a different release instead of looping for minutes'); + const sourceRecoveryBlock = ui.slice(ui.indexOf('function recoverSamePlaybackSource'), ui.indexOf('function resetVlcPanel')); + assert.doesNotMatch(sourceRecoveryBlock, /playNextEpisode\(/, + 'source recovery must never enter the next-episode path'); assert.match(ui, /async function reMountAndResume\(reason = '', attempt = 0\) \{[\s\S]+api\('\/api\/play', \{ method: 'POST', body: playbackRequestBody\(p\.item, p\.name \? \{ name: p\.name \} : null\) \}\)[\s\S]+if \(S\.playing !== p \|\| S\.view !== 'player'\) \{ p\._reMounting = false; return; \}[\s\S]+p\.mountId = r\.id;[\s\S]+startSource\(kind, at, \{ quietSeek: true \}\)[\s\S]+setTimeout\(\(\) => \{ if \(S\.playing === p && S\.view === 'player'\) reMountAndResume\(reason, attempt \+ 1\); \}, 1500 \+ attempt \* 1500\)/, 'reMountAndResume should re-play the same title, reject stale ownership, resume at position, and retry with backoff then fall back'); - assert.match(ui, /async function autoAdvance\(opts = \{\}\) \{[\s\S]+if \(vodPlaybackStarted\(p\) && !opts\.allowMidstreamAdvance\) \{[\s\S]+recoverSamePlaybackSource\('source failed'\);[\s\S]+return;[\s\S]+const at = currentTime\(\);/, - 'auto-advance should remain a startup/source-failure path, not a mid-movie release switch'); - assert.match(ui, /window\.__tvNativeVideoReady = \(pos, dur, token\) => \{[\s\S]+nativePlaybackCallbackMatches\(p, token\)[\s\S]+p\.nativeReady = true;[\s\S]+markVodPlaybackStarted\(p\);[\s\S]+window\.__tvNativeVideoError = \(msg, pos, dur, token\) => \{[\s\S]+nativePlaybackCallbackMatches\(p, token\)[\s\S]+if \(vodPlaybackStarted\(p\)\) \{[\s\S]+recoverSamePlaybackSource\(msg \|\| 'native playback interrupted'\);[\s\S]+return;/, - 'native ExoPlayer errors after READY should recover the same source instead of walking the fallback ladder'); - assert.match(android, /if \(state == Player\.STATE_READY\) \{[\s\S]+if \("video"\.equals\(nativeMode\)\) \{[\s\S]+nativeVideoStarted = true;[\s\S]+window\.__tvNativeVideoReady && __tvNativeVideoReady/, - 'Android ExoPlayer STATE_READY should mark the web VOD session as post-start before later errors are handled'); + assert.match(ui, /async function autoAdvance\(opts = \{\}\) \{[\s\S]+if \(vodPlaybackStarted\(p\) && !opts\.allowMidstreamAdvance\) \{[\s\S]+recoverSamePlaybackSource\('source failed'\);[\s\S]+return;[\s\S]+p\._sourceAdvancePending = true;[\s\S]+const reportedAt = Number\(currentTime\(\)\);[\s\S]+Number\(p\.item && p\.item\.resume\)[\s\S]+p\.nativePos = at;[\s\S]+p\.started = false; p\.startedAt = 0;/, + 'source advance should coalesce callbacks, preserve an early Continue Watching timestamp, and give the replacement a fresh startup boundary'); + assert.match(ui, /catch \(e\) \{[\s\S]+opts\.allowMidstreamAdvance && e && e\.status === 404[\s\S]+reMountAndResume\(opts\.reason/, + 'a server restart that lost the play session should re-mount the title instead of being mistaken for source exhaustion'); + assert.match(ui, /window\.__tvNativeVideoReady = \(pos, dur, token\) => \{[\s\S]+p\.nativeReady = true;[\s\S]+window\.__tvNativeVideoPlaying = \(pos, dur, token\) => \{[\s\S]+markVodPlaybackStarted\(p\);[\s\S]+window\.__tvNativeVideoError = \(msg, pos, dur, token\) => \{[\s\S]+if \(vodPlaybackStarted\(p\)\) \{[\s\S]+recoverSamePlaybackSource\(msg \|\| 'native playback interrupted'\);/, + 'native playback should become established on PLAYING, not READY, while real mid-stream errors keep same-source-first recovery'); + assert.match(android, /if \(state == Player\.STATE_READY\) \{[\s\S]+if \("video"\.equals\(nativeMode\)\) \{[\s\S]+applyNativeStartSeekIfReady\(\);[\s\S]+window\.__tvNativeVideoReady && __tvNativeVideoReady/, + 'Android ExoPlayer STATE_READY should apply the resume seek and report readiness without claiming that frames advanced'); + assert.doesNotMatch(android, /if \(state == Player\.STATE_READY\) \{[\s\S]{0,260}nativeVideoStarted = true;/, + 'Android STATE_READY must not bypass the fast startup-stall path'); assert.match(android, /else if \("live"\.equals\(nativeMode\)\) \{[\s\S]+nativeLiveStarted = true;[\s\S]+window\.__tvNativeLiveReady && __tvNativeLiveReady\(\)/, 'Android ExoPlayer STATE_READY should commit pending Live TV PiP guide tuning only after the live surface is ready'); assert.match(ui, /const clearReadyFrame = \(\) => \{[\s\S]+pReady\.item\.type === 'live' && v\.readyState >= 2[\s\S]+\$\(\'playerLoader\'\)\.classList\.remove\('show'\);[\s\S]+v\.onloadeddata = clearReadyFrame;[\s\S]+v\.oncanplay = clearReadyFrame;/, @@ -4433,8 +4514,8 @@ test('Android native player: direct source and native chrome stay out of the web 'native Live TV should restart instead of staying frozen when a live stream ends quietly'); assert.match(android, /private void recoverNativeLivePlayback\(String reason\) \{[\s\S]+if \(tryNativeLiveFallback\(\)\) return;[\s\S]+nativePlayer\.setMediaItem\(buildNativeMediaItem\(\)\);[\s\S]+nativePlayer\.prepare\(\);[\s\S]+nativePlayer\.play\(\);/, 'native Live TV recovery should stay inside ExoPlayer and restart the active native stream'); - assert.match(android, /private boolean nativeVideoStarted;[\s\S]+NATIVE_VIDEO_HEAVY_STARTUP_STALL_MS = 12000L[\s\S]+NATIVE_VIDEO_REBUFFER_TRIM_MS = 15000L[\s\S]+NATIVE_VIDEO_REBUFFER_RECOVERY_MS = 45000L[\s\S]+private void updateNativeVideoWatchdog\(\) \{[\s\S]+if \(state == Player\.STATE_READY\) \{[\s\S]+nativeVideoStarted = true;[\s\S]+nativeVideoUnhealthySinceMs = 0L;[\s\S]+nativeVideoMemoryTrimmedDuringBuffer = false;[\s\S]+if \(nativeVideoStarted\) \{[\s\S]+boolean waitingForData = state == Player\.STATE_BUFFERING[\s\S]+elapsed >= NATIVE_VIDEO_REBUFFER_TRIM_MS[\s\S]+trimAndroidMemoryCaches\(false\)[\s\S]+elapsed >= NATIVE_VIDEO_REBUFFER_RECOVERY_MS[\s\S]+notifyNativeVideoError\(state == Player\.STATE_IDLE \? "native player idle" : "native rebuffer stalled"[\s\S]+long startupThreshold = nativeLikelyHeavyVod\(\)[\s\S]+NATIVE_VIDEO_HEAVY_STARTUP_STALL_MS/, - 'native movie and episode startup should fail over quickly, while sustained mid-play stalls trim memory and retry the same source'); + assert.match(android, /private boolean nativeVideoStarted;[\s\S]+NATIVE_VIDEO_HEAVY_STARTUP_STALL_MS = 12000L[\s\S]+NATIVE_VIDEO_REBUFFER_TRIM_MS = 15000L[\s\S]+NATIVE_VIDEO_REBUFFER_RECOVERY_MS = 30000L[\s\S]+NATIVE_VIDEO_HEAVY_REBUFFER_RECOVERY_MS = 45000L[\s\S]+private void updateNativeVideoWatchdog\(\) \{[\s\S]+getPlaybackSuppressionReason\(\) == Player\.PLAYBACK_SUPPRESSION_REASON_NONE[\s\S]+boolean readyButNotPlaying = state == Player\.STATE_READY[\s\S]+if \(state == Player\.STATE_READY && !readyButNotPlaying\)[\s\S]+if \(nativeVideoStarted\)[\s\S]+long recoveryMs = nativeLikelyHeavyVod\(\)[\s\S]+NATIVE_VIDEO_HEAVY_REBUFFER_RECOVERY_MS[\s\S]+NATIVE_VIDEO_REBUFFER_RECOVERY_MS[\s\S]+long startupThreshold = nativeLikelyHeavyVod\(\)[\s\S]+NATIVE_VIDEO_HEAVY_STARTUP_STALL_MS/, + 'native resume startup should fail over in 7-12s, while established standard/4K stalls get bounded 30s/45s recovery'); assert.match(android, /private boolean nativeVideoErrorNotified;[\s\S]+private void notifyNativeVideoError\(String msg, long pos, long dur\) \{[\s\S]+if \(nativeVideoErrorNotified\) return;[\s\S]+nativeVideoErrorNotified = true;[\s\S]+releaseNativePlayer\(false\);/, 'native movie and episode error reporting should be one-shot per playback attempt'); assert.match(android, /catch \(Throwable e\) \{[\s\S]+handleNativePlaybackStartFailure\(e, mode, title, backdropUrl, loadingKind,[\s\S]+private void handleNativePlaybackStartFailure\(Throwable e,[\s\S]+trimAndroidMemoryCaches\(true\)[\s\S]+__tvNativeVideoError[\s\S]+__tvNativeLiveError/, diff --git a/web/index.html b/web/index.html index 494dba8..d9ef627 100644 --- a/web/index.html +++ b/web/index.html @@ -9751,6 +9751,7 @@

Open in a video player

function clearPlaybackTimers() { clearTimeout(S._waitT); clearTimeout(_nudgeT); _nudgeSum = 0; _nudgeBase = null; clearWebRebufferRecovery(); + if (S._healthKickT) { clearTimeout(S._healthKickT); S._healthKickT = null; } if (S.upNextTimer) { clearInterval(S.upNextTimer); S.upNextTimer = null; } if (S.healthTimer) { clearInterval(S.healthTimer); S.healthTimer = null; } if (S.watchTimer) { clearInterval(S.watchTimer); S.watchTimer = null; } @@ -9761,11 +9762,24 @@

Open in a video player

S._webRebufferT = null; S._webRebufferWatch = null; } +function webVodHeavy(p) { + const rank = Number(p && p.sourceAttributes && p.sourceAttributes.resolutionRank); + const label = String((p && p.sourceAttributes && (p.sourceAttributes.resolution || p.sourceAttributes.quality)) || ''); + return rank >= 4 || /2160|4k|uhd/i.test(label) || Number(p && p.size || 0) >= 18 * 1024 * 1024 * 1024; +} +function webVodStallRecoveryMs(p, established) { + if (!established) return webVodHeavy(p) ? 18000 : 10000; + return webVodHeavy(p) ? 45000 : 30000; +} function armWebRebufferRecovery(v, p) { - if (S._webRebufferT || !v || !p || !vodPlaybackStarted(p) || p.usingNative + if (S._webRebufferT || !v || !p || p.usingNative || !p.item || p.item.type === 'live' || v.paused || v.seeking || (p.suppressSeekLoaderUntil && appMs() < p.suppressSeekLoaderUntil)) return; - const watch = { playing: p, position: Math.max(0, Number(v.currentTime) || 0) }; + const watch = { + playing: p, + position: Math.max(0, Number(v.currentTime) || 0), + established: vodPlaybackStarted(p), + }; S._webRebufferWatch = watch; S._webRebufferT = setTimeout(() => { S._webRebufferT = null; @@ -9774,8 +9788,9 @@

Open in a video player

|| v.paused || v.seeking || v.readyState >= 3 || (p.suppressSeekLoaderUntil && appMs() < p.suppressSeekLoaderUntil) || Math.abs((Number(v.currentTime) || 0) - watch.position) >= 0.5) return; - recoverSamePlaybackSource('web rebuffer stalled'); - }, 45000); + if (watch.established) recoverSamePlaybackSource('web rebuffer stalled'); + else failover(); + }, webVodStallRecoveryMs(p, watch.established)); } function stopActivePlaybackForReplacement(opts = {}) { if (!S.playing) return; @@ -9802,7 +9817,9 @@

Open in a video player

} function startNativePlayerHousekeeping(it) { stopWebVideoElement(); - if (S.healthTimer) clearInterval(S.healthTimer); + // Native ExoPlayer used to disable source triage entirely. That left a Continue Watching resume + // on a release which had rotted while the viewer was away waiting only on media timeouts. + startHealthPoll(S.playing && S.playing.mountId); if (S.watchTimer) clearInterval(S.watchTimer); S.watchTimer = setInterval(saveWatch, 10000); startActivityHeartbeat(); @@ -9914,7 +9931,10 @@

Open in a video player

bufferGoalSec: mount.bufferGoalSec || 0, // server-side read-ahead goal → native player buffer depth usingRemux: false, usingTranscode: false, startOffset: 0, duration: knownDuration, lastSaved: 0, triedRemux: false, triedTranscode: false, mountId: mount.id, - tracks: null, _tracksJob: null, audioTrack: 0, subTrack: null, quality: 1080, nativePos: 0, nativeDuration: 0, + tracks: null, _tracksJob: null, audioTrack: 0, subTrack: null, quality: 1080, + // Seed native position with the requested resume point. A prompt health verdict can replace a + // rotten source before ExoPlayer's first callback; recovery must still start at Continue Watching. + nativePos: Math.max(0, Number(it.resume) || 0), nativeDuration: 0, nativeStartKind: playbackStartKind(mount), nativeTried: {}, // Echoed by Android on terminal callbacks so a late STATE_ENDED from the previous ExoPlayer // cannot mark or auto-advance a newly-opened episode. @@ -9977,7 +9997,7 @@

Open in a video player

v.onwaiting = () => { if (S.playing !== playingRef || S.view !== 'player') return; const pWait = playingRef; - if (pWait && pWait.started) armWebRebufferRecovery(v, pWait); + if (pWait) armWebRebufferRecovery(v, pWait); if (S.view !== 'player' || (pWait && (pWait.started || (pWait.suppressSeekLoaderUntil && appMs() < pWait.suppressSeekLoaderUntil)))) return; clearTimeout(S._waitT); S._waitT = setTimeout(() => { @@ -10723,11 +10743,13 @@

Open in a video player

const p = S.playing; if (!p || !p.usingNative || !nativePlaybackCallbackMatches(p, token)) return; applyNativeVideoProgress(pos, dur); p.nativeReady = true; - markVodPlaybackStarted(p); }; window.__tvNativeVideoPlaying = (pos, dur, token) => { const p = S.playing; if (!p || !p.usingNative || !nativePlaybackCallbackMatches(p, token)) return; applyNativeVideoProgress(pos, dur); + // READY means ExoPlayer parsed enough media to be ready; it does not prove a frame advanced. + // Mark the recovery boundary only on real PLAYING so a frozen resume keeps the fast startup path. + markVodPlaybackStarted(p); cancelPauseWarmAhead(); }; window.__tvNativeVideoPaused = (pos, dur, token) => { @@ -10958,7 +10980,7 @@

Open in a video player

if (!p.startedAt) p.startedAt = appMs(); } function vodPlaybackStarted(p) { - return !!(p && p.item && p.item.type !== 'live' && (p.started || p.startedAt || p.nativeReady)); + return !!(p && p.item && p.item.type !== 'live' && (p.started || p.startedAt)); } function currentPlaybackUrl(p, atSeconds = currentTime()) { if (!p) return ''; @@ -10975,8 +10997,8 @@

Open in a video player

if ($('vlcTitle')) $('vlcTitle').textContent = 'Playback interrupted'; if (msg) { msg.textContent = reason - ? `The current source stopped responding (${reason}). Triboon kept the same release instead of changing sources.` - : 'The current source stopped responding. Triboon kept the same release instead of changing sources.'; + ? `Playback stopped responding (${reason}), and Triboon could not recover a healthy release.` + : 'Playback stopped responding, and Triboon could not recover a healthy release.'; } const url = currentPlaybackUrl(p); $('vlcUrl').textContent = url ? location.origin + url : ''; @@ -10990,18 +11012,23 @@

Open in a video player

const at = currentTime(); const key = `${p.mountId || p.sessionId || p.streamUrl || ''}:${kind}`; const now = appMs(); - if (p._sameSourceRecoveryKey === key && now - (p._sameSourceRecoveryAt || 0) < 60000) { + if (p._sameSourceRecoveryKey === key && now - (p._sameSourceRecoveryAt || 0) < 120000) { // Same-source resume already failed once. On a CONSERVATIVE / low-power device a persistent // failure mid-playback is more likely an UNDECODABLE source (a 4K / high-bitrate / HEVC-Main10 // stream the budget decoder choked on after starting, or thermal throttle) than a swept mount — // so escalate the ladder (direct → remux → transcode) and land on the budget-friendly transcode // instead of looping the same unplayable kind forever. High-end devices and genuine mount-loss // are untouched: they fall straight through to the re-mount path below. - if (p.usingNative && (clientCaps() || {}).lowPower && tryNextNativeKind(kind, at, reason || 'playback failed')) return true; - // Otherwise the mount is gone (server restarted / swept from memory). Re-mount the SAME title and - // resume from the current position instead of giving up. Failure-path only: if the re-mount can't - // succeed it falls back to the interrupted notice — healthy playback is never touched. - if (!p._reMounting) reMountAndResume(reason); + const decoderFailure = /decode|decoder|codec|renderer|format/i.test(String(reason || '')); + if (decoderFailure && p.usingNative && (clientCaps() || {}).lowPower + && tryNextNativeKind(kind, at, reason || 'playback failed')) return true; + // A normal transport stall gets one timestamp-preserving retry above. If that same source stalls + // again, preserve the episode and timestamp but move to the next ranked release. Session expiry + // is handled separately by autoAdvance's 404 re-mount path below. + if (!p._sourceAdvancePending) { + toast('Source is still stalled - switching releases...'); + autoAdvance({ allowMidstreamAdvance: true, nativePreferred: !!p.usingNative, reason: reason || 'repeated playback stall' }); + } return true; } p._sameSourceRecoveryKey = key; @@ -11027,10 +11054,12 @@

Open in a video player

const r = await api('/api/play', { method: 'POST', body: playbackRequestBody(p.item, p.name ? { name: p.name } : null) }); if (S.playing !== p || S.view !== 'player') { p._reMounting = false; return; } // user moved on / replaced playback // Adopt the new mount (same field set autoAdvance maps), then resume the current source kind. - p.sessionId = r.sessionId; p.streamUrl = r.streamUrl; p.remuxUrl = r.remuxUrl; p.transcodeUrl = r.transcodeUrl; + p.sessionId = r.sessionId; p.streamUrl = r.streamUrl; p.remuxUrl = r.remuxUrl; p.transcodeUrl = r.transcodeUrl; p.hlsUrl = r.hlsUrl; p.encoder = r.encoder; p.tracksUrl = r.tracksUrl; p.subtitleBase = r.subtitleBase; p.streamToken = r.streamToken; - p.mountId = r.id; p.fileName = r.name; p.size = r.size || p.size; + p.mountId = r.id; p.fileName = r.name; p.sourceAttributes = r.candidate && r.candidate.attributes; + p.size = r.size || (r.candidate && r.candidate.sizeBytes) || p.size; p.bufferGoalSec = r.bufferGoalSec || 0; p.triedRemux = false; p.triedTranscode = false; p.nativeTried = {}; + p.started = false; p.startedAt = 0; p.nativePos = at; p.nativeDuration = 0; p._sameSourceRecoveryKey = null; p._sameSourceRecoveryAt = 0; p._reMounting = false; // fresh mount → reset guards if (p.usingNative && canUseNativeVideoPlayer() && tryNativeVideoPlayer(kind, at, { quietSeek: true })) return; p.usingNative = false; @@ -11085,7 +11114,12 @@

Open in a video player

recoverSamePlaybackSource('source failed'); return; } - const at = currentTime(); + if (p._sourceAdvancePending) return; + p._sourceAdvancePending = true; + const reportedAt = Number(currentTime()); + const at = Math.max(0, Number.isFinite(reportedAt) && (reportedAt > 0 || vodPlaybackStarted(p)) + ? reportedAt + : Number(p.item && p.item.resume) || 0); const nativePreferred = !!opts.nativePreferred || (p.usingNative && canUseNativeVideoPlayer()); toast('Source failed — advancing to the next release…'); try { @@ -11097,12 +11131,15 @@

Open in a video player

body: at > 0 && advDur > 0 ? { resumeFrac: Math.max(0, Math.min(0.98, at / advDur)) } : {}, }); if (S.playing !== p || S.view !== 'player') return; - p.streamUrl = r.streamUrl; p.remuxUrl = r.remuxUrl; p.transcodeUrl = r.transcodeUrl; - p.triedRemux = false; p.triedTranscode = false; p.mountId = r.id; + p.streamUrl = r.streamUrl; p.remuxUrl = r.remuxUrl; p.transcodeUrl = r.transcodeUrl; p.hlsUrl = r.hlsUrl; + p.encoder = r.encoder; p.fileName = r.name; p.sourceAttributes = r.candidate && r.candidate.attributes; + p.size = r.size || (r.candidate && r.candidate.sizeBytes) || 0; p.bufferGoalSec = r.bufferGoalSec || 0; + p.triedRemux = false; p.triedTranscode = false; p.usingRemux = false; p.usingTranscode = false; p.mountId = r.id; p.tracksUrl = r.tracksUrl; p.subtitleBase = r.subtitleBase; p.streamToken = r.streamToken; p.name = r.candidate ? r.candidate.name : r.name; - p.duration = 0; p.nativePos = at; p.nativeDuration = 0; + p.duration = 0; p.nativePos = at; p.nativeDuration = 0; p.started = false; p.startedAt = 0; p.nativeStartKind = playbackStartKind(r); p.nativeTried = {}; + p._sameSourceRecoveryKey = null; p._sameSourceRecoveryAt = 0; p.tracks = null; p._tracksJob = null; p.subTrack = null; p.audioTrack = 0; p.forceAacRemux = false; $('pSrc').textContent = r.candidate ? r.candidate.name : r.name; updatePlayerMeta(); @@ -11125,12 +11162,29 @@

Open in a video player

startSource(startKind, at); } catch (e) { if (S.playing !== p || S.view !== 'player') return; + if (opts.allowMidstreamAdvance && e && e.status === 404 && !p._reMounting) { + // A rolling server restart/sweep loses the session as well as the mount. Re-create the same + // title session; do not misreport that infrastructure case as exhausted alternate releases. + reMountAndResume(opts.reason || 'playback session expired'); + return; + } + if (opts.allowMidstreamAdvance) { + if (nativePreferred) { + try { if (window.TriboonTV && window.TriboonTV.closeVideo) window.TriboonTV.closeVideo(); } catch {} + p.usingNative = false; + revealWebPlayerShell(p.item); + } + showPlaybackInterrupted((e && e.message) || 'no healthy alternate release'); + return; + } if (nativePreferred) { toast('No more native-playable releases were available'); closePlayer(); return; } showVlcPanel(); + } finally { + if (S.playing === p) p._sourceAdvancePending = false; } } function resetVlcPanel() { @@ -12088,14 +12142,40 @@

Open in a video player

} function startHealthPoll(id) { if (S.healthTimer) clearInterval(S.healthTimer); + if (S._healthKickT) clearTimeout(S._healthKickT); + S.healthTimer = null; + S._healthKickT = null; if (!id) return; // local files / live channels have no usenet health - S.healthTimer = setInterval(async () => { + let inflight = false; + const poll = async () => { + const p = S.playing; + if (inflight || !p || p.mountId !== id || S.view !== 'player') return; + inflight = true; try { const h = await api('/api/health/' + id); + if (S.playing !== p || p.mountId !== id || S.view !== 'player') return; const cls = h.verdict === 'verified' ? 'ok' : 'warn'; $('pHealth').innerHTML = `${h.verdict === 'verified' ? '✅' : '⚠️'} ${h.verdict}`; - } catch {} - }, 20000); + if (h.verdict === 'blocked' && !p._sourceAdvancePending) { + // Confirmed source rot is not a normal rebuffer. Save the live timestamp and move straight + // to the next ranked release on web AND native instead of waiting for media timeouts. + if (S.healthTimer) clearInterval(S.healthTimer); + if (S._healthKickT) clearTimeout(S._healthKickT); + S.healthTimer = null; S._healthKickT = null; + saveWatch(true); + toast('Source failed health check - switching releases...'); + autoAdvance({ allowMidstreamAdvance: true, nativePreferred: !!p.usingNative, reason: 'source health blocked' }); + } + } catch { + // A transient health-route failure must never interrupt otherwise-progressing playback. + } finally { + inflight = false; + } + }; + // Let startup/seek bytes keep priority, then re-check the live mount promptly. The health lane is + // lower-priority at the NNTP pool, so this cannot jump ahead of first-frame work. + S._healthKickT = setTimeout(poll, 1200); + S.healthTimer = setInterval(poll, 20000); } function showPlayerPlayPrompt(opts = {}) { const p = S.playing;