perf(playlist-switcher): stream Xtream portal status checks instead of blocking on Promise.all

Opening the playlist switcher fired one XTREAM_REQUEST IPC + HTTPS
round-trip per Xtream playlist and only updated the UI after ALL of
them resolved. A single slow or hung portal pinned every status dot
in the menu to the misleading red 'unavailable' state for the full
tail latency (often several seconds, sometimes longer).

Four changes land together:

1. Stream results — each portal's dot updates via signal.update() the
   moment ITS request resolves, independent of the slowest one. The
   previous Promise.all wrote a single Map at the end; now the Map
   grows incrementally.

2. New 'checking' status — extends PortalStatus with a pulsing-dot
   visual so users see "we're working on it" instead of red dots
   that look like failures. Respects prefers-reduced-motion.

3. 30-second TTL cache — opening, closing, and reopening the menu
   within 30s reuses prior status results and skips the IPC entirely.
   Cache survives across menu opens but is per-component instance
   (a global cache is a possible follow-up).

4. AbortController cancellation — closing the menu (or destroying the
   component) cancels in-flight checks so a slow portal can't write
   stale results into the next round. Solves the 'rapidly open/close
   the menu and watch dots flicker' problem.

The actual IPC layer wasn't changed — the wins come purely from
streaming, caching, and not lying to the user about portal state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 58a2d50756dd
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-02 00:51:46 +02:00
1 parent c14b5d5aff
commit 888a2dd80a
3 files changed
+137 -27

No files matched your search

@@ -493,6 +493,30 @@
background-color: #666666;
}
.status-checking {
background-color: #9e9e9e;
animation: switcher-status-checking 1.4s ease-in-out infinite;
}
@keyframes switcher-status-checking {
0%,
100% {
opacity: 0.4;
box-shadow: 0 0 0 0 rgba(158, 158, 158, 0);
}
50% {
opacity: 1;
box-shadow: 0 0 4px 1px rgba(158, 158, 158, 0.6);
}
}
@media (prefers-reduced-motion: reduce) {
.status-checking {
animation: none;
opacity: 0.7;
}
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
@@ -179,6 +179,26 @@ export class PlaylistSwitcherComponent {
);
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
/**
* 30-second TTL cache so that closing and reopening the menu within the
* window reuses prior portal status results instead of re-firing N
* IPC + HTTPS round-trips. Each entry is keyed by playlistId; the
* timestamp gates expiry independently of the in-memory portalStatuses
* signal (which can be cleared without invalidating the cache).
*/
private readonly portalStatusCache = new Map<
string,
{ status: PortalStatus; timestamp: number }
>();
private static readonly PORTAL_STATUS_CACHE_TTL_MS = 30_000;
/**
* Tracks the in-flight check round so we can cancel pending writes when
* the menu closes (avoiding zombie writes from a slow portal landing
* after the user has moved on).
*/
private portalStatusAbortController: AbortController | null = null;
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
@@ -190,6 +210,7 @@ export class PlaylistSwitcherComponent {
this.destroyRef.onDestroy(() => {
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
this.cancelPortalStatusChecks();
});
}
@@ -206,6 +227,7 @@ export class PlaylistSwitcherComponent {
this.isMenuOpen.set(false);
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
this.cancelPortalStatusChecks();
if (this.hasSearchToggle()) {
this.searchExpanded.set(false);
}
@@ -370,7 +392,13 @@ export class PlaylistSwitcherComponent {
getStatusClass(playlistId: string): string {
const status = this.portalStatuses().get(playlistId);
return this.portalStatusService.getStatusClass(status || 'unavailable');
// No entry yet means we haven't checked (or are mid-flight without a
// 'checking' write). Render no status class instead of misleading the
// user with the red 'unavailable' dot.
if (!status) {
return '';
}
return this.portalStatusService.getStatusClass(status);
}
isSelected(playlist: PlaylistMeta): boolean {
@@ -388,33 +416,82 @@ export class PlaylistSwitcherComponent {
}
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
const statusPromises = playlists
.filter(
(playlist) =>
playlist.serverUrl && playlist.username && playlist.password
)
.map(async (playlist) => {
try {
const status =
await this.portalStatusService.checkPortalStatus(
playlist.serverUrl,
playlist.username,
playlist.password
);
return { id: playlist._id, status };
} catch {
return {
id: playlist._id,
status: 'unavailable' as PortalStatus,
};
}
});
// Cancel any prior in-flight round so its results can't overwrite the
// new round (e.g. user closes + reopens the menu rapidly).
this.cancelPortalStatusChecks();
const controller = new AbortController();
this.portalStatusAbortController = controller;
const results = await Promise.all(statusPromises);
const statusMap = new Map(
results.map((result) => [result.id, result.status])
const xtreamPlaylists = playlists.filter(
(playlist) =>
playlist.serverUrl && playlist.username && playlist.password
);
this.portalStatuses.set(statusMap);
if (xtreamPlaylists.length === 0) {
return;
}
const now = Date.now();
const ttl = PlaylistSwitcherComponent.PORTAL_STATUS_CACHE_TTL_MS;
// Hydrate from cache + mark uncached portals as 'checking' in a single
// signal write so the UI flips from blank → cached/checking dots in
// one render, not one render per playlist.
const next = new Map(this.portalStatuses());
const toFetch: PlaylistMeta[] = [];
for (const playlist of xtreamPlaylists) {
const cached = this.portalStatusCache.get(playlist._id);
if (cached && now - cached.timestamp < ttl) {
next.set(playlist._id, cached.status);
} else {
next.set(playlist._id, 'checking');
toFetch.push(playlist);
}
}
this.portalStatuses.set(next);
if (toFetch.length === 0) {
return;
}
// Stream results: each portal's dot updates as soon as ITS request
// resolves, independent of the slowest one. Replaces the prior
// Promise.all that blocked all dots on the tail latency.
await Promise.all(
toFetch.map(async (playlist) => {
let status: PortalStatus;
try {
status = await this.portalStatusService.checkPortalStatus(
playlist.serverUrl,
playlist.username,
playlist.password
);
} catch {
status = 'unavailable';
}
if (controller.signal.aborted) {
return;
}
this.portalStatusCache.set(playlist._id, {
status,
timestamp: Date.now(),
});
this.portalStatuses.update((current) => {
const updated = new Map(current);
updated.set(playlist._id, status);
return updated;
});
})
);
}
private cancelPortalStatusChecks(): void {
if (this.portalStatusAbortController) {
this.portalStatusAbortController.abort();
this.portalStatusAbortController = null;
}
}
private syncMenuOverlayWidthToTrigger(): void {
+10 -1
View File
@@ -1,7 +1,12 @@
import { Injectable, inject } from '@angular/core';
import { DataService } from './data.service';
export type PortalStatus = 'active' | 'inactive' | 'expired' | 'unavailable';
export type PortalStatus =
| 'active'
| 'inactive'
| 'expired'
| 'unavailable'
| 'checking';
interface XtreamPortalStatusResponse {
payload?: {
@@ -89,6 +94,8 @@ export class PortalStatusService {
return 'Portal subscription has expired.';
case 'unavailable':
return 'Could not connect to the portal.';
case 'checking':
return 'Checking portal status…';
default:
return '';
}
@@ -118,6 +125,8 @@ export class PortalStatusService {
return 'cancel';
case 'expired':
return 'warning';
case 'checking':
return 'sync';
case 'unavailable':
default:
return 'error';