From 86066c47ed6f3bd4c013359d78e3e5c8bb979665 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 22:20:44 +0200 Subject: [PATCH] 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 --- .changes/sources-hide-url-credentials.md | 6 + .../sources-redesigned-list-and-switcher.md | 7 + docs/architecture/iptvnator-ui-guidelines.md | 102 ++++- docs/architecture/m3u-playlist-module.md | 5 + docs/architecture/zoneless-migration.md | 8 +- libs/m3u-state/src/lib/actions.ts | 7 - libs/m3u-state/src/lib/playlists.state.ts | 2 - .../src/lib/reducers/filter.reducers.ts | 18 - libs/m3u-state/src/lib/reducers/index.ts | 4 +- libs/m3u-state/src/lib/selectors.ts | 5 - .../empty-state/empty-state.component.html | 17 - .../empty-state/empty-state.component.scss | 34 -- .../empty-state/empty-state.component.ts | 2 +- .../empty-state/empty-state.responsive.scss | 21 - .../playlist-item.component.html | 290 ------------- .../playlist-item.component.scss | 290 ------------- .../playlist-item.component.spec.ts | 408 ------------------ .../playlist-item/playlist-item.component.ts | 173 -------- .../source-health-indicator.component.spec.ts | 65 --- .../source-health-indicator.component.ts | 112 ----- tools/eslint/max-lines-baseline.mjs | 2 - 21 files changed, 107 insertions(+), 1471 deletions(-) create mode 100644 .changes/sources-hide-url-credentials.md create mode 100644 .changes/sources-redesigned-list-and-switcher.md delete mode 100644 libs/m3u-state/src/lib/reducers/filter.reducers.ts delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts delete mode 100644 libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts delete mode 100644 libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts diff --git a/.changes/sources-hide-url-credentials.md b/.changes/sources-hide-url-credentials.md new file mode 100644 index 000000000..812a3c310 --- /dev/null +++ b/.changes/sources-hide-url-credentials.md @@ -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. diff --git a/.changes/sources-redesigned-list-and-switcher.md b/.changes/sources-redesigned-list-and-switcher.md new file mode 100644 index 000000000..f79982aca --- /dev/null +++ b/.changes/sources-redesigned-list-and-switcher.md @@ -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. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 5886a24ae..695347133 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index dc9991bb4..c4191d40b 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index a5eddb7c6..5fede9146 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -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 | diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 53de17f8c..6f8ab9a94 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -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[] }>(), - }, -}); diff --git a/libs/m3u-state/src/lib/playlists.state.ts b/libs/m3u-state/src/lib/playlists.state.ts index 4a46a04ad..671f608ec 100644 --- a/libs/m3u-state/src/lib/playlists.state.ts +++ b/libs/m3u-state/src/lib/playlists.state.ts @@ -10,7 +10,6 @@ export interface PlaylistMetaState extends EntityState { 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 { diff --git a/libs/m3u-state/src/lib/reducers/filter.reducers.ts b/libs/m3u-state/src/lib/reducers/filter.reducers.ts deleted file mode 100644 index a4533992c..000000000 --- a/libs/m3u-state/src/lib/reducers/filter.reducers.ts +++ /dev/null @@ -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, - }, - }; - } - ), -]; diff --git a/libs/m3u-state/src/lib/reducers/index.ts b/libs/m3u-state/src/lib/reducers/index.ts index cac5d90ac..d83d2a87c 100644 --- a/libs/m3u-state/src/lib/reducers/index.ts +++ b/libs/m3u-state/src/lib/reducers/index.ts @@ -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 diff --git a/libs/m3u-state/src/lib/selectors.ts b/libs/m3u-state/src/lib/selectors.ts index be8bdaec9..ba4e3c514 100644 --- a/libs/m3u-state/src/lib/selectors.ts +++ b/libs/m3u-state/src/lib/selectors.ts @@ -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]); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html index 97227f172..c56b235f1 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html @@ -82,23 +82,6 @@ } - @case ('no-results') { -
-
- - - - - - -
- -
-

{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}

-

{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}

-
-
- } @case ('no-data') {
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss index 0fb6176c8..7f51a4cea 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss @@ -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; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts index ca7e2177a..d798445f8 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts @@ -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; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss index ba080ca32..633dfbc52 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss @@ -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; } - } -} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html deleted file mode 100644 index ff2d1ef16..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ /dev/null @@ -1,290 +0,0 @@ -
- @if (isDraggable()) { - drag_indicator - } -
- @if (runtime.supportsSourceHealth) { - - } - -
- @switch (sourceIconKey) { - @case ('stalker') { - {{ sourceIcons.stalker }} - } - @case ('xtream') { - {{ sourceIcons.xtream }} - } - @case ('m3u-url') { - {{ sourceIcons['m3u-url'] }} - } - @default { - {{ sourceIcons['m3u-local'] }} - } - } - @if (showsPortalStatusDot) { - @let statusLabel = - 'HOME.PLAYLISTS.PORTAL_STATUS' - | translate - : { status: (portalStatusLabelKey() | translate) }; - - } @else if (showsAutoRefresh) { - autorenew - } -
-
- -
-
- {{ item.title || item.filename }} -
-
- @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) { - warning - } - } @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() - }} - } - } -
- @if (isBusy() && busyMessage()) { -
- {{ busyMessage() }} - @if (busyProgress() !== null) { - {{ busyProgress() }}% - } -
- } - @if (isBusy() && busyMessage() && busyProgress() !== null) { -
- -
- } - @if (isBusy() && busyMessage() && busyProgress() === null) { -
- -
- } -
- - @if (showActions()) { -
- @if (runtime.supportsSourceHealth && sourceHealthType(item)) { - - } - @if (canCancelBusyAction()) { - - } - @if (item.url || (item.filePath && supportsPlaylistRefresh)) { - - } - @if (item.serverUrl && supportsXtreamSqliteDataSource) { - - } - - - - -
- } -
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss deleted file mode 100644 index 3129a13fa..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss +++ /dev/null @@ -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; - } -} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts deleted file mode 100644 index 2ba2c712b..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts +++ /dev/null @@ -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; - 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( - '.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 - ); - } - ); -}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts deleted file mode 100644 index db537f5d8..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ /dev/null @@ -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 = { - 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(null); - readonly canCancelBusyAction = input(false); - readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting()); - - readonly editPlaylistClicked = output(); - readonly playlistClicked = output(); - readonly refreshClicked = output(); - readonly removeClicked = output(); - readonly cancelBusyActionClicked = output(); - - readonly portalStatus = signal('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(); - } -} diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts deleted file mode 100644 index 30ea72630..000000000 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts +++ /dev/null @@ -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( - 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); - } - ); -}); diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts deleted file mode 100644 index 09baa69b0..000000000 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts +++ /dev/null @@ -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()) { - - }`, - 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(); - 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()); - }); - } -} diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index ea5ee98de..784e63998 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -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',