mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(portals): rework the VOD sources popup and action row (#1359)
* feat(portals): rework the VOD sources popup and action row The Sources popup now anchors above its button and always fits on screen: the overlay caps it to the space beside the chip, only the source list scrolls so the header, search, filter chips and footer stay visible, and it flips below the button when the space above is too small. Filter chips (All / Available / HD+ / language) compose with the playlist search, and "Available" runs check-all itself when nothing has been checked yet. Expanded copy rows no longer repeat the playlist domain: each copy shows its parsed language chip, the provider's raw stream title, and only the tags that differ from the parent copy. Availability checks run at most four at a time and settled verdicts are remembered per movie and source for ten minutes, so reopening a movie no longer re-contacts every foreign portal. Favorites and Download become icon-only buttons with real state: a filled heart when favorited, and a download icon that turns into a progress ring and then a checkmark that reveals the finished file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(portals): match the movie detail's icon-only favorite button The Xtream movie detail's favorite control is now an icon-only button, so the shared "add this detail to favorites" helpers no longer found it by class. They are used against series and Stalker details too, which still render the labeled variant, so they now select by accessible name — the icon button carries the same label in aria-label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): keep provider playback for a downloaded movie Once a movie is downloaded the primary button plays the local file, and the sources popover only exists when another playlist carries the same film. The icon-only rework left those as the only two paths, so a downloaded movie in a single-playlist library had no way at all to stream the provider's copy — the labeled action that used to do it was gone. Restores it as an icon button beside the downloaded checkmark, under the same condition the old one used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
83f6a270a5
commit
0010dd7351
66 files changed
+2583
-347
No files matched your search
@@ -1,4 +1,5 @@
|
||||
export * from './vod-multi-source.controller';
|
||||
export * from './vod-source-discovery.service';
|
||||
export * from './vod-source-metadata.util';
|
||||
export * from './vod-source-probe-cache.service';
|
||||
export * from './vod-source-resolver.service';
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { VodSourceProbeCacheService } from './vod-source-probe-cache.service';
|
||||
|
||||
describe('VodSourceProbeCacheService', () => {
|
||||
let service: VodSourceProbeCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-02T12:00:00Z'));
|
||||
service = new VodSourceProbeCacheService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const stamped = (status: 'ok' | 'fail') => ({
|
||||
status,
|
||||
httpStatus: status === 'ok' ? 200 : 403,
|
||||
probedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
it('remembers settled verdicts and returns them while fresh', () => {
|
||||
service.store('p1:xtream:42', stamped('ok'));
|
||||
service.store('p2:xtream:43', stamped('fail'));
|
||||
|
||||
expect(service.get('p1:xtream:42')?.status).toBe('ok');
|
||||
expect(service.get('p2:xtream:43')?.status).toBe('fail');
|
||||
});
|
||||
|
||||
it('never remembers an inconclusive check', () => {
|
||||
// Caching "could not check" would suppress a retry that might work.
|
||||
service.store('p1:xtream:42', {
|
||||
status: 'unknown',
|
||||
probedAt: new Date().toISOString(),
|
||||
});
|
||||
service.store('p1:xtream:43', { status: 'probing' });
|
||||
// A verdict with no timestamp cannot be aged, so it is not stored.
|
||||
service.store('p1:xtream:44', { status: 'ok' });
|
||||
|
||||
expect(service.get('p1:xtream:42')).toBeNull();
|
||||
expect(service.get('p1:xtream:43')).toBeNull();
|
||||
expect(service.get('p1:xtream:44')).toBeNull();
|
||||
});
|
||||
|
||||
it('expires a verdict after the TTL', () => {
|
||||
service.store('p1:xtream:42', stamped('ok'));
|
||||
|
||||
jest.advanceTimersByTime(9 * 60_000);
|
||||
expect(service.get('p1:xtream:42')?.status).toBe('ok');
|
||||
|
||||
jest.advanceTimersByTime(2 * 60_000);
|
||||
expect(service.get('p1:xtream:42')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* How long a verdict may stand in for a fresh check. Long enough that closing
|
||||
* and reopening the popover — or leaving the movie and coming back — does not
|
||||
* re-fire a round of HEAD requests, short enough that "available" still means
|
||||
* something about the present.
|
||||
*/
|
||||
const PROBE_RESULT_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Session-wide memory of availability checks, keyed by source id
|
||||
* (`playlistId:portalType:contentId` — i.e. per movie **and** per source).
|
||||
*
|
||||
* The per-movie controller dies with its detail page, so without this every
|
||||
* navigation away and back would silently forget what was just checked, and
|
||||
* "check all" would re-resolve and re-contact every foreign portal each time
|
||||
* the popover opens. Root-provided and in-memory only: a probe verdict is a
|
||||
* claim about the present, so persisting it across app launches would let a
|
||||
* stale "available" outlive the stream it described.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VodSourceProbeCacheService {
|
||||
private readonly results = new Map<string, VodSourceProbeResult>();
|
||||
|
||||
/**
|
||||
* Only settled verdicts are worth remembering. `unknown` means the check
|
||||
* could not run — caching it would suppress a retry that might succeed.
|
||||
*/
|
||||
store(sourceId: string, result: VodSourceProbeResult): void {
|
||||
if (
|
||||
(result.status === 'ok' || result.status === 'fail') &&
|
||||
result.probedAt
|
||||
) {
|
||||
this.results.set(sourceId, result);
|
||||
}
|
||||
}
|
||||
|
||||
/** A stored verdict, if it is still fresh; expired entries are dropped. */
|
||||
get(sourceId: string): VodSourceProbeResult | null {
|
||||
const result = this.results.get(sourceId);
|
||||
if (!result?.probedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const age = Date.now() - Date.parse(result.probedAt);
|
||||
if (!Number.isFinite(age) || age < 0 || age > PROBE_RESULT_TTL_MS) {
|
||||
this.results.delete(sourceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,54 @@ export class VodDetailsDownloadsService {
|
||||
: false;
|
||||
});
|
||||
|
||||
/** The manager's row for this movie, whatever state it is in. */
|
||||
private readonly activeDownload = computed(() => {
|
||||
const context = this.context();
|
||||
return context
|
||||
? this.downloadsService.getDownloadByContent(
|
||||
context.vodId,
|
||||
context.playlistId,
|
||||
'vod'
|
||||
)
|
||||
: undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* Real progress percent, or `null` while the total size is unknown — the
|
||||
* button then spins its ring indeterminately instead of faking a number.
|
||||
*/
|
||||
readonly downloadPercent = computed(() => {
|
||||
const item = this.activeDownload();
|
||||
return item && item.totalBytes
|
||||
? this.downloadsService.getProgressPercent(item)
|
||||
: null;
|
||||
});
|
||||
|
||||
/** Cancel whatever download is running or queued for this movie. */
|
||||
async cancelActive(): Promise<void> {
|
||||
const item = this.activeDownload();
|
||||
if (item) {
|
||||
await this.downloadsService.cancelDownload(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the finished file's location in the system file manager. */
|
||||
async revealDownloaded(): Promise<void> {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = this.downloadsService.getDownloadedFilePath(
|
||||
this.routeContentId(),
|
||||
playlistId,
|
||||
'vod'
|
||||
);
|
||||
if (filePath) {
|
||||
await this.downloadsService.revealFile(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async resumePaused(): Promise<void> {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!playlistId) {
|
||||
|
||||
@@ -416,9 +416,11 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
expect(host.textContent).toContain('Movie B');
|
||||
expect(host.textContent).toContain('DOWNLOADS.OFFLINE');
|
||||
expect(host.textContent).toContain('DOWNLOADS.PLAY_LOCAL');
|
||||
expect(host.textContent).toContain(
|
||||
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
|
||||
);
|
||||
// The download slot shows the done-state icon button once playable
|
||||
// details arrive; provider playback now lives in the sources popover.
|
||||
expect(
|
||||
host.querySelector('[data-testid="vod-download-done"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('downloads the route movie with the metadata precedence rendered on screen', async () => {
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
const isDownloaded = jest.fn().mockReturnValue(false);
|
||||
const getDownloadedFilePath = jest.fn();
|
||||
const playDownload = jest.fn().mockResolvedValue(undefined);
|
||||
const revealFile = jest.fn().mockResolvedValue({ success: true });
|
||||
const toggleFavorite = jest.fn();
|
||||
const sparseItem = (): SparseVodItem => ({
|
||||
info: [],
|
||||
@@ -185,6 +186,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
}
|
||||
);
|
||||
playDownload.mockClear();
|
||||
revealFile.mockClear();
|
||||
toggleFavorite.mockClear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodDetailsRouteComponent],
|
||||
@@ -248,6 +250,12 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
resumeDownloadByContent: jest.fn(),
|
||||
startDownload,
|
||||
getDownloadedFilePath,
|
||||
getDownloadByContent: jest.fn(),
|
||||
getProgressPercent: jest.fn().mockReturnValue(0),
|
||||
cancelDownload: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
}),
|
||||
revealFile,
|
||||
playDownload,
|
||||
},
|
||||
},
|
||||
@@ -342,8 +350,12 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.querySelector('button.play-btn')).not.toBeNull();
|
||||
expect(host.querySelector('button.favorite-btn')).not.toBeNull();
|
||||
expect(host.querySelector('button.download-btn')).not.toBeNull();
|
||||
expect(
|
||||
host.querySelector('[data-testid="vod-favorite-toggle"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
host.querySelector('[data-testid="vod-download-start"]')
|
||||
).not.toBeNull();
|
||||
host.querySelector<HTMLButtonElement>('button.play-btn')?.click();
|
||||
fixture.detectChanges();
|
||||
expect(constructVodStreamUrl).toHaveBeenCalledWith(item);
|
||||
@@ -385,8 +397,12 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
'button.play-btn--resume'
|
||||
)?.click();
|
||||
fixture.detectChanges();
|
||||
host.querySelector<HTMLButtonElement>('button.favorite-btn')?.click();
|
||||
host.querySelector<HTMLButtonElement>('button.download-btn')?.click();
|
||||
host.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="vod-favorite-toggle"]'
|
||||
)?.click();
|
||||
host.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="vod-download-start"]'
|
||||
)?.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(constructVodStreamUrl).toHaveBeenNthCalledWith(1, item);
|
||||
@@ -457,14 +473,14 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
host.querySelector<HTMLButtonElement>('button.play-btn');
|
||||
expect(host.textContent).toContain('DOWNLOADS.OFFLINE');
|
||||
expect(primary?.textContent).toContain('DOWNLOADS.PLAY_LOCAL');
|
||||
// The download slot flips to the done state: a checkmark whose click
|
||||
// reveals the finished file, replacing the old labeled secondary.
|
||||
expect(
|
||||
Array.from(host.querySelectorAll('button.download-btn')).some(
|
||||
(button) =>
|
||||
button.textContent?.includes(
|
||||
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
host.querySelector('[data-testid="vod-download-done"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
host.querySelector('[data-testid="vod-download-start"]')
|
||||
).toBeNull();
|
||||
|
||||
primary?.click();
|
||||
await fixture.whenStable();
|
||||
@@ -608,7 +624,11 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
expect(openResolvedPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps pinned provider playback behind the downloaded secondary action', async () => {
|
||||
it('still offers provider playback for a downloaded movie with no alternatives', async () => {
|
||||
// The primary button plays the local file once downloaded, and the
|
||||
// sources chip only appears when another playlist carries the film —
|
||||
// so without this control a single-playlist library loses every way
|
||||
// to stream the provider's copy.
|
||||
selectedItem.set(sparseItem());
|
||||
downloadsAvailable.set(true);
|
||||
isDownloaded.mockReturnValue(true);
|
||||
@@ -618,20 +638,39 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const providerButton = Array.from(
|
||||
(
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelectorAll<HTMLButtonElement>('button')
|
||||
).find((button) =>
|
||||
button.textContent?.includes(
|
||||
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
|
||||
)
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(fixture.componentInstance.multiSource.hasAlternatives()).toBe(
|
||||
false
|
||||
);
|
||||
const providerButton = host.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="vod-play-provider"]'
|
||||
);
|
||||
expect(providerButton).not.toBeNull();
|
||||
|
||||
providerButton?.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(playPinned).toHaveBeenCalled();
|
||||
expect(playDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals the finished file from the downloaded state button', async () => {
|
||||
selectedItem.set(sparseItem());
|
||||
downloadsAvailable.set(true);
|
||||
isDownloaded.mockReturnValue(true);
|
||||
getDownloadedFilePath.mockReturnValue('/downloads/catalog-movie.mp4');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
(fixture.nativeElement as HTMLElement)
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="vod-download-done"]'
|
||||
)
|
||||
?.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(revealFile).toHaveBeenCalledWith('/downloads/catalog-movie.mp4');
|
||||
expect(playDownload).not.toHaveBeenCalled();
|
||||
expect(constructVodStreamUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -75,67 +75,139 @@
|
||||
</button>
|
||||
}
|
||||
@if (playableItem) {
|
||||
@if (!isFavorite()) {
|
||||
<button class="favorite-btn" (click)="toggleFavorite()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="favorite-btn favorite-btn--active"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
@let favoriteLabel =
|
||||
(isFavorite()
|
||||
? 'PORTALS.REMOVE_FROM_FAVORITES'
|
||||
: 'PORTALS.ADD_TO_FAVORITES'
|
||||
) | translate;
|
||||
<button
|
||||
type="button"
|
||||
class="icon-action-btn icon-action-btn--favorite"
|
||||
[class.is-active]="isFavorite()"
|
||||
[class.is-pulsing]="favoritePulse()"
|
||||
(click)="toggleFavorite()"
|
||||
[matTooltip]="favoriteLabel"
|
||||
[attr.aria-label]="favoriteLabel"
|
||||
[attr.aria-pressed]="isFavorite()"
|
||||
data-testid="vod-favorite-toggle"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isFavorite() ? 'favorite' : 'favorite_border'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
@if (isElectron() && !providerOnly()) {
|
||||
@if (isDownloaded()) {
|
||||
<button
|
||||
type="button"
|
||||
class="icon-action-btn icon-action-btn--done"
|
||||
(click)="revealDownloadedFile()"
|
||||
[matTooltip]="'DOWNLOADS.DOWNLOADED_REVEAL' | translate"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.DOWNLOADED_REVEAL' | translate
|
||||
"
|
||||
data-testid="vod-download-done"
|
||||
>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</button>
|
||||
<!-- The primary button plays the local file once a movie
|
||||
is downloaded, and the sources popover only exists
|
||||
when another playlist also carries the film. Without
|
||||
this, a downloaded movie in a single-playlist library
|
||||
offers no way to stream the provider's copy at all. -->
|
||||
@if (externalPrimaryButtonState() === 'idle') {
|
||||
<button
|
||||
class="download-btn"
|
||||
type="button"
|
||||
class="icon-action-btn"
|
||||
(click)="playFromProviderSource(playableItem)"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{
|
||||
[matTooltip]="
|
||||
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
|
||||
| translate
|
||||
}}
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
|
||||
| translate
|
||||
"
|
||||
data-testid="vod-play-provider"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
}
|
||||
} @else if (isPausedDownload()) {
|
||||
<button
|
||||
class="download-btn download-btn--paused"
|
||||
(click)="resumePausedDownload()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'DOWNLOADS.RESUME' | translate }}
|
||||
</button>
|
||||
} @else if (isDownloading()) {
|
||||
<!-- Covers downloading, queued and paused: the ring holds
|
||||
the real percent, spins when the size is unknown, and
|
||||
pauses behind a resume glyph. -->
|
||||
@let downloadLabel =
|
||||
isPausedDownload()
|
||||
? ('DOWNLOADS.RESUME' | translate)
|
||||
: downloadPercent() !== null
|
||||
? ('DOWNLOADS.DOWNLOADING_PERCENT'
|
||||
| translate: { percent: downloadPercent() })
|
||||
: ('DOWNLOADS.STATUS.DOWNLOADING' | translate);
|
||||
<button
|
||||
class="download-btn download-btn--downloading"
|
||||
disabled
|
||||
type="button"
|
||||
class="icon-action-btn icon-action-btn--progress"
|
||||
(click)="
|
||||
isPausedDownload()
|
||||
? resumePausedDownload()
|
||||
: promptCancelDownload()
|
||||
"
|
||||
[matTooltip]="downloadLabel"
|
||||
[attr.aria-label]="downloadLabel"
|
||||
data-testid="vod-download-progress"
|
||||
>
|
||||
<mat-icon>downloading</mat-icon>
|
||||
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
|
||||
<svg
|
||||
class="download-ring"
|
||||
[class.download-ring--indeterminate]="
|
||||
downloadPercent() === null &&
|
||||
!isPausedDownload()
|
||||
"
|
||||
viewBox="0 0 36 36"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
class="download-ring__track"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15.5"
|
||||
/>
|
||||
<circle
|
||||
class="download-ring__value"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15.5"
|
||||
[style.stroke-dasharray]="
|
||||
downloadRingCircumference
|
||||
"
|
||||
[style.stroke-dashoffset]="downloadRingOffset()"
|
||||
/>
|
||||
</svg>
|
||||
@if (isPausedDownload()) {
|
||||
<mat-icon class="icon-action-btn__center"
|
||||
>play_arrow</mat-icon
|
||||
>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="download-btn"
|
||||
type="button"
|
||||
class="icon-action-btn"
|
||||
(click)="downloadVod(playableItem)"
|
||||
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
|
||||
[attr.aria-label]="'DOWNLOADS.DOWNLOAD' | translate"
|
||||
data-testid="vod-download-start"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (multiSource.hasAlternatives()) {
|
||||
<!-- No [count] override: the badge states the TOTAL number of
|
||||
copies across all playlists, i.e. what the popover lists. -->
|
||||
<app-vod-sources-chip
|
||||
[sources]="multiSource.sources()"
|
||||
[title]="multiSourceTitle()"
|
||||
[matchKind]="multiSource.matchKind()"
|
||||
[count]="multiSource.alternativeCount()"
|
||||
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
|
||||
[autoFailoverSupported]="autoFailoverSupported()"
|
||||
[playbackLive]="playbackLive()"
|
||||
|
||||
@@ -258,8 +258,8 @@ describe('VodDetailsRouteComponent', () => {
|
||||
?.textContent
|
||||
).toContain('XTREAM.DETAIL_FALLBACK.STATUS');
|
||||
expect(host.querySelector('button.play-btn')).not.toBeNull();
|
||||
expect(host.querySelector('button.favorite-btn')).not.toBeNull();
|
||||
expect(host.querySelector('button.download-btn')).toBeNull();
|
||||
expect(host.querySelector('[data-testid="vod-favorite-toggle"]')).not.toBeNull();
|
||||
expect(host.querySelector('[data-testid="vod-download-start"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the fallback visible for a minimal info object', () => {
|
||||
@@ -299,7 +299,7 @@ describe('VodDetailsRouteComponent', () => {
|
||||
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.querySelector('button.play-btn')).toBeNull();
|
||||
expect(host.querySelector('button.favorite-btn')).toBeNull();
|
||||
expect(host.querySelector('[data-testid="vod-favorite-toggle"]')).toBeNull();
|
||||
|
||||
selectedItem.set({
|
||||
info: [],
|
||||
@@ -309,7 +309,7 @@ describe('VodDetailsRouteComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(host.querySelector('button.play-btn')).not.toBeNull();
|
||||
expect(host.querySelector('button.favorite-btn')).not.toBeNull();
|
||||
expect(host.querySelector('[data-testid="vod-favorite-toggle"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the full Xtream detail view when usable metadata exists', () => {
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
DialogService,
|
||||
PortalDetailShellComponent,
|
||||
VodSourcesChipComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
@@ -110,6 +112,7 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null {
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
NgTemplateOutlet,
|
||||
PortalDetailShellComponent,
|
||||
SafePipe,
|
||||
@@ -127,6 +130,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly playback = inject(VodDetailsPlaybackService);
|
||||
@@ -305,11 +309,30 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
readonly isDownloading = this.downloads.isDownloading;
|
||||
readonly isPausedDownload = this.downloads.isPausedDownload;
|
||||
readonly downloadPercent = this.downloads.downloadPercent;
|
||||
readonly isOfflinePrimary = computed(
|
||||
() =>
|
||||
this.isDownloaded() && this.externalPrimaryButtonState() === 'idle'
|
||||
);
|
||||
|
||||
/** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */
|
||||
readonly downloadRingCircumference = 2 * Math.PI * 15.5;
|
||||
|
||||
/**
|
||||
* Dash offset that leaves the arc at the real percent — or a fixed
|
||||
* quarter arc when the total size is unknown and the ring spins instead.
|
||||
*/
|
||||
readonly downloadRingOffset = computed(() => {
|
||||
const percent = this.downloadPercent();
|
||||
return percent === null
|
||||
? this.downloadRingCircumference * 0.75
|
||||
: this.downloadRingCircumference * (1 - percent / 100);
|
||||
});
|
||||
|
||||
/** Drives the heart's brief scale pulse when favoriting toggles. */
|
||||
readonly favoritePulse = signal(false);
|
||||
private favoritePulseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly trailerEmbedUrl = computed(() =>
|
||||
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
|
||||
);
|
||||
@@ -461,6 +484,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.favoritePulseTimer) {
|
||||
clearTimeout(this.favoritePulseTimer);
|
||||
}
|
||||
this.xtreamStore.cancelDetailsRequest();
|
||||
this.playback.closeInlinePlayer();
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
@@ -580,6 +606,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
'movie',
|
||||
this.selectedVodInfo()?.backdrop_path?.[0]
|
||||
);
|
||||
|
||||
this.favoritePulse.set(true);
|
||||
if (this.favoritePulseTimer) {
|
||||
clearTimeout(this.favoritePulseTimer);
|
||||
}
|
||||
this.favoritePulseTimer = setTimeout(
|
||||
() => this.favoritePulse.set(false),
|
||||
220
|
||||
);
|
||||
}
|
||||
|
||||
getBackdropUrl(info: XtreamVodInfo): string | undefined {
|
||||
@@ -647,6 +682,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
return this.downloads.resumePaused();
|
||||
}
|
||||
|
||||
/**
|
||||
* A running download is destroyed by one click, so the icon button asks
|
||||
* first — there is no label left to warn what the click does.
|
||||
*/
|
||||
promptCancelDownload(): void {
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translateService.instant(
|
||||
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
|
||||
),
|
||||
message: this.translateService.instant(
|
||||
'DOWNLOADS.CANCEL_CONFIRM_MESSAGE'
|
||||
),
|
||||
onConfirm: () => void this.downloads.cancelActive(),
|
||||
});
|
||||
}
|
||||
|
||||
revealDownloadedFile(): Promise<void> {
|
||||
return this.downloads.revealDownloaded();
|
||||
}
|
||||
|
||||
downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
|
||||
return this.downloads.start(vodItem);
|
||||
}
|
||||
|
||||
@@ -217,6 +217,12 @@ export async function configureVodDetailsRouteTestBed(
|
||||
resumeDownloadByContent: stubs.resumeDownloadByContent,
|
||||
startDownload: stubs.startDownload,
|
||||
getDownloadedFilePath: stubs.getDownloadedFilePath,
|
||||
getDownloadByContent: jest.fn(),
|
||||
getProgressPercent: jest.fn().mockReturnValue(0),
|
||||
cancelDownload: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
}),
|
||||
revealFile: jest.fn().mockResolvedValue({ success: true }),
|
||||
playDownload: stubs.playDownload,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createCheckQueue } from './vod-multi-source-check-queue';
|
||||
|
||||
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
describe('createCheckQueue', () => {
|
||||
it('never runs more tasks than the limit at once', async () => {
|
||||
const queue = createCheckQueue(2);
|
||||
const gates = Array.from({ length: 5 }, createDeferred);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
|
||||
const all = gates.map((gate) =>
|
||||
queue(async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await gate.promise;
|
||||
running--;
|
||||
})
|
||||
);
|
||||
await settle();
|
||||
expect(running).toBe(2);
|
||||
|
||||
gates.forEach((gate) => gate.resolve());
|
||||
await Promise.all(all);
|
||||
expect(peak).toBe(2);
|
||||
expect(running).toBe(0);
|
||||
});
|
||||
|
||||
it('starts waiting tasks in arrival order as slots free up', async () => {
|
||||
const queue = createCheckQueue(1);
|
||||
const first = createDeferred();
|
||||
const started: string[] = [];
|
||||
|
||||
const a = queue(async () => {
|
||||
started.push('a');
|
||||
await first.promise;
|
||||
});
|
||||
const b = queue(async () => {
|
||||
started.push('b');
|
||||
});
|
||||
const c = queue(async () => {
|
||||
started.push('c');
|
||||
});
|
||||
|
||||
await settle();
|
||||
expect(started).toEqual(['a']);
|
||||
|
||||
first.resolve();
|
||||
await Promise.all([a, b, c]);
|
||||
expect(started).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('releases the slot when a task rejects', async () => {
|
||||
const queue = createCheckQueue(1);
|
||||
|
||||
await expect(
|
||||
queue(() => Promise.reject(new Error('boom')))
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
// A leaked slot would leave this waiting forever.
|
||||
await expect(queue(async () => 'after')).resolves.toBe('after');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* A minimal promise concurrency gate for availability checks.
|
||||
*
|
||||
* "Check all" fires one task per unchecked source, and each task costs a live
|
||||
* `get_vod_info` against a foreign portal plus a HEAD request at the stream.
|
||||
* Launched unbounded, a movie found in fifteen playlists turns the popover
|
||||
* into a fifteen-connection burst — enough for a touchy panel to rate-limit
|
||||
* the account it came from. The gate keeps a fixed number in flight and
|
||||
* starts the rest as slots free up, in arrival order.
|
||||
*/
|
||||
export function createCheckQueue(
|
||||
limit: number
|
||||
): <T>(task: () => Promise<T>) => Promise<T> {
|
||||
let active = 0;
|
||||
const waiting: (() => void)[] = [];
|
||||
|
||||
const release = (): void => {
|
||||
active--;
|
||||
waiting.shift()?.();
|
||||
};
|
||||
|
||||
return async <T>(task: () => Promise<T>): Promise<T> => {
|
||||
if (active >= limit) {
|
||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
}
|
||||
|
||||
active++;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
API_LANG_RUS,
|
||||
CURRENT_A_ID,
|
||||
MOVIE_A,
|
||||
MOVIE_B,
|
||||
PARSED_DUB,
|
||||
PROBE_OK,
|
||||
alternative,
|
||||
createDeferred,
|
||||
resolveWith,
|
||||
type AudioFacts,
|
||||
@@ -355,4 +357,104 @@ describe('VodMultiSourceHostService', () => {
|
||||
);
|
||||
expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK);
|
||||
});
|
||||
|
||||
it('marks a row checking immediately and refuses to enqueue it twice', async () => {
|
||||
await loadMovie([ALT_TWO]);
|
||||
const gate = createDeferred<null>();
|
||||
resolver.resolve.mockReturnValue(gate.promise);
|
||||
|
||||
const first = service.check(ALT_TWO.id);
|
||||
expect(rowFor(ALT_TWO.id)?.probe.status).toBe('probing');
|
||||
|
||||
// A second click on the same spinner must not queue a second probe.
|
||||
const second = service.check(ALT_TWO.id);
|
||||
gate.resolve(null);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs at most four checks at once; the rest wait their turn', async () => {
|
||||
const alts = [2, 3, 4, 5, 6].map(alternative);
|
||||
await loadMovie(alts);
|
||||
|
||||
const gates = new Map<string, ReturnType<typeof createDeferred>>();
|
||||
resolver.resolve.mockImplementation((candidate) => {
|
||||
const gate = createDeferred<null>();
|
||||
gates.set((candidate as VodSourceCandidate).id, gate);
|
||||
return gate.promise;
|
||||
});
|
||||
|
||||
const pending = alts.map((alt) => service.check(alt.id));
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// Every row spins from the moment it was asked for...
|
||||
for (const alt of alts) {
|
||||
expect(rowFor(alt.id)?.probe.status).toBe('probing');
|
||||
}
|
||||
// ...but only four have actually started resolving.
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(4);
|
||||
|
||||
gates.get(alts[0].id)?.resolve(null);
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(5);
|
||||
|
||||
for (const [, gate] of gates) {
|
||||
gate.resolve(null);
|
||||
}
|
||||
await Promise.all(pending);
|
||||
});
|
||||
|
||||
it('drops queued checks once the user opens another movie', async () => {
|
||||
const alts = [2, 3, 4, 5, 6, 7].map(alternative);
|
||||
await loadMovie(alts);
|
||||
|
||||
const gates = new Map<string, ReturnType<typeof createDeferred>>();
|
||||
resolver.resolve.mockImplementation((candidate) => {
|
||||
const gate = createDeferred<null>();
|
||||
gates.set((candidate as VodSourceCandidate).id, gate);
|
||||
return gate.promise;
|
||||
});
|
||||
|
||||
const pending = alts.map((alt) => service.check(alt.id));
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
// Four in flight, two still waiting for a slot.
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(4);
|
||||
|
||||
// The user navigates away while those two are still queued. They are
|
||||
// about a film that is no longer on screen, so they must not spend a
|
||||
// resolve against a foreign portal when a slot frees up.
|
||||
await loadMovie([], MOVIE_B);
|
||||
|
||||
for (const [, gate] of gates) {
|
||||
gate.resolve(null);
|
||||
}
|
||||
await Promise.all(pending);
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('remembers verdicts across sessions instead of re-checking', async () => {
|
||||
probes.probe.mockResolvedValue({
|
||||
...PROBE_OK,
|
||||
probedAt: new Date().toISOString(),
|
||||
});
|
||||
await loadMovie([ALT_TWO]);
|
||||
await service.check(ALT_TWO.id);
|
||||
expect(rowFor(ALT_TWO.id)?.probe.status).toBe('ok');
|
||||
|
||||
// Another film resets the controller and its probe states entirely.
|
||||
await loadMovie([], MOVIE_B);
|
||||
expect(rowFor(ALT_TWO.id)).toBeUndefined();
|
||||
|
||||
// Returning to the movie finds the verdict remembered: the row shows
|
||||
// "available" again without a single new request.
|
||||
resolver.resolve.mockClear();
|
||||
probes.probe.mockClear();
|
||||
await loadMovie([ALT_TWO], MOVIE_A);
|
||||
|
||||
expect(rowFor(ALT_TWO.id)?.probe.status).toBe('ok');
|
||||
expect(resolver.resolve).not.toHaveBeenCalled();
|
||||
expect(probes.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
applyApiMetadata,
|
||||
VodMultiSourceController,
|
||||
VodSourceDiscoveryService,
|
||||
VodSourceProbeCacheService,
|
||||
VodSourceResolverService,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from './vod-multi-source-session';
|
||||
import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice';
|
||||
import { createVodSourceCounts } from './vod-multi-source-counts';
|
||||
import { createCheckQueue } from './vod-multi-source-check-queue';
|
||||
import { currentSourceRow } from './vod-multi-source-current-row';
|
||||
import { probeSource } from './vod-multi-source-probe';
|
||||
import {
|
||||
@@ -78,8 +80,16 @@ export class VodMultiSourceHostService {
|
||||
private readonly resolver = inject(VodSourceResolverService);
|
||||
private readonly pins = inject(VodSourcePinService);
|
||||
private readonly probes = inject(StreamProbeService);
|
||||
private readonly probeCache = inject(VodSourceProbeCacheService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
|
||||
/**
|
||||
* At most this many availability checks in flight at once. Each check is
|
||||
* a live `get_vod_info` plus a HEAD against a foreign portal, and "check
|
||||
* all" would otherwise burst one connection per playlist the movie is in.
|
||||
*/
|
||||
private readonly checkQueue = createCheckQueue(4);
|
||||
|
||||
private controller = new VodMultiSourceController();
|
||||
private bindings: VodMultiSourceBindings | null = null;
|
||||
private pinKeys: PinKeySets = { lookup: [], write: [] };
|
||||
@@ -270,6 +280,37 @@ export class VodMultiSourceHostService {
|
||||
routeSource,
|
||||
publish: () => this.publish(),
|
||||
});
|
||||
|
||||
if (token === this.discoveryToken) {
|
||||
this.seedCachedProbes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay remembered verdicts onto freshly discovered rows.
|
||||
*
|
||||
* The controller starts every row at `idle`, but a check made minutes ago
|
||||
* — on this page or before navigating away — is still a usable answer.
|
||||
* Only untouched rows are seeded: a verdict the session already holds is
|
||||
* at least as fresh as the cache's.
|
||||
*/
|
||||
private seedCachedProbes(): void {
|
||||
let seeded = false;
|
||||
for (const source of this.controller.sources()) {
|
||||
if (source.probe.status !== 'idle') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cached = this.probeCache.get(source.id);
|
||||
if (cached) {
|
||||
this.controller.setProbe(source.id, cached);
|
||||
seeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (seeded) {
|
||||
this.publish();
|
||||
}
|
||||
}
|
||||
|
||||
/** The pinned source, when it is not the one the route already plays. */
|
||||
@@ -349,16 +390,57 @@ export class VodMultiSourceHostService {
|
||||
this.publish();
|
||||
}
|
||||
|
||||
/** User-triggered availability check for one row. */
|
||||
/**
|
||||
* User-triggered availability check for one row.
|
||||
*
|
||||
* Gated through the check queue so "check all" cannot burst a connection
|
||||
* per playlist; a row already probing (or queued — its status flips to
|
||||
* `probing` before the queue admits it) is not enqueued twice.
|
||||
*/
|
||||
check(sourceId: string): Promise<void> {
|
||||
return probeSource(sourceId, {
|
||||
controller: this.controller,
|
||||
resolver: this.resolver,
|
||||
probes: this.probes,
|
||||
isCurrent: (session) => session === this.sessionToken,
|
||||
session: this.sessionToken,
|
||||
publish: () => this.publish(),
|
||||
});
|
||||
const current = this._sources().find(
|
||||
(source) => source.id === sourceId
|
||||
);
|
||||
if (!current || current.probe.status === 'probing') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Marked before queueing, not inside the task: the row must show
|
||||
// "checking" the moment the user asks, even while it waits for a slot
|
||||
// — and the guard above reads this same status to dedupe.
|
||||
this.controller.setProbe(sourceId, { status: 'probing' });
|
||||
this.publish();
|
||||
|
||||
// Bound to the session that ASKED for the check, not to whatever is
|
||||
// current when a slot frees up. "Check all" can leave a dozen tasks
|
||||
// waiting, and the user is free to open another movie meanwhile —
|
||||
// those tasks are then about a film that is no longer on screen, so
|
||||
// they are dropped before spending a resolve on a foreign portal.
|
||||
// Both the controller and the session are read when a slot frees up,
|
||||
// NOT captured here — and that is deliberate.
|
||||
//
|
||||
// Capturing them would look safer and is worse: `sessionToken` also
|
||||
// moves when the movie identity is momentarily null, and that path
|
||||
// does NOT replace the controller (only a different film does). A
|
||||
// task dropped on a stale captured token would therefore abandon a
|
||||
// row it had already marked `probing` in the controller still on
|
||||
// screen, leaving it spinning for good.
|
||||
//
|
||||
// Read late, a queued task simply asks the CURRENT controller for the
|
||||
// source: gone when the user moved to another film, so `probeSource`
|
||||
// returns before it spends a request, and present when they came back
|
||||
// to the same one, where finishing the check is exactly right.
|
||||
return this.checkQueue(() =>
|
||||
probeSource(sourceId, {
|
||||
controller: this.controller,
|
||||
resolver: this.resolver,
|
||||
probes: this.probes,
|
||||
isCurrent: (session) => session === this.sessionToken,
|
||||
session: this.sessionToken,
|
||||
publish: () => this.publish(),
|
||||
cacheResult: (id, result) => this.probeCache.store(id, result),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
VodSourceResolverService,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import type { StreamProbeService } from '@iptvnator/services';
|
||||
import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* The on-demand availability check for a single row.
|
||||
@@ -19,6 +20,12 @@ export interface ProbeSourceDeps {
|
||||
session: number;
|
||||
isCurrent: (session: number) => boolean;
|
||||
publish: () => void;
|
||||
/**
|
||||
* Remembers settled verdicts across controller lifetimes, so reopening
|
||||
* the movie does not re-contact every foreign portal. Written even when
|
||||
* the session moved on — the verdict is about the source, not the page.
|
||||
*/
|
||||
cacheResult?: (sourceId: string, result: VodSourceProbeResult) => void;
|
||||
}
|
||||
|
||||
export async function probeSource(
|
||||
@@ -59,6 +66,7 @@ export async function probeSource(
|
||||
origin: resolved.playback.origin,
|
||||
}
|
||||
);
|
||||
deps.cacheResult?.(sourceId, result);
|
||||
if (!isCurrent(session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ 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-copy-row.component';
|
||||
export * from './lib/vod-sources/vod-source-filtering.util';
|
||||
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';
|
||||
|
||||
@@ -462,6 +462,19 @@
|
||||
@include detail-actions.secondary-action-button;
|
||||
}
|
||||
|
||||
// Theme-aware surface for the chip only: the shared mixin's white-alpha
|
||||
// fill vanishes over a bright light-theme hero, and the chip now sits
|
||||
// beside icon buttons that already wear the tag vars. The chip's own
|
||||
// pressed state (.sources-chip--open) is more specific and still wins.
|
||||
::ng-deep app-vod-sources-chip .sources-chip {
|
||||
background: var(--tag-bg);
|
||||
border-color: var(--tag-border);
|
||||
|
||||
&:hover {
|
||||
background: var(--tag-border);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .favorite-btn {
|
||||
@include detail-actions.favorite-button-state;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Shared look of the metadata/status tags on multi-source rows. A mixin
|
||||
// rather than a global class: the row and the copy-row are separate components
|
||||
// under emulated encapsulation, and each needs the rules inside its own scope.
|
||||
@mixin source-tag-base {
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@mixin source-tag-theme-vars {
|
||||
:host {
|
||||
--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);
|
||||
--vod-source-lang-fg: #6d4fc4;
|
||||
--vod-source-lang-bg: rgba(109, 79, 196, 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);
|
||||
--vod-source-lang-fg: #b599ff;
|
||||
--vod-source-lang-bg: rgba(181, 153, 255, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="copy-row" [class.copy-row--active]="source().isActive">
|
||||
<div class="copy-row__main">
|
||||
<div class="copy-row__title-line">
|
||||
@if (language(); as lang) {
|
||||
<span class="copy-row__lang" aria-hidden="true">{{ lang }}</span>
|
||||
}
|
||||
<span class="copy-row__title" [title]="title()">{{ title() }}</span>
|
||||
@if (source().isActive) {
|
||||
<span class="copy-row__badge">{{
|
||||
(playbackLive()
|
||||
? 'PORTALS.MULTI_SOURCE.PLAYING'
|
||||
: 'PORTALS.MULTI_SOURCE.CURRENT'
|
||||
) | translate
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="copy-row__tags">
|
||||
<app-vod-source-tag-list [tags]="tags()" />
|
||||
@if (isPrimaryCopy()) {
|
||||
<span class="copy-row__same">{{
|
||||
'PORTALS.MULTI_SOURCE.SAME_AS_ABOVE' | translate
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="copy-row__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="copy-row__action"
|
||||
(click)="onCheck($event)"
|
||||
[matTooltip]="'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate"
|
||||
[attr.aria-label]="'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-row__action copy-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,157 @@
|
||||
@use 'source-tag' as tag;
|
||||
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@include tag.source-tag-theme-vars;
|
||||
|
||||
.copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 9px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-row__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.copy-row__title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// The parsed language prefix, promoted to a chip so it can be scanned down a
|
||||
// column of copies instead of read out of every title.
|
||||
.copy-row__lang {
|
||||
flex: none;
|
||||
font-family: $font-mono;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
color: var(--vod-source-lang-fg);
|
||||
background: var(--vod-source-lang-bg);
|
||||
}
|
||||
|
||||
// The provider's own raw title — monospace, since it is data, not copy.
|
||||
.copy-row__title {
|
||||
font-family: $font-mono;
|
||||
font-weight: 500;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.4;
|
||||
color: var(--app-on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-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;
|
||||
}
|
||||
|
||||
.copy-row__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 3px;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.copy-row__same {
|
||||
font-family: $font-mono;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
color: var(--app-eyebrow-color);
|
||||
}
|
||||
|
||||
.copy-row__actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.copy-row__action {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
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: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--app-on-surface);
|
||||
background: var(--app-card-hover-bg);
|
||||
}
|
||||
|
||||
&--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,76 @@
|
||||
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 { titleLanguagePrefix } from './vod-source-filtering.util';
|
||||
import { VodSourceTagListComponent } from './vod-source-tag-list.component';
|
||||
import { buildVodSourceCopyTags } from './vod-source-tags';
|
||||
|
||||
/**
|
||||
* One copy inside an expanded "N copies in this playlist" group.
|
||||
*
|
||||
* Deliberately NOT the full source row: the parent row already names the
|
||||
* playlist, so repeating its monogram and label here would drown the only
|
||||
* thing that distinguishes copies — the provider's own stream title. The
|
||||
* title is the primary text, a parsed language prefix becomes a chip before
|
||||
* it, and the tag row carries only what DIFFERS from the parent's copy.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vod-source-copy-row',
|
||||
templateUrl: './vod-source-copy-row.component.html',
|
||||
styleUrls: ['./vod-source-copy-row.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, MatTooltip, TranslatePipe, VodSourceTagListComponent],
|
||||
})
|
||||
export class VodSourceCopyRowComponent {
|
||||
readonly source = input.required<VodSourceDescriptor>();
|
||||
/** The copy shown on the parent row — the baseline every diff is against. */
|
||||
readonly primary = input.required<VodSourceDescriptor>();
|
||||
/** See `VodSourceRowComponent.playbackLive`. */
|
||||
readonly playbackLive = input(false);
|
||||
|
||||
readonly playRequested = output<string>();
|
||||
readonly checkRequested = output<string>();
|
||||
|
||||
readonly language = computed(() =>
|
||||
titleLanguagePrefix(this.source().rawTitle)
|
||||
);
|
||||
|
||||
readonly title = computed(
|
||||
() => this.source().rawTitle || `#${this.source().contentId}`
|
||||
);
|
||||
|
||||
/**
|
||||
* The parent's own copy appears in the expanded list too, so the group
|
||||
* reads complete — but its tags would all be "no difference", which is
|
||||
* exactly what the muted "same as above" note says in two words.
|
||||
*/
|
||||
readonly isPrimaryCopy = computed(
|
||||
() => this.source().id === this.primary().id
|
||||
);
|
||||
|
||||
readonly tags = computed(() =>
|
||||
this.isPrimaryCopy()
|
||||
? buildVodSourceCopyTags(this.source(), this.source()).filter(
|
||||
(tag) => tag.id === 'probe'
|
||||
)
|
||||
: buildVodSourceCopyTags(this.source(), this.primary())
|
||||
);
|
||||
|
||||
onPlay(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.playRequested.emit(this.source().id);
|
||||
}
|
||||
|
||||
onCheck(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.checkRequested.emit(this.source().id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
collectLanguagePrefixes,
|
||||
EMPTY_VOD_SOURCE_FILTERS,
|
||||
hasActiveVodSourceFilters,
|
||||
noChecksRunYet,
|
||||
qualityPixels,
|
||||
sourceMatchesFilters,
|
||||
titleLanguagePrefix,
|
||||
} from './vod-source-filtering.util';
|
||||
|
||||
function source(
|
||||
overrides: Partial<VodSourceDescriptor> = {}
|
||||
): VodSourceDescriptor {
|
||||
return {
|
||||
id: 'playlist-1:xtream:42',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'SharkTV',
|
||||
portalType: 'xtream',
|
||||
contentId: 42,
|
||||
rawTitle: 'Night of the Living Dead (1968)',
|
||||
matchConfidence: 'exact',
|
||||
year: 1968,
|
||||
isActive: false,
|
||||
isPinned: false,
|
||||
isTried: false,
|
||||
probe: { status: 'idle' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('titleLanguagePrefix', () => {
|
||||
it('reads the uppercase prefix before a pipe', () => {
|
||||
expect(titleLanguagePrefix('EN| Night of the Living Dead')).toBe('EN');
|
||||
expect(titleLanguagePrefix('ALB |Some Movie')).toBe('ALB');
|
||||
expect(titleLanguagePrefix(' ru| Ночь')).toBe('RU');
|
||||
});
|
||||
|
||||
it('rejects titles whose pipe is not a language marker', () => {
|
||||
// Five letters is a word, not a language code.
|
||||
expect(titleLanguagePrefix('NIGHT| of something')).toBeNull();
|
||||
expect(titleLanguagePrefix('Night of the Living Dead')).toBeNull();
|
||||
expect(titleLanguagePrefix('4K| Movie')).toBeNull();
|
||||
expect(titleLanguagePrefix(undefined)).toBeNull();
|
||||
expect(titleLanguagePrefix('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLanguagePrefixes', () => {
|
||||
it('collects unique prefixes in first-seen order', () => {
|
||||
const prefixes = collectLanguagePrefixes([
|
||||
source({ rawTitle: 'EN| A' }),
|
||||
source({ rawTitle: 'RU| B' }),
|
||||
source({ rawTitle: 'en| C' }),
|
||||
source({ rawTitle: 'No prefix' }),
|
||||
]);
|
||||
expect(prefixes).toEqual(['EN', 'RU']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualityPixels', () => {
|
||||
it('reads NNNp labels and nothing else', () => {
|
||||
expect(qualityPixels('1080p')).toBe(1080);
|
||||
expect(qualityPixels('2160p')).toBe(2160);
|
||||
expect(qualityPixels('576i')).toBe(576);
|
||||
expect(qualityPixels('4K')).toBeNull();
|
||||
expect(qualityPixels(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceMatchesFilters', () => {
|
||||
it('passes everything with no filters active', () => {
|
||||
expect(hasActiveVodSourceFilters(EMPTY_VOD_SOURCE_FILTERS)).toBe(false);
|
||||
expect(
|
||||
sourceMatchesFilters(source(), EMPTY_VOD_SOURCE_FILTERS)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('available keeps only probe-verified sources', () => {
|
||||
const filters = { ...EMPTY_VOD_SOURCE_FILTERS, availableOnly: true };
|
||||
expect(
|
||||
sourceMatchesFilters(
|
||||
source({ probe: { status: 'ok', latencyMs: 100 } }),
|
||||
filters
|
||||
)
|
||||
).toBe(true);
|
||||
// Unchecked must never pass a filter named "available".
|
||||
for (const status of ['idle', 'unknown', 'probing', 'fail'] as const) {
|
||||
expect(
|
||||
sourceMatchesFilters(source({ probe: { status } }), filters)
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('HD+ keeps stated or guessed quality at 1080p and above', () => {
|
||||
const filters = { ...EMPTY_VOD_SOURCE_FILTERS, hdOnly: true };
|
||||
expect(
|
||||
sourceMatchesFilters(
|
||||
source({ quality: { value: '1080p', provenance: 'api' } }),
|
||||
filters
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
sourceMatchesFilters(
|
||||
source({ quality: { value: '2160p', provenance: 'parsed' } }),
|
||||
filters
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
sourceMatchesFilters(
|
||||
source({ quality: { value: '720p', provenance: 'api' } }),
|
||||
filters
|
||||
)
|
||||
).toBe(false);
|
||||
// No stated quality cannot claim to be HD.
|
||||
expect(sourceMatchesFilters(source(), filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('language matches the parsed title prefix', () => {
|
||||
const filters = { ...EMPTY_VOD_SOURCE_FILTERS, language: 'EN' };
|
||||
expect(
|
||||
sourceMatchesFilters(source({ rawTitle: 'EN| Movie' }), filters)
|
||||
).toBe(true);
|
||||
expect(
|
||||
sourceMatchesFilters(source({ rawTitle: 'RU| Movie' }), filters)
|
||||
).toBe(false);
|
||||
expect(
|
||||
sourceMatchesFilters(source({ rawTitle: 'Movie' }), filters)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('filters compose with AND', () => {
|
||||
const filters = {
|
||||
availableOnly: true,
|
||||
hdOnly: true,
|
||||
language: 'EN',
|
||||
};
|
||||
const matching = source({
|
||||
rawTitle: 'EN| Movie',
|
||||
quality: { value: '1080p', provenance: 'api' },
|
||||
probe: { status: 'ok' as const },
|
||||
});
|
||||
expect(sourceMatchesFilters(matching, filters)).toBe(true);
|
||||
expect(
|
||||
sourceMatchesFilters(
|
||||
{ ...matching, probe: { status: 'idle' } },
|
||||
filters
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noChecksRunYet', () => {
|
||||
it('is true only while every probe is idle or unknown', () => {
|
||||
expect(
|
||||
noChecksRunYet([source(), source({ probe: { status: 'unknown' } })])
|
||||
).toBe(true);
|
||||
expect(
|
||||
noChecksRunYet([source(), source({ probe: { status: 'probing' } })])
|
||||
).toBe(false);
|
||||
expect(
|
||||
noChecksRunYet([source({ probe: { status: 'fail' } })])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Filter logic for the sources popover's chip row.
|
||||
*
|
||||
* Pure functions over descriptors so the menu component stays a thin view and
|
||||
* the composition rules (filters AND each other and the host search) can be
|
||||
* tested without a fixture.
|
||||
*/
|
||||
|
||||
export interface VodSourceFilterState {
|
||||
/** Keep only copies whose probe verified them reachable. */
|
||||
availableOnly: boolean;
|
||||
/** Keep only copies whose stated quality is 1080p or better. */
|
||||
hdOnly: boolean;
|
||||
/** Keep only copies carrying this title language prefix; null = all. */
|
||||
language: string | null;
|
||||
}
|
||||
|
||||
export const EMPTY_VOD_SOURCE_FILTERS: VodSourceFilterState = {
|
||||
availableOnly: false,
|
||||
hdOnly: false,
|
||||
language: null,
|
||||
};
|
||||
|
||||
export function hasActiveVodSourceFilters(
|
||||
filters: VodSourceFilterState
|
||||
): boolean {
|
||||
return filters.availableOnly || filters.hdOnly || filters.language !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `EN| Night of the Living Dead` → `EN`.
|
||||
*
|
||||
* Providers prefix the raw stream title with a short uppercase language tag
|
||||
* before a pipe; that convention is the only language signal most panels
|
||||
* emit. Anything longer than four letters is a title that happens to contain
|
||||
* a pipe, not a language.
|
||||
*/
|
||||
export function titleLanguagePrefix(
|
||||
rawTitle: string | null | undefined
|
||||
): string | null {
|
||||
const match = /^\s*([A-Za-z]{2,4})\s*\|/.exec(rawTitle ?? '');
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
/** Every language prefix present in the list, in first-seen order. */
|
||||
export function collectLanguagePrefixes(
|
||||
sources: readonly VodSourceDescriptor[]
|
||||
): string[] {
|
||||
const languages: string[] = [];
|
||||
for (const source of sources) {
|
||||
const language = titleLanguagePrefix(source.rawTitle);
|
||||
if (language && !languages.includes(language)) {
|
||||
languages.push(language);
|
||||
}
|
||||
}
|
||||
return languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* `1080p` → 1080. Only the `NNNp` labels both the parser and the API
|
||||
* normalization emit are readable; anything else is not a comparable quality.
|
||||
*/
|
||||
export function qualityPixels(value: string | undefined): number | null {
|
||||
const match = /^(\d{3,4})[pi]$/i.exec(value ?? '');
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one copy survives the chip filters.
|
||||
*
|
||||
* HD+ reads the quality tag whether stated or guessed — this is a browse
|
||||
* filter the user drives, not an automatic decision, and hiding a copy whose
|
||||
* own title says 1080p would make the filter look broken. "Available" is
|
||||
* stricter: only a verified probe counts, because an unchecked source must
|
||||
* never pass a filter named available.
|
||||
*/
|
||||
export function sourceMatchesFilters(
|
||||
source: VodSourceDescriptor,
|
||||
filters: VodSourceFilterState
|
||||
): boolean {
|
||||
if (filters.availableOnly && source.probe.status !== 'ok') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.hdOnly) {
|
||||
const pixels = qualityPixels(source.quality?.value);
|
||||
if (pixels === null || pixels < 1080) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filters.language !== null &&
|
||||
titleLanguagePrefix(source.rawTitle) !== filters.language
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True while no source has a probe verdict or one in flight. */
|
||||
export function noChecksRunYet(
|
||||
sources: readonly VodSourceDescriptor[]
|
||||
): boolean {
|
||||
return sources.every(
|
||||
(source) =>
|
||||
source.probe.status === 'idle' || source.probe.status === 'unknown'
|
||||
);
|
||||
}
|
||||
@@ -55,21 +55,3 @@ export function groupVodSources(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
@@ -19,36 +19,7 @@
|
||||
</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>
|
||||
}
|
||||
<app-vod-source-tag-list [tags]="tags()" />
|
||||
@if (showCheckChip()) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
@use 'source-tag' as tag;
|
||||
|
||||
$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);
|
||||
}
|
||||
// Status colours shared with the tag list and the copy row.
|
||||
@include tag.source-tag-theme-vars;
|
||||
|
||||
// Base tag look for the check chip below; the value tags themselves render
|
||||
// inside <app-vod-source-tag-list>, which carries its own copy of these rules.
|
||||
@include tag.source-tag-base;
|
||||
|
||||
.source-row {
|
||||
display: flex;
|
||||
@@ -122,36 +110,6 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -181,34 +139,6 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
sourceMonogram,
|
||||
sourceNeedsCheck,
|
||||
} from './vod-source-tags';
|
||||
import { VodSourceTagListComponent } from './vod-source-tag-list.component';
|
||||
import { playlistDisplayLabel } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,7 @@ import { playlistDisplayLabel } from '@iptvnator/shared/interfaces';
|
||||
templateUrl: './vod-source-row.component.html',
|
||||
styleUrls: ['./vod-source-row.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, MatTooltip, TranslatePipe],
|
||||
imports: [MatIcon, MatTooltip, TranslatePipe, VodSourceTagListComponent],
|
||||
})
|
||||
export class VodSourceRowComponent {
|
||||
readonly source = input.required<VodSourceDescriptor>();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
@use 'source-tag' as tag;
|
||||
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@include tag.source-tag-base;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type { VodSourceRowTag } from './vod-source-tags';
|
||||
|
||||
/**
|
||||
* Renders a list of `VodSourceRowTag`s — the one shared way multi-source tags
|
||||
* reach the screen, so the full parent row and the compact copy row cannot
|
||||
* drift in how they present a guess, a fact, or a probe verdict.
|
||||
*
|
||||
* `display: contents` keeps the tags direct flex items of whatever container
|
||||
* hosts the component, so it adds markup without adding layout.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vod-source-tag-list',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, TranslatePipe],
|
||||
styleUrl: './vod-source-tag-list.component.scss',
|
||||
template: `
|
||||
@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>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class VodSourceTagListComponent {
|
||||
readonly tags = input.required<VodSourceRowTag[]>();
|
||||
}
|
||||
@@ -168,6 +168,40 @@ export function buildVodSourceTags(
|
||||
return tags.filter((tag): tag is VodSourceRowTag => tag !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags for one copy inside an expanded "N copies" group.
|
||||
*
|
||||
* The parent row already states the primary copy's metadata, so a child row
|
||||
* repeating it would bury the one thing the user opened the group to learn:
|
||||
* what makes this copy different. Only fields that DIFFER from the primary
|
||||
* are kept — plus the fuzzy-match warning and the probe verdict, which are
|
||||
* per-copy claims and never redundant.
|
||||
*/
|
||||
export function buildVodSourceCopyTags(
|
||||
source: VodSourceDescriptor,
|
||||
primary: VodSourceDescriptor
|
||||
): VodSourceRowTag[] {
|
||||
const differing = (
|
||||
id: string,
|
||||
field: VodSourceField | undefined,
|
||||
primaryField: VodSourceField | undefined
|
||||
): VodSourceRowTag | null =>
|
||||
field && field.value !== primaryField?.value
|
||||
? fieldTag(id, field)
|
||||
: null;
|
||||
|
||||
const tags: (VodSourceRowTag | null)[] = [
|
||||
differing('quality', source.quality, primary.quality),
|
||||
differing('codec', source.codec, primary.codec),
|
||||
differing('container', source.container, primary.container),
|
||||
differing('audio', source.audio, primary.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
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
[cdkConnectedOverlayPositions]="overlayPositions"
|
||||
[cdkConnectedOverlayViewportMargin]="16"
|
||||
[cdkConnectedOverlayFlexibleDimensions]="true"
|
||||
[cdkConnectedOverlayGrowAfterOpen]="true"
|
||||
[cdkConnectedOverlayMinHeight]="minPanelHeight"
|
||||
(backdropClick)="close()"
|
||||
(detach)="close()"
|
||||
(overlayKeydown)="onOverlayKeydown($event)"
|
||||
|
||||
@@ -29,6 +29,19 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
}
|
||||
}
|
||||
|
||||
// Pressed while the popover is open, so the button and the panel read as one
|
||||
// control. Doubled class beats the host surface rule the hero applies.
|
||||
.sources-chip.sources-chip--open {
|
||||
background: color-mix(in srgb, var(--app-selection-color) 16%, transparent);
|
||||
border-color: var(--app-selection-color);
|
||||
color: var(--app-selection-color);
|
||||
|
||||
.sources-chip__icon,
|
||||
.sources-chip__caret {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.sources-chip__icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
@@ -66,13 +79,56 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
// 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;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// The overlay's flexible bounding box is the height authority; the panel
|
||||
// takes what it grants and the menu's list scrolls inside it.
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
width: 660px;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 24px 70px rgba(0, 0, 0, 0.45),
|
||||
0 4px 18px rgba(0, 0, 0, 0.3);
|
||||
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);
|
||||
}
|
||||
|
||||
// The pane carries these classes (per-position `panelClass`), so they must
|
||||
// escape this component's scoping. The overlay's `minHeight` exists for the
|
||||
// FLIP calculation — as an inline style on the pane it would also stretch a
|
||||
// short panel, so it is neutralized visually.
|
||||
::ng-deep .vod-sources-pop--above,
|
||||
::ng-deep .vod-sources-pop--below {
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
// Pointer tail: an 11px rotated square on the edge facing the chip, wearing
|
||||
// the panel's own surface and border so it reads as part of it.
|
||||
::ng-deep .vod-sources-pop--above .sources-chip__panel::after,
|
||||
::ng-deep .vod-sources-pop--below .sources-chip__panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: var(--app-widget-bg, #161a22);
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
::ng-deep .vod-sources-pop--above .sources-chip__panel::after {
|
||||
bottom: -6px;
|
||||
border-right: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.1));
|
||||
border-bottom: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
::ng-deep .vod-sources-pop--below .sources-chip__panel::after {
|
||||
top: -6px;
|
||||
border-left: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.1));
|
||||
border-top: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
@@ -62,40 +62,59 @@ export class VodSourcesChipComponent {
|
||||
);
|
||||
|
||||
/**
|
||||
* Below the chip by default, flipping above when the action row sits near
|
||||
* the bottom of the window. `viewportMargin` keeps it off the edges.
|
||||
* Above the chip, right edges aligned — the action row sits low in the
|
||||
* hero, so up is where the space is. Flexible dimensions cap the panel to
|
||||
* the space on that side (`viewportMargin` keeps it off the edges), the
|
||||
* list inside is the only part that scrolls, and when less than
|
||||
* `MIN_PANEL_HEIGHT` remains above, the position is skipped and the panel
|
||||
* flips below the chip instead. The strategy re-applies itself on window
|
||||
* resize, so the fit holds at any proportion.
|
||||
*
|
||||
* Each position stamps its `panelClass` on the pane so the pointer tail
|
||||
* can mirror when the panel flips.
|
||||
*/
|
||||
readonly overlayPositions: ConnectedPosition[] = [
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: 8,
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -8,
|
||||
offsetY: -12,
|
||||
panelClass: 'vod-sources-pop--above',
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetY: 8,
|
||||
offsetY: 12,
|
||||
panelClass: 'vod-sources-pop--below',
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -8,
|
||||
offsetY: -12,
|
||||
panelClass: 'vod-sources-pop--above',
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: 12,
|
||||
panelClass: 'vod-sources-pop--below',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Below this, a position is not worth squeezing into: header, search,
|
||||
* chips and footer alone need ~220px, and a list holding fewer than two
|
||||
* rows helps nobody. The overlay tries the other side instead.
|
||||
*/
|
||||
readonly minPanelHeight = 280;
|
||||
|
||||
toggle(): void {
|
||||
this.isOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
@@ -19,33 +19,100 @@
|
||||
</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 (showFilter() || uncheckedCount() > 0) {
|
||||
<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__chips">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
[class.filter-chip--on]="!filtersActive()"
|
||||
(click)="clearFilters()"
|
||||
>
|
||||
<span>{{ 'PORTALS.MULTI_SOURCE.FILTER_ALL' | translate }}</span>
|
||||
<b class="filter-chip__count">{{ totalCount() }}</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
[class.filter-chip--on]="filters().availableOnly"
|
||||
[attr.aria-pressed]="filters().availableOnly"
|
||||
(click)="toggleAvailableFilter()"
|
||||
>
|
||||
<mat-icon class="filter-chip__icon">check</mat-icon>
|
||||
<span>{{
|
||||
'PORTALS.MULTI_SOURCE.FILTER_AVAILABLE' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
[class.filter-chip--on]="filters().hdOnly"
|
||||
[attr.aria-pressed]="filters().hdOnly"
|
||||
(click)="toggleHdFilter()"
|
||||
>
|
||||
<span>{{ 'PORTALS.MULTI_SOURCE.FILTER_HD' | translate }}</span>
|
||||
</button>
|
||||
|
||||
@if (showReducedCount()) {
|
||||
<span class="sources-menu__reduced">{{
|
||||
'PORTALS.MULTI_SOURCE.SHOWING_OF_TOTAL'
|
||||
| translate
|
||||
: { visible: visibleCount(), total: totalCount() }
|
||||
}}</span>
|
||||
}
|
||||
@if (uncheckedCount() > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="sources-menu__check-all"
|
||||
(click)="checkAllVisible()"
|
||||
|
||||
@if (languages().length > 0) {
|
||||
<select
|
||||
class="sources-menu__lang"
|
||||
[ngModel]="filters().language"
|
||||
(ngModelChange)="setLanguageFilter($event)"
|
||||
[attr.aria-label]="
|
||||
'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
<span>{{ 'PORTALS.MULTI_SOURCE.CHECK_ALL' | translate }}</span>
|
||||
</button>
|
||||
<option [ngValue]="null">
|
||||
{{ 'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate }}
|
||||
</option>
|
||||
@for (lang of languages(); track lang) {
|
||||
<option [ngValue]="lang">{{ lang }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +132,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="sources-menu__variants-toggle"
|
||||
[class.is-open]="isExpanded(group.key)"
|
||||
[attr.aria-expanded]="isExpanded(group.key)"
|
||||
(click)="toggleGroup(group.key)"
|
||||
>
|
||||
@@ -80,35 +148,17 @@
|
||||
</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()"
|
||||
[playbackLive]="playbackLive()"
|
||||
(playRequested)="
|
||||
playRequested.emit($event)
|
||||
"
|
||||
(pinRequested)="
|
||||
pinRequested.emit($event)
|
||||
"
|
||||
(checkRequested)="
|
||||
checkRequested.emit($event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="sources-menu__copies">
|
||||
@for (copy of group.sources; track copy.id) {
|
||||
<app-vod-source-copy-row
|
||||
[source]="copy"
|
||||
[primary]="group.primary"
|
||||
[playbackLive]="playbackLive()"
|
||||
(playRequested)="playRequested.emit($event)"
|
||||
(checkRequested)="
|
||||
checkRequested.emit($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
|
||||
// The popover is height-capped by its overlay to the space beside the anchor,
|
||||
// so everything from the host down must let the list — and only the list —
|
||||
// give way and scroll. Header, toolbar, chips and footer stay visible.
|
||||
:host {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
// 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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
background: var(--app-widget-bg, #161a22);
|
||||
@@ -16,6 +26,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
}
|
||||
|
||||
.sources-menu__header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
@@ -49,13 +60,23 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
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.
|
||||
// The one scrollable region. The overlay's bounding box caps the panel;
|
||||
// the viewport fallback keeps a detached host (error screen) usable.
|
||||
flex: 1 1 auto;
|
||||
min-height: 80px;
|
||||
max-height: min(56vh, 460px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
// Inside the height-capped overlay the bounding box is the limit; the local
|
||||
// viewport cap above would otherwise stop the list from using granted space.
|
||||
:host-context(.cdk-overlay-pane) .sources-menu__list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.sources-menu__footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -154,6 +175,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
// Controls live on their own line, below the heading, so nothing in the header
|
||||
// can be mistaken for a column label.
|
||||
.sources-menu__toolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -171,6 +193,98 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// ── Filter chips ────────────────────────────────────────────────────────────
|
||||
|
||||
.sources-menu__chips {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 14px 11px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--app-widget-border);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--app-eyebrow-color);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--app-on-surface);
|
||||
}
|
||||
|
||||
&--on {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 16%,
|
||||
transparent
|
||||
);
|
||||
border-color: var(--app-selection-color);
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-chip__count {
|
||||
font-family: $font-mono;
|
||||
font-weight: 700;
|
||||
font-size: 9.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filter-chip__icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.sources-menu__reduced {
|
||||
margin-left: 6px;
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
color: var(--app-eyebrow-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sources-menu__lang {
|
||||
margin-left: auto;
|
||||
max-width: 40%;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--app-widget-border);
|
||||
border-radius: 999px;
|
||||
background: var(--app-card-hover-bg);
|
||||
color: var(--app-eyebrow-color);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
outline: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copies expansion ────────────────────────────────────────────────────────
|
||||
|
||||
.sources-menu__variants-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -200,21 +314,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.sources-menu__copies {
|
||||
// Indented to sit under the parent's text column: the playlist already
|
||||
// named itself on the row above, so its copies hang off that name.
|
||||
margin: 0 0 4px;
|
||||
padding-left: 47px;
|
||||
}
|
||||
@@ -31,6 +31,16 @@ class FakeTranslateLoader implements TranslateLoader {
|
||||
PROBE_FAIL: 'unavailable',
|
||||
PROBE_CHECKING: 'checking',
|
||||
OPEN_SOURCES: 'Show alternative sources',
|
||||
CHECK_ALL: 'check all',
|
||||
FILTER_PLACEHOLDER: 'Filter by playlist',
|
||||
NO_MATCHES: 'No playlists match that filter',
|
||||
COPIES: '{{count}} copies in this playlist',
|
||||
FILTER_ALL: 'All',
|
||||
FILTER_AVAILABLE: 'Available',
|
||||
FILTER_HD: 'HD+',
|
||||
ALL_LANGUAGES: 'All languages',
|
||||
SAME_AS_ABOVE: 'same as above',
|
||||
SHOWING_OF_TOTAL: '{{visible}} of {{total}}',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -280,6 +290,241 @@ describe('VodSourcesMenuComponent', () => {
|
||||
expect(badge()).toBe('Playing');
|
||||
});
|
||||
|
||||
describe('filter chips', () => {
|
||||
function chipByText(text: string) {
|
||||
return fixture.debugElement
|
||||
.queryAll(By.css('.filter-chip'))
|
||||
.find((chip) =>
|
||||
(chip.nativeElement.textContent ?? '').includes(text)
|
||||
);
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return fixture.debugElement
|
||||
.queryAll(By.css('.source-row__name'))
|
||||
.map((el) => (el.nativeElement.textContent ?? '').trim());
|
||||
}
|
||||
|
||||
it('HD+ keeps only groups with a copy at 1080p or better', () => {
|
||||
render([
|
||||
createSource({
|
||||
id: 'a:xtream:1',
|
||||
playlistId: 'a',
|
||||
playlistName: 'HD Portal',
|
||||
quality: { value: '1080p', provenance: 'api' },
|
||||
}),
|
||||
createSource({
|
||||
id: 'b:xtream:2',
|
||||
playlistId: 'b',
|
||||
playlistName: 'SD Portal',
|
||||
quality: { value: '720p', provenance: 'api' },
|
||||
}),
|
||||
]);
|
||||
|
||||
chipByText('HD+')?.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(rowNames()).toEqual(['HD Portal']);
|
||||
expect(fixture.nativeElement.textContent).toContain('1 of 2');
|
||||
});
|
||||
|
||||
it('activating Available with no verdicts anywhere runs check-all', () => {
|
||||
const checked: string[] = [];
|
||||
fixture.componentInstance.checkRequested.subscribe((id: string) =>
|
||||
checked.push(id)
|
||||
);
|
||||
render([
|
||||
createSource({ id: 'a:xtream:1', playlistId: 'a' }),
|
||||
createSource({
|
||||
id: 'b:xtream:2',
|
||||
playlistId: 'b',
|
||||
probe: { status: 'unknown' },
|
||||
}),
|
||||
]);
|
||||
|
||||
chipByText('Available')?.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(checked).toEqual(['a:xtream:1', 'b:xtream:2']);
|
||||
});
|
||||
|
||||
it('does not re-run check-all once any verdict exists', () => {
|
||||
const checked: string[] = [];
|
||||
fixture.componentInstance.checkRequested.subscribe((id: string) =>
|
||||
checked.push(id)
|
||||
);
|
||||
render([
|
||||
createSource({
|
||||
id: 'a:xtream:1',
|
||||
playlistId: 'a',
|
||||
probe: { status: 'ok', latencyMs: 90 },
|
||||
}),
|
||||
createSource({ id: 'b:xtream:2', playlistId: 'b' }),
|
||||
]);
|
||||
|
||||
chipByText('Available')?.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(checked).toEqual([]);
|
||||
// Only the verified source survives the filter.
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('app-vod-source-row'))
|
||||
.length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('builds the language select from parsed title prefixes and filters by it', () => {
|
||||
render([
|
||||
createSource({
|
||||
id: 'a:xtream:1',
|
||||
playlistId: 'a',
|
||||
playlistName: 'English Portal',
|
||||
rawTitle: 'EN| Dune (2021)',
|
||||
}),
|
||||
createSource({
|
||||
id: 'b:xtream:2',
|
||||
playlistId: 'b',
|
||||
playlistName: 'Russian Portal',
|
||||
rawTitle: 'RU| Дюна (2021)',
|
||||
}),
|
||||
]);
|
||||
|
||||
const select = fixture.debugElement.query(
|
||||
By.css('.sources-menu__lang')
|
||||
);
|
||||
const options = select.nativeElement
|
||||
.textContent as string;
|
||||
expect(options).toContain('EN');
|
||||
expect(options).toContain('RU');
|
||||
|
||||
fixture.componentInstance.setLanguageFilter('RU');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(rowNames()).toEqual(['Russian Portal']);
|
||||
});
|
||||
|
||||
it('the All chip resets every filter and restores the full list', () => {
|
||||
render([
|
||||
createSource({
|
||||
id: 'a:xtream:1',
|
||||
playlistId: 'a',
|
||||
quality: { value: '1080p', provenance: 'api' },
|
||||
}),
|
||||
createSource({ id: 'b:xtream:2', playlistId: 'b' }),
|
||||
]);
|
||||
|
||||
chipByText('HD+')?.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('app-vod-source-row'))
|
||||
.length
|
||||
).toBe(1);
|
||||
|
||||
chipByText('All')?.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('app-vod-source-row'))
|
||||
.length
|
||||
).toBe(2);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('of 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copies expansion', () => {
|
||||
function expandFirstGroup(): void {
|
||||
fixture.debugElement
|
||||
.query(By.css('.sources-menu__variants-toggle'))
|
||||
.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
const primaryCopy = createSource({
|
||||
id: 'a:xtream:1',
|
||||
playlistId: 'a',
|
||||
playlistName: 'Shark Portal',
|
||||
rawTitle: 'EN| Night of the Living Dead (1968)',
|
||||
quality: { value: '720p', provenance: 'api' },
|
||||
container: { value: 'mp4', provenance: 'api' },
|
||||
isActive: true,
|
||||
});
|
||||
const hdCopy = createSource({
|
||||
id: 'a:xtream:2',
|
||||
playlistId: 'a',
|
||||
playlistName: 'Shark Portal',
|
||||
rawTitle: 'RU| Ночь живых мертвецов (1968)',
|
||||
quality: { value: '1080p', provenance: 'api' },
|
||||
container: { value: 'mp4', provenance: 'api' },
|
||||
});
|
||||
|
||||
it('lists every copy with a language chip and the raw stream title', () => {
|
||||
render([primaryCopy, hdCopy]);
|
||||
expandFirstGroup();
|
||||
|
||||
const copies = fixture.debugElement.queryAll(
|
||||
By.css('app-vod-source-copy-row')
|
||||
);
|
||||
expect(copies.length).toBe(2);
|
||||
|
||||
const langs = fixture.debugElement
|
||||
.queryAll(By.css('.copy-row__lang'))
|
||||
.map((el) => el.nativeElement.textContent.trim());
|
||||
expect(langs).toEqual(['EN', 'RU']);
|
||||
|
||||
const titles = fixture.debugElement
|
||||
.queryAll(By.css('.copy-row__title'))
|
||||
.map((el) => el.nativeElement.textContent.trim());
|
||||
expect(titles).toEqual([
|
||||
'EN| Night of the Living Dead (1968)',
|
||||
'RU| Ночь живых мертвецов (1968)',
|
||||
]);
|
||||
// The playlist names itself once, on the parent row.
|
||||
const nameCount = fixture.debugElement.queryAll(
|
||||
By.css('.source-row__name')
|
||||
).length;
|
||||
expect(nameCount).toBe(1);
|
||||
});
|
||||
|
||||
it('shows only tags that differ from the parent copy', () => {
|
||||
render([primaryCopy, hdCopy]);
|
||||
expandFirstGroup();
|
||||
|
||||
const copyTags = fixture.debugElement
|
||||
.queryAll(By.css('app-vod-source-copy-row .source-tag'))
|
||||
.map((el) => (el.nativeElement.textContent ?? '').trim());
|
||||
// The HD copy differs in quality only; the shared container and
|
||||
// the identical primary copy contribute no tags at all.
|
||||
expect(copyTags).toEqual(['1080p']);
|
||||
});
|
||||
|
||||
it('marks the parent-identical copy with "same as above" instead of tags', () => {
|
||||
render([primaryCopy, hdCopy]);
|
||||
expandFirstGroup();
|
||||
|
||||
const sameNotes = fixture.debugElement.queryAll(
|
||||
By.css('.copy-row__same')
|
||||
);
|
||||
expect(sameNotes.length).toBe(1);
|
||||
expect(sameNotes[0].nativeElement.textContent).toContain(
|
||||
'same as above'
|
||||
);
|
||||
});
|
||||
|
||||
it('plays the exact copy that was clicked', () => {
|
||||
const played: string[] = [];
|
||||
fixture.componentInstance.playRequested.subscribe((id: string) =>
|
||||
played.push(id)
|
||||
);
|
||||
render([primaryCopy, hdCopy]);
|
||||
expandFirstGroup();
|
||||
|
||||
fixture.debugElement
|
||||
.queryAll(By.css('.copy-row__action--play'))[1]
|
||||
.nativeElement.click();
|
||||
|
||||
expect(played).toEqual(['a:xtream:2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the match kind in the header, or the resume label when given', () => {
|
||||
fixture.componentRef.setInput('sources', [createSource()]);
|
||||
fixture.componentRef.setInput('matchKind', 'tmdb');
|
||||
|
||||
@@ -18,21 +18,30 @@ import {
|
||||
VodSourceDescriptor,
|
||||
VodSourceMatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodSourceRowComponent } from './vod-source-row.component';
|
||||
import { VodSourceCopyRowComponent } from './vod-source-copy-row.component';
|
||||
import {
|
||||
groupVodSources,
|
||||
variantLabel,
|
||||
type VodSourceGroup,
|
||||
} from './vod-source-groups.util';
|
||||
collectLanguagePrefixes,
|
||||
EMPTY_VOD_SOURCE_FILTERS,
|
||||
hasActiveVodSourceFilters,
|
||||
noChecksRunYet,
|
||||
sourceMatchesFilters,
|
||||
type VodSourceFilterState,
|
||||
} from './vod-source-filtering.util';
|
||||
import { VodSourceRowComponent } from './vod-source-row.component';
|
||||
import { groupVodSources } from './vod-source-groups.util';
|
||||
|
||||
/** Below this many sources, a filter box costs more than it saves. */
|
||||
/** Below this many sources, a search 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
|
||||
* Body of the "sources" popover: header, search + filter chips, 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, so it
|
||||
* owns no anchoring of its own.
|
||||
*
|
||||
* Laid out as a flex column whose only scrollable region is the source list —
|
||||
* the host caps the popover's height to the space beside its anchor, and the
|
||||
* header, search, chips and footer must stay visible while the list scrolls.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vod-sources-menu',
|
||||
@@ -44,6 +53,7 @@ const FILTER_FROM = 8;
|
||||
MatIcon,
|
||||
MatSlideToggle,
|
||||
TranslatePipe,
|
||||
VodSourceCopyRowComponent,
|
||||
VodSourceRowComponent,
|
||||
],
|
||||
})
|
||||
@@ -76,16 +86,31 @@ export class VodSourcesMenuComponent {
|
||||
readonly checkRequested = output<string>();
|
||||
readonly autoFailoverToggled = output<boolean>();
|
||||
|
||||
/** Filter text; only reachable once the list is long enough to need it. */
|
||||
/** Search text; only reachable once the list is long enough to need it. */
|
||||
readonly filter = signal('');
|
||||
|
||||
/** The chip filters — compose with each other and the search (AND). */
|
||||
readonly filters = signal<VodSourceFilterState>(EMPTY_VOD_SOURCE_FILTERS);
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
readonly totalCount = computed(() => this.sources().length);
|
||||
|
||||
/** Options for the language select — only prefixes actually present. */
|
||||
readonly languages = computed(() =>
|
||||
collectLanguagePrefixes(this.sources())
|
||||
);
|
||||
|
||||
readonly filtersActive = computed(() =>
|
||||
hasActiveVodSourceFilters(this.filters())
|
||||
);
|
||||
|
||||
/** Sources surviving the search box; what "check all" acts on. */
|
||||
private readonly searchedSources = computed(() => {
|
||||
const query = this.filter().trim().toLowerCase();
|
||||
const sources = this.sources();
|
||||
if (!query || !this.showFilter()) {
|
||||
@@ -99,6 +124,21 @@ export class VodSourcesMenuComponent {
|
||||
);
|
||||
});
|
||||
|
||||
/** Sources surviving search AND chip filters — what the list shows. */
|
||||
readonly visibleSources = computed(() => {
|
||||
const filters = this.filters();
|
||||
return this.searchedSources().filter((source) =>
|
||||
sourceMatchesFilters(source, filters)
|
||||
);
|
||||
});
|
||||
|
||||
readonly visibleCount = computed(() => this.visibleSources().length);
|
||||
|
||||
/** "X of N", shown only once something actually reduced the list. */
|
||||
readonly showReducedCount = computed(
|
||||
() => this.visibleCount() !== this.totalCount()
|
||||
);
|
||||
|
||||
/**
|
||||
* Sources collapsed per playlist. One playlist often lists the same film
|
||||
* several times; flat they are indistinguishable rows.
|
||||
@@ -121,15 +161,10 @@ export class VodSourcesMenuComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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(
|
||||
this.searchedSources().filter(
|
||||
(source) =>
|
||||
source.probe.status === 'idle' ||
|
||||
source.probe.status === 'unknown'
|
||||
@@ -142,16 +177,47 @@ export class VodSourcesMenuComponent {
|
||||
: 'PORTALS.MULTI_SOURCE.MATCHED_BY_TITLE'
|
||||
);
|
||||
|
||||
clearFilters(): void {
|
||||
this.filters.set(EMPTY_VOD_SOURCE_FILTERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks are lazy, so "show me what is available" would filter against
|
||||
* nothing the first time. Turning the filter on with no verdicts anywhere
|
||||
* therefore runs check-all itself — the question implies it.
|
||||
*/
|
||||
toggleAvailableFilter(): void {
|
||||
const next = !this.filters().availableOnly;
|
||||
this.filters.update((filters) => ({ ...filters, availableOnly: next }));
|
||||
if (next && noChecksRunYet(this.sources())) {
|
||||
this.checkAllVisible();
|
||||
}
|
||||
}
|
||||
|
||||
toggleHdFilter(): void {
|
||||
this.filters.update((filters) => ({
|
||||
...filters,
|
||||
hdOnly: !filters.hdOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
setLanguageFilter(language: string | null): void {
|
||||
this.filters.update((filters) => ({ ...filters, language }));
|
||||
}
|
||||
|
||||
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.
|
||||
* itself; the host caps how many probes actually run at once.
|
||||
*
|
||||
* Based on the SEARCHED list, not the chip-filtered one: the "Available"
|
||||
* chip calls this precisely when its own filter would hide everything.
|
||||
*/
|
||||
checkAllVisible(): void {
|
||||
for (const source of this.visibleSources()) {
|
||||
for (const source of this.searchedSources()) {
|
||||
if (
|
||||
source.probe.status === 'idle' ||
|
||||
source.probe.status === 'unknown'
|
||||
|
||||
@@ -107,6 +107,131 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Icon-only secondary actions (Favorites, Download) from the sources-popup
|
||||
// handoff: square ghosts at the primary CTA's height, so the label-less pair
|
||||
// reads as satellites of Play instead of competing with it. The translucent
|
||||
// surface keeps the border — over bright hero art these buttons otherwise
|
||||
// lose their edges.
|
||||
@mixin icon-action-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
padding: 0;
|
||||
// Theme-aware ghost surface: over a bright light-theme hero a white-alpha
|
||||
// fill and border simply vanish, which is exactly the lost-edges failure
|
||||
// the border exists to prevent. The tag vars already flip per theme.
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--tag-border, rgba(255, 255, 255, 0.14));
|
||||
border-color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin icon-action-button-states {
|
||||
// Favorited: same ghost surface — only the glyph fills and turns red.
|
||||
&--favorite.is-active {
|
||||
color: #ff5b6e;
|
||||
|
||||
&:hover {
|
||||
color: #ff7b8b;
|
||||
}
|
||||
}
|
||||
|
||||
&--favorite.is-pulsing mat-icon {
|
||||
animation: icon-action-pulse 180ms ease;
|
||||
}
|
||||
|
||||
// Downloaded: green check; the click now opens the file's location.
|
||||
&--done {
|
||||
color: #4ade80;
|
||||
|
||||
&:hover {
|
||||
color: #6ee7a0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The download button's circular progress: track + value arc, spinning when
|
||||
// the total size — and so the percent — is unknown.
|
||||
@mixin download-progress-ring {
|
||||
.download-ring {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transform: rotate(-90deg);
|
||||
|
||||
&--indeterminate {
|
||||
animation: download-ring-spin 0.9s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.download-ring__track {
|
||||
fill: none;
|
||||
stroke: var(--tag-border, rgba(255, 255, 255, 0.12));
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.download-ring__value {
|
||||
fill: none;
|
||||
stroke: var(--accent-color, #4f8eff);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-action-btn__center {
|
||||
position: absolute;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin icon-action-keyframes {
|
||||
@keyframes icon-action-pulse {
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes download-ring-spin {
|
||||
to {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon-action-btn mat-icon {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.download-ring--indeterminate {
|
||||
animation-duration: 3.6s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin mobile-action-buttons {
|
||||
.play-btn,
|
||||
.favorite-btn,
|
||||
|
||||
@@ -512,6 +512,14 @@
|
||||
@include detail-actions.download-button-state;
|
||||
}
|
||||
|
||||
.icon-action-btn {
|
||||
@include detail-actions.icon-action-button;
|
||||
@include detail-actions.icon-action-button-states;
|
||||
@include detail-actions.download-progress-ring;
|
||||
}
|
||||
|
||||
@include detail-actions.icon-action-keyframes;
|
||||
|
||||
// ============================================================================
|
||||
// Content Sections - Below the hero fold
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user