feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 08:34:56 +02:00
1 parent e2300bea11
commit 004cb65fe6
101 files changed
+7183 -192

No files matched your search

+4
View File
@@ -16,5 +16,9 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
export * from './lib/vod-sources/vod-source-row.component';
export * from './lib/vod-sources/vod-source-tags';
export * from './lib/vod-sources/vod-sources-chip.component';
export * from './lib/vod-sources/vod-sources-menu.component';
export * from './lib/watched-badge/watched-badge.component';
export * from './lib/window-controls/window-controls.component';
@@ -446,7 +446,11 @@
}
::ng-deep .favorite-btn,
::ng-deep .download-btn {
::ng-deep .download-btn,
// The sources chip is a peer secondary action, so it wears the same
// surface, height and radius. Without this it reads as a link floating
// between three solid buttons.
::ng-deep app-vod-sources-chip .sources-chip {
@include detail-actions.secondary-action-button;
}
@@ -0,0 +1,75 @@
import {
playlistDisplayLabel,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
/**
* Collapses sources that come from the same playlist.
*
* One playlist frequently carries the same film several times under different
* stream ids — different rips, dubs or quality ladders. Flat, those rows are
* indistinguishable: same label, same monogram, same tags. Grouping turns "five
* identical rows" into "one playlist, 5 copies" and moves the difference into
* the expanded variants, where it can actually be read.
*/
export interface VodSourceGroup {
/** Stable across renders: the playlist is the group. */
key: string;
label: string;
/** The row shown when collapsed. */
primary: VodSourceDescriptor;
/** Every source in the group, primary included, in discovery order. */
sources: VodSourceDescriptor[];
/** Collapsed groups still need to advertise how much they hide. */
count: number;
hasActive: boolean;
}
export function groupVodSources(
sources: readonly VodSourceDescriptor[]
): VodSourceGroup[] {
const byPlaylist = new Map<string, VodSourceDescriptor[]>();
for (const source of sources) {
const existing = byPlaylist.get(source.playlistId);
if (existing) {
existing.push(source);
} else {
byPlaylist.set(source.playlistId, [source]);
}
}
return [...byPlaylist.entries()].map(([key, groupSources]) => ({
key,
label: playlistDisplayLabel(
groupSources[0].playlistName,
groupSources[0].playlistId
),
// The playing copy must be the face of the group, or the user sees
// "5 copies" with no indication that one of them is on screen.
primary: groupSources.find((s) => s.isActive) ?? groupSources[0],
sources: groupSources,
count: groupSources.length,
hasActive: groupSources.some((s) => s.isActive),
}));
}
/**
* What tells two copies from the same playlist apart.
*
* Their tags are usually identical (both parsed from near-identical titles), so
* the provider's own raw title is the only honest discriminator left. Falls
* back to the stream id when even the titles match.
*/
export function variantLabel(
source: VodSourceDescriptor,
siblings: readonly VodSourceDescriptor[]
): string {
const titles = new Set(siblings.map((s) => s.rawTitle));
if (titles.size > 1 && source.rawTitle) {
return source.rawTitle;
}
return `#${source.contentId}`;
}
@@ -0,0 +1,94 @@
<div class="source-row" [class.source-row--active]="source().isActive">
<span class="source-row__monogram" aria-hidden="true">{{
monogram()
}}</span>
<div class="source-row__info">
<div class="source-row__name-line">
<span class="source-row__name" [title]="displayLabel()">{{
displayLabel()
}}</span>
@if (source().isActive) {
<span class="source-row__badge">{{
'PORTALS.MULTI_SOURCE.PLAYING' | translate
}}</span>
}
</div>
<div class="source-row__tags">
@for (tag of tags(); track tag.id) {
<span
class="source-tag"
[class.source-tag--ok]="tag.tone === 'ok'"
[class.source-tag--warn]="tag.tone === 'warn'"
[class.source-tag--bad]="tag.tone === 'bad'"
>
@if (tag.spinner) {
<span
class="source-tag__spinner"
aria-hidden="true"
></span>
} @else if (tag.icon) {
<mat-icon class="source-tag__icon">{{
tag.icon
}}</mat-icon>
}
@if (tag.prefix) {
<span class="source-tag__prefix">{{ tag.prefix }}</span>
}
@if (tag.labelKey) {
<span>{{ tag.labelKey | translate }}</span>
} @else {
<span>{{ tag.text }}</span>
}
@if (tag.suffix) {
<span class="source-tag__suffix">{{ tag.suffix }}</span>
}
</span>
}
@if (showCheckChip()) {
<button
type="button"
class="source-tag source-tag--action"
(click)="onCheck($event)"
[matTooltip]="
'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate
"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate
"
>
<mat-icon class="source-tag__icon">refresh</mat-icon>
<span>{{ 'PORTALS.MULTI_SOURCE.CHECK' | translate }}</span>
</button>
}
</div>
</div>
<div class="source-row__actions">
@if (showPin()) {
<button
type="button"
class="source-row__action"
[class.is-active]="source().isPinned"
[attr.aria-pressed]="source().isPinned"
(click)="onPin($event)"
[matTooltip]="pinLabelKey() | translate"
[attr.aria-label]="pinLabelKey() | translate"
>
<mat-icon>push_pin</mat-icon>
</button>
}
<button
type="button"
class="source-row__action source-row__action--play"
(click)="onPlay($event)"
[matTooltip]="'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
</div>
</div>
@@ -0,0 +1,277 @@
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
:host {
display: block;
// Feature-specific status colours. Light theme is the default (no
// .dark-theme class on body); the dark overrides follow, mirroring the
// pattern in libs/ui/styles/_detail-view.scss. The handoff's dark values
// are far too light on a white surface, so light theme uses darker
// equivalents that clear ~4.5:1 against the tinted tag background.
--vod-source-ok-fg: #15803d;
--vod-source-ok-bg: rgba(21, 128, 61, 0.1);
--vod-source-warn-fg: #b45309;
--vod-source-warn-bg: rgba(180, 83, 9, 0.1);
--vod-source-bad-fg: #b91c1c;
--vod-source-bad-bg: rgba(185, 28, 28, 0.1);
}
:host-context(.dark-theme) {
--vod-source-ok-fg: #4ade80;
--vod-source-ok-bg: rgba(74, 222, 128, 0.1);
--vod-source-warn-fg: #ffcf99;
--vod-source-warn-bg: rgba(255, 207, 153, 0.1);
--vod-source-bad-fg: #ff9aa6;
--vod-source-bad-bg: rgba(255, 91, 110, 0.1);
}
.source-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 10px;
box-sizing: border-box;
transition:
background 0.15s ease,
outline-color 0.15s ease;
&:hover {
background: var(--app-card-hover-bg);
}
&--active,
&--active:hover {
background: var(--app-selection-surface);
outline: 1px solid var(--app-selection-color);
outline-offset: -1px;
}
}
.source-row__monogram {
flex: none;
width: 28px;
height: 28px;
// Concentric with the row: outer 10px radius minus 3px inset.
border-radius: 7px;
display: grid;
place-items: center;
background: var(--app-card-hover-bg);
color: var(--app-eyebrow-color);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.02em;
}
.source-row__info {
flex: 1;
min-width: 0;
// One line, not three: the name takes what it needs, the tags sit on the
// horizontal space that was previously empty. Roughly halves row height,
// which doubles how many sources fit without touching the panel size.
display: flex;
align-items: center;
gap: 10px;
}
.source-row__name-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
// Shrinks before the tags do — the status tags are the deciding
// information, the label only has to stay recognizable.
flex: 1 1 auto;
}
.source-row__name {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
color: var(--app-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-row__badge {
flex: none;
font-family: $font-mono;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--app-selection-color);
background: color-mix(in srgb, var(--app-selection-color) 12%, transparent);
border-radius: 4px;
padding: 2px 6px;
}
.source-row__tags {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex: 0 1 auto;
min-width: 0;
// Never wrap to a second line; overflowing tags are clipped from the left
// so the trailing status tag (the one that matters) always stays visible.
flex-wrap: nowrap;
overflow: hidden;
}
.source-tag {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
font-family: $font-mono;
font-weight: 500;
font-size: 10.5px;
line-height: 1.5;
border-radius: 4px;
padding: 2px 6px;
border: 1px solid transparent;
color: var(--app-eyebrow-color);
background: var(--app-card-hover-bg);
white-space: nowrap;
&--ok {
color: var(--vod-source-ok-fg);
background: var(--vod-source-ok-bg);
}
&--warn {
color: var(--vod-source-warn-fg);
background: var(--vod-source-warn-bg);
}
&--bad {
color: var(--vod-source-bad-fg);
background: var(--vod-source-bad-bg);
}
// The one tag the user can act on: no fill, dashed edge, so an unknown
// never reads as a value.
&--action {
border: 1px dashed var(--app-widget-border);
background: transparent;
color: var(--app-eyebrow-color);
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
&:hover {
color: var(--app-on-surface);
border-color: var(--app-selection-color);
background: color-mix(
in srgb,
var(--app-selection-color) 8%,
transparent
);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
}
.source-tag__icon {
font-size: 12px;
width: 12px;
height: 12px;
line-height: 12px;
}
.source-tag__spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
animation: vod-source-spin 0.7s linear infinite;
}
@keyframes vod-source-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.source-tag__spinner {
animation-duration: 2.4s;
}
}
.source-row__actions {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}
.source-row__action {
width: 30px;
height: 30px;
padding: 0;
box-sizing: border-box;
border-radius: 8px;
display: grid;
place-items: center;
border: 1px solid var(--app-widget-border);
background: transparent;
color: var(--app-eyebrow-color);
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
&:hover {
color: var(--app-on-surface);
background: var(--app-card-hover-bg);
}
&.is-active {
color: var(--app-selection-color);
border-color: var(--app-selection-color);
background: color-mix(
in srgb,
var(--app-selection-color) 12%,
transparent
);
}
&--play {
border-color: transparent;
background: var(--app-selection-color);
color: var(--mat-sys-on-primary, #fff);
&:hover {
background: color-mix(
in srgb,
var(--app-selection-color) 86%,
var(--app-on-surface)
);
color: var(--mat-sys-on-primary, #fff);
}
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
@@ -0,0 +1,78 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
import {
buildVodSourceTags,
sourceMonogram,
sourceNeedsCheck,
} from './vod-source-tags';
import { playlistDisplayLabel } from '@iptvnator/shared/interfaces';
/**
* One alternative source for the movie being viewed.
*
* Shared by the sources popover (details + player) and the playback error
* screen. It renders only what the descriptor can prove: unknown metadata
* produces no tag, just the actionable check chip.
*/
@Component({
selector: 'app-vod-source-row',
templateUrl: './vod-source-row.component.html',
styleUrls: ['./vod-source-row.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, MatTooltip, TranslatePipe],
})
export class VodSourceRowComponent {
readonly source = input.required<VodSourceDescriptor>();
/** The error screen lists alternatives without offering the pin action. */
readonly showPin = input(true);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
/**
* Short, credential-safe label. Playlist names are routinely the pasted
* URL, which carries `username=`/`password=` — never render that raw.
*/
readonly displayLabel = computed(() =>
playlistDisplayLabel(
this.source().playlistName,
this.source().playlistId
)
);
// From the display label, not the raw name: every URL-named playlist would
// otherwise monogram to "HT" (from "http://") and be indistinguishable.
readonly monogram = computed(() => sourceMonogram(this.displayLabel()));
readonly tags = computed(() => buildVodSourceTags(this.source()));
readonly showCheckChip = computed(() => sourceNeedsCheck(this.source()));
readonly pinLabelKey = computed(() =>
this.source().isPinned
? 'PORTALS.MULTI_SOURCE.UNPIN_SOURCE'
: 'PORTALS.MULTI_SOURCE.PIN_SOURCE'
);
onPlay(event: Event): void {
event.stopPropagation();
this.playRequested.emit(this.source().id);
}
onPin(event: Event): void {
event.stopPropagation();
this.pinRequested.emit(this.source().id);
}
onCheck(event: Event): void {
event.stopPropagation();
this.checkRequested.emit(this.source().id);
}
}
@@ -0,0 +1,200 @@
import {
VodSourceDescriptor,
VodSourceField,
VodSourcePortalType,
} from '@iptvnator/shared/interfaces';
/**
* Presentation helpers for the VOD multi-source row.
*
* The whole feature rests on one rule: never present a guess as a fact. These
* helpers are the single place that turns a `VodSourceDescriptor` into tags, so
* the honesty rules cannot drift between the three surfaces that render rows
* (details popover, in-player popover, playback error screen).
*/
export type VodSourceTagTone = 'neutral' | 'ok' | 'warn' | 'bad';
export interface VodSourceRowTag {
/** Stable identity for `@for` tracking; also the tag's semantic slot. */
id: string;
tone: VodSourceTagTone;
/** A provider value, rendered verbatim — never translated. */
text?: string;
/** Translation key, used for app copy such as probe outcomes. */
labelKey?: string;
/** `~` marks a value that was guessed from the title rather than stated. */
prefix?: string;
/** Trailing detail such as `· 0.4s`. */
suffix?: string;
/** Material Symbol rendered before the label. */
icon?: string;
/** Renders an inline spinner instead of an icon. */
spinner?: boolean;
}
const PORTAL_TYPE_LABELS: Record<VodSourcePortalType, string> = {
xtream: 'Xtream',
stalker: 'Stalker',
m3u: 'M3U',
};
export const VOD_SOURCE_TAG_KEYS = {
probeOk: 'PORTALS.MULTI_SOURCE.PROBE_OK',
probeFail: 'PORTALS.MULTI_SOURCE.PROBE_FAIL',
probeChecking: 'PORTALS.MULTI_SOURCE.PROBE_CHECKING',
matchByTitle: 'PORTALS.MULTI_SOURCE.MATCH_BY_TITLE',
} as const;
/**
* `400` becomes `0.4s`. Returns `undefined` for anything that is not a usable
* measurement, so the caller renders no latency rather than `NaNs`.
*/
export function formatProbeLatency(latencyMs?: number): string | undefined {
if (
typeof latencyMs !== 'number' ||
!Number.isFinite(latencyMs) ||
latencyMs < 0
) {
return undefined;
}
return `${(latencyMs / 1000).toFixed(1)}s`;
}
/**
* `api` and `probe` values are facts and render as plain tags. A `parsed` value
* is a regex guess, so it is prefixed with `~` and carries the warn colour. An
* absent field renders nothing at all — the row offers the check chip instead.
*/
function fieldTag(
id: string,
field: VodSourceField | undefined
): VodSourceRowTag | null {
if (!field) {
return null;
}
const isGuess = field.provenance === 'parsed';
return {
id,
tone: isGuess ? 'warn' : 'neutral',
text: field.value,
prefix: isGuess ? '~' : undefined,
};
}
/**
* `idle` and `unknown` both mean "not checked". Neither may ever be shown as
* offline, so both produce no probe tag and let the check chip take the slot.
*/
function probeTag(source: VodSourceDescriptor): VodSourceRowTag | null {
const { status, latencyMs } = source.probe;
if (status === 'probing') {
return {
id: 'probe',
tone: 'neutral',
labelKey: VOD_SOURCE_TAG_KEYS.probeChecking,
spinner: true,
};
}
if (status === 'ok') {
const latency = formatProbeLatency(latencyMs);
return {
id: 'probe',
tone: 'ok',
icon: 'check',
labelKey: VOD_SOURCE_TAG_KEYS.probeOk,
suffix: latency ? `· ${latency}` : undefined,
};
}
if (status === 'fail') {
return {
id: 'probe',
tone: 'bad',
icon: 'close',
labelKey: VOD_SOURCE_TAG_KEYS.probeFail,
};
}
return null;
}
/**
* A fuzzy match is a guess about *which movie this is*, so it is flagged with
* the same `~` language as guessed metadata, and shows the year when known
* because that is what lets the user disambiguate.
*/
function matchTag(source: VodSourceDescriptor): VodSourceRowTag | null {
if (source.matchConfidence !== 'fuzzy') {
return null;
}
return {
id: 'match',
tone: 'warn',
prefix: '~',
labelKey: VOD_SOURCE_TAG_KEYS.matchByTitle,
suffix: source.year ? `· ${source.year}` : undefined,
};
}
/**
* Fixed order: portal type → quality → codec → container → audio → probe.
* The fuzzy-match flag sits just before the probe status so the trailing slot
* stays the availability one across every row.
*/
export function buildVodSourceTags(
source: VodSourceDescriptor
): VodSourceRowTag[] {
const tags: (VodSourceRowTag | null)[] = [
{
id: 'portal',
tone: 'neutral',
text: PORTAL_TYPE_LABELS[source.portalType] ?? source.portalType,
},
fieldTag('quality', source.quality),
fieldTag('codec', source.codec),
fieldTag('container', source.container),
fieldTag('audio', source.audio),
matchTag(source),
probeTag(source),
];
return tags.filter((tag): tag is VodSourceRowTag => tag !== null);
}
/**
* The check chip is offered whenever something is unknown — either the stream
* was never probed, or a metadata field is missing. It is deliberately a single
* chip rather than one per gap: the user asks once, and the check fills in what
* it can. While a probe is in flight the chip stands down for the spinner tag.
*/
export function sourceNeedsCheck(source: VodSourceDescriptor): boolean {
const status = source.probe.status;
if (status === 'probing') {
return false;
}
const isUnchecked = status === 'idle' || status === 'unknown';
const hasMissingMetadata =
!source.quality || !source.codec || !source.container || !source.audio;
return isUnchecked || hasMissingMetadata;
}
/** Portals have no logos, so the row falls back to a two-letter monogram. */
export function sourceMonogram(playlistName: string): string {
const trimmed = (playlistName ?? '').trim();
if (!trimmed) {
return '?';
}
return [...trimmed].slice(0, 2).join('').toUpperCase();
}
@@ -0,0 +1,49 @@
<button
type="button"
class="sources-chip"
[class.sources-chip--open]="isOpen()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
(click)="toggle()"
[attr.aria-expanded]="isOpen()"
[attr.aria-label]="'PORTALS.MULTI_SOURCE.OPEN_SOURCES' | translate"
>
<mat-icon class="sources-chip__icon">video_library</mat-icon>
<span class="sources-chip__label">{{
'PORTALS.MULTI_SOURCE.SOURCES' | translate
}}</span>
<span class="sources-chip__count">{{ displayCount() }}</span>
<mat-icon class="sources-chip__caret" [class.is-open]="isOpen()"
>expand_more</mat-icon
>
</button>
<!-- CDK Overlay rather than MatMenu: this is a rich panel, not a list of menu
items, and Material's menu caps its width at 280px in a way that fought
every override. Here the width is simply ours. -->
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayViewportMargin]="16"
(backdropClick)="close()"
(detach)="close()"
(overlayKeydown)="onOverlayKeydown($event)"
>
<app-vod-sources-menu
class="sources-chip__panel"
[sources]="sources()"
[title]="title()"
[matchKind]="matchKind()"
[autoFailoverEnabled]="autoFailoverEnabled()"
[resumeLabel]="resumeLabel()"
[showPin]="showPin()"
(playRequested)="onPlay($event)"
(pinRequested)="onPin($event)"
(checkRequested)="onCheck($event)"
(autoFailoverToggled)="autoFailoverToggled.emit($event)"
/>
</ng-template>
@@ -0,0 +1,78 @@
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
:host {
display: inline-block;
}
.sources-chip {
// Layout plus a full reset of the UA button appearance. The surface itself
// is supplied by whichever bar hosts the chip, because "looks like its
// neighbours" means something different in the hero than in the player.
// Without the reset, a host that sets no background gets Chromium's grey
// `buttonface` default instead of nothing.
appearance: none;
background: transparent;
border: 0;
color: inherit;
padding: 0;
display: inline-flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
cursor: pointer;
font: inherit;
// Tactile press, matching the rest of the action row.
&:active {
transform: scale(0.985);
}
}
.sources-chip__icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
color: var(--app-eyebrow-color);
}
.sources-chip__count {
font-family: $font-mono;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
border-radius: 999px;
padding: 2px 8px;
color: var(--app-selection-color);
background: color-mix(in srgb, var(--app-selection-color) 12%, transparent);
}
.sources-chip__caret {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
color: var(--app-eyebrow-color);
transition: transform 0.14s ease-out;
&.is-open {
transform: rotate(180deg);
}
}
// The popover surface. Width lives here, on an element we fully own, instead
// of being fought out with Material's 280px menu cap.
.sources-chip__panel {
display: block;
width: 620px;
max-width: calc(100vw - 32px);
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.35);
border-radius: 14px;
// Opaque here as well as on the inner surface: a CDK overlay floats over
// arbitrary page content, so a missing background is not a cosmetic bug —
// it makes the whole panel unreadable.
background: var(--app-widget-bg, #161a22);
}
@@ -0,0 +1,125 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
CdkOverlayOrigin,
CdkConnectedOverlay,
type ConnectedPosition,
} from '@angular/cdk/overlay';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
VodSourceDescriptor,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { VodSourcesMenuComponent } from './vod-sources-menu.component';
/**
* The "Sources N" chip on the VOD details action row, together with its
* anchored popover. Hosts render it only when at least one alternative source
* was found.
*/
@Component({
selector: 'app-vod-sources-chip',
templateUrl: './vod-sources-chip.component.html',
styleUrls: ['./vod-sources-chip.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CdkConnectedOverlay,
CdkOverlayOrigin,
MatIcon,
TranslatePipe,
VodSourcesMenuComponent,
],
})
export class VodSourcesChipComponent {
readonly sources = input.required<VodSourceDescriptor[]>();
readonly title = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = 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. */
readonly count = input<number | null>(null);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
readonly autoFailoverToggled = output<boolean>();
readonly isOpen = signal(false);
readonly displayCount = computed(
() => this.count() ?? this.sources().length
);
/**
* Below the chip by default, flipping above when the action row sits near
* the bottom of the window. `viewportMargin` keeps it off the edges.
*/
readonly overlayPositions: ConnectedPosition[] = [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 8,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -8,
},
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: 8,
},
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -8,
},
];
toggle(): void {
this.isOpen.update((open) => !open);
}
close(): void {
this.isOpen.set(false);
}
onOverlayKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.close();
}
}
/** Playing from a row hands the screen back to the player. */
onPlay(sourceId: string): void {
this.close();
this.playRequested.emit(sourceId);
}
/** Pinning is a decision, not a browse step — the popover steps aside. */
onPin(sourceId: string): void {
this.close();
this.pinRequested.emit(sourceId);
}
/** Checking stays open: the spinner and its outcome belong on the row. */
onCheck(sourceId: string): void {
this.checkRequested.emit(sourceId);
}
}
@@ -0,0 +1,135 @@
<div class="sources-menu">
<div class="sources-menu__header">
<h3 class="sources-menu__title">
@if (title()) {
{{
'PORTALS.MULTI_SOURCE.SOURCES_FOR'
| translate: { title: title() }
}}
} @else {
{{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }}
}
</h3>
<span class="sources-menu__hint">
@if (resumeLabel(); as resume) {
{{ resume }}
} @else {
{{ matchKindLabelKey() | translate }}
}
</span>
</div>
<div class="sources-menu__toolbar">
@if (showFilter()) {
<div class="sources-menu__filter">
<mat-icon class="sources-menu__filter-icon">search</mat-icon>
<input
type="text"
class="sources-menu__filter-input"
[ngModel]="filter()"
(ngModelChange)="filter.set($event)"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.FILTER_PLACEHOLDER' | translate
"
[placeholder]="
'PORTALS.MULTI_SOURCE.FILTER_PLACEHOLDER' | translate
"
/>
</div>
}
@if (uncheckedCount() > 0) {
<button
type="button"
class="sources-menu__check-all"
(click)="checkAllVisible()"
>
<mat-icon>refresh</mat-icon>
<span>{{ 'PORTALS.MULTI_SOURCE.CHECK_ALL' | translate }}</span>
</button>
}
</div>
<div class="sources-menu__list">
@for (group of groups(); track group.key) {
<div class="sources-menu__group">
<app-vod-source-row
[source]="group.primary"
[showPin]="showPin()"
(playRequested)="playRequested.emit($event)"
(pinRequested)="pinRequested.emit($event)"
(checkRequested)="checkRequested.emit($event)"
/>
@if (group.count > 1) {
<button
type="button"
class="sources-menu__variants-toggle"
[attr.aria-expanded]="isExpanded(group.key)"
(click)="toggleGroup(group.key)"
>
<mat-icon>{{
isExpanded(group.key)
? 'expand_less'
: 'expand_more'
}}</mat-icon>
<span>{{
'PORTALS.MULTI_SOURCE.COPIES'
| translate: { count: group.count }
}}</span>
</button>
@if (isExpanded(group.key)) {
<div class="sources-menu__variants">
@for (
variant of group.sources;
track variant.id;
let i = $index
) {
@if (variant.id !== group.primary.id) {
<div class="sources-menu__variant">
<span
class="sources-menu__variant-label"
[title]="variantLabelFor(group, i)"
>{{ variantLabelFor(group, i) }}</span
>
<app-vod-source-row
[source]="variant"
[showPin]="showPin()"
(playRequested)="
playRequested.emit($event)
"
(pinRequested)="
pinRequested.emit($event)
"
(checkRequested)="
checkRequested.emit($event)
"
/>
</div>
}
}
</div>
}
}
</div>
}
@if (visibleSources().length === 0) {
<p class="sources-menu__empty">
{{ 'PORTALS.MULTI_SOURCE.NO_MATCHES' | translate }}
</p>
}
</div>
<div class="sources-menu__footer">
<span class="sources-menu__footer-label">{{
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
}}</span>
<mat-slide-toggle
class="sources-menu__toggle"
[checked]="autoFailoverEnabled()"
(change)="onAutoFailoverChange($event)"
(click)="$event.stopPropagation()"
[attr.aria-label]="'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate"
/>
</div>
</div>
@@ -0,0 +1,220 @@
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
:host {
display: block;
}
// Self-sufficient surface: the popover body is also dropped straight into the
// playback error screen, where there is no menu panel to supply a background.
.sources-menu {
box-sizing: border-box;
padding: 8px;
border-radius: 14px;
background: var(--app-widget-bg, #161a22);
outline: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.08));
color: var(--app-on-surface);
}
.sources-menu__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 12px 14px 10px;
}
.sources-menu__title {
margin: 0;
min-width: 0;
// Reads as the panel's heading. Previously it sat at the same weight and
// size as the hint and the action beside it, so all three scanned as
// column headers over the list.
font-size: 15px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--app-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sources-menu__hint {
flex: none;
font-size: 11px;
line-height: 1.35;
color: var(--app-eyebrow-color);
}
.sources-menu__list {
display: flex;
flex-direction: column;
gap: 2px;
// Compact rows mean this now shows ~9 sources instead of ~3. Viewport-aware
// so a short window still gets a usable list rather than a clipped one.
max-height: min(56vh, 460px);
overflow-y: auto;
}
.sources-menu__footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 8px;
border-top: 1px solid var(--app-widget-border);
margin-top: 4px;
font-size: 11.5px;
color: var(--app-eyebrow-color);
}
.sources-menu__footer-label {
flex: 1;
min-width: 0;
line-height: 1.35;
}
.sources-menu__toggle {
flex: none;
margin-left: auto;
}
.sources-menu__check-all {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid var(--app-widget-border);
border-radius: 999px;
background: none;
color: var(--app-eyebrow-color);
font: inherit;
font-size: 11px;
cursor: pointer;
&:hover {
background: var(--app-card-hover-bg);
color: var(--app-on-surface);
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
}
.sources-menu__filter {
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 6px;
padding: 6px 10px;
border: 1px solid var(--app-widget-border);
// Concentric with the row radius one level in.
border-radius: 8px;
background: var(--app-card-hover-bg);
&:focus-within {
border-color: var(--app-selection-color);
}
}
.sources-menu__filter-icon {
flex: none;
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
color: var(--app-eyebrow-color);
}
.sources-menu__filter-input {
flex: 1;
min-width: 0;
border: 0;
background: none;
outline: none;
color: var(--app-on-surface);
font: inherit;
font-size: 12.5px;
&::placeholder {
color: var(--app-eyebrow-color);
}
}
.sources-menu__empty {
margin: 0;
padding: 18px 12px;
text-align: center;
color: var(--app-eyebrow-color);
font-size: 12.5px;
}
// Controls live on their own line, below the heading, so nothing in the header
// can be mistaken for a column label.
.sources-menu__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px 10px;
}
.sources-menu__toolbar .sources-menu__filter {
flex: 1;
min-width: 0;
margin: 0;
}
.sources-menu__toolbar .sources-menu__check-all {
flex: none;
margin-left: auto;
}
.sources-menu__variants-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
// Aligned under the row's info block, past the monogram, so it reads as
// belonging to the row above rather than as a list item of its own.
margin: 0 0 2px 48px;
padding: 2px 8px;
border: 0;
border-radius: 999px;
background: none;
color: var(--app-eyebrow-color);
font: inherit;
font-size: 11px;
cursor: pointer;
&:hover {
background: var(--app-card-hover-bg);
color: var(--app-on-surface);
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
}
.sources-menu__variants {
// A left rail is the cheapest way to show these belong to the row above
// without nesting another card inside the panel.
margin: 0 0 4px 20px;
padding-left: 12px;
border-left: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.08));
}
.sources-menu__variant-label {
display: block;
padding: 2px 10px 0;
color: var(--app-eyebrow-color);
font-size: 10.5px;
font-family: $font-mono;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,264 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
import { VodSourcesMenuComponent } from './vod-sources-menu.component';
class FakeTranslateLoader implements TranslateLoader {
getTranslation(): Observable<Record<string, unknown>> {
return of({
PORTALS: {
MULTI_SOURCE: {
SOURCE: 'Source',
SOURCES_FOR: 'Sources for “{{title}}”',
MATCHED_BY_TMDB: 'matched by TMDB',
MATCHED_BY_TITLE: 'matched by title',
MATCH_BY_TITLE: 'by title',
AUTO_FAILOVER: 'Auto-switch on error',
PLAYING: 'Playing',
PLAY_FROM_SOURCE: 'Play from this source',
PIN_SOURCE: 'Make this the main source',
UNPIN_SOURCE: 'Unpin this source',
CHECK: 'check',
CHECK_TOOLTIP: 'Check availability',
PROBE_OK: 'available',
PROBE_FAIL: 'unavailable',
PROBE_CHECKING: 'checking',
OPEN_SOURCES: 'Show alternative sources',
},
},
});
}
}
function createSource(
overrides: Partial<VodSourceDescriptor> = {}
): VodSourceDescriptor {
return {
id: 'playlist-1:xtream:42',
playlistId: 'playlist-1',
playlistName: 'SharkTV',
portalType: 'xtream',
contentId: 42,
rawTitle: 'Dune Part Two 2160p',
matchConfidence: 'exact',
year: 2024,
isActive: false,
isPinned: false,
isTried: false,
probe: { status: 'idle' },
...overrides,
};
}
describe('VodSourcesMenuComponent', () => {
let fixture: ComponentFixture<VodSourcesMenuComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
VodSourcesMenuComponent,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: FakeTranslateLoader,
},
}),
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setDefaultLang('en');
translate.use('en');
fixture = TestBed.createComponent(VodSourcesMenuComponent);
});
function render(sources: VodSourceDescriptor[]): void {
fixture.componentRef.setInput('sources', sources);
fixture.detectChanges();
}
/** Metadata tags only — the actionable check chip is not a value. */
function tagTexts(): string[] {
return fixture.debugElement
.queryAll(By.css('.source-tag:not(.source-tag--action)'))
.map((tag) => (tag.nativeElement.textContent ?? '').trim());
}
function checkChip() {
return fixture.debugElement.query(By.css('.source-tag--action'));
}
it('renders a stated value as a plain tag, without the guess marker', () => {
render([
createSource({
quality: { value: '1080p', provenance: 'api' },
}),
]);
const qualityTag = tagTexts().find((text) => text.includes('1080p'));
expect(qualityTag).toBeDefined();
expect(qualityTag).not.toContain('~');
expect(
fixture.debugElement.queryAll(By.css('.source-tag--warn')).length
).toBe(0);
});
it('marks a parsed value as a guess with ~ and the warn tone', () => {
render([
createSource({
quality: { value: '2160p', provenance: 'parsed' },
}),
]);
const warnTags = fixture.debugElement.queryAll(
By.css('.source-tag--warn')
);
expect(warnTags.length).toBe(1);
const text = (warnTags[0].nativeElement.textContent ?? '').trim();
expect(text).toContain('~');
expect(text).toContain('2160p');
});
it('renders no tag for an unknown field and offers the check chip instead', () => {
render([
createSource({
quality: undefined,
codec: undefined,
container: { value: 'mkv', provenance: 'api' },
audio: undefined,
probe: { status: 'ok', latencyMs: 120 },
}),
]);
const texts = tagTexts().join(' | ');
expect(texts).not.toContain('unknown');
expect(texts).not.toContain('—');
// Portal type, the one known field, and the probe outcome — nothing else.
expect(tagTexts().length).toBe(3);
expect(checkChip()).not.toBeNull();
});
it('never renders an unchecked probe as offline and keeps offering the check', () => {
render([
createSource({
quality: { value: '1080p', provenance: 'api' },
codec: { value: 'h264', provenance: 'api' },
container: { value: 'mkv', provenance: 'api' },
audio: { value: 'rus', provenance: 'api' },
probe: { status: 'unknown' },
}),
]);
expect(
fixture.debugElement.queryAll(By.css('.source-tag--bad')).length
).toBe(0);
expect(tagTexts().join(' | ')).not.toContain('unavailable');
expect(checkChip()).not.toBeNull();
});
it('renders a successful probe with its latency in seconds', () => {
render([
createSource({
quality: { value: '1080p', provenance: 'api' },
codec: { value: 'h264', provenance: 'api' },
container: { value: 'mkv', provenance: 'api' },
audio: { value: 'rus', provenance: 'api' },
probe: { status: 'ok', latencyMs: 400 },
}),
]);
const okTag = fixture.debugElement.query(By.css('.source-tag--ok'));
expect(okTag).not.toBeNull();
const text = (okTag.nativeElement.textContent ?? '').trim();
expect(text).toContain('available');
expect(text).toContain('0.4s');
// Nothing left unknown, so the check chip stands down.
expect(checkChip()).toBeNull();
});
it('orders tags portal type → quality → codec → container → audio → probe', () => {
render([
createSource({
quality: { value: '2160p', provenance: 'api' },
codec: { value: 'hevc', provenance: 'api' },
container: { value: 'mp4', provenance: 'api' },
audio: { value: 'dub', provenance: 'api' },
probe: { status: 'ok', latencyMs: 400 },
}),
]);
const texts = tagTexts();
expect(texts[0]).toContain('Xtream');
expect(texts[1]).toContain('2160p');
expect(texts[2]).toContain('hevc');
expect(texts[3]).toContain('mp4');
expect(texts[4]).toContain('dub');
expect(texts[5]).toContain('available');
expect(texts.length).toBe(6);
});
it('flags a fuzzy match with ~ and the year that disambiguates it', () => {
render([
createSource({
matchConfidence: 'fuzzy',
year: 2024,
quality: { value: '720p', provenance: 'api' },
}),
]);
const warnTag = fixture.debugElement.query(By.css('.source-tag--warn'));
expect(warnTag).not.toBeNull();
const text = (warnTag.nativeElement.textContent ?? '').trim();
expect(text).toContain('~');
expect(text).toContain('by title');
expect(text).toContain('2024');
});
it('emits the toggled state when the footer switch is flipped', () => {
render([createSource()]);
const emitted: boolean[] = [];
fixture.componentInstance.autoFailoverToggled.subscribe(
(value: boolean) => emitted.push(value)
);
const toggle = fixture.debugElement.query(
By.css('.sources-menu__toggle button[role="switch"]')
);
expect(toggle).not.toBeNull();
toggle.nativeElement.click();
fixture.detectChanges();
expect(emitted).toEqual([true]);
});
it('shows the match kind in the header, or the resume label when given', () => {
fixture.componentRef.setInput('sources', [createSource()]);
fixture.componentRef.setInput('matchKind', 'tmdb');
fixture.detectChanges();
const hint = fixture.debugElement.query(By.css('.sources-menu__hint'));
expect(hint.nativeElement.textContent.trim()).toBe('matched by TMDB');
fixture.componentRef.setInput(
'resumeLabel',
'timecode will be kept · 0:42:18'
);
fixture.detectChanges();
expect(hint.nativeElement.textContent.trim()).toBe(
'timecode will be kept · 0:42:18'
);
});
});
@@ -0,0 +1,155 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import {
MatSlideToggle,
MatSlideToggleChange,
} from '@angular/material/slide-toggle';
import { TranslatePipe } from '@ngx-translate/core';
import {
playlistDisplayLabel,
VodSourceDescriptor,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from './vod-source-row.component';
import {
groupVodSources,
variantLabel,
type VodSourceGroup,
} from './vod-source-groups.util';
/** Below this many sources, a filter box costs more than it saves. */
const FILTER_FROM = 8;
/**
* Body of the "sources" popover: header, the source rows, and the auto-failover
* footer. Rendered inside the chip's anchored menu on the details page and
* reused as-is by the in-player popover and the playback error screen, so it
* owns no anchoring of its own.
*/
@Component({
selector: 'app-vod-sources-menu',
templateUrl: './vod-sources-menu.component.html',
styleUrls: ['./vod-sources-menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
MatIcon,
MatSlideToggle,
TranslatePipe,
VodSourceRowComponent,
],
})
export class VodSourcesMenuComponent {
readonly sources = input.required<VodSourceDescriptor[]>();
/**
* Movie title for the header. Empty renders the bare "Source" heading used
* by the in-player variant, where the title is already on screen.
*/
readonly title = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
/**
* In-player variant: replaces the match kind with e.g.
* "timecode will be kept · 0:42:18". Already formatted by the host.
*/
readonly resumeLabel = input<string | null>(null);
readonly showPin = input(true);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
readonly autoFailoverToggled = output<boolean>();
/** Filter text; only reachable once the list is long enough to need it. */
readonly filter = signal('');
/**
* Progressive disclosure: a search box for four sources is chrome nobody
* asked for, but scanning eleven by eye is worse.
*/
readonly showFilter = computed(() => this.sources().length > FILTER_FROM);
readonly visibleSources = computed(() => {
const query = this.filter().trim().toLowerCase();
const sources = this.sources();
if (!query || !this.showFilter()) {
return sources;
}
return sources.filter((source) =>
playlistDisplayLabel(source.playlistName, source.playlistId)
.toLowerCase()
.includes(query)
);
});
/**
* Sources collapsed per playlist. One playlist often lists the same film
* several times; flat they are indistinguishable rows.
*/
readonly groups = computed(() => groupVodSources(this.visibleSources()));
private readonly expanded = signal<ReadonlySet<string>>(new Set());
isExpanded(key: string): boolean {
return this.expanded().has(key);
}
toggleGroup(key: string): void {
this.expanded.update((keys) => {
const next = new Set(keys);
if (!next.delete(key)) {
next.add(key);
}
return next;
});
}
/** Distinguishes copies within a group — usually the provider's own title. */
variantLabelFor(group: VodSourceGroup, index: number): string {
return variantLabel(group.sources[index], group.sources);
}
/** Anything still worth probing — drives the "check all" affordance. */
readonly uncheckedCount = computed(
() =>
this.visibleSources().filter(
(source) =>
source.probe.status === 'idle' ||
source.probe.status === 'unknown'
).length
);
readonly matchKindLabelKey = computed(() =>
this.matchKind() === 'tmdb'
? 'PORTALS.MULTI_SOURCE.MATCHED_BY_TMDB'
: 'PORTALS.MULTI_SOURCE.MATCHED_BY_TITLE'
);
onAutoFailoverChange(event: MatSlideToggleChange): void {
this.autoFailoverToggled.emit(event.checked);
}
/**
* Checking eleven sources one row at a time is busywork the app can do
* itself; the probe service already dedupes and caches.
*/
checkAllVisible(): void {
for (const source of this.visibleSources()) {
if (
source.probe.status === 'idle' ||
source.probe.status === 'unknown'
) {
this.checkRequested.emit(source.id);
}
}
}
}