refactor(sources): remove the old source row, filter state and dead styles

- Delete app-playlist-item and SourceHealthIndicatorComponent, replaced by
  app-source-row and its tile dot.
- Delete the ngrx type-filter slice (FilterActions, selectedFilters,
  selectActiveTypeFilters); the Sources filters live in
  SourcesViewStateService.
- Drop the empty state's unused "no results" variant and its styles.
- The switcher and Sources list components are no longer in the max-lines
  baseline.
- Docs: UI guidelines (source row, Sources page and switcher, status dot,
  selected-row tokens, UI-kit mixins), the source-health contract and the
  zoneless inventory. Release notes for the redesign and the credentials fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 22:34:22 +02:00
1 parent 9feab4f83b
commit 86066c47ed
21 files changed
+107 -1471

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: sources
---
Playlists added from a URL no longer show the login or password from that URL in the Sources list or the toolbar: they show the server's host instead.
@@ -0,0 +1,7 @@
---
type: feature
area: sources
highlight: Redesigned Sources and playlist switcher
---
Sources and the playlist switcher share a new, compact row with a status dot. Filter Sources by type and by whether sources respond, delete with Undo, and open the switcher with ⌘P / Ctrl+P to search, filter by type and switch from the keyboard.
+84 -18
View File
@@ -38,6 +38,12 @@ Use it when changing existing views or introducing new list-based UI in the work
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss`
- Shared list selection style:
`libs/ui/styles/_nav-list.scss`
- Source row (Sources page and playlist switcher):
`libs/playlist/shared/ui/src/lib/source-row/source-row.component.html`
- UI-kit buttons, menus and segmented control:
`libs/ui/styles/_app-controls.scss`
- Side lists (title, group labels, single-choice rows):
`libs/ui/styles/_side-list.scss`
- Theme tokens:
`apps/web/src/m3-theme.scss`
- Settings surfaces:
@@ -67,6 +73,15 @@ in `apps/web/src/m3-theme.scss`):
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
- `--app-success-color` — done/watched status (episode watched toggle)
- `--app-list-selected-bg` / `--app-list-selected-fg` /
`--app-list-selected-outline` — the chosen row of a side list and the
switcher's current row (see Selection Pattern)
- `--app-line-strong` — popover and menu borders, a step above
`--app-widget-header-border`
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and
destructive menu items; `--app-neutral-tag-surface` for a neutral tag
- `--app-font-mono` — URLs and keyboard hints (JetBrains Mono, 400/500)
- `--app-popover-shadow` / `--app-popover-backdrop` — the switcher popover
Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the
@@ -167,6 +182,12 @@ Use this pattern for:
Do not add extra badges, left rails, or second selection systems unless there is a strong reason.
Side lists styled with `_side-list.scss` (the Sources filters) and the
playlist switcher's current row use the Settings navigation's variant
instead, through `--app-list-selected-*`: a soft tint with no lift or glow,
outlined in light theme. Its text is the heading colour in light theme (the
selection blue on its own tint is 3.3:1) and the selection colour in dark.
## Single-Choice Menus And Status Chips
A `mat-menu` that picks one value, such as a sort order or a rating
@@ -1304,27 +1325,72 @@ A visual change is not done until:
### Source row keyboard access
A Sources row (`app-playlist-item`) opens on a click anywhere on it, and it
also holds a drag handle, a health indicator and actions. Like
`app-content-card`, it opens from the keyboard through a dedicated element
that is a sibling of those controls: the title and meta block
`.playlist-content` (`role="button"`, `tabindex="0"`, named by the source
title, `aria-current` on the active source, `aria-disabled` while busy).
Enter and Space open the source (Space prevents the page scroll), and Tab
reaches each row before its actions. Never put `role="button"` or key
handlers on the whole row: the actions would sit inside a button, and their
Enter and Space would bubble into it. The row draws the ring for that
element, inset (`$offset: -2px`), because the list's scroller would cut a
ring drawn outside the full-width row. `sources-keyboard.e2e.ts` (Electron)
tabs to a row, checks its ring and opens the source with Enter.
A Sources row (`app-source-row`, regular size) opens on a click anywhere on
it, and it also holds a drag handle and its actions. Like `app-content-card`,
it opens from the keyboard through a dedicated element that is a sibling of
those controls: the title and meta block `.source-row__body`
(`role="button"`, `tabindex="0"`, named by the source's display name,
`aria-current` on the active source, `aria-disabled` while busy). Enter and
Space open the source (Space prevents the page scroll), and Tab reaches each
row before its actions. Never put `role="button"` or key handlers on the whole
row: the actions would sit inside a button, and their Enter and Space would
bubble into it. The row draws the ring for that element, inset
(`$offset: -2px`), because the list's scroller would cut a ring drawn outside
the full-width row. `R` on a focused row refreshes it, as its menu item shows.
`sources-keyboard.e2e.ts` (Electron) tabs to a row, checks its ring, opens
the source with Enter, and deletes and undoes a source keyboard-only.
### Sources page and playlist switcher
One `app-source-row` serves both lists: `size="regular"` on the Sources page
(56px, 36px tile, hover actions Refresh, Edit and ⋯; a failing row keeps
Retry in view; touch screens always show the actions) and `size="compact"`
in the switcher (52px, 32px tile, ⋯ only; the current row is tinted with a
check). The meta line is always type · channels · added · updated · URL, with
empty parts left out; only M3U playlists store a channel count. The helpers
in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`,
`source-list`) own that text, the filters and the counts.
- **No credentials on screen.** Names come from `sourceDisplayName`: a URL, a
URL-derived name such as `get.php?username=…&password=…` or any name holding
the source's password, MAC or URL secrets becomes the host. URLs come from
`sourceDisplayUrl`, built on `stripUrlCredentials` (shared logging): no
userinfo, sensitive query or fragment parameters, or Xtream path
credentials; Xtream shows `scheme://host:port · username`. Copy URL copies
the full address on request.
- **Delete with Undo.** A single delete has no confirmation: the row leaves
every list at once (`SourceRemovalService.pendingIds`) and the delete runs
when the toast closes; its close button commits at once, and quitting
within the four seconds keeps the source. Deleting the active source
switches to the next row. A desktop Xtream delete brings its row back as
"Removing…" with progress and Cancel. When the delete took the row and
with it the focus, the toast's Undo takes the focus. Bulk cleanup keeps
its confirmation dialog.
- **Side list.** Type and, on desktop, Status (Any / Responding / Not
responding) combine and persist (`SourcesViewStateService`); each group
counts within the other's choice.
- **Switcher.** The trigger is a 40px secondary button; ⌘P / Ctrl+P opens it
(⌘K stays the command palette). The popover is a CDK connected overlay, so
row menus open above it, not inside its scrolling list. Focus stays in the
search field, which drives a `role="listbox"` through
`aria-activedescendant`: ↑/↓ move and wrap, Enter opens, Shift+F10 opens
the highlighted row's menu, Escape closes and returns focus to the
trigger. The compact row's ⋯ is pointer-only (`aria-hidden`, not
focusable), since an option may not contain a control. "Recent" lists the
three most recently opened sources once there are six or more.
### Network source indicators (Electron)
The switcher and source rows use `SourceHealthIndicatorComponent`: green means
available, orange expired, red disabled/access refused, and neutral means
checking or unverified. Accessible tooltip text distinguishes refusal from a
confirmed disabled account and includes the check time. M3U indicators describe
the source URL only. Check again is a separate action, not a playlist refresh.
The status dot on a source tile reads `SourceStatusService`: green answers,
red does not, orange is an expired account, grey is not checked yet; local
files have no dot. On desktop the live `SourceHealthService` result wins; the
last conclusive result per source is kept across restarts
(`SourceHealthHistoryService`, no URL or credential stored), so a source that
failed yesterday shows as failing before it is checked again, with "Not
responding · 3 days". The dot's tooltip and accessible name give the reason
and check time; M3U checks describe the source URL only. Retry and Check
again rerun the check; they are not a playlist refresh. The browser build
keeps the Xtream portal status only.
### Inactive-source cleanup dialog
+5
View File
@@ -1843,6 +1843,11 @@ shared data access. Electron checks Xtream account info, Stalker account/profile
facts through the existing session, and the first 64 KiB of M3U URL responses.
An M3U success describes the playlist URL, not every channel. Local files and
text imports have no network indicator. PWA retains its existing Xtream path.
Lists read one status per source through `SourceStatusService`, which falls
back to the last conclusive result kept by `SourceHealthHistoryService`
(localStorage, by playlist ID: state, check time and the start of a failing
run; no URL or credential). Deleting a source or resetting the library clears
its entry. See the [UI guidelines](iptvnator-ui-guidelines.md#network-source-indicators-electron).
Checks are demand-driven, never a startup readiness barrier: four at a time,
two per origin, shared between surfaces. Known results remain visible during
+3 -5
View File
@@ -61,7 +61,7 @@ must be ticked here.
## Eager components
65 production files, 66 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
two); two have since been deleted (the old Sources row and its health dot). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
(or setting OnPush) once its template state is signals, signal inputs or
explicitly marked. The guard spec compares the unticked entries with the
files whose component metadata still sets
@@ -127,7 +127,7 @@ picker), so each marks itself on the form's `events` through
- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
- [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
### libs/playlist (14)
### libs/playlist (12)
- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
@@ -140,9 +140,7 @@ picker), so each marks itself on the form's `events` through
- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
@@ -173,7 +171,7 @@ the field a signal (or a `computed`), or writes it through one.
| --- | --- | --- | --- |
| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [x] | `libs/portal/shared/data-access/src/lib/source-status.service.ts` `checkPortal` (replaced the old Sources row's `checkPortalStatus`) | the PWA Xtream portal status lands after `await`; it is written to a signal | playlist |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist |
| [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
-7
View File
@@ -93,10 +93,3 @@ export const FavoritesActions = createActionGroup({
'Hydrate Favorites': props<{ channelIds: string[] }>(),
},
});
export const FilterActions = createActionGroup({
source: 'Filters',
events: {
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
},
});
@@ -10,7 +10,6 @@ export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
selectedId: string;
allPlaylistsLoaded: boolean;
loadFailed: boolean;
selectedFilters: string[];
}
export const initialPlaylistMetaState: PlaylistMetaState =
@@ -18,7 +17,6 @@ export const initialPlaylistMetaState: PlaylistMetaState =
selectedId: '',
allPlaylistsLoaded: false,
loadFailed: false,
selectedFilters: ['m3u', 'xtream', 'stalker'],
});
export const {
@@ -1,18 +0,0 @@
import { on } from '@ngrx/store';
import { FilterActions } from '../actions';
import { PlaylistState } from '../state';
export const filterReducers = [
on(
FilterActions.setSelectedFilters,
(state, { selectedFilters }): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedFilters,
},
};
}
),
];
+1 -3
View File
@@ -3,7 +3,6 @@ import { initialState, PlaylistState } from '../state';
import { channelReducers } from './channel.reducers';
import { epgReducers } from './epg.reducers';
import { favoritesReducers } from './favorites.reducers';
import { filterReducers } from './filter.reducers';
import { playlistReducers } from './playlist.reducers';
export const playlistReducer = createReducer(
@@ -11,8 +10,7 @@ export const playlistReducer = createReducer(
...epgReducers,
...channelReducers,
...playlistReducers,
...favoritesReducers,
...filterReducers
...favoritesReducers
);
// Selectors
-5
View File
@@ -63,11 +63,6 @@ export const selectAllPlaylistsMeta = createSelector(
fromPlaylistMetaState.getAllPlaylistsMeta
);
export const selectActiveTypeFilters = createSelector(
selectPlaylistsMetaState,
(state) => state.selectedFilters
);
export const selectPlaylistEntity = (id: string) =>
createSelector(selectPlaylistEntities, (entities) => entities[id]);
@@ -82,23 +82,6 @@
</div>
</div>
}
@case ('no-results') {
<div class="empty-state no-results-state">
<div class="illustration-container small">
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
</svg>
</div>
<div class="content">
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
</div>
</div>
}
@case ('no-data') {
<div class="empty-state no-data-state">
<div class="empty-icon-container">
@@ -66,40 +66,6 @@
}
}
.no-results-state {
padding: 40px 24px;
.illustration-container {
margin-bottom: 24px;
opacity: 0.5;
&.small {
animation: gentleFloat 1s ease-out;
}
}
.illustration {
width: 120px;
height: 120px;
color: var(--app-body-color);
.search-circle,
.search-handle {
stroke: currentColor;
opacity: 0.4;
}
.empty-box,
.empty-line {
stroke: currentColor;
}
}
.description {
margin-bottom: 0;
font-size: 0.875rem;
}
}
.no-data-state {
padding: 56px 24px;
@@ -11,7 +11,7 @@ import type { PlaylistType } from '../../add-playlist-menu/playlist-type';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
export type EmptyStateType =
'welcome-dashboard' | 'welcome-sources' | 'no-results' | 'no-data';
'welcome-dashboard' | 'welcome-sources' | 'no-data';
interface FeatureCard {
icon: string;
@@ -1,12 +1,4 @@
@media (max-width: 480px) {
.no-results-state {
padding: 30px 20px;
.illustration {
width: 100px;
height: 100px;
}
}
.no-data-state {
padding: 40px 20px;
@@ -19,10 +11,6 @@
}
@media (max-height: 700px) {
.no-results-state {
padding: 24px 20px;
.illustration-container { margin-bottom: 16px; }
}
.no-data-state {
padding: 32px 24px;
@@ -30,12 +18,3 @@
}
}
@media (max-height: 550px) {
.no-results-state {
padding: 16px 20px;
.illustration-container { margin-bottom: 12px; }
.illustration { width: 80px; height: 80px; }
.title { font-size: 1.125rem; }
.description { font-size: 0.8125rem; }
}
}
@@ -1,290 +0,0 @@
<div
class="playlist-item"
cdkDrag
[cdkDragDisabled]="!isDraggable() || isBusy()"
cdkDragPreviewContainer="parent"
[class.selected]="isSelected()"
[class.is-busy]="isBusy()"
(click)="onPlaylistClick()"
>
@if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
}
<div class="playlist-icon">
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="item"
[enabled]="!isBusy()"
/>
}
<!-- One provider icon per row, by the shared precedence: a restored
Stalker or Xtream playlist can also carry a URL. -->
<div class="icon-container">
@switch (sourceIconKey) {
@case ('stalker') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.STALKER_PORTAL' | translate
"
>{{ sourceIcons.stalker }}</mat-icon
>
}
@case ('xtream') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate
"
>{{ sourceIcons.xtream }}</mat-icon
>
}
@case ('m3u-url') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) +
' ' +
item.url
"
>{{ sourceIcons['m3u-url'] }}</mat-icon
>
}
@default {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate
"
>{{ sourceIcons['m3u-local'] }}</mat-icon
>
}
}
@if (showsPortalStatusDot) {
@let statusLabel =
'HOME.PLAYLISTS.PORTAL_STATUS'
| translate
: { status: (portalStatusLabelKey() | translate) };
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="statusLabel"
[attr.aria-label]="statusLabel"
></span>
} @else if (showsAutoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
"
>autorenew</mat-icon
>
}
</div>
</div>
<!-- The keyboard activation element is its own element so the drag
handle and the actions stay siblings: an interactive control nested
inside a role="button" is an invalid accessibility structure. A
pointer click anywhere on the row still opens it via the row. -->
<div
class="playlist-content"
role="button"
tabindex="0"
[attr.aria-label]="item.title || item.filename"
[attr.aria-current]="isSelected() || null"
[attr.aria-disabled]="isBusy() || null"
(keydown.enter)="onActivationKey($event)"
(keydown.space)="onActivationKey($event)"
>
<div class="playlist-title">
{{ item.title || item.filename }}
</div>
<div class="meta">
@if (!item.serverUrl && !item.portalUrl) {
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{
item.updateDate
| date
: 'MMMM d, yyyy, HH:mm'
: undefined
: currentLocale()
}}
}
@if (item.updateState === 2) {
<mat-icon
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
| translate
"
[inline]="true"
>warning</mat-icon
>
}
} @else {
@if (item.macAddress) {
Stalker
} @else {
Xtream
}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{
item.updateDate
| date
: 'MMMM d, yyyy, HH:mm'
: undefined
: currentLocale()
}}
}
}
</div>
@if (isBusy() && busyMessage()) {
<div class="busy-state">
<span class="busy-state__message">{{ busyMessage() }}</span>
@if (busyProgress() !== null) {
<span class="busy-state__value">{{ busyProgress() }}%</span>
}
</div>
}
@if (isBusy() && busyMessage() && busyProgress() !== null) {
<div class="busy-state__bar">
<mat-progress-bar
mode="determinate"
[value]="busyProgress() ?? 0"
/>
</div>
}
@if (isBusy() && busyMessage() && busyProgress() === null) {
<div class="busy-state__bar">
<mat-progress-bar mode="indeterminate" />
</div>
}
</div>
@if (showActions()) {
<div class="action-buttons">
@if (runtime.supportsSourceHealth && sourceHealthType(item)) {
<button
mat-icon-button
[disabled]="isBusy()"
(click)="$event.stopPropagation(); recheckSource()"
[matTooltip]="'SOURCE_HEALTH.RECHECK' | translate"
[attr.aria-label]="'SOURCE_HEALTH.RECHECK' | translate"
>
<mat-icon>network_check</mat-icon>
</button>
}
@if (canCancelBusyAction()) {
<button
mat-icon-button
class="cancel-btn app-destructive-button"
(click)="
$event.stopPropagation(); cancelBusyActionClicked.emit()
"
[matTooltip]="
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@if (item.url || (item.filePath && supportsPlaylistRefresh)) {
<button
mat-icon-button
class="refresh-btn"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate"
>
@if (isRefreshing()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>sync</mat-icon>
}
</button>
}
@if (item.serverUrl && supportsXtreamSqliteDataSource) {
<button
mat-icon-button
class="refresh-btn"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
[attr.aria-label]="
'HOME.PLAYLISTS.REFRESH_XTREAM' | translate
"
>
@if (isRefreshing()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>sync</mat-icon>
}
</button>
}
<button
mat-icon-button
class="edit-btn"
(click)="
$event.stopPropagation(); editPlaylistClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
class="delete-btn"
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.REMOVE' | translate"
>
@if (isDeleting()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>delete</mat-icon>
}
</button>
</div>
}
</div>
@@ -1,290 +0,0 @@
@use '../../../../../../../ui/styles/focus-ring';
:host {
display: block;
width: 100%;
}
.playlist-item {
width: 100%;
min-height: 68px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px 8px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
cursor: pointer;
box-sizing: border-box;
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease,
color 0.15s ease;
&:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
&.selected {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
color: var(--app-selection-color);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.meta,
.upload-type-icon,
.drag-icon,
.action-buttons button,
.action-buttons mat-icon {
color: inherit;
}
.status-dot {
border-color: color-mix(
in srgb,
var(--app-selection-surface-strong) 85%,
transparent
);
}
}
&.is-busy {
cursor: progress;
}
}
.playlist-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.playlist-content {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
// The row draws this element's focus ring.
outline: none;
}
// The keyboard ring belongs to the whole row, the unit that opens. It sits
// inside the row: the list's scroller would cut one drawn outside it.
.playlist-item:has(> .playlist-content:focus-visible) {
@include focus-ring.focus-ring-declarations($offset: -2px);
}
.playlist-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.96rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
}
.meta {
font-size: 0.82rem;
color: var(--mat-sys-on-surface-variant);
opacity: 0.84;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.busy-state {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 0.7rem;
color: var(--app-selection-border, #8aa4c8);
margin-top: 2px;
}
.busy-state__message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.busy-state__value {
opacity: 0.8;
}
.busy-state__bar {
margin-top: 4px;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 8px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.25),
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
opacity: 0.9;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.drag-icon {
flex-shrink: 0;
cursor: move;
opacity: 0.46;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}
.action-buttons {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
margin-inline-start: auto;
button {
width: 34px;
height: 34px;
border-radius: 10px;
color: inherit;
opacity: 0.76;
transition:
opacity 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
transform: translateY(-1px);
}
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
&:disabled {
transform: none;
opacity: 0.38;
}
}
}
// The action buttons inherit the row color, including the selected row's,
// which masks the .app-destructive-button token on the cancel action.
.playlist-item .action-buttons .cancel-btn,
.playlist-item.selected .action-buttons .cancel-btn {
color: var(--mat-sys-error);
}
.action-spinner {
display: block;
margin: 0 auto;
}
.icon-container {
position: relative;
display: inline-flex;
.upload-type-icon {
opacity: 0.75;
transition: opacity 0.15s ease;
}
.playlist-item:hover & .upload-type-icon {
opacity: 0.95;
}
}
.status-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
right: 0;
bottom: 0;
border: 2px solid var(--mat-sys-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.15s ease;
}
.status-active {
background-color: #4caf50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
}
.status-inactive {
background-color: #f44336;
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
}
.status-expired {
background-color: #ff9800;
box-shadow: 0 0 8px rgba(255, 152, 0, 0.4);
}
.status-unavailable {
background-color: var(--mat-sys-on-surface-variant);
opacity: 0.5;
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #4caf50;
background-color: var(--mat-sys-surface);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.15s ease;
.playlist-item:hover & {
color: #66bb6a;
transform: rotate(90deg);
}
}
@media only screen and (max-width: 480px) {
.upload-type-icon,
.icon-container,
.action-buttons .refresh-btn,
.action-buttons .edit-btn,
.action-buttons .delete-btn {
display: none;
}
}
@@ -1,408 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import {
PortalStatusService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { PlaylistItemComponent } from './playlist-item.component';
describe('PlaylistItemComponent', () => {
let component: PlaylistItemComponent;
let fixture: ComponentFixture<PlaylistItemComponent>;
let runtime: {
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
beforeEach(waitForAsync(() => {
runtime = {
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
TestBed.configureTestingModule({
imports: [
PlaylistItemComponent,
MockModule(MatIconModule),
MockModule(MatListModule),
MockModule(MatTooltipModule),
TranslateModule.forRoot(),
],
providers: [
{
provide: PortalStatusService,
useValue: {
checkPortalStatus: jest
.fn()
.mockResolvedValue('active'),
getStatusClass: jest.fn(() => 'status-active'),
getStatusIcon: jest.fn(() => 'check_circle'),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Playlist',
_id: '1',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
};
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('shows busy UI and suppresses playlist clicks while deleting', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
fixture.componentRef.setInput('isDeleting', true);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const deleteButton = nativeElement.querySelector(
'.delete-btn'
) as HTMLButtonElement;
const editButton = nativeElement.querySelector(
'.edit-btn'
) as HTMLButtonElement;
expect(component.isBusy()).toBe(true);
expect(deleteButton.disabled).toBe(true);
expect(editButton.disabled).toBe(true);
expect(nativeElement.querySelector('.action-spinner')).not.toBeNull();
component.onPlaylistClick();
expect(emitSpy).not.toHaveBeenCalled();
});
describe('keyboard activation', () => {
const row = () => fixture.nativeElement as HTMLElement;
const activation = () =>
row().querySelector('[role="button"]') as HTMLElement;
const keydown = (target: HTMLElement, key: string) => {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event;
};
it('is a Tab stop named after the source that Enter and Space open', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
const surface = activation();
expect(surface).not.toBeNull();
expect(surface.getAttribute('tabindex')).toBe('0');
expect(surface.getAttribute('aria-label')).toBe('Playlist');
const enter = keydown(surface, 'Enter');
const space = keydown(surface, ' ');
expect(emitSpy).toHaveBeenNthCalledWith(1, '1');
expect(emitSpy).toHaveBeenNthCalledWith(2, '1');
expect(enter.defaultPrevented).toBe(true);
// Space must not scroll the page.
expect(space.defaultPrevented).toBe(true);
});
it('keeps the drag handle and actions outside the activation element so their keys never open the source', () => {
fixture.componentRef.setInput('isDraggable', true);
fixture.detectChanges();
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
const editSpy = jest.spyOn(component.editPlaylistClicked, 'emit');
const controls = Array.from(
row().querySelectorAll<HTMLElement>(
'.action-buttons button, .drag-icon'
)
);
expect(activation()).not.toBeNull();
expect(controls.length).toBeGreaterThan(1);
// No interactive control nested inside a role="button".
for (const control of controls) {
expect(control.closest('[role="button"]')).toBeNull();
}
const editButton = row().querySelector(
'.edit-btn'
) as HTMLButtonElement;
keydown(editButton, 'Enter');
const space = keydown(editButton, ' ');
editButton.click();
// The button's own activation edits; the row must neither open
// the source nor swallow the Space the button relies on.
expect(emitSpy).not.toHaveBeenCalled();
expect(space.defaultPrevented).toBe(false);
expect(editSpy).toHaveBeenCalledTimes(1);
});
it('opens once for a pointer click anywhere on the row', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
activation().click();
(row().querySelector('.playlist-item') as HTMLElement).click();
(row().querySelector('.playlist-icon') as HTMLElement).click();
expect(emitSpy).toHaveBeenCalledTimes(3);
});
it('announces the selected source and ignores keys while busy', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
expect(activation().hasAttribute('aria-current')).toBe(false);
expect(activation().hasAttribute('aria-disabled')).toBe(false);
fixture.componentRef.setInput('isSelected', true);
fixture.componentRef.setInput('isRefreshing', true);
fixture.detectChanges();
expect(activation().getAttribute('aria-current')).toBe('true');
expect(activation().getAttribute('aria-disabled')).toBe('true');
// Still a Tab stop, so focus is not lost when a refresh starts.
expect(activation().getAttribute('tabindex')).toBe('0');
const space = keydown(activation(), ' ');
keydown(activation(), 'Enter');
expect(emitSpy).not.toHaveBeenCalled();
expect(space.defaultPrevented).toBe(true);
});
it('falls back to the file name for the accessible name', () => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
fixture.componentInstance.item = {
title: '',
filename: 'channels.m3u',
_id: 'file-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
};
fixture.detectChanges();
expect(activation().getAttribute('aria-label')).toBe(
'channels.m3u'
);
});
});
it('renders a refresh action for file-backed M3U playlists', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = true;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Local Source',
_id: 'local-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
filePath: '/tmp/local-source.m3u',
};
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull();
});
it('hides file-backed M3U refresh without the refresh bridge', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Local Source',
_id: 'local-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
filePath: '/tmp/local-source.m3u',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).toBeNull();
});
it('keeps URL-backed M3U refresh visible without the refresh bridge', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Remote Source',
_id: 'remote-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
url: 'https://example.com/playlist.m3u',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).not.toBeNull();
});
it('renders the Xtream refresh action only when the SQLite data source is available', () => {
fixture.destroy();
runtime.supportsXtreamSqliteDataSource = true;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Xtream Source',
_id: 'xtream-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
serverUrl: 'https://example.com',
username: 'demo',
password: 'secret',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).not.toBeNull();
fixture.destroy();
runtime.supportsXtreamSqliteDataSource = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Xtream Source',
_id: 'xtream-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
serverUrl: 'https://example.com',
username: 'demo',
password: 'secret',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).toBeNull();
});
// OnPush: the status arrives after an await outside any template event,
// so it must reach the view without a zone-triggered tick.
it('renders the portal status that resolves after the first render', async () => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Xtream Source',
_id: 'xtream-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
serverUrl: 'https://example.com',
username: 'demo',
password: 'secret',
};
const translate = TestBed.inject(TranslateService);
// Not English text, so the label must come from the keys.
translate.setTranslation('en', {
HOME: {
PLAYLISTS: {
PORTAL_STATUS: 'Portalstatus: {{status}}',
PORTAL_STATUS_ACTIVE: 'aktiv',
},
},
});
translate.use('en');
// The fixture renders on its own: a forced detectChanges() after the
// await would hide a status that does not schedule a render.
fixture.autoDetectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
const statusDot = (fixture.nativeElement as HTMLElement).querySelector(
'.status-dot'
);
expect(statusDot?.getAttribute('aria-label')).toBe(
'Portalstatus: aktiv'
);
});
it('renders cancel and progress UI for long-running playlist actions', () => {
fixture.componentRef.setInput('isDeleting', true);
fixture.componentRef.setInput(
'busyMessage',
'Removing cached content...'
);
fixture.componentRef.setInput('busyProgress', 42);
fixture.componentRef.setInput('canCancelBusyAction', true);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
expect(
nativeElement.querySelector('.busy-state__message')?.textContent
).toContain('Removing cached content...');
expect(
nativeElement.querySelector('.busy-state__value')?.textContent
).toContain('42%');
expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull();
});
it.each([
[
{ macAddress: '00:1A:79:00:00:01', url: 'http://portal.test' },
'cast',
true,
],
[{ macAddress: '00:1A:79:00:00:01' }, 'cast', false],
[{ serverUrl: 'http://xtream.test' }, 'cloud', false],
[{ url: 'http://list.test/playlist.m3u' }, 'link', true],
[{}, 'description', true],
])(
'shows one provider icon for %o (%s) and keeps the auto-refresh badge: %s',
(source, icon, autoRefreshBadge) => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
fixture.componentInstance.item = {
title: 'Source',
_id: 'source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: true,
...source,
};
fixture.detectChanges();
const row = fixture.nativeElement as HTMLElement;
const icons = Array.from(
row.querySelectorAll('.upload-type-icon'),
(element) => element.textContent?.trim()
);
expect(icons).toEqual([icon]);
expect(row.querySelector('.auto-refresh-indicator') !== null).toBe(
autoRefreshBadge
);
}
);
});
@@ -1,173 +0,0 @@
import { Injector } from '@angular/core';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import {
PlaylistSourceIconKey,
resolvePlaylistSourceIconKey,
SOURCE_TYPE_ICONS,
sourceHealthType,
} from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../../source-health/source-health-indicator.component';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
import {
Component,
Input,
OnInit,
computed,
inject,
input,
output,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
PortalStatus,
PortalStatusService,
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
/** Translation keys for the status word in the portal status tooltip. */
const PORTAL_STATUS_LABEL_KEYS: Record<PortalStatus, string> = {
active: 'HOME.PLAYLISTS.PORTAL_STATUS_ACTIVE',
inactive: 'HOME.PLAYLISTS.PORTAL_STATUS_INACTIVE',
expired: 'HOME.PLAYLISTS.PORTAL_STATUS_EXPIRED',
unavailable: 'HOME.PLAYLISTS.PORTAL_STATUS_UNAVAILABLE',
checking: 'HOME.PLAYLISTS.PORTAL_STATUS_CHECKING',
};
@Component({
selector: 'app-playlist-item',
templateUrl: './playlist-item.component.html',
styleUrls: ['./playlist-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SourceHealthIndicatorComponent,
DatePipe,
DragDropModule,
MatIconButton,
MatIcon,
MatProgressBarModule,
MatProgressSpinnerModule,
MatTooltip,
TranslatePipe,
],
})
export class PlaylistItemComponent implements OnInit {
@Input() item!: PlaylistMeta;
readonly showActions = input(true);
readonly isDraggable = input(false);
readonly isSelected = input(false);
readonly isRefreshing = input(false);
readonly isDeleting = input(false);
readonly busyMessage = input('');
readonly busyProgress = input<number | null>(null);
readonly canCancelBusyAction = input(false);
readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting());
readonly editPlaylistClicked = output<PlaylistMeta>();
readonly playlistClicked = output<string>();
readonly refreshClicked = output<PlaylistMeta>();
readonly removeClicked = output<string>();
readonly cancelBusyActionClicked = output<void>();
readonly portalStatus = signal<PortalStatus>('unavailable');
readonly portalStatusLabelKey = computed(
() => PORTAL_STATUS_LABEL_KEYS[this.portalStatus()]
);
private readonly portalStatusService = inject(PortalStatusService);
readonly runtime = inject(RuntimeCapabilitiesService);
readonly sourceIcons = SOURCE_TYPE_ICONS;
get sourceIconKey(): PlaylistSourceIconKey {
return resolvePlaylistSourceIconKey(this.item);
}
/** Without source health, Xtream rows badge the portal status instead. */
get showsPortalStatusDot(): boolean {
return (
this.sourceIconKey === 'xtream' &&
!this.runtime.supportsSourceHealth
);
}
/**
* Auto-refresh re-fetches a URL or a local file, so any row with a URL
* keeps the badge, whichever provider icon it shows.
*/
get showsAutoRefresh(): boolean {
return (
!!this.item.autoRefresh &&
(!!this.item.url || this.sourceIconKey === 'm3u-local')
);
}
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
readonly supportsPlaylistRefresh = this.runtime.supportsPlaylistRefresh;
readonly supportsXtreamSqliteDataSource =
this.runtime.supportsXtreamSqliteDataSource;
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
private readonly healthInjector = inject(Injector);
readonly sourceHealthType = sourceHealthType;
recheckSource(): void {
void this.healthInjector.get(SourceHealthService).recheck(this.item);
}
async ngOnInit() {
if (!this.runtime.supportsSourceHealth) await this.checkPortalStatus();
}
private async checkPortalStatus() {
if (this.item.serverUrl && this.item.username && this.item.password) {
this.portalStatus.set(
await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
)
);
}
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.portalStatus());
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.portalStatus());
}
onPlaylistClick(): void {
if (this.isBusy()) {
return;
}
this.playlistClicked.emit(this.item._id);
}
/**
* Enter/Space open the source like a click on the row; Space also
* prevents the page scroll. The drag handle and the actions are siblings
* of the activation element, never descendants, so their keys cannot
* reach this handler.
*/
onActivationKey(event: Event): void {
event.preventDefault();
this.onPlaylistClick();
}
}
@@ -1,65 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { SourceHealthSnapshot } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from './source-health-indicator.component';
describe('SourceHealthIndicatorComponent', () => {
it.each(['en', 'zhtw'])(
'updates the dot and localized time without restarting its request (%s)',
(language) => {
const snapshot = signal<SourceHealthSnapshot | undefined>(
undefined
);
const check = jest.fn().mockImplementation(() => {
snapshot(); // The real coordinator reads its signal-backed cache.
return Promise.resolve();
});
TestBed.configureTestingModule({
imports: [
SourceHealthIndicatorComponent,
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSourceHealth: true },
},
{
provide: SourceHealthService,
useValue: { get: snapshot, check },
},
],
});
TestBed.inject(TranslateService).use(language);
const fixture = TestBed.createComponent(
SourceHealthIndicatorComponent
);
fixture.componentRef.setInput('playlist', {
_id: 'a',
url: 'https://source.test/list',
});
fixture.detectChanges();
const requestSignal = check.mock.calls[0][1].signal as AbortSignal;
snapshot.set({
state: 'active',
reason: 'available',
confirmedInactive: false,
checkedAt: Date.now(),
});
fixture.detectChanges();
expect(fixture.componentInstance.description()).toContain(' · ');
expect(check).toHaveBeenCalledTimes(1);
expect(requestSignal.aborted).toBe(false);
expect(
fixture.nativeElement.querySelector('.health-dot').dataset.state
).toBe('active');
fixture.destroy();
expect(requestSignal.aborted).toBe(true);
}
);
});
@@ -1,112 +0,0 @@
import {
Component,
Injector,
computed,
effect,
inject,
input,
untracked,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-source-health-indicator',
imports: [MatTooltip],
template: `@if (supported()) {
<span
class="health-dot"
[attr.data-state]="snapshot()?.state ?? 'unknown'"
[matTooltip]="description()"
[attr.aria-label]="description()"
role="img"
></span>
}`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: inline-flex;
vertical-align: middle;
}
.health-dot {
display: block;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--mat-sys-on-surface-variant);
border: 2px solid var(--mat-sys-surface);
}
[data-state='active'] {
background: #4caf50;
}
[data-state='expired'] {
background: #ff9800;
}
[data-state='inactive'] {
background: #f44336;
}
[data-state='checking'] {
opacity: 0.45;
}
`,
],
})
export class SourceHealthIndicatorComponent {
readonly playlist = input.required<PlaylistMeta>();
readonly enabled = input(true);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly injector = inject(Injector);
private readonly translate = inject(TranslateService);
private readonly language = injectTranslationTick();
readonly supported = computed(
() =>
this.runtime.supportsSourceHealth &&
!!sourceHealthType(this.playlist())
);
readonly snapshot = computed(() =>
this.supported()
? this.injector.get(SourceHealthService).get(this.playlist())
: undefined
);
readonly description = computed(() => {
this.language();
const snapshot = this.snapshot();
const reason =
snapshot?.state === 'checking'
? 'checking'
: (snapshot?.reason ?? 'unknown');
const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
const time = snapshot?.checkedAt
? new Date(snapshot.checkedAt).toLocaleTimeString(
normalizeDateLocale(this.translate.currentLang || 'en')
)
: '';
const scope =
sourceHealthType(this.playlist()) === 'm3u'
? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE')
: '';
return [label, scope, time].filter(Boolean).join(' · ');
});
constructor() {
effect((onCleanup) => {
const p = this.playlist();
if (!this.enabled() || !this.supported()) return;
const controller = new AbortController();
untracked(() => {
void this.injector
.get(SourceHealthService)
.check(p, { signal: controller.signal });
});
onCleanup(() => controller.abort());
});
}
}
-2
View File
@@ -24,9 +24,7 @@ export const maxLinesBaseline = [
'apps/xtream-mock-server/src/app/generators/marketing.generator.ts',
'libs/m3u-state/src/lib/effects.ts',
'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts',
'libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts',
'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts',
'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts',
'libs/playlist/shared/util/src/lib/playlist-context.facade.ts',
'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts',