feat(playback): local timeshift buffer with live-edge aware LIVE button - #1201
feat(playback): local timeshift buffer with live-edge aware LIVE button#1201SalemOurabi wants to merge 12 commits into
Conversation
…port Release the owner slot synchronously and tear the replaced session down in the background so rapid channel changes are not serialized behind FFmpeg termination. Purge leaked public-session entries when a renderer stops without a session id. Enable seeking and a live-edge action in embedded MPV while the local buffer is active. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
# Conflicts: # libs/ui/playback/src/lib/art-player/art-player.component.html # libs/ui/playback/src/lib/art-player/art-player.component.scss # libs/ui/playback/src/lib/art-player/art-player.component.spec.ts # libs/ui/playback/src/lib/art-player/art-player.component.ts # libs/ui/playback/src/lib/vjs-player/vjs-player.component.html # libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts # libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts # libs/ui/playback/src/lib/web-player-view/web-player-view.component.html # libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts # libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
|
@greptileai Please review this PR |
… state The LIVE/timeshift button now lives inside each player's control bar and fades with the controls instead of floating over the video. It shows a red dot only while playback is at the live edge and a grey dot when behind; clicking it while behind seeks back to the live edge and resumes playback. - add shared live-edge helpers (getMediaLiveEdge, isMediaAtLiveEdge, observeMediaLiveEdge, seekMediaToLiveEdge) with regression tests - add LiveEdgeObserver signal wrapper used by Video.js and HTML5 players - wire live-edge state into ArtPlayer and Embedded MPV (session-based) - extract vjs error classification and native <video> source helpers to keep components within the max-lines lint budget
Greptile SummaryThis PR adds an opt-in local timeshift buffer for Electron's built-in inline players, a live-edge aware LIVE button that fades with player controls, and a set of shared live-edge helpers. The feature requires FFmpeg and is wired end-to-end through an Electron IPC layer backed by a Node.js HTTP server, with
Confidence Score: 5/5Safe to merge. The timeshift feature is Electron-only, opt-in, and falls back gracefully to the original stream when FFmpeg is absent or a session fails. The backend implementation uses generation counters and abort signals to prevent stale starts from racing a newer configuration, timing-safe token comparison, and an explicit ffmpegResolved flag that correctly caches not-found results. The renderer-side coordinator properly falls back to the source URL on error. The before-quit async handler is correctly re-entrant-guarded. The only finding is a theoretical key-stability concern around JSON.stringify of a headers object, which is extremely unlikely to trigger given the stable computed-signal chain. No files require special attention. The new local-timeshift-coordinator.ts is the most complex renderer file but is well-covered by the 202-case spec suite referenced in the PR. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant R as Renderer (Angular)
participant C as LocalTimeshiftCoordinator
participant E as Electron IPC (events)
participant S as LocalTimeshiftService
participant F as FFmpeg
participant H as HLS HTTP Server
R->>C: configure(playback, settings, eligible)
C->>C: generate key, bump generation
C->>E: getLocalTimeshiftSupport()
E->>S: getSupport()
S-->>E: "{supported: true, engine: 'ffmpeg'}"
E-->>C: support result
C->>E: stopLocalTimeshift(prevId?)
E->>S: stopForOwner(ownerId)
S->>F: SIGTERM to SIGKILL (graceful)
C->>E: startLocalTimeshift(request)
E->>S: start(request)
S->>F: spawn FFmpeg HLS remux
S->>H: createLocalTimeshiftHttpServer()
H-->>S: "{playbackUrl, close()}"
S->>S: poll for playable playlist
S-->>E: LocalTimeshiftSession snapshot
E-->>C: session
C->>C: "status=ready, playback=timeshiftUrl"
C-->>R: playerPlayback() updated, player loads timeshift URL
Note over R,H: User clicks LIVE button
R->>R: seekMediaToLiveEdge(video)
R->>R: "media.currentTime = liveEdge - 0.25s"
R->>R: media.play()
Note over R,S: Renderer destroyed or channel change
C->>E: stopLocalTimeshift(sessionId)
E->>S: stop(sessionId, ownerId)
S->>F: SIGTERM
S->>H: close()
S->>S: rm session directory
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant R as Renderer (Angular)
participant C as LocalTimeshiftCoordinator
participant E as Electron IPC (events)
participant S as LocalTimeshiftService
participant F as FFmpeg
participant H as HLS HTTP Server
R->>C: configure(playback, settings, eligible)
C->>C: generate key, bump generation
C->>E: getLocalTimeshiftSupport()
E->>S: getSupport()
S-->>E: "{supported: true, engine: 'ffmpeg'}"
E-->>C: support result
C->>E: stopLocalTimeshift(prevId?)
E->>S: stopForOwner(ownerId)
S->>F: SIGTERM to SIGKILL (graceful)
C->>E: startLocalTimeshift(request)
E->>S: start(request)
S->>F: spawn FFmpeg HLS remux
S->>H: createLocalTimeshiftHttpServer()
H-->>S: "{playbackUrl, close()}"
S->>S: poll for playable playlist
S-->>E: LocalTimeshiftSession snapshot
E-->>C: session
C->>C: "status=ready, playback=timeshiftUrl"
C-->>R: playerPlayback() updated, player loads timeshift URL
Note over R,H: User clicks LIVE button
R->>R: seekMediaToLiveEdge(video)
R->>R: "media.currentTime = liveEdge - 0.25s"
R->>R: media.play()
Note over R,S: Renderer destroyed or channel change
C->>E: stopLocalTimeshift(sessionId)
E->>S: stop(sessionId, ownerId)
S->>F: SIGTERM
S->>H: close()
S->>S: rm session directory
Reviews (9): Last reviewed commit: "merge: sync latest upstream into timeshi..." | Re-trigger Greptile |
Greptile SummaryThis PR adds an opt-in local timeshift buffer for Electron's built-in inline players (HTML5/hls.js, Video.js, ArtPlayer, Embedded MPV), plus a live-edge-aware LIVE button across all four players. FFmpeg remuxes the live stream into a bounded sliding HLS playlist served from a token-protected loopback HTTP server, while rapid channel zapping is handled via a generation-based cancellation system.
Confidence Score: 4/5The change is well-structured and safe to merge; the findings are quality concerns rather than runtime failures. The core FFmpeg and HTTP server path is carefully written with timing-safe token checks, path-traversal guards, header injection protection, and loopback-only binding. The generation-based cancellation logic correctly prevents stale sessions from racing newer channel changes. The async before-quit shutdown sequence is correct. Two quality issues stand out: the ??= assignment in getSupport() does not memoize the undefined case, so resolveFfmpegCommand() is re-run on every new coordinator instance when FFmpeg is absent; and the new localTimeshiftActive input uses the legacy @input() decorator while the rest of the file has migrated to input() signals. apps/electron-backend/src/app/services/local-timeshift.service.ts (FFmpeg caching), apps/electron-backend/src/app/events/local-timeshift.events.ts (misleading no-op bootstrap method), libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts (legacy @input decorator) Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant R as Renderer
participant C as LocalTimeshiftCoordinator
participant P as Preload Bridge
participant M as Main Process
participant S as LocalTimeshiftService
participant F as FFmpeg
participant H as Loopback HTTP Server
R->>C: configure(playback, settings, eligible)
C->>C: "increment generation, status=starting"
C->>P: getLocalTimeshiftSupport()
P->>M: IPC GET_SUPPORT
M->>S: getSupport()
S-->>C: "supported=true"
C->>P: startLocalTimeshift(request)
P->>M: IPC START
M->>S: start(request)
S->>S: mkdtemp() session directory
S->>H: createHttpServer(dir, token)
H-->>S: playbackUrl
S->>F: spawn ffmpeg -i src -f hls ...
S->>S: poll for playable playlist
S-->>C: LocalTimeshiftSession
C->>C: "status=ready, playback=timeshiftUrl"
C-->>R: playerPlayback() emits timeshiftUrl
R->>R: setChannel(timeshiftUrl)
Note over R,H: User rewinds, LIVE button shows grey dot
R->>R: goLive() seekMediaToLiveEdge(media)
Note over R,M: Channel change or destroy
R->>C: configure(newPlayback) or onDestroy
C->>P: stopLocalTimeshift(sessionId)
P->>M: IPC STOP
M->>S: cleanupSession
S->>F: SIGTERM then SIGKILL
S->>H: close()
S->>S: rm -rf session directory
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant R as Renderer
participant C as LocalTimeshiftCoordinator
participant P as Preload Bridge
participant M as Main Process
participant S as LocalTimeshiftService
participant F as FFmpeg
participant H as Loopback HTTP Server
R->>C: configure(playback, settings, eligible)
C->>C: "increment generation, status=starting"
C->>P: getLocalTimeshiftSupport()
P->>M: IPC GET_SUPPORT
M->>S: getSupport()
S-->>C: "supported=true"
C->>P: startLocalTimeshift(request)
P->>M: IPC START
M->>S: start(request)
S->>S: mkdtemp() session directory
S->>H: createHttpServer(dir, token)
H-->>S: playbackUrl
S->>F: spawn ffmpeg -i src -f hls ...
S->>S: poll for playable playlist
S-->>C: LocalTimeshiftSession
C->>C: "status=ready, playback=timeshiftUrl"
C-->>R: playerPlayback() emits timeshiftUrl
R->>R: setChannel(timeshiftUrl)
Note over R,H: User rewinds, LIVE button shows grey dot
R->>R: goLive() seekMediaToLiveEdge(media)
Note over R,M: Channel change or destroy
R->>C: configure(newPlayback) or onDestroy
C->>P: stopLocalTimeshift(sessionId)
P->>M: IPC STOP
M->>S: cleanupSession
S->>F: SIGTERM then SIGKILL
S->>H: close()
S->>S: rm -rf session directory
Prompt To Fix All With AIFix the following 4 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 4
apps/electron-backend/src/app/services/local-timeshift.service.ts:96-100
**FFmpeg "not found" result is never cached**
`??=` only skips the RHS when the LHS is non-nullish. When `resolveFfmpegCommand()` returns `undefined` (FFmpeg absent), `this.ffmpegCommand` is assigned `undefined` and the LHS stays nullish on every subsequent call, so `resolveFfmpegCommand()` — which spawns a subprocess for each candidate path — is invoked again on every new `getSupport()` call. A new `LocalTimeshiftCoordinator` instance (created on every navigation to the player view) triggers a fresh IPC probe, which re-runs the spawns. A sentinel flag would pin the result after the first probe.
### Issue 2 of 4
libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts:72
**Legacy `@Input()` decorator for new input — CLAUDE.md requires `input()` signal**
`channel`, `volume`, `startTime`, and `seriesNavigation` are pre-existing legacy inputs; CLAUDE.md's Angular coding standards say new inputs must use `input()`. The new `localTimeshiftActive` should follow the same pattern as `isLive` and `interactionEnabled` already in this file.
```suggestion
readonly localTimeshiftActive = input(false);
```
### Issue 3 of 4
apps/electron-backend/src/app/events/local-timeshift.events.ts:43-47
**`bootstrapLocalTimeshiftEvents()` is a no-op — IPC handlers are already registered at module load time**
All three `ipcMain.handle()` calls (lines 49, 51, 70) and `service.setFailureHandler()` run at the top level when the module is first imported. The static bootstrap method just returns `ipcMain` without doing anything. Other event classes register their handlers inside the bootstrap method; this class breaks that pattern.
### Issue 4 of 4
apps/electron-backend/src/app/services/local-timeshift.service.ts:68
**`??=` leaves `ffmpegCommand` permanently `undefined` when FFmpeg is absent**
Replace the nullish-coalescing assignment with an explicit first-probe flag so the `undefined` result is remembered across calls.
```suggestion
private ffmpegCommand?: string;
private ffmpegResolved = false;
```
Reviews (2): Last reviewed commit: "feat(timeshift): move LIVE button into p..." | Re-trigger Greptile |
…uffer Resolves conflicts with the shared web player controls toggle (4gray#1198) and shared picture-in-picture controls (4gray#1199): keeps the memoized settings load and webPlayerSharedControls handling from upstream while restoring localTimeshift normalization, combines the LocalTimeshiftCoordinator and WEB_PLAYER_SHARED_CONTROLS providers, and adopts the queryByTestId helper in the playback settings spec. Extracts embedded-mpv adapter spec fixtures into spec-helpers to stay within the max-lines lint budget.
83e0c67 to
5e290ca
Compare
… service - convert localTimeshiftActive to a signal input in the HTML5 player - reuse DEFAULT_LIVE_EDGE_TOLERANCE_SECONDS in the embedded MPV live-edge check - cache a missing FFmpeg probe result instead of re-probing on every call - register local-timeshift IPC handlers in bootstrapLocalTimeshiftEvents() - extract session start orchestration into LocalTimeshiftSessionStarter
|
@greptileai Please review the latest commit 645f343 — it addresses all previous review comments (signal input, shared live-edge tolerance constant, FFmpeg probe caching, IPC bootstrap registration, and the service split via LocalTimeshiftSessionStarter). |
|
@greptileai Please also include d3044a9 in the review — a CI test fix adding the missing webPlayerSharedControls form control to the local-timeshift settings spec. |
|
@greptileai Please also review 9f2d213 — CSS fix raising the LIVE button above ArtPlayer's vendor bottom bar (progress + controls stack) while keeping the shared-controls offset aligned with the HTML5 player. |
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1201 +/- ##
==========================================
- Coverage 71.05% 63.58% -7.47%
==========================================
Files 40 688 +648
Lines 691 40453 +39762
Branches 87 8857 +8770
==========================================
+ Hits 491 25723 +25232
- Misses 176 11448 +11272
- Partials 24 3282 +3258
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@greptile please re-review the updated Timeshift branch. It now includes current upstream, resolves the Electron shutdown and settings-test conflicts, and carries the shared EPG schema split required by the current lint policy. Validation: all 41 Nx lint projects pass, Electron backend TypeScript passes, database build passes, 22 database tests pass, and the Timeshift/playback validation remains green (705 tests across the affected suites). |
|
@greptileai Please review this pull request |
|
@greptileai Please review this pull request |
# Conflicts: # libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html
|
@greptile please re-review after the latest upstream sync and semantic conflict resolution. The Timeshift behavior and the new upstream MPV/EPG behavior were preserved together; targeted unit tests, formatting, diff checks, and affected-project lint all pass locally. |
Summary
127.0.0.1endpoint, enabling pause/rewind in Video.js, HTML5/hls.js, ArtPlayer, and Embedded MPV. Radio, VOD/series, catch-up, and external MPV/VLC remain unchanged. Defaults: disabled, 30 min buffer (5–180 min configurable), system temp dir. FFmpeg is detected viaFFMPEG_PATH/PATH/known locations; unavailable FFmpeg falls back to the original stream. Sessions are renderer-owned with full cleanup on channel change, destroy, and app shutdown. Architecture doc:docs/architecture/local-timeshift.md.getMediaLiveEdge,isMediaAtLiveEdge,observeMediaLiveEdge,seekMediaToLiveEdge) and aLiveEdgeObserversignal wrapper, wired into all four inline players.Merged with latest master (shared web player controls toggle #1198 and shared picture-in-picture controls #1199); conflict resolution keeps upstream's memoized settings load and combines the timeshift and shared-controls providers.
Validation
pnpm nx lint ui-playback services✅servicessettings-store suite andwebplayback settings spec (26 tests) ✅git diff --check✅Test plan
Settings > Playback > Local timeshift, play a live channel, pause/rewind, then click LIVE → jumps back to live edge, red dot returns