From 888a2dd80a8477e5a458a3e4a3f3762364fd58e8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 2 May 2026 00:51:46 +0200 Subject: [PATCH] perf(playlist-switcher): stream Xtream portal status checks instead of blocking on Promise.all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Entire-Checkpoint: 58a2d50756dd --- .../playlist-switcher.component.scss | 24 ++++ .../playlist-switcher.component.ts | 129 ++++++++++++++---- .../services/src/lib/portal-status.service.ts | 11 +- 3 files changed, 137 insertions(+), 27 deletions(-) diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss index c09cf72e5..fdd6f4df9 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss @@ -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; diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index c20992659..425b4e816 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -179,6 +179,26 @@ export class PlaylistSwitcherComponent { ); readonly portalStatuses = signal>(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 { diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts index 9dcfb14c3..544146b4f 100644 --- a/libs/services/src/lib/portal-status.service.ts +++ b/libs/services/src/lib/portal-status.service.ts @@ -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';