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