From cf4a3421933b103ad1e05c29f6666447eceb9843 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 10:26:28 +0200 Subject: [PATCH] fix(portals): say "Playing" only while something is playing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isActive` means "the source a switch or Play would use". Discovery sets it the moment the page opens and it survives closing the player, so it could not back the two claims the UI made in the present tense: the "Playing from" caption and the source row's Playing badge. Both appeared on a page where nothing had started, and came back after the player was closed. `playbackLive` is now that statement, and both read it. Inline it needs a timeupdate — `inlinePlayback()` is only the REQUEST to play, non-null while the engine is still opening the stream and still non-null after it fails — and external it needs the session past `launching`. A row that is merely selected reads "Current" (new key, filled for all 19 locales). Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/hu.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/vod-multi-source.md | 16 +++++ .../vod-details-route-caption.spec.ts | 58 +++++++++++++++++++ .../vod-details-route.component.html | 2 + .../vod-details-route.component.ts | 37 ++++++++++-- .../vod-sources/vod-source-row.component.html | 5 +- .../vod-sources/vod-source-row.component.ts | 7 +++ .../vod-sources-chip.component.html | 1 + .../vod-sources/vod-sources-chip.component.ts | 2 + .../vod-sources-menu.component.html | 2 + .../vod-sources-menu.component.spec.ts | 21 +++++++ .../vod-sources/vod-sources-menu.component.ts | 2 + .../portal-inline-player.component.html | 1 + .../portal-inline-player.component.ts | 2 + 33 files changed, 171 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8cf6b45e2..c32b70dd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -837,6 +837,7 @@ engine` (restart required) or - Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials. - Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password. - Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them. +- Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`. - Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact. - HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled. - See `docs/architecture/vod-multi-source.md` diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 659d8c840..654096acb 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "تمت المطابقة حسب العنوان", "MATCH_BY_TITLE": "حسب العنوان", "PLAYING": "قيد التشغيل", + "CURRENT": "الحالي", "PLAY_FROM_SOURCE": "التشغيل من هذا المصدر", "PIN_SOURCE": "اجعله المصدر الرئيسي", "UNPIN_SOURCE": "إلغاء تثبيت هذا المصدر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index fa4b161fd..0084723e9 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "تطابق عن طريق العنوان", "MATCH_BY_TITLE": "بالعنوان", "PLAYING": "كيتشغل", + "CURRENT": "الحالي", "PLAY_FROM_SOURCE": "شغل من هاد المصدر", "PIN_SOURCE": "دير هاد المصدر هو الرئيسي", "UNPIN_SOURCE": "حيد هاد المصدر من الرئيسي", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7192012fe..45b8749a8 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "супастаўлена па назве", "MATCH_BY_TITLE": "па назве", "PLAYING": "Прайграецца", + "CURRENT": "Бягучая", "PLAY_FROM_SOURCE": "Прайграць з гэтай крыніцы", "PIN_SOURCE": "Зрабіць асноўнай крыніцай", "UNPIN_SOURCE": "Адмацаваць гэтую крыніцу", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index dc4705ee1..cba71cb40 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "über den Titel zugeordnet", "MATCH_BY_TITLE": "per Titel", "PLAYING": "Läuft", + "CURRENT": "Aktuell", "PLAY_FROM_SOURCE": "Von dieser Quelle abspielen", "PIN_SOURCE": "Als Hauptquelle festlegen", "UNPIN_SOURCE": "Hauptquelle aufheben", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 479a0cf10..92648d177 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "αντιστοιχίστηκε βάσει τίτλου", "MATCH_BY_TITLE": "βάσει τίτλου", "PLAYING": "Παίζει", + "CURRENT": "Τρέχουσα", "PLAY_FROM_SOURCE": "Αναπαραγωγή από αυτήν την πηγή", "PIN_SOURCE": "Ορισμός ως κύρια πηγή", "UNPIN_SOURCE": "Κατάργηση κύριας πηγής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 72c298d13..727ecd09d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "matched by title", "MATCH_BY_TITLE": "by title", "PLAYING": "Playing", + "CURRENT": "Current", "PLAY_FROM_SOURCE": "Play from this source", "PIN_SOURCE": "Make this the main source", "UNPIN_SOURCE": "Unpin this source", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index fce39ec94..9df3d17d3 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "coincidencia por título", "MATCH_BY_TITLE": "por título", "PLAYING": "Reproduciendo", + "CURRENT": "Actual", "PLAY_FROM_SOURCE": "Reproducir desde esta fuente", "PIN_SOURCE": "Fijar como fuente principal", "UNPIN_SOURCE": "Dejar de fijar esta fuente", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index c5236d96a..85a2652c9 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "correspondance trouvée d'après le titre", "MATCH_BY_TITLE": "par titre", "PLAYING": "En lecture", + "CURRENT": "Actuelle", "PLAY_FROM_SOURCE": "Lire depuis cette source", "PIN_SOURCE": "Définir comme source principale", "UNPIN_SOURCE": "Ne plus utiliser comme source principale", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index e3baabea1..3beff9c9e 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "Cím alapján párosítva", "MATCH_BY_TITLE": "cím alapján", "PLAYING": "Lejátszás alatt", + "CURRENT": "Aktuális", "PLAY_FROM_SOURCE": "Lejátszás erről a forrásról", "PIN_SOURCE": "Beállítás fő forrásként", "UNPIN_SOURCE": "Fő forrás beállításának visszavonása", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 07c807edb..9e110d47a 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "abbinato per titolo", "MATCH_BY_TITLE": "per titolo", "PLAYING": "In riproduzione", + "CURRENT": "Attuale", "PLAY_FROM_SOURCE": "Riproduci da questa sorgente", "PIN_SOURCE": "Imposta come sorgente principale", "UNPIN_SOURCE": "Rimuovi come sorgente principale", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 4d77096fd..6051bc47b 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "タイトルで一致", "MATCH_BY_TITLE": "タイトル一致", "PLAYING": "再生中", + "CURRENT": "現在", "PLAY_FROM_SOURCE": "このソースから再生", "PIN_SOURCE": "メインのソースに設定", "UNPIN_SOURCE": "メインのソース設定を解除", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d9dc94275..2b85d61d1 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "제목으로 매칭됨", "MATCH_BY_TITLE": "제목 기준", "PLAYING": "재생 중", + "CURRENT": "현재", "PLAY_FROM_SOURCE": "이 소스에서 재생", "PIN_SOURCE": "기본 소스로 지정", "UNPIN_SOURCE": "기본 소스 지정 해제", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4df408049..fad64c58e 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "gekoppeld op titel", "MATCH_BY_TITLE": "op titel", "PLAYING": "Speelt nu", + "CURRENT": "Huidige", "PLAY_FROM_SOURCE": "Afspelen vanaf deze bron", "PIN_SOURCE": "Instellen als hoofdbron", "UNPIN_SOURCE": "Niet meer als hoofdbron gebruiken", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 8470cc1c8..cb7be5465 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "dopasowano na podstawie tytułu", "MATCH_BY_TITLE": "wg tytułu", "PLAYING": "Odtwarzane", + "CURRENT": "Bieżące", "PLAY_FROM_SOURCE": "Odtwórz z tego źródła", "PIN_SOURCE": "Ustaw jako główne źródło", "UNPIN_SOURCE": "Przestań używać jako główne źródło", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 47dc174db..cfb5b07fb 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "correspondência por título", "MATCH_BY_TITLE": "por título", "PLAYING": "Reproduzindo", + "CURRENT": "Atual", "PLAY_FROM_SOURCE": "Reproduzir desta fonte", "PIN_SOURCE": "Definir como fonte principal", "UNPIN_SOURCE": "Remover como fonte principal", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 024890666..1d8915fe8 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "сопоставлено по названию", "MATCH_BY_TITLE": "по названию", "PLAYING": "Играет", + "CURRENT": "Текущий", "PLAY_FROM_SOURCE": "Воспроизвести из этого источника", "PIN_SOURCE": "Сделать основным источником", "UNPIN_SOURCE": "Отменить основной источник", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 44b4a478f..d7ffb59e8 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "Başlığa göre eşleştirildi", "MATCH_BY_TITLE": "başlığa göre", "PLAYING": "Oynatılıyor", + "CURRENT": "Geçerli", "PLAY_FROM_SOURCE": "Bu kaynaktan oynat", "PIN_SOURCE": "Bunu ana kaynak yap", "UNPIN_SOURCE": "Bu kaynağın sabitlemesini kaldır", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e98040188..b86a28784 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "通过标题匹配", "MATCH_BY_TITLE": "标题匹配", "PLAYING": "正在播放", + "CURRENT": "当前", "PLAY_FROM_SOURCE": "从此播放源播放", "PIN_SOURCE": "设为主播放源", "UNPIN_SOURCE": "取消主播放源", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 0aa2eea9c..2665f5508 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1012,6 +1012,7 @@ "MATCHED_BY_TITLE": "依標題比對", "MATCH_BY_TITLE": "依標題", "PLAYING": "播放中", + "CURRENT": "目前", "PLAY_FROM_SOURCE": "從此來源播放", "PIN_SOURCE": "設為主要來源", "UNPIN_SOURCE": "取消設為主要來源", diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 0fd04eda0..d094fbe6a 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -411,6 +411,22 @@ Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching `CatalogTitleMatchService`. In the PWA the chip renders nothing and the auto-failover setting is hidden. +## Claims about the present + +`isActive` means "the source a switch or Play would use". Discovery sets it the +moment the page opens, and it survives closing the player — so it cannot, on +its own, back a statement in the present tense. + +`VodDetailsRouteComponent.playbackLive` is that statement. Inline, it needs a +`timeupdate`: `inlinePlayback()` is only the REQUEST to play, non-null while +the engine is still opening the stream and still non-null after it fails, while +a timeupdate is the engine reporting frames. External, it needs the session +past `launching`. + +Two things read it, and they must agree: the "Playing from" caption, and the +source row's badge — which reads `Current` when a source is merely selected and +`Playing` once one really is. + ## Which engines can fail over Only the built-in web players (HTML5, Video.js, ArtPlayer) raise the playback diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts index 8a418cac8..786dc03d0 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts @@ -95,16 +95,72 @@ describe('VodDetailsRouteComponent — source caption', () => { // pressed — and again after the player is closed. expect(component.activeSourceCaption()).toBeNull(); + // Mounting the player is a REQUEST to play. The stream may still be + // opening, or about to fail; naming a source now is a guess. playback.inlinePlayback.set({ streamUrl: 'http://example.com/movie.mkv', title: 'Example', }); + expect(component.activeSourceCaption()).toBeNull(); + + // The engine reporting time is the evidence that it really is playing. + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); expect(component.activeSourceCaption()).not.toBeNull(); playback.inlinePlayback.set(null); expect(component.activeSourceCaption()).toBeNull(); }); + it('waits for the next stream to start after Play is pressed again', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + // The inline path, or Restart would tear the host down and the test + // would pass on the player being gone rather than on the latch. + stubs.isEmbeddedPlayer.mockReturnValue(true); + withActiveSource('playlist-1', 650020); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + + // Restart keeps the same host mounted, so without clearing the latch + // the caption would carry the previous stream's claim into the new one. + component.playVod({ + movie_data: { stream_id: 650020, name: 'Example' }, + } as never); + expect(playback.inlinePlayback()).not.toBeNull(); + expect(component.activeSourceCaption()).toBeNull(); + }); + + it('says nothing while an external player is still launching', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + withActiveSource('playlist-1', 650020); + + const session = (status: string) => ({ + player: 'mpv', + status, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + }, + }); + + // MPV has been asked to open; nothing is on screen yet. + activeSession.set(session('launching')); + expect(component.activeSourceCaption()).toBeNull(); + + // Its window is up, so the film really is playing — just not here. + activeSession.set(session('playing')); + expect(component.activeSourceCaption()).not.toBeNull(); + }); + it('stops claiming playback once the error screen is up', async () => { currentPlaylist.set({ id: 'playlist-1' }); const component = fixture.componentInstance; @@ -116,6 +172,7 @@ describe('VodDetailsRouteComponent — source caption', () => { streamUrl: 'http://example.com/movie.mkv', title: 'Example', }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); expect(component.activeSourceCaption()).not.toBeNull(); // The host stays mounted through a failure, so the caption would go on @@ -139,6 +196,7 @@ describe('VodDetailsRouteComponent — source caption', () => { streamUrl: 'http://example.com/movie.mkv', title: 'Example', }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); await component.onPlaybackFailed(); expect(component.activeSourceCaption()).toBeNull(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 700b3dc08..584fcd777 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -198,6 +198,7 @@ [count]="multiSource.alternativeCount()" [autoFailoverEnabled]="multiSource.autoFailoverEnabled()" [autoFailoverSupported]="autoFailoverSupported()" + [playbackLive]="playbackLive()" (playRequested)="playFromSource($event)" (pinRequested)="pinSource($event)" (checkRequested)="checkSource($event)" @@ -289,6 +290,7 @@ multiSource.autoFailoverEnabled() " [sourcesAutoFailoverSupported]="autoFailoverSupported()" + [sourcesPlaybackLive]="playbackLive()" (sourcesAutoFailoverToggled)="setAutoFailover($event)" (alternativeSourceRequested)="playFromSource($event)" (sourcePinRequested)="pinSource($event)" diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 79247e8b6..37e8910b3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -461,12 +461,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.multiSource.reportPosition(0); this.multiSource.markRouteSourceActive(); this.playbackFailed.set(false); + this.inlineTimeSeen.set(false); this.playback.playVod(vodItem); } resumeVod(vodItem: XtreamVodDetails | null): void { this.multiSource.markRouteSourceActive(); this.playbackFailed.set(false); + this.inlineTimeSeen.set(false); this.playback.resumeVod(vodItem); } @@ -556,10 +558,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // source active as soon as the page opens, so gating on that alone // makes the line a claim about a player that has not started, one the // user has since closed, or one that failed and is showing an error. - const playing = - (!!this.inlinePlayback() && !this.playbackFailed()) || - !!this.matchedExternalPlayback(); - if (!active || !playing) { + if (!active || !this.playbackLive()) { return null; } @@ -677,12 +676,42 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { /** True from a playback failure until something plays again. */ private readonly playbackFailed = signal(false); + /** + * Cleared on every start; set by the first timeupdate. + * + * `inlinePlayback()` is the REQUEST to play — it is non-null while the + * engine is still opening the stream, and stays non-null after it fails. + * A timeupdate is the engine reporting that it is producing frames, which + * is the only evidence the page has that something is really playing. + */ + private readonly inlineTimeSeen = signal(false); + + /** + * Whether a stream is on screen right now. + * + * Both the "Playing from" caption and the row's Playing badge are claims + * about the present, and discovery marks a source active the moment the + * page opens — long before anything plays, and again after the player is + * closed. Gating both on this keeps them from lying. + */ + readonly playbackLive = computed(() => { + if (this.inlinePlayback()) { + return !this.playbackFailed() && this.inlineTimeSeen(); + } + + // The external player is a window of its own: once it is open the film + // is on screen, and only 'launching' means it is not there yet. + const status = this.matchedExternalPlayback()?.status; + return status === 'opened' || status === 'playing'; + }); + handleInlineTimeUpdate(event: { currentTime: number; duration: number; }): void { // The engine is producing time, so whatever failed before is over. this.playbackFailed.set(false); + this.inlineTimeSeen.set(true); const settled = this.playback.handleInlineTimeUpdate(event); // Ahead of the service's 15s persistence throttle, so a source switch diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html index 3b2dbc606..95e1b0ccb 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html +++ b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html @@ -10,7 +10,10 @@ }} @if (source().isActive) { {{ - 'PORTALS.MULTI_SOURCE.PLAYING' | translate + (playbackLive() + ? 'PORTALS.MULTI_SOURCE.PLAYING' + : 'PORTALS.MULTI_SOURCE.CURRENT' + ) | translate }} } diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.ts b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.ts index 1bb49e67e..cd15e3229 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.ts @@ -34,6 +34,13 @@ export class VodSourceRowComponent { readonly source = input.required(); /** The error screen lists alternatives without offering the pin action. */ readonly showPin = input(true); + /** + * Whether a stream is on screen right now. The active row is the one a + * switch or Play would use, which is true from the moment the page opens — + * so without this the badge claims "Playing" before anything has started + * and again after the player is closed. + */ + readonly playbackLive = input(false); readonly playRequested = output(); readonly pinRequested = output(); diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html index 29f1c528a..eadddefe1 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html @@ -42,6 +42,7 @@ [autoFailoverSupported]="autoFailoverSupported()" [resumeLabel]="resumeLabel()" [showPin]="showPin()" + [playbackLive]="playbackLive()" (playRequested)="onPlay($event)" (pinRequested)="onPin($event)" (checkRequested)="onCheck($event)" diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts index 03165f7ad..9991d7df5 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts @@ -44,6 +44,8 @@ export class VodSourcesChipComponent { readonly autoFailoverEnabled = input(false); /** See `VodSourcesMenuComponent.autoFailoverSupported`. */ readonly autoFailoverSupported = input(true); + /** See `VodSourceRowComponent.playbackLive`. */ + readonly playbackLive = input(false); readonly resumeLabel = input(null); readonly showPin = input(true); /** Falls back to the number of sources when the host does not override it. */ diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html index 1920d6d4d..860647ccf 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html @@ -55,6 +55,7 @@ { ); }); + it('badges the active row as Playing only while a player is running', () => { + // Discovery marks a source active the moment the page opens, and the + // row keeps that state after the player is closed — so an + // unconditional "Playing" badge is a claim about nothing. + render([createSource({ isActive: true })]); + + const badge = () => + ( + fixture.debugElement.query(By.css('.source-row__badge')) + ?.nativeElement.textContent ?? '' + ).trim(); + + expect(badge()).toBe('Current'); + + fixture.componentRef.setInput('playbackLive', true); + fixture.detectChanges(); + + expect(badge()).toBe('Playing'); + }); + it('shows the match kind in the header, or the resume label when given', () => { fixture.componentRef.setInput('sources', [createSource()]); fixture.componentRef.setInput('matchKind', 'tmdb'); diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts index b09b73385..81d373134 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts @@ -68,6 +68,8 @@ export class VodSourcesMenuComponent { */ readonly resumeLabel = input(null); readonly showPin = input(true); + /** See `VodSourceRowComponent.playbackLive`. */ + readonly playbackLive = input(false); readonly playRequested = output(); readonly pinRequested = output(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index a9f68ccf6..43d54185d 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -38,6 +38,7 @@ [autoFailoverSupported]=" sourcesAutoFailoverSupported() " + [playbackLive]="sourcesPlaybackLive()" (playRequested)=" alternativeSourceRequested.emit($event) " diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 40f6b70e0..7555504d3 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -223,6 +223,8 @@ export class PortalInlinePlayerComponent { readonly sourcesAutoFailoverEnabled = input(false); /** See `VodSourcesMenuComponent.autoFailoverSupported`. */ readonly sourcesAutoFailoverSupported = input(true); + /** See `VodSourceRowComponent.playbackLive`. */ + readonly sourcesPlaybackLive = input(false); readonly sourcesAutoFailoverToggled = output(); readonly sourcePinRequested = output(); readonly sourceCheckRequested = output();