From 0010dd735128bfae8a29a487570619a90999163c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:27:24 +0200 Subject: [PATCH] feat(portals): rework the VOD sources popup and action row (#1359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(portals): rework the VOD sources popup and action row The Sources popup now anchors above its button and always fits on screen: the overlay caps it to the space beside the chip, only the source list scrolls so the header, search, filter chips and footer stay visible, and it flips below the button when the space above is too small. Filter chips (All / Available / HD+ / language) compose with the playlist search, and "Available" runs check-all itself when nothing has been checked yet. Expanded copy rows no longer repeat the playlist domain: each copy shows its parsed language chip, the provider's raw stream title, and only the tags that differ from the parent copy. Availability checks run at most four at a time and settled verdicts are remembered per movie and source for ten minutes, so reopening a movie no longer re-contacts every foreign portal. Favorites and Download become icon-only buttons with real state: a filled heart when favorited, and a download icon that turns into a progress ring and then a checkmark that reveals the finished file. Co-Authored-By: Claude Fable 5 * test(portals): match the movie detail's icon-only favorite button The Xtream movie detail's favorite control is now an icon-only button, so the shared "add this detail to favorites" helpers no longer found it by class. They are used against series and Stalker details too, which still render the labeled variant, so they now select by accessible name — the icon button carries the same label in aria-label. Co-Authored-By: Claude Fable 5 * fix(portals): keep provider playback for a downloaded movie Once a movie is downloaded the primary button plays the local file, and the sources popover only exists when another playlist carries the same film. The icon-only rework left those as the only two paths, so a downloaded movie in a single-playlist library had no way at all to stream the provider's copy — the labeled action that used to do it was gone. Restores it as an icon button beside the downloaded checkmark, under the same condition the old one used. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/portals-vod-sources-popup.md | 6 + CLAUDE.md | 2 +- .../src/dashboard-activation.e2e.ts | 9 +- .../electron-backend-e2e/src/favorites.e2e.ts | 9 +- apps/electron-backend-e2e/src/search.e2e.ts | 11 +- .../src/vod-multi-source.e2e.ts | 5 +- .../src/xtream-vod-details.e2e.ts | 9 +- apps/web/src/assets/i18n/ar.json | 12 +- apps/web/src/assets/i18n/ary.json | 12 +- apps/web/src/assets/i18n/by.json | 12 +- apps/web/src/assets/i18n/de.json | 12 +- apps/web/src/assets/i18n/el.json | 12 +- apps/web/src/assets/i18n/en.json | 12 +- apps/web/src/assets/i18n/es.json | 12 +- apps/web/src/assets/i18n/fr.json | 12 +- apps/web/src/assets/i18n/hu.json | 12 +- apps/web/src/assets/i18n/it.json | 12 +- apps/web/src/assets/i18n/ja.json | 12 +- apps/web/src/assets/i18n/ko.json | 12 +- apps/web/src/assets/i18n/nl.json | 12 +- apps/web/src/assets/i18n/pl.json | 12 +- apps/web/src/assets/i18n/pt.json | 12 +- apps/web/src/assets/i18n/ru.json | 12 +- apps/web/src/assets/i18n/tr.json | 12 +- apps/web/src/assets/i18n/zh.json | 12 +- apps/web/src/assets/i18n/zhtw.json | 12 +- docs/architecture/vod-multi-source.md | 87 ++++++- .../data-access/src/lib/multi-source/index.ts | 1 + .../vod-source-probe-cache.service.spec.ts | 53 ++++ .../vod-source-probe-cache.service.ts | 55 ++++ .../vod-details-downloads.service.ts | 48 ++++ .../vod-details-route-playback.spec.ts | 8 +- .../vod-details-route.actions.spec.ts | 79 ++++-- .../vod-details-route.component.html | 140 +++++++--- .../vod-details-route.component.spec.ts | 8 +- .../vod-details-route.component.ts | 55 ++++ .../vod-details/vod-details-route.harness.ts | 6 + .../vod-multi-source-check-queue.spec.ts | 71 +++++ .../vod-multi-source-check-queue.ts | 34 +++ .../vod-multi-source-host.service.spec.ts | 102 ++++++++ .../vod-multi-source-host.service.ts | 100 ++++++- .../lib/vod-details/vod-multi-source-probe.ts | 8 + libs/ui/components/src/index.ts | 2 + .../content-hero/content-hero.component.scss | 13 + .../src/lib/vod-sources/_source-tag.scss | 95 +++++++ .../vod-source-copy-row.component.html | 50 ++++ .../vod-source-copy-row.component.scss | 157 +++++++++++ .../vod-source-copy-row.component.ts | 76 ++++++ .../vod-source-filtering.util.spec.ts | 165 ++++++++++++ .../vod-sources/vod-source-filtering.util.ts | 112 ++++++++ .../lib/vod-sources/vod-source-groups.util.ts | 18 -- .../vod-sources/vod-source-row.component.html | 31 +-- .../vod-sources/vod-source-row.component.scss | 86 +----- .../vod-sources/vod-source-row.component.ts | 3 +- .../vod-source-tag-list.component.scss | 7 + .../vod-source-tag-list.component.ts | 49 ++++ .../src/lib/vod-sources/vod-source-tags.ts | 34 +++ .../vod-sources-chip.component.html | 3 + .../vod-sources-chip.component.scss | 62 ++++- .../vod-sources/vod-sources-chip.component.ts | 51 ++-- .../vod-sources-menu.component.html | 158 +++++++---- .../vod-sources-menu.component.scss | 142 ++++++++-- .../vod-sources-menu.component.spec.ts | 245 ++++++++++++++++++ .../vod-sources/vod-sources-menu.component.ts | 104 ++++++-- libs/ui/styles/_detail-view-actions.scss | 125 +++++++++ libs/ui/styles/_detail-view.scss | 8 + 66 files changed, 2583 insertions(+), 347 deletions(-) create mode 100644 .changes/portals-vod-sources-popup.md create mode 100644 libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.ts create mode 100644 libs/ui/components/src/lib/vod-sources/_source-tag.scss create mode 100644 libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.html create mode 100644 libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss create mode 100644 libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts create mode 100644 libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts create mode 100644 libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts create mode 100644 libs/ui/components/src/lib/vod-sources/vod-source-tag-list.component.scss create mode 100644 libs/ui/components/src/lib/vod-sources/vod-source-tag-list.component.ts diff --git a/.changes/portals-vod-sources-popup.md b/.changes/portals-vod-sources-popup.md new file mode 100644 index 000000000..dba9956e3 --- /dev/null +++ b/.changes/portals-vod-sources-popup.md @@ -0,0 +1,6 @@ +--- +type: feature +area: portals +--- + +The movie page's Sources popup now always fits on screen — only the source list scrolls, and it flips below the button in short windows — and gains All / Available / HD+ / language filters ("Available" checks every source on first use). Expanded copies show the stream title with only what differs, availability results are remembered, and Favorites/Download became icon buttons with live progress. diff --git a/CLAUDE.md b/CLAUDE.md index 14406ad6b..b228386c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -976,7 +976,7 @@ engine` (restart required) or **VOD Multi-Source** (alternative sources for a movie): -- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The chip counts alternative **streams**; the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. +- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 660px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the overlay's flexible bounding box so only the source list scrolls, and flips below when less than the overlay `minHeight` remains above; filter chips (All / Available / HD+ / language-prefix select) compose with the host search, "Available" auto-runs check-all when no verdicts exist, and expanded copy rows show a parsed language chip + raw stream title with diff-only tags ("same as above" for the parent's copy). Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source (`VodSourceProbeCacheService`). Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The details-page chip badge counts TOTAL **copies** across all playlists (the in-player chip still counts alternatives); the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when favorited, and a download idle icon → progress ring (real percent, indeterminate spin, paused-resume) → green done-checkmark whose click reveals the file (state read from the download manager; the labeled "Play from source" secondary is gone — provider playback for a downloaded movie goes through the Sources popover). - Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing. - **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying `api` provenance. The route's OWN row is never resolved, so it takes its facts from the `get_vod_info` the page already loaded (`providerVodMetadataOf`, shared with the resolver) and picks them up via `refreshRouteFacts()` even when they arrive without changing the movie identity — otherwise `audioDiffersFactually` has nothing on one side and the dub warning cannot fire on a route-to-alternative switch. - Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an _exact_ match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials. diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 0b99de0f4..ed9ded21b 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -254,13 +254,18 @@ async function goBackFromDetail(page: Page): Promise { } } +// By accessible name, not class: the Xtream movie detail's favorite control is +// an icon-only button that carries its label in aria-label, while series and +// Stalker details still use the labeled variant. This matches both. async function addCurrentDetailToFavorites(page: Page): Promise { - const addButton = page.locator('button.favorite-btn').first(); + const addButton = page + .getByRole('button', { name: /add to favorites/i }) + .first(); await expect(addButton).toBeVisible({ timeout: 20000 }); await addButton.click(); await expect( - page.locator('button.favorite-btn--active').first() + page.getByRole('button', { name: /remove from favorites/i }).first() ).toBeVisible({ timeout: 20000, }); diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 4db9c3edc..dcea53471 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -576,13 +576,18 @@ const xtreamCredentials = { password: defaultXtreamPassword, }; +// By accessible name, not class: the Xtream movie detail's favorite control is +// an icon-only button that carries its label in aria-label, while series and +// Stalker details still use the labeled variant. This matches both. async function addCurrentDetailToFavorites(page: Page): Promise { - const addButton = page.locator('button.favorite-btn').first(); + const addButton = page + .getByRole('button', { name: /add to favorites/i }) + .first(); await expect(addButton).toBeVisible({ timeout: 20000 }); await addButton.click(); await expect( - page.locator('button.favorite-btn--active').first() + page.getByRole('button', { name: /remove from favorites/i }).first() ).toBeVisible({ timeout: 20000, }); diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index dc96e4b45..974e7c17c 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1524,19 +1524,18 @@ async function expectXtreamRootCatalogSearch( ); } +// By accessible name, not class plus visible text: the Xtream movie detail's +// favorite control is an icon-only button whose label lives in aria-label, +// while series and Stalker details still use the labeled variant. async function addCurrentDetailToFavorites(page: Page): Promise { const addButton = page - .locator('button.favorite-btn') - .filter({ hasText: /Add to favorites/i }) + .getByRole('button', { name: /add to favorites/i }) .first(); await expect(addButton).toBeVisible({ timeout: 20000 }); await addButton.click(); await expect( - page - .locator('button.favorite-btn.favorite-btn--active') - .filter({ hasText: /Remove from favorites/i }) - .first() + page.getByRole('button', { name: /remove from favorites/i }).first() ).toBeVisible({ timeout: 20000 }); } diff --git a/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts b/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts index 0cb3e6e65..ac4f875a7 100644 --- a/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts +++ b/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts @@ -65,8 +65,9 @@ test.describe('VOD multi-source', () => { // Discovery is lazy, so the chip appears after the detail view. const chip = app.mainWindow.locator('app-vod-sources-chip button'); await expect(chip).toBeVisible({ timeout: 20000 }); - // Exactly one alternative: the same film in the other playlist. - await expect(chip).toContainText('1'); + // The badge counts every copy across all playlists — the route's + // own plus the same film in the other portal. + await expect(chip).toContainText('2'); await chip.click(); diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index b1565689c..cda0f7f52 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -93,11 +93,16 @@ test.describe('Xtream VOD Details', () => { .locator('button.play-btn') .first(); await expect(playButton).toBeVisible(); + // Both secondaries are icon-only on the movie detail now. await expect( - app.mainWindow.locator('button.favorite-btn').first() + app.mainWindow + .locator('[data-testid="vod-favorite-toggle"]') + .first() ).toBeVisible(); await expect( - app.mainWindow.locator('button.download-btn').first() + app.mainWindow + .locator('[data-testid="vod-download-start"]') + .first() ).toBeVisible(); const movieResponsePromise = app.mainWindow.waitForResponse( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index e1705d5c7..d3a03fefd 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "التصفية حسب قائمة التشغيل", "NO_MATCHES": "لا توجد قوائم تشغيل تطابق هذا الفلتر", "COPIES": "{{count}} نسخ في قائمة التشغيل هذه", - "MORE_SOURCES": "+{{count}} أخرى في قائمة المصادر" + "MORE_SOURCES": "+{{count}} أخرى في قائمة المصادر", + "FILTER_ALL": "الكل", + "FILTER_AVAILABLE": "متاح", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "كل اللغات", + "SAME_AS_ABOVE": "كما في الأعلى", + "SHOWING_OF_TOTAL": "{{visible}} من {{total}}" } }, "INFORMATION": "معلومات", @@ -1071,6 +1077,10 @@ "PAUSE": "إيقاف مؤقت", "RESUME": "استئناف", "CANCEL": "إلغاء", + "DOWNLOADED_REVEAL": "تم التنزيل — عرض في المجلد", + "DOWNLOADING_PERCENT": "جارٍ التنزيل… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "إلغاء التنزيل", + "CANCEL_CONFIRM_MESSAGE": "هل تريد إيقاف هذا التنزيل؟ سيتم حذف الملف الذي تم تنزيله جزئيًا.", "RETRY": "إعادة المحاولة", "REMOVE": "إزالة", "PLAY": "تشغيل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 058bab158..e39c28aec 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "فلتر بقائمة التشغيل", "NO_MATCHES": "ما كاينش قوائم تشغيل مطابقة لهاد الفلتر", "COPIES": "{{count}} ديال النسخ ف هاد قائمة التشغيل", - "MORE_SOURCES": "+{{count}} أخرى ف لائحة المصادر" + "MORE_SOURCES": "+{{count}} أخرى ف لائحة المصادر", + "FILTER_ALL": "الكل", + "FILTER_AVAILABLE": "متوفر", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "كل اللغات", + "SAME_AS_ABOVE": "بحال اللي الفوق", + "SHOWING_OF_TOTAL": "{{visible}} من {{total}}" } }, "INFORMATION": "معلومات", @@ -1071,6 +1077,10 @@ "PAUSE": "وقّف مؤقتاً", "RESUME": "كمّل", "CANCEL": "إلغاء", + "DOWNLOADED_REVEAL": "تحمّل — وري في المجلد", + "DOWNLOADING_PERCENT": "جاري التحميل… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "إلغاء التحميل", + "CANCEL_CONFIRM_MESSAGE": "بغيتي توقف هاد التحميل؟ الملف اللي تحمّل شي جزء منه غادي يتمسح.", "RETRY": "حاول مرة أخرى", "REMOVE": "حيد", "PLAY": "شغل", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 91e0a2b59..42c74c3fb 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Фільтраваць па плэйлісце", "NO_MATCHES": "Ніводны плэйліст не адпавядае гэтаму фільтру", "COPIES": "{{count}} копій у гэтым плэйлісце", - "MORE_SOURCES": "+{{count}} у спісе крыніц" + "MORE_SOURCES": "+{{count}} у спісе крыніц", + "FILTER_ALL": "Усе", + "FILTER_AVAILABLE": "Даступныя", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Усе мовы", + "SAME_AS_ABOVE": "як радком вышэй", + "SHOWING_OF_TOTAL": "{{visible}} з {{total}}" } }, "INFORMATION": "Інфармацыя", @@ -1071,6 +1077,10 @@ "PAUSE": "Паўза", "RESUME": "Узнавіць", "CANCEL": "Скасаваць", + "DOWNLOADED_REVEAL": "Спампавана — паказаць у папцы", + "DOWNLOADING_PERCENT": "Спампоўваецца… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Скасаваць спампоўванне", + "CANCEL_CONFIRM_MESSAGE": "Спыніць гэтае спампоўванне? Часткова спампаваны файл будзе выдалены.", "RETRY": "Паўтарыць", "REMOVE": "Выдаліць", "PLAY": "Прайграць", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 473d2c5cf..f06d9772f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Nach Playlist filtern", "NO_MATCHES": "Keine Playlists entsprechen dem Filter", "COPIES": "{{count}} Kopien in dieser Playlist", - "MORE_SOURCES": "+{{count}} weitere unter „Quellen“" + "MORE_SOURCES": "+{{count}} weitere unter „Quellen“", + "FILTER_ALL": "Alle", + "FILTER_AVAILABLE": "Verfügbar", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Alle Sprachen", + "SAME_AS_ABOVE": "wie oben", + "SHOWING_OF_TOTAL": "{{visible}} von {{total}}" } }, "INFORMATION": "Information", @@ -1071,6 +1077,10 @@ "PAUSE": "Pausieren", "RESUME": "Fortsetzen", "CANCEL": "Abbrechen", + "DOWNLOADED_REVEAL": "Heruntergeladen — im Ordner anzeigen", + "DOWNLOADING_PERCENT": "Wird heruntergeladen… {{percent}} %", + "CANCEL_CONFIRM_TITLE": "Download abbrechen", + "CANCEL_CONFIRM_MESSAGE": "Diesen Download beenden? Die teilweise heruntergeladene Datei wird verworfen.", "RETRY": "Wiederholen", "REMOVE": "Entfernen", "PLAY": "Abspielen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 6b5b6278c..a6d2eb7c8 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Φιλτράρισμα κατά λίστα αναπαραγωγής", "NO_MATCHES": "Δεν βρέθηκαν αντίστοιχες λίστες αναπαραγωγής", "COPIES": "{{count}} αντίγραφα σε αυτήν τη λίστα", - "MORE_SOURCES": "+{{count}} ακόμη στη λίστα πηγών" + "MORE_SOURCES": "+{{count}} ακόμη στη λίστα πηγών", + "FILTER_ALL": "Όλα", + "FILTER_AVAILABLE": "Διαθέσιμα", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Όλες οι γλώσσες", + "SAME_AS_ABOVE": "ίδιο με το παραπάνω", + "SHOWING_OF_TOTAL": "{{visible}} από {{total}}" } }, "INFORMATION": "Πληροφορίες", @@ -1071,6 +1077,10 @@ "PAUSE": "Παύση", "RESUME": "Συνέχιση", "CANCEL": "Ακύρωση", + "DOWNLOADED_REVEAL": "Λήφθηκε — εμφάνιση στον φάκελο", + "DOWNLOADING_PERCENT": "Λήψη… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Ακύρωση λήψης", + "CANCEL_CONFIRM_MESSAGE": "Διακοπή αυτής της λήψης; Το μερικώς ληφθέν αρχείο θα διαγραφεί.", "RETRY": "Επανάληψη", "REMOVE": "Αφαίρεση", "PLAY": "Αναπαραγωγή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 85f744542..1deffc51a 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Filter by playlist", "NO_MATCHES": "No playlists match that filter", "COPIES": "{{count}} copies in this playlist", - "MORE_SOURCES": "+{{count}} more in the sources list" + "MORE_SOURCES": "+{{count}} more in the sources list", + "FILTER_ALL": "All", + "FILTER_AVAILABLE": "Available", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "All languages", + "SAME_AS_ABOVE": "same as above", + "SHOWING_OF_TOTAL": "{{visible}} of {{total}}" } }, "INFORMATION": "Information", @@ -1071,6 +1077,10 @@ "PAUSE": "Pause", "RESUME": "Resume", "CANCEL": "Cancel", + "DOWNLOADED_REVEAL": "Downloaded — show in folder", + "DOWNLOADING_PERCENT": "Downloading… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Cancel download", + "CANCEL_CONFIRM_MESSAGE": "Stop this download? The partially downloaded file will be discarded.", "RETRY": "Retry", "REMOVE": "Remove", "PLAY": "Play", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 9c40aa2da..78154682c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Filtrar por lista", "NO_MATCHES": "Ninguna lista coincide con ese filtro", "COPIES": "{{count}} copias en esta lista", - "MORE_SOURCES": "+{{count}} más en la lista de fuentes" + "MORE_SOURCES": "+{{count}} más en la lista de fuentes", + "FILTER_ALL": "Todas", + "FILTER_AVAILABLE": "Disponibles", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Todos los idiomas", + "SAME_AS_ABOVE": "igual que la anterior", + "SHOWING_OF_TOTAL": "{{visible}} de {{total}}" } }, "INFORMATION": "Información", @@ -1071,6 +1077,10 @@ "PAUSE": "Pausar", "RESUME": "Reanudar", "CANCEL": "Cancelar", + "DOWNLOADED_REVEAL": "Descargado — mostrar en carpeta", + "DOWNLOADING_PERCENT": "Descargando… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Cancelar descarga", + "CANCEL_CONFIRM_MESSAGE": "¿Detener esta descarga? El archivo descargado parcialmente se descartará.", "RETRY": "Reintentar", "REMOVE": "Eliminar", "PLAY": "Reproducir", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ab787e7b4..d6748278f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Filtrer par liste de lecture", "NO_MATCHES": "Aucune liste de lecture ne correspond à ce filtre", "COPIES": "{{count}} copies dans cette liste de lecture", - "MORE_SOURCES": "+{{count}} dans la liste des sources" + "MORE_SOURCES": "+{{count}} dans la liste des sources", + "FILTER_ALL": "Toutes", + "FILTER_AVAILABLE": "Disponibles", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Toutes les langues", + "SAME_AS_ABOVE": "identique à la ligne au-dessus", + "SHOWING_OF_TOTAL": "{{visible}} sur {{total}}" } }, "INFORMATION": "Information", @@ -1071,6 +1077,10 @@ "PAUSE": "Mettre en pause", "RESUME": "Reprendre", "CANCEL": "Annuler", + "DOWNLOADED_REVEAL": "Téléchargé — afficher dans le dossier", + "DOWNLOADING_PERCENT": "Téléchargement… {{percent}} %", + "CANCEL_CONFIRM_TITLE": "Annuler le téléchargement", + "CANCEL_CONFIRM_MESSAGE": "Arrêter ce téléchargement ? Le fichier partiellement téléchargé sera supprimé.", "RETRY": "Réessayer", "REMOVE": "Supprimer", "PLAY": "Lire", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 32d8b4df3..5f742718c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Szűrés lejátszási lista alapján", "NO_MATCHES": "Nincs a szűrőnek megfelelő lejátszási lista", "COPIES": "{{count}} másolat ebben a listában", - "MORE_SOURCES": "+{{count}} további a forráslistában" + "MORE_SOURCES": "+{{count}} további a forráslistában", + "FILTER_ALL": "Összes", + "FILTER_AVAILABLE": "Elérhető", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Minden nyelv", + "SAME_AS_ABOVE": "ugyanaz, mint fent", + "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" } }, "INFORMATION": "Információk", @@ -1071,6 +1077,10 @@ "PAUSE": "Pause", "RESUME": "Resume", "CANCEL": "Mégse", + "DOWNLOADED_REVEAL": "Letöltve — megjelenítés a mappában", + "DOWNLOADING_PERCENT": "Letöltés… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Letöltés megszakítása", + "CANCEL_CONFIRM_MESSAGE": "Leállítja ezt a letöltést? A részben letöltött fájl törlődik.", "RETRY": "Újrapróbálkozás", "REMOVE": "Eltávolítás", "PLAY": "Lejátszás", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a42a6f76c..548a96245 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Filtra per playlist", "NO_MATCHES": "Nessuna playlist corrisponde al filtro", "COPIES": "{{count}} copie in questa playlist", - "MORE_SOURCES": "+{{count}} nell'elenco sorgenti" + "MORE_SOURCES": "+{{count}} nell'elenco sorgenti", + "FILTER_ALL": "Tutte", + "FILTER_AVAILABLE": "Disponibili", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Tutte le lingue", + "SAME_AS_ABOVE": "uguale alla riga sopra", + "SHOWING_OF_TOTAL": "{{visible}} di {{total}}" } }, "INFORMATION": "Informazioni", @@ -1071,6 +1077,10 @@ "PAUSE": "Pausa", "RESUME": "Riprendi", "CANCEL": "Annulla", + "DOWNLOADED_REVEAL": "Scaricato — mostra nella cartella", + "DOWNLOADING_PERCENT": "Download in corso… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Annulla download", + "CANCEL_CONFIRM_MESSAGE": "Interrompere questo download? Il file scaricato parzialmente verrà eliminato.", "RETRY": "Riprova", "REMOVE": "Rimuovi", "PLAY": "Riproduci", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 814ea5fdf..97939b47c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "プレイリストでフィルター", "NO_MATCHES": "このフィルターに一致するプレイリストはありません", "COPIES": "このプレイリスト内に{{count}}件", - "MORE_SOURCES": "他 {{count}} 件はソース一覧から" + "MORE_SOURCES": "他 {{count}} 件はソース一覧から", + "FILTER_ALL": "すべて", + "FILTER_AVAILABLE": "利用可能", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "すべての言語", + "SAME_AS_ABOVE": "上と同じ", + "SHOWING_OF_TOTAL": "{{total}}件中{{visible}}件" } }, "INFORMATION": "情報", @@ -1071,6 +1077,10 @@ "PAUSE": "一時停止", "RESUME": "再開", "CANCEL": "キャンセル", + "DOWNLOADED_REVEAL": "ダウンロード済み — フォルダで表示", + "DOWNLOADING_PERCENT": "ダウンロード中… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "ダウンロードをキャンセル", + "CANCEL_CONFIRM_MESSAGE": "このダウンロードを中止しますか?部分的にダウンロードされたファイルは破棄されます。", "RETRY": "再試行", "REMOVE": "削除", "PLAY": "再生", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 13e3a950c..ffc761341 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "재생목록으로 필터", "NO_MATCHES": "이 필터에 해당하는 재생목록이 없습니다", "COPIES": "이 재생목록에 소스 {{count}}개", - "MORE_SOURCES": "소스 목록에 {{count}}개 더 있음" + "MORE_SOURCES": "소스 목록에 {{count}}개 더 있음", + "FILTER_ALL": "전체", + "FILTER_AVAILABLE": "사용 가능", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "모든 언어", + "SAME_AS_ABOVE": "위와 동일", + "SHOWING_OF_TOTAL": "{{total}}개 중 {{visible}}개" } }, "INFORMATION": "정보", @@ -1071,6 +1077,10 @@ "PAUSE": "일시정지", "RESUME": "재개", "CANCEL": "취소", + "DOWNLOADED_REVEAL": "다운로드 완료 — 폴더에서 보기", + "DOWNLOADING_PERCENT": "다운로드 중… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "다운로드 취소", + "CANCEL_CONFIRM_MESSAGE": "이 다운로드를 중단하시겠습니까? 일부만 다운로드된 파일은 삭제됩니다.", "RETRY": "다시 시도", "REMOVE": "제거", "PLAY": "재생", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 9d7d3066e..ede809d6a 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Filter op afspeellijst", "NO_MATCHES": "Geen afspeellijsten komen overeen met dit filter", "COPIES": "{{count}} kopieën in deze afspeellijst", - "MORE_SOURCES": "+{{count}} meer in de bronnenlijst" + "MORE_SOURCES": "+{{count}} meer in de bronnenlijst", + "FILTER_ALL": "Alle", + "FILTER_AVAILABLE": "Beschikbaar", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Alle talen", + "SAME_AS_ABOVE": "zelfde als hierboven", + "SHOWING_OF_TOTAL": "{{visible}} van {{total}}" } }, "INFORMATION": "Informatie", @@ -1071,6 +1077,10 @@ "PAUSE": "Pauzeren", "RESUME": "Hervatten", "CANCEL": "Annuleren", + "DOWNLOADED_REVEAL": "Gedownload — toon in map", + "DOWNLOADING_PERCENT": "Downloaden… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Download annuleren", + "CANCEL_CONFIRM_MESSAGE": "Deze download stoppen? Het gedeeltelijk gedownloade bestand wordt verwijderd.", "RETRY": "Opnieuw proberen", "REMOVE": "Verwijderen", "PLAY": "Afspelen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index dcbcd69c4..8180bd95a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Filtruj według listy odtwarzania", "NO_MATCHES": "Nie znaleziono pasujących list odtwarzania", "COPIES": "{{count}} kopii na tej liście", - "MORE_SOURCES": "+{{count}} na liście źródeł" + "MORE_SOURCES": "+{{count}} na liście źródeł", + "FILTER_ALL": "Wszystkie", + "FILTER_AVAILABLE": "Dostępne", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Wszystkie języki", + "SAME_AS_ABOVE": "tak samo jak wyżej", + "SHOWING_OF_TOTAL": "{{visible}} z {{total}}" } }, "INFORMATION": "Informacje", @@ -1071,6 +1077,10 @@ "PAUSE": "Wstrzymaj", "RESUME": "Wznów", "CANCEL": "Anuluj", + "DOWNLOADED_REVEAL": "Pobrano — pokaż w folderze", + "DOWNLOADING_PERCENT": "Pobieranie… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Anuluj pobieranie", + "CANCEL_CONFIRM_MESSAGE": "Zatrzymać to pobieranie? Częściowo pobrany plik zostanie usunięty.", "RETRY": "Spróbuj ponownie", "REMOVE": "Usuń", "PLAY": "Odtwórz", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 42fb7a57b..882228c41 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Filtrar por playlist", "NO_MATCHES": "Nenhuma playlist corresponde a este filtro", "COPIES": "{{count}} cópias nesta playlist", - "MORE_SOURCES": "+{{count}} na lista de fontes" + "MORE_SOURCES": "+{{count}} na lista de fontes", + "FILTER_ALL": "Todas", + "FILTER_AVAILABLE": "Disponíveis", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Todos os idiomas", + "SAME_AS_ABOVE": "igual à linha acima", + "SHOWING_OF_TOTAL": "{{visible}} de {{total}}" } }, "INFORMATION": "Informação", @@ -1071,6 +1077,10 @@ "PAUSE": "Pausar", "RESUME": "Retomar", "CANCEL": "Cancelar", + "DOWNLOADED_REVEAL": "Baixado — mostrar na pasta", + "DOWNLOADING_PERCENT": "Baixando… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Cancelar download", + "CANCEL_CONFIRM_MESSAGE": "Parar este download? O arquivo baixado parcialmente será descartado.", "RETRY": "Tentar novamente", "REMOVE": "Remover", "PLAY": "Reproduzir", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 09b6e33fb..3cea2ebd9 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Фильтровать по плейлисту", "NO_MATCHES": "Ни один плейлист не соответствует фильтру", "COPIES": "Копий в этом плейлисте: {{count}}", - "MORE_SOURCES": "+{{count}} в списке источников" + "MORE_SOURCES": "+{{count}} в списке источников", + "FILTER_ALL": "Все", + "FILTER_AVAILABLE": "Доступные", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Все языки", + "SAME_AS_ABOVE": "как строкой выше", + "SHOWING_OF_TOTAL": "{{visible}} из {{total}}" } }, "INFORMATION": "Инфо", @@ -1071,6 +1077,10 @@ "PAUSE": "Пауза", "RESUME": "Возобновить", "CANCEL": "Отмена", + "DOWNLOADED_REVEAL": "Скачано — показать в папке", + "DOWNLOADING_PERCENT": "Скачивается… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "Отменить скачивание", + "CANCEL_CONFIRM_MESSAGE": "Остановить это скачивание? Частично скачанный файл будет удалён.", "RETRY": "Повторить", "REMOVE": "Удалить", "PLAY": "Воспроизвести", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 361923064..7f7c365e9 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "Oynatma listesine göre filtrele", "NO_MATCHES": "Bu filtreyle eşleşen oynatma listesi yok", "COPIES": "Bu oynatma listesinde {{count}} kopya", - "MORE_SOURCES": "Kaynak listesinde +{{count}} daha" + "MORE_SOURCES": "Kaynak listesinde +{{count}} daha", + "FILTER_ALL": "Tümü", + "FILTER_AVAILABLE": "Kullanılabilir", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "Tüm diller", + "SAME_AS_ABOVE": "üsttekiyle aynı", + "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" } }, "INFORMATION": "Bilgi", @@ -1071,6 +1077,10 @@ "PAUSE": "Duraklat", "RESUME": "Devam et", "CANCEL": "İptal", + "DOWNLOADED_REVEAL": "İndirildi — klasörde göster", + "DOWNLOADING_PERCENT": "İndiriliyor… %{{percent}}", + "CANCEL_CONFIRM_TITLE": "İndirmeyi iptal et", + "CANCEL_CONFIRM_MESSAGE": "Bu indirme durdurulsun mu? Kısmen indirilen dosya silinecek.", "RETRY": "Tekrar Dene", "REMOVE": "Kaldır", "PLAY": "Oynat", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index b405219f5..ee7ef14d4 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "按播放列表筛选", "NO_MATCHES": "未找到匹配的播放列表", "COPIES": "此播放列表中有 {{count}} 个版本", - "MORE_SOURCES": "其余 {{count}} 个在播放源列表中" + "MORE_SOURCES": "其余 {{count}} 个在播放源列表中", + "FILTER_ALL": "全部", + "FILTER_AVAILABLE": "可用", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "所有语言", + "SAME_AS_ABOVE": "与上行相同", + "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" } }, "INFORMATION": "信息", @@ -1071,6 +1077,10 @@ "PAUSE": "暂停", "RESUME": "继续", "CANCEL": "取消", + "DOWNLOADED_REVEAL": "已下载 — 在文件夹中显示", + "DOWNLOADING_PERCENT": "下载中… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "取消下载", + "CANCEL_CONFIRM_MESSAGE": "停止此下载?已下载的部分文件将被删除。", "RETRY": "重试", "REMOVE": "移除", "PLAY": "播放", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 5e1818cb6..b75377c9a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1040,7 +1040,13 @@ "FILTER_PLACEHOLDER": "依播放清單篩選", "NO_MATCHES": "沒有符合篩選條件的播放清單", "COPIES": "此播放清單中的 {{count}} 個版本", - "MORE_SOURCES": "播放來源清單中另有 {{count}} 個" + "MORE_SOURCES": "播放來源清單中另有 {{count}} 個", + "FILTER_ALL": "全部", + "FILTER_AVAILABLE": "可用", + "FILTER_HD": "HD+", + "ALL_LANGUAGES": "所有語言", + "SAME_AS_ABOVE": "與上行相同", + "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" } }, "INFORMATION": "資訊", @@ -1071,6 +1077,10 @@ "PAUSE": "暫停", "RESUME": "繼續", "CANCEL": "取消", + "DOWNLOADED_REVEAL": "已下載 — 在資料夾中顯示", + "DOWNLOADING_PERCENT": "下載中… {{percent}}%", + "CANCEL_CONFIRM_TITLE": "取消下載", + "CANCEL_CONFIRM_MESSAGE": "停止此下載?已下載的部分檔案將被刪除。", "RETRY": "重試", "REMOVE": "移除", "PLAY": "播放", diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 16c205a26..a99657739 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -123,11 +123,90 @@ the toggle off while it was on — and did nothing when flipped — would be wor than not offering it. The two numbers around it are deliberately different, because their sentences -are: the chip says "Sources N" and counts alternative **streams**, while the +are: the details-page chip says "Sources N" and counts every copy across all +playlists — the total the popover lists, route copy included — while the caption says "also found in N other playlists" and counts distinct -**playlists** (`alternativePlaylistCount`). The popover groups a portal's three -copies of a film under that one portal, so counting rows there would contradict -the list the caption invites the user to open. +**playlists** (`alternativePlaylistCount`). The popover groups a portal's +three copies of a film under that one portal, so a badge counting playlists +would contradict the "All (N)" chip inside, and a caption counting streams +would contradict the grouped list it invites the user to open. (The in-player +chip still passes the alternatives count: its sentence is "N other places to +go", not "N copies exist".) + +### The popover: anchoring and fit + +The chip opens the popover **above** itself, right edges aligned, through a +CDK connected overlay with flexible dimensions. That combination is the whole +fit contract: + +- The overlay's bounding box caps the panel to the space between the chip and + the viewport edge (16px margin), and inside the panel only the source LIST + scrolls — header, search, filter chips and the auto-failover footer are + `flex: none`, so they stay visible however short the panel gets. +- The overlay's `minHeight` (280px) exists for the FLIP decision, not for + sizing: when less than that remains above the chip, CDK skips the position + and the panel opens below instead, tail mirrored. As an inline pane style + that minimum would also stretch a two-source panel to 280px, so the pane + classes neutralize it visually (`.vod-sources-pop--*`). +- The position strategy re-applies itself on window resize, so the cap and + the flip both hold at any window proportion without component code. + +### The popover: filters + +The chip row composes with the host search (AND): **All (N)** resets the chip +filters and states the total copy count, **Available** keeps only sources +whose probe verified them, **HD+** keeps sources whose quality tag reads +1080p or better, and the language select is built from the `EN|`-style +prefixes actually present in the raw titles. Two of these encode a decision +worth writing down: + +- "Available" is strict: only `probe.status === 'ok'` passes. An unchecked + source must never pass a filter with that name — and because checks are + lazy, activating the filter while NO source anywhere has a verdict runs + check-all itself rather than filtering against nothing. +- "HD+" reads the quality tag whether stated (`api`) or guessed (`parsed`). + This is a deliberate, narrow exception to "guesses never drive decisions": + the filter is a browse aid the user operates while looking at the `~` + markers, not an automatic choice — and hiding a copy whose own title says + 1080p would make the filter look broken. Ranking and failover still go + through `factualOnly()` and cannot see guesses. + +When search or filters reduce the list, a muted "X of N" counter appears, and +groups with no matching copy disappear entirely. + +### The popover: copy rows + +Expanding "N copies in this playlist" lists EVERY copy of the group — +including the one the parent row already shows — as compact +`app-vod-source-copy-row`s indented under the parent's text column. The +playlist's name and monogram are not repeated: the primary text is the +provider's own raw stream title (mono), with the parsed `EN|`/`RU|` language +prefix promoted to a chip before it. The tag row shows only values that +DIFFER from the parent's copy (identical container/codec are omitted), so +what distinguishes a copy is the only thing on the line; the copy identical +to the parent shows a muted "same as above" note instead. The fuzzy-match +warning and the probe verdict are per-copy claims and always render. Each +copy row keeps its own check and play actions — play switches playback to +that exact stream and moves the active badge. + +### Checks: bounded, remembered + +"Check all" (and the Available filter's implicit one) fires one task per +unchecked source, and each task costs a live `get_vod_info` against a foreign +portal plus a HEAD at the stream. The host therefore runs at most **4** +checks concurrently (`createCheckQueue` in the vod-details folder); a row +flips to "checking" the moment it is asked for — which is also what dedupes a +second click while it waits for a slot. + +Settled verdicts (`ok`/`fail`, never `unknown`) are remembered for 10 minutes +in the root-provided `VodSourceProbeCacheService`, keyed by source id — i.e. +per movie **and** per source. The per-movie controller dies with its detail +page, so without this every navigation away and back would re-contact every +foreign portal on the next check-all; with it, a rediscovery seeds untouched +rows from the cache and shows "available · 0.3s" again without a request. The +cache is in-memory only: a probe verdict is a claim about the present, and +persisting it across app launches would let a stale "available" outlive the +stream it described. ## Identity diff --git a/libs/portal/shared/data-access/src/lib/multi-source/index.ts b/libs/portal/shared/data-access/src/lib/multi-source/index.ts index 5113803fc..623407c0d 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/index.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/index.ts @@ -1,4 +1,5 @@ export * from './vod-multi-source.controller'; export * from './vod-source-discovery.service'; export * from './vod-source-metadata.util'; +export * from './vod-source-probe-cache.service'; export * from './vod-source-resolver.service'; diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.spec.ts new file mode 100644 index 000000000..ede5d3ff2 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.spec.ts @@ -0,0 +1,53 @@ +import { VodSourceProbeCacheService } from './vod-source-probe-cache.service'; + +describe('VodSourceProbeCacheService', () => { + let service: VodSourceProbeCacheService; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-02T12:00:00Z')); + service = new VodSourceProbeCacheService(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const stamped = (status: 'ok' | 'fail') => ({ + status, + httpStatus: status === 'ok' ? 200 : 403, + probedAt: new Date().toISOString(), + }); + + it('remembers settled verdicts and returns them while fresh', () => { + service.store('p1:xtream:42', stamped('ok')); + service.store('p2:xtream:43', stamped('fail')); + + expect(service.get('p1:xtream:42')?.status).toBe('ok'); + expect(service.get('p2:xtream:43')?.status).toBe('fail'); + }); + + it('never remembers an inconclusive check', () => { + // Caching "could not check" would suppress a retry that might work. + service.store('p1:xtream:42', { + status: 'unknown', + probedAt: new Date().toISOString(), + }); + service.store('p1:xtream:43', { status: 'probing' }); + // A verdict with no timestamp cannot be aged, so it is not stored. + service.store('p1:xtream:44', { status: 'ok' }); + + expect(service.get('p1:xtream:42')).toBeNull(); + expect(service.get('p1:xtream:43')).toBeNull(); + expect(service.get('p1:xtream:44')).toBeNull(); + }); + + it('expires a verdict after the TTL', () => { + service.store('p1:xtream:42', stamped('ok')); + + jest.advanceTimersByTime(9 * 60_000); + expect(service.get('p1:xtream:42')?.status).toBe('ok'); + + jest.advanceTimersByTime(2 * 60_000); + expect(service.get('p1:xtream:42')).toBeNull(); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.ts new file mode 100644 index 000000000..289372e77 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-probe-cache.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces'; + +/** + * How long a verdict may stand in for a fresh check. Long enough that closing + * and reopening the popover — or leaving the movie and coming back — does not + * re-fire a round of HEAD requests, short enough that "available" still means + * something about the present. + */ +const PROBE_RESULT_TTL_MS = 10 * 60_000; + +/** + * Session-wide memory of availability checks, keyed by source id + * (`playlistId:portalType:contentId` — i.e. per movie **and** per source). + * + * The per-movie controller dies with its detail page, so without this every + * navigation away and back would silently forget what was just checked, and + * "check all" would re-resolve and re-contact every foreign portal each time + * the popover opens. Root-provided and in-memory only: a probe verdict is a + * claim about the present, so persisting it across app launches would let a + * stale "available" outlive the stream it described. + */ +@Injectable({ providedIn: 'root' }) +export class VodSourceProbeCacheService { + private readonly results = new Map(); + + /** + * Only settled verdicts are worth remembering. `unknown` means the check + * could not run — caching it would suppress a retry that might succeed. + */ + store(sourceId: string, result: VodSourceProbeResult): void { + if ( + (result.status === 'ok' || result.status === 'fail') && + result.probedAt + ) { + this.results.set(sourceId, result); + } + } + + /** A stored verdict, if it is still fresh; expired entries are dropped. */ + get(sourceId: string): VodSourceProbeResult | null { + const result = this.results.get(sourceId); + if (!result?.probedAt) { + return null; + } + + const age = Date.now() - Date.parse(result.probedAt); + if (!Number.isFinite(age) || age < 0 || age > PROBE_RESULT_TTL_MS) { + this.results.delete(sourceId); + return null; + } + + return result; + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts index 720006b3b..653897d79 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -101,6 +101,54 @@ export class VodDetailsDownloadsService { : false; }); + /** The manager's row for this movie, whatever state it is in. */ + private readonly activeDownload = computed(() => { + const context = this.context(); + return context + ? this.downloadsService.getDownloadByContent( + context.vodId, + context.playlistId, + 'vod' + ) + : undefined; + }); + + /** + * Real progress percent, or `null` while the total size is unknown — the + * button then spins its ring indeterminately instead of faking a number. + */ + readonly downloadPercent = computed(() => { + const item = this.activeDownload(); + return item && item.totalBytes + ? this.downloadsService.getProgressPercent(item) + : null; + }); + + /** Cancel whatever download is running or queued for this movie. */ + async cancelActive(): Promise { + const item = this.activeDownload(); + if (item) { + await this.downloadsService.cancelDownload(item.id); + } + } + + /** Open the finished file's location in the system file manager. */ + async revealDownloaded(): Promise { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + + const filePath = this.downloadsService.getDownloadedFilePath( + this.routeContentId(), + playlistId, + 'vod' + ); + if (filePath) { + await this.downloadsService.revealFile(filePath); + } + } + async resumePaused(): Promise { const playlistId = this.xtreamStore.currentPlaylist()?.id; if (!playlistId) { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 130bdf0dc..11f98ac39 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -416,9 +416,11 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(host.textContent).toContain('Movie B'); expect(host.textContent).toContain('DOWNLOADS.OFFLINE'); expect(host.textContent).toContain('DOWNLOADS.PLAY_LOCAL'); - expect(host.textContent).toContain( - 'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' - ); + // The download slot shows the done-state icon button once playable + // details arrive; provider playback now lives in the sources popover. + expect( + host.querySelector('[data-testid="vod-download-done"]') + ).not.toBeNull(); }); it('downloads the route movie with the metadata precedence rendered on screen', async () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 9987eaccd..250f71fed 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -90,6 +90,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { const isDownloaded = jest.fn().mockReturnValue(false); const getDownloadedFilePath = jest.fn(); const playDownload = jest.fn().mockResolvedValue(undefined); + const revealFile = jest.fn().mockResolvedValue({ success: true }); const toggleFavorite = jest.fn(); const sparseItem = (): SparseVodItem => ({ info: [], @@ -185,6 +186,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { } ); playDownload.mockClear(); + revealFile.mockClear(); toggleFavorite.mockClear(); await TestBed.configureTestingModule({ imports: [VodDetailsRouteComponent], @@ -248,6 +250,12 @@ describe('VodDetailsRouteComponent fallback actions', () => { resumeDownloadByContent: jest.fn(), startDownload, getDownloadedFilePath, + getDownloadByContent: jest.fn(), + getProgressPercent: jest.fn().mockReturnValue(0), + cancelDownload: jest.fn().mockResolvedValue({ + success: true, + }), + revealFile, playDownload, }, }, @@ -342,8 +350,12 @@ describe('VodDetailsRouteComponent fallback actions', () => { fixture.detectChanges(); const host = fixture.nativeElement as HTMLElement; expect(host.querySelector('button.play-btn')).not.toBeNull(); - expect(host.querySelector('button.favorite-btn')).not.toBeNull(); - expect(host.querySelector('button.download-btn')).not.toBeNull(); + expect( + host.querySelector('[data-testid="vod-favorite-toggle"]') + ).not.toBeNull(); + expect( + host.querySelector('[data-testid="vod-download-start"]') + ).not.toBeNull(); host.querySelector('button.play-btn')?.click(); fixture.detectChanges(); expect(constructVodStreamUrl).toHaveBeenCalledWith(item); @@ -385,8 +397,12 @@ describe('VodDetailsRouteComponent fallback actions', () => { 'button.play-btn--resume' )?.click(); fixture.detectChanges(); - host.querySelector('button.favorite-btn')?.click(); - host.querySelector('button.download-btn')?.click(); + host.querySelector( + '[data-testid="vod-favorite-toggle"]' + )?.click(); + host.querySelector( + '[data-testid="vod-download-start"]' + )?.click(); await fixture.whenStable(); expect(constructVodStreamUrl).toHaveBeenNthCalledWith(1, item); @@ -457,14 +473,14 @@ describe('VodDetailsRouteComponent fallback actions', () => { host.querySelector('button.play-btn'); expect(host.textContent).toContain('DOWNLOADS.OFFLINE'); expect(primary?.textContent).toContain('DOWNLOADS.PLAY_LOCAL'); + // The download slot flips to the done state: a checkmark whose click + // reveals the finished file, replacing the old labeled secondary. expect( - Array.from(host.querySelectorAll('button.download-btn')).some( - (button) => - button.textContent?.includes( - 'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' - ) - ) - ).toBe(true); + host.querySelector('[data-testid="vod-download-done"]') + ).not.toBeNull(); + expect( + host.querySelector('[data-testid="vod-download-start"]') + ).toBeNull(); primary?.click(); await fixture.whenStable(); @@ -608,7 +624,11 @@ describe('VodDetailsRouteComponent fallback actions', () => { expect(openResolvedPlayback).not.toHaveBeenCalled(); }); - it('keeps pinned provider playback behind the downloaded secondary action', async () => { + it('still offers provider playback for a downloaded movie with no alternatives', async () => { + // The primary button plays the local file once downloaded, and the + // sources chip only appears when another playlist carries the film — + // so without this control a single-playlist library loses every way + // to stream the provider's copy. selectedItem.set(sparseItem()); downloadsAvailable.set(true); isDownloaded.mockReturnValue(true); @@ -618,20 +638,39 @@ describe('VodDetailsRouteComponent fallback actions', () => { fixture.detectChanges(); - const providerButton = Array.from( - ( - fixture.nativeElement as HTMLElement - ).querySelectorAll('button') - ).find((button) => - button.textContent?.includes( - 'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' - ) + const host = fixture.nativeElement as HTMLElement; + expect(fixture.componentInstance.multiSource.hasAlternatives()).toBe( + false ); + const providerButton = host.querySelector( + '[data-testid="vod-play-provider"]' + ); + expect(providerButton).not.toBeNull(); + providerButton?.click(); await fixture.whenStable(); expect(playPinned).toHaveBeenCalled(); expect(playDownload).not.toHaveBeenCalled(); + }); + + it('reveals the finished file from the downloaded state button', async () => { + selectedItem.set(sparseItem()); + downloadsAvailable.set(true); + isDownloaded.mockReturnValue(true); + getDownloadedFilePath.mockReturnValue('/downloads/catalog-movie.mp4'); + + fixture.detectChanges(); + + (fixture.nativeElement as HTMLElement) + .querySelector( + '[data-testid="vod-download-done"]' + ) + ?.click(); + await fixture.whenStable(); + + expect(revealFile).toHaveBeenCalledWith('/downloads/catalog-movie.mp4'); + expect(playDownload).not.toHaveBeenCalled(); expect(constructVodStreamUrl).not.toHaveBeenCalled(); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index fdc276bb4..536d415e6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -75,67 +75,139 @@ } @if (playableItem) { - @if (!isFavorite()) { - - } @else { - - } + @let favoriteLabel = + (isFavorite() + ? 'PORTALS.REMOVE_FROM_FAVORITES' + : 'PORTALS.ADD_TO_FAVORITES' + ) | translate; + @if (isElectron() && !providerOnly()) { @if (isDownloaded()) { + + @if (externalPrimaryButtonState() === 'idle') { } - } @else if (isPausedDownload()) { - } @else if (isDownloading()) { + + @let downloadLabel = + isPausedDownload() + ? ('DOWNLOADS.RESUME' | translate) + : downloadPercent() !== null + ? ('DOWNLOADS.DOWNLOADING_PERCENT' + | translate: { percent: downloadPercent() }) + : ('DOWNLOADS.STATUS.DOWNLOADING' | translate); } @else { } } @if (multiSource.hasAlternatives()) { + { ?.textContent ).toContain('XTREAM.DETAIL_FALLBACK.STATUS'); expect(host.querySelector('button.play-btn')).not.toBeNull(); - expect(host.querySelector('button.favorite-btn')).not.toBeNull(); - expect(host.querySelector('button.download-btn')).toBeNull(); + expect(host.querySelector('[data-testid="vod-favorite-toggle"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="vod-download-start"]')).toBeNull(); }); it('keeps the fallback visible for a minimal info object', () => { @@ -299,7 +299,7 @@ describe('VodDetailsRouteComponent', () => { const host = fixture.nativeElement as HTMLElement; expect(host.querySelector('button.play-btn')).toBeNull(); - expect(host.querySelector('button.favorite-btn')).toBeNull(); + expect(host.querySelector('[data-testid="vod-favorite-toggle"]')).toBeNull(); selectedItem.set({ info: [], @@ -309,7 +309,7 @@ describe('VodDetailsRouteComponent', () => { fixture.detectChanges(); expect(host.querySelector('button.play-btn')).not.toBeNull(); - expect(host.querySelector('button.favorite-btn')).not.toBeNull(); + expect(host.querySelector('[data-testid="vod-favorite-toggle"]')).not.toBeNull(); }); it('keeps the full Xtream detail view when usable metadata exists', () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index ed5489759..80c463fb6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -13,12 +13,14 @@ import { import { toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, DetailTagsTemplateDirective, + DialogService, PortalDetailShellComponent, VodSourcesChipComponent, } from '@iptvnator/ui/components'; @@ -110,6 +112,7 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null { DetailMetaTemplateDirective, DetailTagsTemplateDirective, MatIcon, + MatTooltip, NgTemplateOutlet, PortalDetailShellComponent, SafePipe, @@ -127,6 +130,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly crossPortalSimilar = inject(CrossPortalSimilarService); private readonly xtreamStore = inject(XtreamStore); private readonly downloadsService = inject(DownloadsService); + private readonly dialogService = inject(DialogService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly playback = inject(VodDetailsPlaybackService); @@ -305,11 +309,30 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ); readonly isDownloading = this.downloads.isDownloading; readonly isPausedDownload = this.downloads.isPausedDownload; + readonly downloadPercent = this.downloads.downloadPercent; readonly isOfflinePrimary = computed( () => this.isDownloaded() && this.externalPrimaryButtonState() === 'idle' ); + /** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */ + readonly downloadRingCircumference = 2 * Math.PI * 15.5; + + /** + * Dash offset that leaves the arc at the real percent — or a fixed + * quarter arc when the total size is unknown and the ring spins instead. + */ + readonly downloadRingOffset = computed(() => { + const percent = this.downloadPercent(); + return percent === null + ? this.downloadRingCircumference * 0.75 + : this.downloadRingCircumference * (1 - percent / 100); + }); + + /** Drives the heart's brief scale pulse when favoriting toggles. */ + readonly favoritePulse = signal(false); + private favoritePulseTimer: ReturnType | null = null; + readonly trailerEmbedUrl = computed(() => youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer) ); @@ -461,6 +484,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + if (this.favoritePulseTimer) { + clearTimeout(this.favoritePulseTimer); + } this.xtreamStore.cancelDetailsRequest(); this.playback.closeInlinePlayer(); this.xtreamStore.setSelectedItem(null); @@ -580,6 +606,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { 'movie', this.selectedVodInfo()?.backdrop_path?.[0] ); + + this.favoritePulse.set(true); + if (this.favoritePulseTimer) { + clearTimeout(this.favoritePulseTimer); + } + this.favoritePulseTimer = setTimeout( + () => this.favoritePulse.set(false), + 220 + ); } getBackdropUrl(info: XtreamVodInfo): string | undefined { @@ -647,6 +682,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { return this.downloads.resumePaused(); } + /** + * A running download is destroyed by one click, so the icon button asks + * first — there is no label left to warn what the click does. + */ + promptCancelDownload(): void { + this.dialogService.openConfirmDialog({ + title: this.translateService.instant( + 'DOWNLOADS.CANCEL_CONFIRM_TITLE' + ), + message: this.translateService.instant( + 'DOWNLOADS.CANCEL_CONFIRM_MESSAGE' + ), + onConfirm: () => void this.downloads.cancelActive(), + }); + } + + revealDownloadedFile(): Promise { + return this.downloads.revealDownloaded(); + } + downloadVod(vodItem: XtreamVodDetails | null): Promise { return this.downloads.start(vodItem); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts index eb2922000..96c228ecd 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -217,6 +217,12 @@ export async function configureVodDetailsRouteTestBed( resumeDownloadByContent: stubs.resumeDownloadByContent, startDownload: stubs.startDownload, getDownloadedFilePath: stubs.getDownloadedFilePath, + getDownloadByContent: jest.fn(), + getProgressPercent: jest.fn().mockReturnValue(0), + cancelDownload: jest.fn().mockResolvedValue({ + success: true, + }), + revealFile: jest.fn().mockResolvedValue({ success: true }), playDownload: stubs.playDownload, }, }, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.spec.ts new file mode 100644 index 000000000..d4442278f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.spec.ts @@ -0,0 +1,71 @@ +import { createCheckQueue } from './vod-multi-source-check-queue'; + +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve)); + +describe('createCheckQueue', () => { + it('never runs more tasks than the limit at once', async () => { + const queue = createCheckQueue(2); + const gates = Array.from({ length: 5 }, createDeferred); + let running = 0; + let peak = 0; + + const all = gates.map((gate) => + queue(async () => { + running++; + peak = Math.max(peak, running); + await gate.promise; + running--; + }) + ); + await settle(); + expect(running).toBe(2); + + gates.forEach((gate) => gate.resolve()); + await Promise.all(all); + expect(peak).toBe(2); + expect(running).toBe(0); + }); + + it('starts waiting tasks in arrival order as slots free up', async () => { + const queue = createCheckQueue(1); + const first = createDeferred(); + const started: string[] = []; + + const a = queue(async () => { + started.push('a'); + await first.promise; + }); + const b = queue(async () => { + started.push('b'); + }); + const c = queue(async () => { + started.push('c'); + }); + + await settle(); + expect(started).toEqual(['a']); + + first.resolve(); + await Promise.all([a, b, c]); + expect(started).toEqual(['a', 'b', 'c']); + }); + + it('releases the slot when a task rejects', async () => { + const queue = createCheckQueue(1); + + await expect( + queue(() => Promise.reject(new Error('boom'))) + ).rejects.toThrow('boom'); + + // A leaked slot would leave this waiting forever. + await expect(queue(async () => 'after')).resolves.toBe('after'); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.ts new file mode 100644 index 000000000..27c098d4f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-check-queue.ts @@ -0,0 +1,34 @@ +/** + * A minimal promise concurrency gate for availability checks. + * + * "Check all" fires one task per unchecked source, and each task costs a live + * `get_vod_info` against a foreign portal plus a HEAD request at the stream. + * Launched unbounded, a movie found in fifteen playlists turns the popover + * into a fifteen-connection burst — enough for a touchy panel to rate-limit + * the account it came from. The gate keeps a fixed number in flight and + * starts the rest as slots free up, in arrival order. + */ +export function createCheckQueue( + limit: number +): (task: () => Promise) => Promise { + let active = 0; + const waiting: (() => void)[] = []; + + const release = (): void => { + active--; + waiting.shift()?.(); + }; + + return async (task: () => Promise): Promise => { + if (active >= limit) { + await new Promise((resolve) => waiting.push(resolve)); + } + + active++; + try { + return await task(); + } finally { + release(); + } + }; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts index 1b85679d3..fe687f79c 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -25,8 +25,10 @@ import { API_LANG_RUS, CURRENT_A_ID, MOVIE_A, + MOVIE_B, PARSED_DUB, PROBE_OK, + alternative, createDeferred, resolveWith, type AudioFacts, @@ -355,4 +357,104 @@ describe('VodMultiSourceHostService', () => { ); expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK); }); + + it('marks a row checking immediately and refuses to enqueue it twice', async () => { + await loadMovie([ALT_TWO]); + const gate = createDeferred(); + resolver.resolve.mockReturnValue(gate.promise); + + const first = service.check(ALT_TWO.id); + expect(rowFor(ALT_TWO.id)?.probe.status).toBe('probing'); + + // A second click on the same spinner must not queue a second probe. + const second = service.check(ALT_TWO.id); + gate.resolve(null); + await Promise.all([first, second]); + + expect(resolver.resolve).toHaveBeenCalledTimes(1); + }); + + it('runs at most four checks at once; the rest wait their turn', async () => { + const alts = [2, 3, 4, 5, 6].map(alternative); + await loadMovie(alts); + + const gates = new Map>(); + resolver.resolve.mockImplementation((candidate) => { + const gate = createDeferred(); + gates.set((candidate as VodSourceCandidate).id, gate); + return gate.promise; + }); + + const pending = alts.map((alt) => service.check(alt.id)); + await new Promise((resolve) => setTimeout(resolve)); + + // Every row spins from the moment it was asked for... + for (const alt of alts) { + expect(rowFor(alt.id)?.probe.status).toBe('probing'); + } + // ...but only four have actually started resolving. + expect(resolver.resolve).toHaveBeenCalledTimes(4); + + gates.get(alts[0].id)?.resolve(null); + await new Promise((resolve) => setTimeout(resolve)); + expect(resolver.resolve).toHaveBeenCalledTimes(5); + + for (const [, gate] of gates) { + gate.resolve(null); + } + await Promise.all(pending); + }); + + it('drops queued checks once the user opens another movie', async () => { + const alts = [2, 3, 4, 5, 6, 7].map(alternative); + await loadMovie(alts); + + const gates = new Map>(); + resolver.resolve.mockImplementation((candidate) => { + const gate = createDeferred(); + gates.set((candidate as VodSourceCandidate).id, gate); + return gate.promise; + }); + + const pending = alts.map((alt) => service.check(alt.id)); + await new Promise((resolve) => setTimeout(resolve)); + // Four in flight, two still waiting for a slot. + expect(resolver.resolve).toHaveBeenCalledTimes(4); + + // The user navigates away while those two are still queued. They are + // about a film that is no longer on screen, so they must not spend a + // resolve against a foreign portal when a slot frees up. + await loadMovie([], MOVIE_B); + + for (const [, gate] of gates) { + gate.resolve(null); + } + await Promise.all(pending); + + expect(resolver.resolve).toHaveBeenCalledTimes(4); + }); + + it('remembers verdicts across sessions instead of re-checking', async () => { + probes.probe.mockResolvedValue({ + ...PROBE_OK, + probedAt: new Date().toISOString(), + }); + await loadMovie([ALT_TWO]); + await service.check(ALT_TWO.id); + expect(rowFor(ALT_TWO.id)?.probe.status).toBe('ok'); + + // Another film resets the controller and its probe states entirely. + await loadMovie([], MOVIE_B); + expect(rowFor(ALT_TWO.id)).toBeUndefined(); + + // Returning to the movie finds the verdict remembered: the row shows + // "available" again without a single new request. + resolver.resolve.mockClear(); + probes.probe.mockClear(); + await loadMovie([ALT_TWO], MOVIE_A); + + expect(rowFor(ALT_TWO.id)?.probe.status).toBe('ok'); + expect(resolver.resolve).not.toHaveBeenCalled(); + expect(probes.probe).not.toHaveBeenCalled(); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index a2b28c9b4..8da659aa8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -10,6 +10,7 @@ import { applyApiMetadata, VodMultiSourceController, VodSourceDiscoveryService, + VodSourceProbeCacheService, VodSourceResolverService, } from '@iptvnator/portal/shared/data-access'; import { @@ -31,6 +32,7 @@ import { } from './vod-multi-source-session'; import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice'; import { createVodSourceCounts } from './vod-multi-source-counts'; +import { createCheckQueue } from './vod-multi-source-check-queue'; import { currentSourceRow } from './vod-multi-source-current-row'; import { probeSource } from './vod-multi-source-probe'; import { @@ -78,8 +80,16 @@ export class VodMultiSourceHostService { private readonly resolver = inject(VodSourceResolverService); private readonly pins = inject(VodSourcePinService); private readonly probes = inject(StreamProbeService); + private readonly probeCache = inject(VodSourceProbeCacheService); private readonly settingsStore = inject(SettingsStore); + /** + * At most this many availability checks in flight at once. Each check is + * a live `get_vod_info` plus a HEAD against a foreign portal, and "check + * all" would otherwise burst one connection per playlist the movie is in. + */ + private readonly checkQueue = createCheckQueue(4); + private controller = new VodMultiSourceController(); private bindings: VodMultiSourceBindings | null = null; private pinKeys: PinKeySets = { lookup: [], write: [] }; @@ -270,6 +280,37 @@ export class VodMultiSourceHostService { routeSource, publish: () => this.publish(), }); + + if (token === this.discoveryToken) { + this.seedCachedProbes(); + } + } + + /** + * Overlay remembered verdicts onto freshly discovered rows. + * + * The controller starts every row at `idle`, but a check made minutes ago + * — on this page or before navigating away — is still a usable answer. + * Only untouched rows are seeded: a verdict the session already holds is + * at least as fresh as the cache's. + */ + private seedCachedProbes(): void { + let seeded = false; + for (const source of this.controller.sources()) { + if (source.probe.status !== 'idle') { + continue; + } + + const cached = this.probeCache.get(source.id); + if (cached) { + this.controller.setProbe(source.id, cached); + seeded = true; + } + } + + if (seeded) { + this.publish(); + } } /** The pinned source, when it is not the one the route already plays. */ @@ -349,16 +390,57 @@ export class VodMultiSourceHostService { this.publish(); } - /** User-triggered availability check for one row. */ + /** + * User-triggered availability check for one row. + * + * Gated through the check queue so "check all" cannot burst a connection + * per playlist; a row already probing (or queued — its status flips to + * `probing` before the queue admits it) is not enqueued twice. + */ check(sourceId: string): Promise { - return probeSource(sourceId, { - controller: this.controller, - resolver: this.resolver, - probes: this.probes, - isCurrent: (session) => session === this.sessionToken, - session: this.sessionToken, - publish: () => this.publish(), - }); + const current = this._sources().find( + (source) => source.id === sourceId + ); + if (!current || current.probe.status === 'probing') { + return Promise.resolve(); + } + + // Marked before queueing, not inside the task: the row must show + // "checking" the moment the user asks, even while it waits for a slot + // — and the guard above reads this same status to dedupe. + this.controller.setProbe(sourceId, { status: 'probing' }); + this.publish(); + + // Bound to the session that ASKED for the check, not to whatever is + // current when a slot frees up. "Check all" can leave a dozen tasks + // waiting, and the user is free to open another movie meanwhile — + // those tasks are then about a film that is no longer on screen, so + // they are dropped before spending a resolve on a foreign portal. + // Both the controller and the session are read when a slot frees up, + // NOT captured here — and that is deliberate. + // + // Capturing them would look safer and is worse: `sessionToken` also + // moves when the movie identity is momentarily null, and that path + // does NOT replace the controller (only a different film does). A + // task dropped on a stale captured token would therefore abandon a + // row it had already marked `probing` in the controller still on + // screen, leaving it spinning for good. + // + // Read late, a queued task simply asks the CURRENT controller for the + // source: gone when the user moved to another film, so `probeSource` + // returns before it spends a request, and present when they came back + // to the same one, where finishing the check is exactly right. + return this.checkQueue(() => + probeSource(sourceId, { + controller: this.controller, + resolver: this.resolver, + probes: this.probes, + isCurrent: (session) => session === this.sessionToken, + session: this.sessionToken, + publish: () => this.publish(), + cacheResult: (id, result) => this.probeCache.store(id, result), + }) + ); } /** diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts index 910136796..3ae8a78c5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts @@ -3,6 +3,7 @@ import type { VodSourceResolverService, } from '@iptvnator/portal/shared/data-access'; import type { StreamProbeService } from '@iptvnator/services'; +import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces'; /** * The on-demand availability check for a single row. @@ -19,6 +20,12 @@ export interface ProbeSourceDeps { session: number; isCurrent: (session: number) => boolean; publish: () => void; + /** + * Remembers settled verdicts across controller lifetimes, so reopening + * the movie does not re-contact every foreign portal. Written even when + * the session moved on — the verdict is about the source, not the page. + */ + cacheResult?: (sourceId: string, result: VodSourceProbeResult) => void; } export async function probeSource( @@ -59,6 +66,7 @@ export async function probeSource( origin: resolved.playback.origin, } ); + deps.cacheResult?.(sourceId, result); if (!isCurrent(session)) { return; } diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 1b92a2a2b..dfb6719d0 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -16,6 +16,8 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component'; export * from './lib/progress-capsule/progress-capsule.component'; export * from './lib/resizable/resizable.directive'; export * from './lib/season-container/season-container.component'; +export * from './lib/vod-sources/vod-source-copy-row.component'; +export * from './lib/vod-sources/vod-source-filtering.util'; export * from './lib/vod-sources/vod-source-row.component'; export * from './lib/vod-sources/vod-source-tags'; export * from './lib/vod-sources/vod-sources-chip.component'; diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 1126fe6b3..e7cc676f3 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -462,6 +462,19 @@ @include detail-actions.secondary-action-button; } + // Theme-aware surface for the chip only: the shared mixin's white-alpha + // fill vanishes over a bright light-theme hero, and the chip now sits + // beside icon buttons that already wear the tag vars. The chip's own + // pressed state (.sources-chip--open) is more specific and still wins. + ::ng-deep app-vod-sources-chip .sources-chip { + background: var(--tag-bg); + border-color: var(--tag-border); + + &:hover { + background: var(--tag-border); + } + } + ::ng-deep .favorite-btn { @include detail-actions.favorite-button-state; } diff --git a/libs/ui/components/src/lib/vod-sources/_source-tag.scss b/libs/ui/components/src/lib/vod-sources/_source-tag.scss new file mode 100644 index 000000000..bd4a6d33a --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/_source-tag.scss @@ -0,0 +1,95 @@ +// Shared look of the metadata/status tags on multi-source rows. A mixin +// rather than a global class: the row and the copy-row are separate components +// under emulated encapsulation, and each needs the rules inside its own scope. +@mixin source-tag-base { + $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); + + .source-tag { + display: inline-flex; + align-items: center; + gap: 4px; + box-sizing: border-box; + font-family: $font-mono; + font-weight: 500; + font-size: 10.5px; + line-height: 1.5; + border-radius: 4px; + padding: 2px 6px; + border: 1px solid transparent; + color: var(--app-eyebrow-color); + background: var(--app-card-hover-bg); + white-space: nowrap; + + &--ok { + color: var(--vod-source-ok-fg); + background: var(--vod-source-ok-bg); + } + + &--warn { + color: var(--vod-source-warn-fg); + background: var(--vod-source-warn-bg); + } + + &--bad { + color: var(--vod-source-bad-fg); + background: var(--vod-source-bad-bg); + } + } + + .source-tag__icon { + font-size: 12px; + width: 12px; + height: 12px; + line-height: 12px; + } + + .source-tag__spinner { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid color-mix(in srgb, currentColor 30%, transparent); + border-top-color: currentColor; + animation: vod-source-spin 0.7s linear infinite; + } + + @keyframes vod-source-spin { + to { + transform: rotate(360deg); + } + } + + @media (prefers-reduced-motion: reduce) { + .source-tag__spinner { + animation-duration: 2.4s; + } + } +} + +// Feature-specific status colours. Light theme is the default (no .dark-theme +// class on body); the dark overrides follow, mirroring the pattern in +// libs/ui/styles/_detail-view.scss. The handoff's dark values are far too +// light on a white surface, so light theme uses darker equivalents that clear +// ~4.5:1 against the tinted tag background. +@mixin source-tag-theme-vars { + :host { + --vod-source-ok-fg: #15803d; + --vod-source-ok-bg: rgba(21, 128, 61, 0.1); + --vod-source-warn-fg: #b45309; + --vod-source-warn-bg: rgba(180, 83, 9, 0.1); + --vod-source-bad-fg: #b91c1c; + --vod-source-bad-bg: rgba(185, 28, 28, 0.1); + --vod-source-lang-fg: #6d4fc4; + --vod-source-lang-bg: rgba(109, 79, 196, 0.1); + } + + :host-context(.dark-theme) { + --vod-source-ok-fg: #4ade80; + --vod-source-ok-bg: rgba(74, 222, 128, 0.1); + --vod-source-warn-fg: #ffcf99; + --vod-source-warn-bg: rgba(255, 207, 153, 0.1); + --vod-source-bad-fg: #ff9aa6; + --vod-source-bad-bg: rgba(255, 91, 110, 0.1); + --vod-source-lang-fg: #b599ff; + --vod-source-lang-bg: rgba(181, 153, 255, 0.1); + } +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.html b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.html new file mode 100644 index 000000000..20c70f4ed --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.html @@ -0,0 +1,50 @@ +
+
+
+ @if (language(); as lang) { + + } + {{ title() }} + @if (source().isActive) { + {{ + (playbackLive() + ? 'PORTALS.MULTI_SOURCE.PLAYING' + : 'PORTALS.MULTI_SOURCE.CURRENT' + ) | translate + }} + } +
+ +
+ + @if (isPrimaryCopy()) { + {{ + 'PORTALS.MULTI_SOURCE.SAME_AS_ABOVE' | translate + }} + } +
+
+ +
+ + +
+
diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss new file mode 100644 index 000000000..64315b79a --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss @@ -0,0 +1,157 @@ +@use 'source-tag' as tag; + +$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); + +:host { + display: block; +} + +@include tag.source-tag-theme-vars; + +.copy-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 9px; + box-sizing: border-box; + transition: + background 0.15s ease, + outline-color 0.15s ease; + + &:hover { + background: var(--app-card-hover-bg); + } + + &--active, + &--active:hover { + background: var(--app-selection-surface); + outline: 1px solid var(--app-selection-color); + outline-offset: -1px; + } +} + +.copy-row__main { + flex: 1; + min-width: 0; +} + +.copy-row__title-line { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +// The parsed language prefix, promoted to a chip so it can be scanned down a +// column of copies instead of read out of every title. +.copy-row__lang { + flex: none; + font-family: $font-mono; + font-weight: 600; + font-size: 10px; + line-height: 1.5; + border-radius: 4px; + padding: 1px 6px; + color: var(--vod-source-lang-fg); + background: var(--vod-source-lang-bg); +} + +// The provider's own raw title — monospace, since it is data, not copy. +.copy-row__title { + font-family: $font-mono; + font-weight: 500; + font-size: 11.5px; + line-height: 1.4; + color: var(--app-on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.copy-row__badge { + flex: none; + font-family: $font-mono; + font-weight: 600; + font-size: 9px; + line-height: 1.3; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--app-selection-color); + background: color-mix(in srgb, var(--app-selection-color) 12%, transparent); + border-radius: 4px; + padding: 2px 6px; +} + +.copy-row__tags { + display: flex; + align-items: center; + gap: 5px; + margin-top: 3px; + flex-wrap: nowrap; + overflow: hidden; +} + +.copy-row__same { + font-family: $font-mono; + font-weight: 500; + font-size: 10px; + color: var(--app-eyebrow-color); +} + +.copy-row__actions { + flex: none; + display: flex; + align-items: center; + gap: 5px; +} + +.copy-row__action { + width: 26px; + height: 26px; + padding: 0; + box-sizing: border-box; + border-radius: 8px; + display: grid; + place-items: center; + border: 1px solid var(--app-widget-border); + background: transparent; + color: var(--app-eyebrow-color); + cursor: pointer; + transition: + color 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + line-height: 15px; + } + + &:hover { + color: var(--app-on-surface); + background: var(--app-card-hover-bg); + } + + &--play { + border-color: transparent; + background: var(--app-selection-color); + color: var(--mat-sys-on-primary, #fff); + + &:hover { + background: color-mix( + in srgb, + var(--app-selection-color) 86%, + var(--app-on-surface) + ); + color: var(--mat-sys-on-primary, #fff); + } + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: 2px; + } +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts new file mode 100644 index 000000000..59db2ec8c --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts @@ -0,0 +1,76 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; +import { titleLanguagePrefix } from './vod-source-filtering.util'; +import { VodSourceTagListComponent } from './vod-source-tag-list.component'; +import { buildVodSourceCopyTags } from './vod-source-tags'; + +/** + * One copy inside an expanded "N copies in this playlist" group. + * + * Deliberately NOT the full source row: the parent row already names the + * playlist, so repeating its monogram and label here would drown the only + * thing that distinguishes copies — the provider's own stream title. The + * title is the primary text, a parsed language prefix becomes a chip before + * it, and the tag row carries only what DIFFERS from the parent's copy. + */ +@Component({ + selector: 'app-vod-source-copy-row', + templateUrl: './vod-source-copy-row.component.html', + styleUrls: ['./vod-source-copy-row.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIcon, MatTooltip, TranslatePipe, VodSourceTagListComponent], +}) +export class VodSourceCopyRowComponent { + readonly source = input.required(); + /** The copy shown on the parent row — the baseline every diff is against. */ + readonly primary = input.required(); + /** See `VodSourceRowComponent.playbackLive`. */ + readonly playbackLive = input(false); + + readonly playRequested = output(); + readonly checkRequested = output(); + + readonly language = computed(() => + titleLanguagePrefix(this.source().rawTitle) + ); + + readonly title = computed( + () => this.source().rawTitle || `#${this.source().contentId}` + ); + + /** + * The parent's own copy appears in the expanded list too, so the group + * reads complete — but its tags would all be "no difference", which is + * exactly what the muted "same as above" note says in two words. + */ + readonly isPrimaryCopy = computed( + () => this.source().id === this.primary().id + ); + + readonly tags = computed(() => + this.isPrimaryCopy() + ? buildVodSourceCopyTags(this.source(), this.source()).filter( + (tag) => tag.id === 'probe' + ) + : buildVodSourceCopyTags(this.source(), this.primary()) + ); + + onPlay(event: Event): void { + event.stopPropagation(); + this.playRequested.emit(this.source().id); + } + + onCheck(event: Event): void { + event.stopPropagation(); + this.checkRequested.emit(this.source().id); + } +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts new file mode 100644 index 000000000..a4b7cf0d3 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts @@ -0,0 +1,165 @@ +import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; +import { + collectLanguagePrefixes, + EMPTY_VOD_SOURCE_FILTERS, + hasActiveVodSourceFilters, + noChecksRunYet, + qualityPixels, + sourceMatchesFilters, + titleLanguagePrefix, +} from './vod-source-filtering.util'; + +function source( + overrides: Partial = {} +): VodSourceDescriptor { + return { + id: 'playlist-1:xtream:42', + playlistId: 'playlist-1', + playlistName: 'SharkTV', + portalType: 'xtream', + contentId: 42, + rawTitle: 'Night of the Living Dead (1968)', + matchConfidence: 'exact', + year: 1968, + isActive: false, + isPinned: false, + isTried: false, + probe: { status: 'idle' }, + ...overrides, + }; +} + +describe('titleLanguagePrefix', () => { + it('reads the uppercase prefix before a pipe', () => { + expect(titleLanguagePrefix('EN| Night of the Living Dead')).toBe('EN'); + expect(titleLanguagePrefix('ALB |Some Movie')).toBe('ALB'); + expect(titleLanguagePrefix(' ru| Ночь')).toBe('RU'); + }); + + it('rejects titles whose pipe is not a language marker', () => { + // Five letters is a word, not a language code. + expect(titleLanguagePrefix('NIGHT| of something')).toBeNull(); + expect(titleLanguagePrefix('Night of the Living Dead')).toBeNull(); + expect(titleLanguagePrefix('4K| Movie')).toBeNull(); + expect(titleLanguagePrefix(undefined)).toBeNull(); + expect(titleLanguagePrefix('')).toBeNull(); + }); +}); + +describe('collectLanguagePrefixes', () => { + it('collects unique prefixes in first-seen order', () => { + const prefixes = collectLanguagePrefixes([ + source({ rawTitle: 'EN| A' }), + source({ rawTitle: 'RU| B' }), + source({ rawTitle: 'en| C' }), + source({ rawTitle: 'No prefix' }), + ]); + expect(prefixes).toEqual(['EN', 'RU']); + }); +}); + +describe('qualityPixels', () => { + it('reads NNNp labels and nothing else', () => { + expect(qualityPixels('1080p')).toBe(1080); + expect(qualityPixels('2160p')).toBe(2160); + expect(qualityPixels('576i')).toBe(576); + expect(qualityPixels('4K')).toBeNull(); + expect(qualityPixels(undefined)).toBeNull(); + }); +}); + +describe('sourceMatchesFilters', () => { + it('passes everything with no filters active', () => { + expect(hasActiveVodSourceFilters(EMPTY_VOD_SOURCE_FILTERS)).toBe(false); + expect( + sourceMatchesFilters(source(), EMPTY_VOD_SOURCE_FILTERS) + ).toBe(true); + }); + + it('available keeps only probe-verified sources', () => { + const filters = { ...EMPTY_VOD_SOURCE_FILTERS, availableOnly: true }; + expect( + sourceMatchesFilters( + source({ probe: { status: 'ok', latencyMs: 100 } }), + filters + ) + ).toBe(true); + // Unchecked must never pass a filter named "available". + for (const status of ['idle', 'unknown', 'probing', 'fail'] as const) { + expect( + sourceMatchesFilters(source({ probe: { status } }), filters) + ).toBe(false); + } + }); + + it('HD+ keeps stated or guessed quality at 1080p and above', () => { + const filters = { ...EMPTY_VOD_SOURCE_FILTERS, hdOnly: true }; + expect( + sourceMatchesFilters( + source({ quality: { value: '1080p', provenance: 'api' } }), + filters + ) + ).toBe(true); + expect( + sourceMatchesFilters( + source({ quality: { value: '2160p', provenance: 'parsed' } }), + filters + ) + ).toBe(true); + expect( + sourceMatchesFilters( + source({ quality: { value: '720p', provenance: 'api' } }), + filters + ) + ).toBe(false); + // No stated quality cannot claim to be HD. + expect(sourceMatchesFilters(source(), filters)).toBe(false); + }); + + it('language matches the parsed title prefix', () => { + const filters = { ...EMPTY_VOD_SOURCE_FILTERS, language: 'EN' }; + expect( + sourceMatchesFilters(source({ rawTitle: 'EN| Movie' }), filters) + ).toBe(true); + expect( + sourceMatchesFilters(source({ rawTitle: 'RU| Movie' }), filters) + ).toBe(false); + expect( + sourceMatchesFilters(source({ rawTitle: 'Movie' }), filters) + ).toBe(false); + }); + + it('filters compose with AND', () => { + const filters = { + availableOnly: true, + hdOnly: true, + language: 'EN', + }; + const matching = source({ + rawTitle: 'EN| Movie', + quality: { value: '1080p', provenance: 'api' }, + probe: { status: 'ok' as const }, + }); + expect(sourceMatchesFilters(matching, filters)).toBe(true); + expect( + sourceMatchesFilters( + { ...matching, probe: { status: 'idle' } }, + filters + ) + ).toBe(false); + }); +}); + +describe('noChecksRunYet', () => { + it('is true only while every probe is idle or unknown', () => { + expect( + noChecksRunYet([source(), source({ probe: { status: 'unknown' } })]) + ).toBe(true); + expect( + noChecksRunYet([source(), source({ probe: { status: 'probing' } })]) + ).toBe(false); + expect( + noChecksRunYet([source({ probe: { status: 'fail' } })]) + ).toBe(false); + }); +}); diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts new file mode 100644 index 000000000..9a3f2b662 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts @@ -0,0 +1,112 @@ +import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; + +/** + * Filter logic for the sources popover's chip row. + * + * Pure functions over descriptors so the menu component stays a thin view and + * the composition rules (filters AND each other and the host search) can be + * tested without a fixture. + */ + +export interface VodSourceFilterState { + /** Keep only copies whose probe verified them reachable. */ + availableOnly: boolean; + /** Keep only copies whose stated quality is 1080p or better. */ + hdOnly: boolean; + /** Keep only copies carrying this title language prefix; null = all. */ + language: string | null; +} + +export const EMPTY_VOD_SOURCE_FILTERS: VodSourceFilterState = { + availableOnly: false, + hdOnly: false, + language: null, +}; + +export function hasActiveVodSourceFilters( + filters: VodSourceFilterState +): boolean { + return filters.availableOnly || filters.hdOnly || filters.language !== null; +} + +/** + * `EN| Night of the Living Dead` → `EN`. + * + * Providers prefix the raw stream title with a short uppercase language tag + * before a pipe; that convention is the only language signal most panels + * emit. Anything longer than four letters is a title that happens to contain + * a pipe, not a language. + */ +export function titleLanguagePrefix( + rawTitle: string | null | undefined +): string | null { + const match = /^\s*([A-Za-z]{2,4})\s*\|/.exec(rawTitle ?? ''); + return match ? match[1].toUpperCase() : null; +} + +/** Every language prefix present in the list, in first-seen order. */ +export function collectLanguagePrefixes( + sources: readonly VodSourceDescriptor[] +): string[] { + const languages: string[] = []; + for (const source of sources) { + const language = titleLanguagePrefix(source.rawTitle); + if (language && !languages.includes(language)) { + languages.push(language); + } + } + return languages; +} + +/** + * `1080p` → 1080. Only the `NNNp` labels both the parser and the API + * normalization emit are readable; anything else is not a comparable quality. + */ +export function qualityPixels(value: string | undefined): number | null { + const match = /^(\d{3,4})[pi]$/i.exec(value ?? ''); + return match ? Number(match[1]) : null; +} + +/** + * Whether one copy survives the chip filters. + * + * HD+ reads the quality tag whether stated or guessed — this is a browse + * filter the user drives, not an automatic decision, and hiding a copy whose + * own title says 1080p would make the filter look broken. "Available" is + * stricter: only a verified probe counts, because an unchecked source must + * never pass a filter named available. + */ +export function sourceMatchesFilters( + source: VodSourceDescriptor, + filters: VodSourceFilterState +): boolean { + if (filters.availableOnly && source.probe.status !== 'ok') { + return false; + } + + if (filters.hdOnly) { + const pixels = qualityPixels(source.quality?.value); + if (pixels === null || pixels < 1080) { + return false; + } + } + + if ( + filters.language !== null && + titleLanguagePrefix(source.rawTitle) !== filters.language + ) { + return false; + } + + return true; +} + +/** True while no source has a probe verdict or one in flight. */ +export function noChecksRunYet( + sources: readonly VodSourceDescriptor[] +): boolean { + return sources.every( + (source) => + source.probe.status === 'idle' || source.probe.status === 'unknown' + ); +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-groups.util.ts b/libs/ui/components/src/lib/vod-sources/vod-source-groups.util.ts index 6b748f22d..f73214d65 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-groups.util.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-source-groups.util.ts @@ -55,21 +55,3 @@ export function groupVodSources( })); } -/** - * What tells two copies from the same playlist apart. - * - * Their tags are usually identical (both parsed from near-identical titles), so - * the provider's own raw title is the only honest discriminator left. Falls - * back to the stream id when even the titles match. - */ -export function variantLabel( - source: VodSourceDescriptor, - siblings: readonly VodSourceDescriptor[] -): string { - const titles = new Set(siblings.map((s) => s.rawTitle)); - if (titles.size > 1 && source.rawTitle) { - return source.rawTitle; - } - - return `#${source.contentId}`; -} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html index 95e1b0ccb..9b563f22c 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html +++ b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html @@ -19,36 +19,7 @@
- @for (tag of tags(); track tag.id) { - - @if (tag.spinner) { - - } @else if (tag.icon) { - {{ - tag.icon - }} - } - @if (tag.prefix) { - {{ tag.prefix }} - } - @if (tag.labelKey) { - {{ tag.labelKey | translate }} - } @else { - {{ tag.text }} - } - @if (tag.suffix) { - {{ tag.suffix }} - } - - } + @if (showCheckChip()) {
-
- @if (showFilter()) { -
- search - -
+ @if (showFilter() || uncheckedCount() > 0) { +
+ @if (showFilter()) { +
+ search + +
+ } + @if (uncheckedCount() > 0) { + + } +
+ } + +
+ + + + + @if (showReducedCount()) { + {{ + 'PORTALS.MULTI_SOURCE.SHOWING_OF_TOTAL' + | translate + : { visible: visibleCount(), total: totalCount() } + }} } - @if (uncheckedCount() > 0) { -
@@ -65,6 +132,7 @@ @if (isExpanded(group.key)) { -
- @for ( - variant of group.sources; - track variant.id; - let i = $index - ) { - @if (variant.id !== group.primary.id) { -
- {{ variantLabelFor(group, i) }} - -
- } +
+ @for (copy of group.sources; track copy.id) { + }
} diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss index 1fd37be64..46ba4092d 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss @@ -1,13 +1,23 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); +// The popover is height-capped by its overlay to the space beside the anchor, +// so everything from the host down must let the list — and only the list — +// give way and scroll. Header, toolbar, chips and footer stay visible. :host { - display: block; + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; } // Self-sufficient surface: the popover body is also dropped straight into the // playback error screen, where there is no menu panel to supply a background. .sources-menu { box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; padding: 8px; border-radius: 14px; background: var(--app-widget-bg, #161a22); @@ -16,6 +26,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); } .sources-menu__header { + flex: none; display: flex; align-items: baseline; justify-content: space-between; @@ -49,13 +60,23 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); display: flex; flex-direction: column; gap: 2px; - // Compact rows mean this now shows ~9 sources instead of ~3. Viewport-aware - // so a short window still gets a usable list rather than a clipped one. + // The one scrollable region. The overlay's bounding box caps the panel; + // the viewport fallback keeps a detached host (error screen) usable. + flex: 1 1 auto; + min-height: 80px; max-height: min(56vh, 460px); overflow-y: auto; + overscroll-behavior: contain; +} + +// Inside the height-capped overlay the bounding box is the limit; the local +// viewport cap above would otherwise stop the list from using granted space. +:host-context(.cdk-overlay-pane) .sources-menu__list { + max-height: none; } .sources-menu__footer { + flex: none; display: flex; align-items: center; gap: 8px; @@ -154,6 +175,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); // Controls live on their own line, below the heading, so nothing in the header // can be mistaken for a column label. .sources-menu__toolbar { + flex: none; display: flex; align-items: center; gap: 8px; @@ -171,6 +193,98 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); margin-left: auto; } +// ── Filter chips ──────────────────────────────────────────────────────────── + +.sources-menu__chips { + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 0 14px 11px; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + border: 1px solid var(--app-widget-border); + border-radius: 999px; + background: none; + color: var(--app-eyebrow-color); + font: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: + color 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; + + &:hover { + color: var(--app-on-surface); + } + + &--on { + background: color-mix( + in srgb, + var(--app-selection-color) 16%, + transparent + ); + border-color: var(--app-selection-color); + color: var(--app-selection-color); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: 2px; + } +} + +.filter-chip__count { + font-family: $font-mono; + font-weight: 700; + font-size: 9.5px; + opacity: 0.7; +} + +.filter-chip__icon { + font-size: 13px; + width: 13px; + height: 13px; + line-height: 13px; +} + +.sources-menu__reduced { + margin-left: 6px; + font-family: $font-mono; + font-size: 10px; + color: var(--app-eyebrow-color); + white-space: nowrap; +} + +.sources-menu__lang { + margin-left: auto; + max-width: 40%; + padding: 5px 9px; + border: 1px solid var(--app-widget-border); + border-radius: 999px; + background: var(--app-card-hover-bg); + color: var(--app-eyebrow-color); + font: inherit; + font-size: 11px; + font-weight: 600; + outline: 0; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: 2px; + } +} + +// ── Copies expansion ──────────────────────────────────────────────────────── + .sources-menu__variants-toggle { display: inline-flex; align-items: center; @@ -200,21 +314,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); } } -.sources-menu__variants { - // A left rail is the cheapest way to show these belong to the row above - // without nesting another card inside the panel. - margin: 0 0 4px 20px; - padding-left: 12px; - border-left: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.08)); -} - -.sources-menu__variant-label { - display: block; - padding: 2px 10px 0; - color: var(--app-eyebrow-color); - font-size: 10.5px; - font-family: $font-mono; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.sources-menu__copies { + // Indented to sit under the parent's text column: the playlist already + // named itself on the row above, so its copies hang off that name. + margin: 0 0 4px; + padding-left: 47px; } diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts index d7a083253..d5d42e5dc 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts @@ -31,6 +31,16 @@ class FakeTranslateLoader implements TranslateLoader { PROBE_FAIL: 'unavailable', PROBE_CHECKING: 'checking', OPEN_SOURCES: 'Show alternative sources', + CHECK_ALL: 'check all', + FILTER_PLACEHOLDER: 'Filter by playlist', + NO_MATCHES: 'No playlists match that filter', + COPIES: '{{count}} copies in this playlist', + FILTER_ALL: 'All', + FILTER_AVAILABLE: 'Available', + FILTER_HD: 'HD+', + ALL_LANGUAGES: 'All languages', + SAME_AS_ABOVE: 'same as above', + SHOWING_OF_TOTAL: '{{visible}} of {{total}}', }, }, }); @@ -280,6 +290,241 @@ describe('VodSourcesMenuComponent', () => { expect(badge()).toBe('Playing'); }); + describe('filter chips', () => { + function chipByText(text: string) { + return fixture.debugElement + .queryAll(By.css('.filter-chip')) + .find((chip) => + (chip.nativeElement.textContent ?? '').includes(text) + ); + } + + function rowNames(): string[] { + return fixture.debugElement + .queryAll(By.css('.source-row__name')) + .map((el) => (el.nativeElement.textContent ?? '').trim()); + } + + it('HD+ keeps only groups with a copy at 1080p or better', () => { + render([ + createSource({ + id: 'a:xtream:1', + playlistId: 'a', + playlistName: 'HD Portal', + quality: { value: '1080p', provenance: 'api' }, + }), + createSource({ + id: 'b:xtream:2', + playlistId: 'b', + playlistName: 'SD Portal', + quality: { value: '720p', provenance: 'api' }, + }), + ]); + + chipByText('HD+')?.nativeElement.click(); + fixture.detectChanges(); + + expect(rowNames()).toEqual(['HD Portal']); + expect(fixture.nativeElement.textContent).toContain('1 of 2'); + }); + + it('activating Available with no verdicts anywhere runs check-all', () => { + const checked: string[] = []; + fixture.componentInstance.checkRequested.subscribe((id: string) => + checked.push(id) + ); + render([ + createSource({ id: 'a:xtream:1', playlistId: 'a' }), + createSource({ + id: 'b:xtream:2', + playlistId: 'b', + probe: { status: 'unknown' }, + }), + ]); + + chipByText('Available')?.nativeElement.click(); + fixture.detectChanges(); + + expect(checked).toEqual(['a:xtream:1', 'b:xtream:2']); + }); + + it('does not re-run check-all once any verdict exists', () => { + const checked: string[] = []; + fixture.componentInstance.checkRequested.subscribe((id: string) => + checked.push(id) + ); + render([ + createSource({ + id: 'a:xtream:1', + playlistId: 'a', + probe: { status: 'ok', latencyMs: 90 }, + }), + createSource({ id: 'b:xtream:2', playlistId: 'b' }), + ]); + + chipByText('Available')?.nativeElement.click(); + fixture.detectChanges(); + + expect(checked).toEqual([]); + // Only the verified source survives the filter. + expect( + fixture.debugElement.queryAll(By.css('app-vod-source-row')) + .length + ).toBe(1); + }); + + it('builds the language select from parsed title prefixes and filters by it', () => { + render([ + createSource({ + id: 'a:xtream:1', + playlistId: 'a', + playlistName: 'English Portal', + rawTitle: 'EN| Dune (2021)', + }), + createSource({ + id: 'b:xtream:2', + playlistId: 'b', + playlistName: 'Russian Portal', + rawTitle: 'RU| Дюна (2021)', + }), + ]); + + const select = fixture.debugElement.query( + By.css('.sources-menu__lang') + ); + const options = select.nativeElement + .textContent as string; + expect(options).toContain('EN'); + expect(options).toContain('RU'); + + fixture.componentInstance.setLanguageFilter('RU'); + fixture.detectChanges(); + + expect(rowNames()).toEqual(['Russian Portal']); + }); + + it('the All chip resets every filter and restores the full list', () => { + render([ + createSource({ + id: 'a:xtream:1', + playlistId: 'a', + quality: { value: '1080p', provenance: 'api' }, + }), + createSource({ id: 'b:xtream:2', playlistId: 'b' }), + ]); + + chipByText('HD+')?.nativeElement.click(); + fixture.detectChanges(); + expect( + fixture.debugElement.queryAll(By.css('app-vod-source-row')) + .length + ).toBe(1); + + chipByText('All')?.nativeElement.click(); + fixture.detectChanges(); + expect( + fixture.debugElement.queryAll(By.css('app-vod-source-row')) + .length + ).toBe(2); + expect(fixture.nativeElement.textContent).not.toContain('of 2'); + }); + }); + + describe('copies expansion', () => { + function expandFirstGroup(): void { + fixture.debugElement + .query(By.css('.sources-menu__variants-toggle')) + .nativeElement.click(); + fixture.detectChanges(); + } + + const primaryCopy = createSource({ + id: 'a:xtream:1', + playlistId: 'a', + playlistName: 'Shark Portal', + rawTitle: 'EN| Night of the Living Dead (1968)', + quality: { value: '720p', provenance: 'api' }, + container: { value: 'mp4', provenance: 'api' }, + isActive: true, + }); + const hdCopy = createSource({ + id: 'a:xtream:2', + playlistId: 'a', + playlistName: 'Shark Portal', + rawTitle: 'RU| Ночь живых мертвецов (1968)', + quality: { value: '1080p', provenance: 'api' }, + container: { value: 'mp4', provenance: 'api' }, + }); + + it('lists every copy with a language chip and the raw stream title', () => { + render([primaryCopy, hdCopy]); + expandFirstGroup(); + + const copies = fixture.debugElement.queryAll( + By.css('app-vod-source-copy-row') + ); + expect(copies.length).toBe(2); + + const langs = fixture.debugElement + .queryAll(By.css('.copy-row__lang')) + .map((el) => el.nativeElement.textContent.trim()); + expect(langs).toEqual(['EN', 'RU']); + + const titles = fixture.debugElement + .queryAll(By.css('.copy-row__title')) + .map((el) => el.nativeElement.textContent.trim()); + expect(titles).toEqual([ + 'EN| Night of the Living Dead (1968)', + 'RU| Ночь живых мертвецов (1968)', + ]); + // The playlist names itself once, on the parent row. + const nameCount = fixture.debugElement.queryAll( + By.css('.source-row__name') + ).length; + expect(nameCount).toBe(1); + }); + + it('shows only tags that differ from the parent copy', () => { + render([primaryCopy, hdCopy]); + expandFirstGroup(); + + const copyTags = fixture.debugElement + .queryAll(By.css('app-vod-source-copy-row .source-tag')) + .map((el) => (el.nativeElement.textContent ?? '').trim()); + // The HD copy differs in quality only; the shared container and + // the identical primary copy contribute no tags at all. + expect(copyTags).toEqual(['1080p']); + }); + + it('marks the parent-identical copy with "same as above" instead of tags', () => { + render([primaryCopy, hdCopy]); + expandFirstGroup(); + + const sameNotes = fixture.debugElement.queryAll( + By.css('.copy-row__same') + ); + expect(sameNotes.length).toBe(1); + expect(sameNotes[0].nativeElement.textContent).toContain( + 'same as above' + ); + }); + + it('plays the exact copy that was clicked', () => { + const played: string[] = []; + fixture.componentInstance.playRequested.subscribe((id: string) => + played.push(id) + ); + render([primaryCopy, hdCopy]); + expandFirstGroup(); + + fixture.debugElement + .queryAll(By.css('.copy-row__action--play'))[1] + .nativeElement.click(); + + expect(played).toEqual(['a:xtream:2']); + }); + }); + it('shows the match kind in the header, or the resume label when given', () => { fixture.componentRef.setInput('sources', [createSource()]); fixture.componentRef.setInput('matchKind', 'tmdb'); diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts index 81d373134..ebe6a15c5 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts @@ -18,21 +18,30 @@ import { VodSourceDescriptor, VodSourceMatchKind, } from '@iptvnator/shared/interfaces'; -import { VodSourceRowComponent } from './vod-source-row.component'; +import { VodSourceCopyRowComponent } from './vod-source-copy-row.component'; import { - groupVodSources, - variantLabel, - type VodSourceGroup, -} from './vod-source-groups.util'; + collectLanguagePrefixes, + EMPTY_VOD_SOURCE_FILTERS, + hasActiveVodSourceFilters, + noChecksRunYet, + sourceMatchesFilters, + type VodSourceFilterState, +} from './vod-source-filtering.util'; +import { VodSourceRowComponent } from './vod-source-row.component'; +import { groupVodSources } from './vod-source-groups.util'; -/** Below this many sources, a filter box costs more than it saves. */ +/** Below this many sources, a search box costs more than it saves. */ const FILTER_FROM = 8; /** - * Body of the "sources" popover: header, the source rows, and the auto-failover - * footer. Rendered inside the chip's anchored menu on the details page and - * reused as-is by the in-player popover and the playback error screen, so it + * Body of the "sources" popover: header, search + filter chips, the source + * rows, and the auto-failover footer. Rendered inside the chip's anchored + * menu on the details page and reused as-is by the in-player popover, so it * owns no anchoring of its own. + * + * Laid out as a flex column whose only scrollable region is the source list — + * the host caps the popover's height to the space beside its anchor, and the + * header, search, chips and footer must stay visible while the list scrolls. */ @Component({ selector: 'app-vod-sources-menu', @@ -44,6 +53,7 @@ const FILTER_FROM = 8; MatIcon, MatSlideToggle, TranslatePipe, + VodSourceCopyRowComponent, VodSourceRowComponent, ], }) @@ -76,16 +86,31 @@ export class VodSourcesMenuComponent { readonly checkRequested = output(); readonly autoFailoverToggled = output(); - /** Filter text; only reachable once the list is long enough to need it. */ + /** Search text; only reachable once the list is long enough to need it. */ readonly filter = signal(''); + /** The chip filters — compose with each other and the search (AND). */ + readonly filters = signal(EMPTY_VOD_SOURCE_FILTERS); + /** * Progressive disclosure: a search box for four sources is chrome nobody * asked for, but scanning eleven by eye is worse. */ readonly showFilter = computed(() => this.sources().length > FILTER_FROM); - readonly visibleSources = computed(() => { + readonly totalCount = computed(() => this.sources().length); + + /** Options for the language select — only prefixes actually present. */ + readonly languages = computed(() => + collectLanguagePrefixes(this.sources()) + ); + + readonly filtersActive = computed(() => + hasActiveVodSourceFilters(this.filters()) + ); + + /** Sources surviving the search box; what "check all" acts on. */ + private readonly searchedSources = computed(() => { const query = this.filter().trim().toLowerCase(); const sources = this.sources(); if (!query || !this.showFilter()) { @@ -99,6 +124,21 @@ export class VodSourcesMenuComponent { ); }); + /** Sources surviving search AND chip filters — what the list shows. */ + readonly visibleSources = computed(() => { + const filters = this.filters(); + return this.searchedSources().filter((source) => + sourceMatchesFilters(source, filters) + ); + }); + + readonly visibleCount = computed(() => this.visibleSources().length); + + /** "X of N", shown only once something actually reduced the list. */ + readonly showReducedCount = computed( + () => this.visibleCount() !== this.totalCount() + ); + /** * Sources collapsed per playlist. One playlist often lists the same film * several times; flat they are indistinguishable rows. @@ -121,15 +161,10 @@ export class VodSourcesMenuComponent { }); } - /** Distinguishes copies within a group — usually the provider's own title. */ - variantLabelFor(group: VodSourceGroup, index: number): string { - return variantLabel(group.sources[index], group.sources); - } - /** Anything still worth probing — drives the "check all" affordance. */ readonly uncheckedCount = computed( () => - this.visibleSources().filter( + this.searchedSources().filter( (source) => source.probe.status === 'idle' || source.probe.status === 'unknown' @@ -142,16 +177,47 @@ export class VodSourcesMenuComponent { : 'PORTALS.MULTI_SOURCE.MATCHED_BY_TITLE' ); + clearFilters(): void { + this.filters.set(EMPTY_VOD_SOURCE_FILTERS); + } + + /** + * Checks are lazy, so "show me what is available" would filter against + * nothing the first time. Turning the filter on with no verdicts anywhere + * therefore runs check-all itself — the question implies it. + */ + toggleAvailableFilter(): void { + const next = !this.filters().availableOnly; + this.filters.update((filters) => ({ ...filters, availableOnly: next })); + if (next && noChecksRunYet(this.sources())) { + this.checkAllVisible(); + } + } + + toggleHdFilter(): void { + this.filters.update((filters) => ({ + ...filters, + hdOnly: !filters.hdOnly, + })); + } + + setLanguageFilter(language: string | null): void { + this.filters.update((filters) => ({ ...filters, language })); + } + onAutoFailoverChange(event: MatSlideToggleChange): void { this.autoFailoverToggled.emit(event.checked); } /** * Checking eleven sources one row at a time is busywork the app can do - * itself; the probe service already dedupes and caches. + * itself; the host caps how many probes actually run at once. + * + * Based on the SEARCHED list, not the chip-filtered one: the "Available" + * chip calls this precisely when its own filter would hide everything. */ checkAllVisible(): void { - for (const source of this.visibleSources()) { + for (const source of this.searchedSources()) { if ( source.probe.status === 'idle' || source.probe.status === 'unknown' diff --git a/libs/ui/styles/_detail-view-actions.scss b/libs/ui/styles/_detail-view-actions.scss index 890768c31..096457523 100644 --- a/libs/ui/styles/_detail-view-actions.scss +++ b/libs/ui/styles/_detail-view-actions.scss @@ -107,6 +107,131 @@ } } +// Icon-only secondary actions (Favorites, Download) from the sources-popup +// handoff: square ghosts at the primary CTA's height, so the label-less pair +// reads as satellites of Play instead of competing with it. The translucent +// surface keeps the border — over bright hero art these buttons otherwise +// lose their edges. +@mixin icon-action-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 64px; + height: 64px; + min-height: 64px; + padding: 0; + // Theme-aware ghost surface: over a bright light-theme hero a white-alpha + // fill and border simply vanish, which is exactly the lost-edges failure + // the border exists to prevent. The tag vars already flip per theme. + background: var(--tag-bg, rgba(255, 255, 255, 0.08)); + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12)); + border-radius: 16px; + cursor: pointer; + transition: + background 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; + + &:hover { + background: var(--tag-border, rgba(255, 255, 255, 0.14)); + border-color: var(--text-muted, rgba(255, 255, 255, 0.5)); + color: var(--text-primary, #fff); + } + + mat-icon { + font-size: 26px; + width: 26px; + height: 26px; + } +} + +@mixin icon-action-button-states { + // Favorited: same ghost surface — only the glyph fills and turns red. + &--favorite.is-active { + color: #ff5b6e; + + &:hover { + color: #ff7b8b; + } + } + + &--favorite.is-pulsing mat-icon { + animation: icon-action-pulse 180ms ease; + } + + // Downloaded: green check; the click now opens the file's location. + &--done { + color: #4ade80; + + &:hover { + color: #6ee7a0; + } + } +} + +// The download button's circular progress: track + value arc, spinning when +// the total size — and so the percent — is unknown. +@mixin download-progress-ring { + .download-ring { + width: 30px; + height: 30px; + transform: rotate(-90deg); + + &--indeterminate { + animation: download-ring-spin 0.9s linear infinite; + } + } + + .download-ring__track { + fill: none; + stroke: var(--tag-border, rgba(255, 255, 255, 0.12)); + stroke-width: 3; + } + + .download-ring__value { + fill: none; + stroke: var(--accent-color, #4f8eff); + stroke-width: 3; + stroke-linecap: round; + transition: stroke-dashoffset 0.3s ease; + } + + .icon-action-btn__center { + position: absolute; + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + } +} + +@mixin icon-action-keyframes { + @keyframes icon-action-pulse { + 50% { + transform: scale(1.15); + } + } + + @keyframes download-ring-spin { + to { + transform: rotate(270deg); + } + } + + @media (prefers-reduced-motion: reduce) { + .icon-action-btn mat-icon { + animation: none !important; + } + + .download-ring--indeterminate { + animation-duration: 3.6s; + } + } +} + @mixin mobile-action-buttons { .play-btn, .favorite-btn, diff --git a/libs/ui/styles/_detail-view.scss b/libs/ui/styles/_detail-view.scss index 891b2af8a..f82fb9446 100644 --- a/libs/ui/styles/_detail-view.scss +++ b/libs/ui/styles/_detail-view.scss @@ -512,6 +512,14 @@ @include detail-actions.download-button-state; } + .icon-action-btn { + @include detail-actions.icon-action-button; + @include detail-actions.icon-action-button-states; + @include detail-actions.download-progress-ring; + } + + @include detail-actions.icon-action-keyframes; + // ============================================================================ // Content Sections - Below the hero fold // ============================================================================