mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
1 parent
9feab4f83b
commit
86066c47ed
21 files changed
+107
-1471
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
-17
@@ -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">
|
||||
|
||||
-34
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
-21
@@ -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; }
|
||||
}
|
||||
}
|
||||
-290
@@ -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>
|
||||
-290
@@ -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;
|
||||
}
|
||||
}
|
||||
-408
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
-173
@@ -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();
|
||||
}
|
||||
}
|
||||
-65
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user