mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
1 parent
e2300bea11
commit
004cb65fe6
101 files changed
+7183
-192
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user