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();