fix(portals): say "Playing" only while something is playing

`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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 10:26:28 +02:00
1 parent 56733db1ec
commit cf4a342193
33 files changed
+171 -5

No files matched your search

+1
View File
@@ -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`
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "تمت المطابقة حسب العنوان",
"MATCH_BY_TITLE": "حسب العنوان",
"PLAYING": "قيد التشغيل",
"CURRENT": "الحالي",
"PLAY_FROM_SOURCE": "التشغيل من هذا المصدر",
"PIN_SOURCE": "اجعله المصدر الرئيسي",
"UNPIN_SOURCE": "إلغاء تثبيت هذا المصدر",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "تطابق عن طريق العنوان",
"MATCH_BY_TITLE": "بالعنوان",
"PLAYING": "كيتشغل",
"CURRENT": "الحالي",
"PLAY_FROM_SOURCE": "شغل من هاد المصدر",
"PIN_SOURCE": "دير هاد المصدر هو الرئيسي",
"UNPIN_SOURCE": "حيد هاد المصدر من الرئيسي",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "супастаўлена па назве",
"MATCH_BY_TITLE": "па назве",
"PLAYING": "Прайграецца",
"CURRENT": "Бягучая",
"PLAY_FROM_SOURCE": "Прайграць з гэтай крыніцы",
"PIN_SOURCE": "Зрабіць асноўнай крыніцай",
"UNPIN_SOURCE": "Адмацаваць гэтую крыніцу",
+1
View File
@@ -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",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "αντιστοιχίστηκε βάσει τίτλου",
"MATCH_BY_TITLE": "βάσει τίτλου",
"PLAYING": "Παίζει",
"CURRENT": "Τρέχουσα",
"PLAY_FROM_SOURCE": "Αναπαραγωγή από αυτήν την πηγή",
"PIN_SOURCE": "Ορισμός ως κύρια πηγή",
"UNPIN_SOURCE": "Κατάργηση κύριας πηγής",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "タイトルで一致",
"MATCH_BY_TITLE": "タイトル一致",
"PLAYING": "再生中",
"CURRENT": "現在",
"PLAY_FROM_SOURCE": "このソースから再生",
"PIN_SOURCE": "メインのソースに設定",
"UNPIN_SOURCE": "メインのソース設定を解除",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "제목으로 매칭됨",
"MATCH_BY_TITLE": "제목 기준",
"PLAYING": "재생 중",
"CURRENT": "현재",
"PLAY_FROM_SOURCE": "이 소스에서 재생",
"PIN_SOURCE": "기본 소스로 지정",
"UNPIN_SOURCE": "기본 소스 지정 해제",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "сопоставлено по названию",
"MATCH_BY_TITLE": "по названию",
"PLAYING": "Играет",
"CURRENT": "Текущий",
"PLAY_FROM_SOURCE": "Воспроизвести из этого источника",
"PIN_SOURCE": "Сделать основным источником",
"UNPIN_SOURCE": "Отменить основной источник",
+1
View File
@@ -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",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "通过标题匹配",
"MATCH_BY_TITLE": "标题匹配",
"PLAYING": "正在播放",
"CURRENT": "当前",
"PLAY_FROM_SOURCE": "从此播放源播放",
"PIN_SOURCE": "设为主播放源",
"UNPIN_SOURCE": "取消主播放源",
+1
View File
@@ -1012,6 +1012,7 @@
"MATCHED_BY_TITLE": "依標題比對",
"MATCH_BY_TITLE": "依標題",
"PLAYING": "播放中",
"CURRENT": "目前",
"PLAY_FROM_SOURCE": "從此來源播放",
"PIN_SOURCE": "設為主要來源",
"UNPIN_SOURCE": "取消設為主要來源",
+16
View File
@@ -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
@@ -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();
@@ -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)"
@@ -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
@@ -10,7 +10,10 @@
}}</span>
@if (source().isActive) {
<span class="source-row__badge">{{
'PORTALS.MULTI_SOURCE.PLAYING' | translate
(playbackLive()
? 'PORTALS.MULTI_SOURCE.PLAYING'
: 'PORTALS.MULTI_SOURCE.CURRENT'
) | translate
}}</span>
}
</div>
@@ -34,6 +34,13 @@ export class VodSourceRowComponent {
readonly source = input.required<VodSourceDescriptor>();
/** 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<string>();
readonly pinRequested = output<string>();
@@ -42,6 +42,7 @@
[autoFailoverSupported]="autoFailoverSupported()"
[resumeLabel]="resumeLabel()"
[showPin]="showPin()"
[playbackLive]="playbackLive()"
(playRequested)="onPlay($event)"
(pinRequested)="onPin($event)"
(checkRequested)="onCheck($event)"
@@ -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<string | null>(null);
readonly showPin = input(true);
/** Falls back to the number of sources when the host does not override it. */
@@ -55,6 +55,7 @@
<app-vod-source-row
[source]="group.primary"
[showPin]="showPin()"
[playbackLive]="playbackLive()"
(playRequested)="playRequested.emit($event)"
(pinRequested)="pinRequested.emit($event)"
(checkRequested)="checkRequested.emit($event)"
@@ -95,6 +96,7 @@
<app-vod-source-row
[source]="variant"
[showPin]="showPin()"
[playbackLive]="playbackLive()"
(playRequested)="
playRequested.emit($event)
"
@@ -21,6 +21,7 @@ class FakeTranslateLoader implements TranslateLoader {
MATCH_BY_TITLE: 'by title',
AUTO_FAILOVER: 'Auto-switch on error',
PLAYING: 'Playing',
CURRENT: 'Current',
PLAY_FROM_SOURCE: 'Play from this source',
PIN_SOURCE: 'Make this the main source',
UNPIN_SOURCE: 'Unpin this source',
@@ -259,6 +260,26 @@ describe('VodSourcesMenuComponent', () => {
);
});
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');
@@ -68,6 +68,8 @@ export class VodSourcesMenuComponent {
*/
readonly resumeLabel = input<string | null>(null);
readonly showPin = input(true);
/** See `VodSourceRowComponent.playbackLive`. */
readonly playbackLive = input(false);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
@@ -38,6 +38,7 @@
[autoFailoverSupported]="
sourcesAutoFailoverSupported()
"
[playbackLive]="sourcesPlaybackLive()"
(playRequested)="
alternativeSourceRequested.emit($event)
"
@@ -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<boolean>();
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();