From 7f724494b9b3bd7acbc7becb42e661792bd50ac8 Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Mon, 14 Sep 2026 21:05:19 +0200
Subject: [PATCH] feat(portal): mark movies as watched from the detail page
(#1605)
---
.changes/portal-mark-movie-watched.md | 10 +
CLAUDE.md | 1 +
apps/web/src/assets/i18n/ar.json | 3 +
apps/web/src/assets/i18n/ary.json | 3 +
apps/web/src/assets/i18n/by.json | 3 +
apps/web/src/assets/i18n/de.json | 3 +
apps/web/src/assets/i18n/el.json | 3 +
apps/web/src/assets/i18n/en.json | 3 +
apps/web/src/assets/i18n/es.json | 3 +
apps/web/src/assets/i18n/fr.json | 3 +
apps/web/src/assets/i18n/hu.json | 3 +
apps/web/src/assets/i18n/it.json | 3 +
apps/web/src/assets/i18n/ja.json | 3 +
apps/web/src/assets/i18n/ko.json | 3 +
apps/web/src/assets/i18n/nl.json | 3 +
apps/web/src/assets/i18n/pl.json | 3 +
apps/web/src/assets/i18n/pt.json | 3 +
apps/web/src/assets/i18n/ru.json | 3 +
apps/web/src/assets/i18n/tr.json | 3 +
apps/web/src/assets/i18n/zh.json | 3 +
apps/web/src/assets/i18n/zhtw.json | 3 +
docs/architecture/embedded-inline-playback.md | 44 +++
.../grid-list/grid-list.component.ts | 19 +-
libs/portal/shared/util/src/index.ts | 3 +
.../src/lib/pending-playback-start.spec.ts | 30 ++
.../util/src/lib/pending-playback-start.ts | 44 +++
.../util/src/lib/portal-catalog-facade.ts | 14 +-
.../shared/util/src/lib/portal-watch-state.ts | 78 +++++
.../util/src/lib/vod-watched-toggle.spec.ts | 293 ++++++++++++++++++
.../shared/util/src/lib/vod-watched-toggle.ts | 154 +++++++++
.../stalker-catalog-detail.component.html | 5 +
.../stalker-catalog-detail.component.spec.ts | 137 +++++++-
.../stalker-catalog-detail.component.ts | 66 +++-
.../stalker-catalog-facade.service.spec.ts | 41 ++-
.../src/lib/stalker-catalog-facade.service.ts | 35 +--
...talker-collection-detail.component.spec.ts | 286 ++++++++++++++++-
.../stalker-collection-detail.component.ts | 20 +-
.../stalker-collection-playback.controller.ts | 59 ++++
.../stalker-inline-detail.component.html | 5 +
.../stalker-inline-detail.component.spec.ts | 32 ++
.../stalker-inline-detail.component.ts | 12 +
.../lib/stalker-vod-playback-controller.ts | 39 ++-
.../lib/stalker-vod-watched-toggle.spec.ts | 96 ++++++
.../src/lib/stalker-vod-watched-toggle.ts | 95 ++++++
.../vod-details-playback.service.spec.ts | 83 +++++
.../vod-details-playback.service.ts | 116 ++++---
.../vod-details-route.actions.spec.ts | 39 +++
.../vod-details-route.component.html | 18 ++
.../vod-details-route.component.ts | 12 +-
.../vod-details-watched.service.spec.ts | 241 ++++++++++++++
.../vod-details-watched.service.ts | 99 ++++++
.../lib/xtream-catalog-facade.service.spec.ts | 38 ++-
.../src/lib/xtream-catalog-facade.service.ts | 15 +-
.../vod-details/vod-details.component.html | 20 ++
.../vod-details/vod-details.component.spec.ts | 100 ++++++
.../lib/vod-details/vod-details.component.ts | 53 +++-
libs/ui/styles/_detail-view-actions.scss | 33 ++
57 files changed, 2420 insertions(+), 122 deletions(-)
create mode 100644 .changes/portal-mark-movie-watched.md
create mode 100644 libs/portal/shared/util/src/lib/pending-playback-start.spec.ts
create mode 100644 libs/portal/shared/util/src/lib/pending-playback-start.ts
create mode 100644 libs/portal/shared/util/src/lib/portal-watch-state.ts
create mode 100644 libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts
create mode 100644 libs/portal/shared/util/src/lib/vod-watched-toggle.ts
create mode 100644 libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.spec.ts
create mode 100644 libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.ts
create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-watched.service.spec.ts
create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-watched.service.ts
diff --git a/.changes/portal-mark-movie-watched.md b/.changes/portal-mark-movie-watched.md
new file mode 100644
index 000000000..37f0c5ce3
--- /dev/null
+++ b/.changes/portal-mark-movie-watched.md
@@ -0,0 +1,10 @@
+---
+type: feature
+area: portal
+---
+
+Movies can now be marked as watched (or unwatched) by hand from their
+detail page on Xtream and Stalker sources, including the Favorites and
+Recently Viewed views — the same way seasons and episodes already could. The
+catalog shows the green check right away, and a watched movie offers Play
+instead of a Resume near the end.
diff --git a/CLAUDE.md b/CLAUDE.md
index 1acd3a9b9..60da49c6a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1563,6 +1563,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
+- Movies get the same manual toggle in the detail action row (Xtream: icon square after Favorite, `VodDetailsWatchedService`; Stalker: labelled button in the shared `app-vod-details`, both the routed catalog detail and the collection inline detail wire it). Both portals go through one helper, `createVodWatchedToggle` in `@iptvnator/portal/shared/util`: marking writes a full-progress `vod` position row (stored duration → provider `duration_secs` → 1 s fallback, since Stalker states no runtime), unmarking deletes the row and so forgets the resume point, both through the rejecting `*OrThrow` persistence boundary so the row on screen changes only after a confirmed write. The toggle is disabled while the movie plays inline or in an external session (the ~15 s position tick would overwrite the row), acts only on the route copy's row (a pinned multi-source alternative keeps its own), and a completion landing after navigation refreshes the catalog badges but neither patches the new page nor shows its snackbar. A watched copy shows Play, never "Resume" from its final seconds. Catalog cards on both portals derive their corner badge from one shared `PortalWatchState` (`resolvePortalWatchState` / `watchStateFromProgressPercent`, 90% threshold; `resolvePortalSeriesWatchState` reports a series as at most `in-progress`, because the list payload never carries the episode total).
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 7dcd6a825..5425e8ebb 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "تم وضع علامة على المسلسل كغير مشاهد",
"SERIES_WATCH_UPDATE_FAILED": "فشل تحديث المسلسل",
"SERIES_WATCH_MENU": "إجراءات مشاهدة المسلسل",
+ "MOVIE_MARKED_WATCHED": "تم وضع علامة كمشاهد",
+ "MOVIE_MARKED_UNWATCHED": "تم وضع علامة كغير مشاهد",
+ "MOVIE_WATCH_UPDATE_FAILED": "فشل تحديث حالة المشاهدة",
"EPISODES_WATCHED": "تمت مشاهدة {{count}} من {{total}} حلقة",
"DETAIL_FALLBACK": {
"STATUS": "البيانات الوصفية للبوابة غير متاحة",
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 05cbda747..b54da8eeb 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "السلسلة تعلمات كما متشافاش",
"SERIES_WATCH_UPDATE_FAILED": "فشل تحديث السلسلة",
"SERIES_WATCH_MENU": "إجراءات مشاهدة السلسلة",
+ "MOVIE_MARKED_WATCHED": "تعلم كمتشاف",
+ "MOVIE_MARKED_UNWATCHED": "تعلم كما متشافش",
+ "MOVIE_WATCH_UPDATE_FAILED": "فشل تحديث حالة المشاهدة",
"EPISODES_WATCHED": "{{count}} من {{total}} ديال الحلقات متشافين",
"DETAIL_FALLBACK": {
"STATUS": "البيانات الوصفية ديال البوابة ما متوفراش",
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 56c58b83f..90af07b39 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Серыял адзначаны як непрагледжаны",
"SERIES_WATCH_UPDATE_FAILED": "Не ўдалося абнавіць серыял",
"SERIES_WATCH_MENU": "Дзеянні прагляду серыяла",
+ "MOVIE_MARKED_WATCHED": "Адзначана як прагледжанае",
+ "MOVIE_MARKED_UNWATCHED": "Адзначана як непрагледжанае",
+ "MOVIE_WATCH_UPDATE_FAILED": "Не ўдалося абнавіць стан прагляду",
"EPISODES_WATCHED": "{{count}} з {{total}} эпізодаў прагледжана",
"DETAIL_FALLBACK": {
"STATUS": "Метаданыя партала недаступныя",
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index 0503d6cac..90d946f34 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Serie als nicht angesehen markiert",
"SERIES_WATCH_UPDATE_FAILED": "Aktualisierung der Serie fehlgeschlagen",
"SERIES_WATCH_MENU": "Aktionen zum Markieren der Serie",
+ "MOVIE_MARKED_WATCHED": "Als angesehen markiert",
+ "MOVIE_MARKED_UNWATCHED": "Als nicht angesehen markiert",
+ "MOVIE_WATCH_UPDATE_FAILED": "Aktualisierung des Status „angesehen“ fehlgeschlagen",
"EPISODES_WATCHED": "{{count}} von {{total}} Folgen angesehen",
"DETAIL_FALLBACK": {
"STATUS": "Portal-Metadaten fehlen",
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index c20094823..f701bcd84 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Η σειρά σημειώθηκε ως μη προβληθείσα",
"SERIES_WATCH_UPDATE_FAILED": "Η ενημέρωση της σειράς απέτυχε",
"SERIES_WATCH_MENU": "Ενέργειες παρακολούθησης σειράς",
+ "MOVIE_MARKED_WATCHED": "Σημειώθηκε ως προβληθέν",
+ "MOVIE_MARKED_UNWATCHED": "Σημειώθηκε ως μη προβληθέν",
+ "MOVIE_WATCH_UPDATE_FAILED": "Η ενημέρωση της κατάστασης προβολής απέτυχε",
"EPISODES_WATCHED": "{{count}} από {{total}} επεισόδια προβλήθηκαν",
"DETAIL_FALLBACK": {
"STATUS": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα",
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index a9fe5a77b..8eae1e98a 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Series marked as unwatched",
"SERIES_WATCH_UPDATE_FAILED": "Updating the series failed",
"SERIES_WATCH_MENU": "Series watch actions",
+ "MOVIE_MARKED_WATCHED": "Marked as watched",
+ "MOVIE_MARKED_UNWATCHED": "Marked as unwatched",
+ "MOVIE_WATCH_UPDATE_FAILED": "Updating the watched state failed",
"EPISODES_WATCHED": "{{count}} of {{total}} episodes watched",
"DETAIL_FALLBACK": {
"STATUS": "Portal metadata unavailable",
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index 1cced59af..31b746032 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Serie marcada como no vista",
"SERIES_WATCH_UPDATE_FAILED": "La actualización de la serie falló",
"SERIES_WATCH_MENU": "Acciones de visualización de la serie",
+ "MOVIE_MARKED_WATCHED": "Marcado como visto",
+ "MOVIE_MARKED_UNWATCHED": "Marcado como no visto",
+ "MOVIE_WATCH_UPDATE_FAILED": "La actualización del estado de visto falló",
"EPISODES_WATCHED": "{{count}} de {{total}} episodios vistos",
"DETAIL_FALLBACK": {
"STATUS": "Metadatos del portal no disponibles",
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index 843ec3e85..495eaaa8d 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Série marquée comme non vue",
"SERIES_WATCH_UPDATE_FAILED": "Échec de la mise à jour de la série",
"SERIES_WATCH_MENU": "Actions de visionnage de la série",
+ "MOVIE_MARKED_WATCHED": "Marqué comme vu",
+ "MOVIE_MARKED_UNWATCHED": "Marqué comme non vu",
+ "MOVIE_WATCH_UPDATE_FAILED": "Échec de la mise à jour de l’état « vu »",
"EPISODES_WATCHED": "{{count}} sur {{total}} épisodes vus",
"DETAIL_FALLBACK": {
"STATUS": "Métadonnées du portail indisponibles",
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index 2fb55d30f..b5088ad2f 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Sorozat megjelölve nem megtekintettként",
"SERIES_WATCH_UPDATE_FAILED": "Nem sikerült frissíteni a sorozatot",
"SERIES_WATCH_MENU": "A sorozat megtekintési műveletei",
+ "MOVIE_MARKED_WATCHED": "Megjelölve megtekintettként",
+ "MOVIE_MARKED_UNWATCHED": "Megjelölve nem megtekintettként",
+ "MOVIE_WATCH_UPDATE_FAILED": "Nem sikerült frissíteni a megtekintési állapotot",
"EPISODES_WATCHED": "Megtekintett epizódok: {{count}} / {{total}}",
"DETAIL_FALLBACK": {
"STATUS": "A portál metaadatai nem állnak rendelkezésre",
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index 3bb7a3d33..f01f63221 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Serie segnata come non vista",
"SERIES_WATCH_UPDATE_FAILED": "Aggiornamento della serie non riuscito",
"SERIES_WATCH_MENU": "Azioni di visione della serie",
+ "MOVIE_MARKED_WATCHED": "Segnato come visto",
+ "MOVIE_MARKED_UNWATCHED": "Segnato come non visto",
+ "MOVIE_WATCH_UPDATE_FAILED": "Aggiornamento dello stato di visione non riuscito",
"EPISODES_WATCHED": "{{count}} di {{total}} episodi visti",
"DETAIL_FALLBACK": {
"STATUS": "Metadati del portale non disponibili",
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index f4c8ec8df..bd3960095 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "シリーズを未視聴にしました",
"SERIES_WATCH_UPDATE_FAILED": "シリーズの更新に失敗しました",
"SERIES_WATCH_MENU": "シリーズの視聴操作",
+ "MOVIE_MARKED_WATCHED": "視聴済みにしました",
+ "MOVIE_MARKED_UNWATCHED": "未視聴にしました",
+ "MOVIE_WATCH_UPDATE_FAILED": "視聴状態の更新に失敗しました",
"EPISODES_WATCHED": "{{count}} / {{total}}エピソード視聴済み",
"DETAIL_FALLBACK": {
"STATUS": "ポータルのメタデータは利用できません",
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 3429e5ae6..b73b4e881 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "시리즈가 시청하지 않은 것으로 표시되었습니다",
"SERIES_WATCH_UPDATE_FAILED": "시리즈 업데이트에 실패했습니다",
"SERIES_WATCH_MENU": "시리즈 시청 작업",
+ "MOVIE_MARKED_WATCHED": "시청한 것으로 표시되었습니다",
+ "MOVIE_MARKED_UNWATCHED": "시청하지 않은 것으로 표시되었습니다",
+ "MOVIE_WATCH_UPDATE_FAILED": "시청 상태 업데이트에 실패했습니다",
"EPISODES_WATCHED": "{{total}}개 중 {{count}}개 에피소드 시청",
"DETAIL_FALLBACK": {
"STATUS": "포털 메타데이터를 사용할 수 없습니다",
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index d65ea1ab4..251a079ae 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Serie gemarkeerd als niet bekeken",
"SERIES_WATCH_UPDATE_FAILED": "Bijwerken van de serie is mislukt",
"SERIES_WATCH_MENU": "Kijkacties voor serie",
+ "MOVIE_MARKED_WATCHED": "Gemarkeerd als bekeken",
+ "MOVIE_MARKED_UNWATCHED": "Gemarkeerd als niet bekeken",
+ "MOVIE_WATCH_UPDATE_FAILED": "Bijwerken van de kijkstatus is mislukt",
"EPISODES_WATCHED": "{{count}} van {{total}} afleveringen bekeken",
"DETAIL_FALLBACK": {
"STATUS": "Portaalmetadata niet beschikbaar",
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index 52275f216..3000ee8f2 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Oznaczono serial jako nieobejrzany",
"SERIES_WATCH_UPDATE_FAILED": "Nie udało się zaktualizować serialu",
"SERIES_WATCH_MENU": "Akcje oglądania serialu",
+ "MOVIE_MARKED_WATCHED": "Oznaczono jako obejrzane",
+ "MOVIE_MARKED_UNWATCHED": "Oznaczono jako nieobejrzane",
+ "MOVIE_WATCH_UPDATE_FAILED": "Nie udało się zaktualizować stanu obejrzenia",
"EPISODES_WATCHED": "Obejrzano {{count}} z {{total}} odcinków",
"DETAIL_FALLBACK": {
"STATUS": "Metadane portalu niedostępne",
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index 364582bcb..49ae5217e 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Série marcada como não assistida",
"SERIES_WATCH_UPDATE_FAILED": "A atualização da série falhou",
"SERIES_WATCH_MENU": "Ações de visualização da série",
+ "MOVIE_MARKED_WATCHED": "Marcado como assistido",
+ "MOVIE_MARKED_UNWATCHED": "Marcado como não assistido",
+ "MOVIE_WATCH_UPDATE_FAILED": "A atualização do estado de assistido falhou",
"EPISODES_WATCHED": "{{count}} de {{total}} episódios assistidos",
"DETAIL_FALLBACK": {
"STATUS": "Metadados do portal indisponíveis",
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index bdfe7dc3d..fcf050c39 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Сериал отмечен как непросмотренный",
"SERIES_WATCH_UPDATE_FAILED": "Не удалось обновить сериал",
"SERIES_WATCH_MENU": "Действия просмотра сериала",
+ "MOVIE_MARKED_WATCHED": "Отмечено как просмотренное",
+ "MOVIE_MARKED_UNWATCHED": "Отмечено как непросмотренное",
+ "MOVIE_WATCH_UPDATE_FAILED": "Не удалось обновить статус просмотра",
"EPISODES_WATCHED": "Просмотрено {{count}} из {{total}} эпизодов",
"DETAIL_FALLBACK": {
"STATUS": "Метаданные портала недоступны",
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index 5e883964d..3846b7b8f 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "Dizi izlenmedi olarak işaretlendi",
"SERIES_WATCH_UPDATE_FAILED": "Dizi güncellemesi başarısız oldu",
"SERIES_WATCH_MENU": "Dizi izleme işlemleri",
+ "MOVIE_MARKED_WATCHED": "İzlendi olarak işaretlendi",
+ "MOVIE_MARKED_UNWATCHED": "İzlenmedi olarak işaretlendi",
+ "MOVIE_WATCH_UPDATE_FAILED": "İzlenme durumu güncellemesi başarısız oldu",
"EPISODES_WATCHED": "{{total}} bölümden {{count}} tanesi izlendi",
"DETAIL_FALLBACK": {
"STATUS": "Portal üst verisi mevcut değil",
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index 5d3595771..935e58f0e 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "剧集已标记为未观看",
"SERIES_WATCH_UPDATE_FAILED": "无法更新剧集",
"SERIES_WATCH_MENU": "剧集观看操作",
+ "MOVIE_MARKED_WATCHED": "已标记为已观看",
+ "MOVIE_MARKED_UNWATCHED": "已标记为未观看",
+ "MOVIE_WATCH_UPDATE_FAILED": "无法更新观看状态",
"EPISODES_WATCHED": "已观看 {{count}} / {{total}} 集",
"DETAIL_FALLBACK": {
"STATUS": "门户元数据不可用",
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index 0afdb9eb0..3764551b9 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -1079,6 +1079,9 @@
"SERIES_MARKED_UNWATCHED": "本影集已標記為未觀看",
"SERIES_WATCH_UPDATE_FAILED": "本影集更新失敗",
"SERIES_WATCH_MENU": "影集觀看操作",
+ "MOVIE_MARKED_WATCHED": "已標記為已觀看",
+ "MOVIE_MARKED_UNWATCHED": "已標記為未觀看",
+ "MOVIE_WATCH_UPDATE_FAILED": "觀看狀態更新失敗",
"EPISODES_WATCHED": "已觀看 {{total}} 集中的 {{count}} 集",
"DETAIL_FALLBACK": {
"STATUS": "無法取得入口網站中繼資料",
diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md
index 38f8aa7e7..6e1fb0b27 100644
--- a/docs/architecture/embedded-inline-playback.md
+++ b/docs/architecture/embedded-inline-playback.md
@@ -266,6 +266,50 @@ episode" chip appears when the playing episode is outside the opened
season. Season descriptions come from `get_series_info` seasons (Xtream)
or `TmdbEnrichmentService.getSeason` (Stalker).
+### Manual watched toggle for movies
+
+Movies carry the same manual "watched" affordance as episodes, in the
+detail action row of both portals: Xtream renders an icon square after
+Favorite (`vod-details-watched.service.ts`), Stalker a labelled button
+inside the shared `app-vod-details` component, wired by the routed
+catalog detail and by the collection inline detail (Favorites / Recent).
+Both hosts delegate to `createVodWatchedToggle()` in
+`@iptvnator/portal/shared/util`:
+
+- **Marking** writes a full-progress `vod` position row — the same shape
+ playback leaves behind, so catalog badges, the dashboard and the
+ Play/Resume rule need no new state. The duration is the stored row's
+ (real), else the provider's `duration_secs` (Xtream), else 1 s: Stalker
+ VOD details state no runtime, and "position === duration" is what
+ "watched" means, whatever the number.
+- **Unmarking** deletes the row, which also forgets the resume point —
+ the trade the episode toggle already makes.
+- Both writes use the rejecting `savePlaybackPositionOrThrow` /
+ `clearPlaybackPositionOrThrow` boundary: the row on screen changes only
+ after a confirmed write, and a refused write reports instead of showing
+ the movie as (un)watched.
+- The toggle is **disabled while the movie plays** (inline player mounted,
+ external session live or launching for this content): the player
+ persists its position every ~15 s and would overwrite a just-written
+ row, silently flipping the movie back.
+- It acts on the **route copy's row only**. Positions are keyed by
+ (playlist, stream), so a pinned multi-source alternative keeps its own
+ state, exactly as playback would leave it.
+- A completion that lands **after navigation** (`stillCurrent`) still
+ refreshes the playlist's catalog positions (the write did land), but
+ neither patches the new page's row nor shows its snackbar.
+- A watched copy shows **Play**, never "Resume 1:32:00" from its final
+ seconds — `app-vod-details` folds `isWatched` into `hasPlaybackPosition`,
+ which Xtream's route already did through its 90% rule.
+
+Catalog cards derive their corner badge from one shared `PortalWatchState`
+(`unwatched` / `in-progress` / `watched`, `portal-watch-state.ts`): both
+facades map a movie's position through `watchStateFromProgressPercent` /
+`resolvePortalWatchState` (90% threshold, shared with the Resume rule),
+and a series through `resolvePortalSeriesWatchState`, which reports at
+most `in-progress` — the list payload never carries the episode total, so
+"every episode watched" is not decidable there.
+
The season header carries a season-level watched toggle next to
"Download season" (`season-watch-toggle.util.ts` builds the request:
marking touches only unwatched episodes so real durations survive;
diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts
index dcd42b66b..28ac27fd6 100644
--- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts
@@ -15,6 +15,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import {
+ PortalWatchState,
getXtreamCatchupDays,
isXtreamCatchupAvailable,
} from '@iptvnator/portal/shared/util';
@@ -42,8 +43,7 @@ interface GridListItem {
rating?: string | number;
rating_imdb?: string | number;
progress?: number;
- isWatched?: boolean;
- hasSeriesProgress?: boolean;
+ watchState?: PortalWatchState;
tv_archive?: number | string | null;
tv_archive_duration?: number | string | null;
[key: string]: unknown;
@@ -173,7 +173,8 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
data-test-id="grid-catchup-badge"
[matTooltip]="
catchupLabelKey(i)
- | translate: { days: catchupDays(i) }
+ | translate
+ : { days: catchupDays(i) }
"
>
history
@@ -181,16 +182,22 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
status as text for AT users -->
{{
catchupLabelKey(i)
- | translate: { days: catchupDays(i) }
+ | translate
+ : { days: catchupDays(i) }
}}
}
- @if (i.isWatched) {
+ @if (i.watchState === 'watched') {
- } @else if (i.hasSeriesProgress) {
+ } @else if (
+ i.watchState === 'in-progress' && !i.progress
+ ) {
+
{
+ it('is pending only for the owner of the latest start', () => {
+ const tracker = createPendingPlaybackStart();
+ const pendingForA = computed(() => tracker.isPendingFor('a'));
+ const pendingForB = computed(() => tracker.isPendingFor('b'));
+
+ expect(pendingForA()).toBe(false);
+ const first = tracker.begin('a');
+ expect(pendingForA()).toBe(true);
+ expect(pendingForB()).toBe(false);
+
+ tracker.settle(first);
+ expect(pendingForA()).toBe(false);
+ });
+
+ it('lets only the latest start clear the flag', () => {
+ const tracker = createPendingPlaybackStart();
+ const older = tracker.begin('a');
+ const newer = tracker.begin('b');
+
+ tracker.settle(older);
+ expect(tracker.isPendingFor('b')).toBe(true);
+
+ tracker.settle(newer);
+ expect(tracker.isPendingFor('b')).toBe(false);
+ });
+});
diff --git a/libs/portal/shared/util/src/lib/pending-playback-start.ts b/libs/portal/shared/util/src/lib/pending-playback-start.ts
new file mode 100644
index 000000000..280dc016c
--- /dev/null
+++ b/libs/portal/shared/util/src/lib/pending-playback-start.ts
@@ -0,0 +1,44 @@
+import { signal } from '@angular/core';
+
+/**
+ * Tracks the one playback start still between the click and the player
+ * (a portal resolution, or closing the previous player), keyed by the
+ * content it was started for.
+ *
+ * Only the latest start may clear the flag, so an older resolution that
+ * settles late cannot erase a newer one — and `isPendingFor` answers for
+ * ONE owner, so a stale start for the previous movie never holds the next
+ * movie's watched toggle hostage.
+ */
+export interface PendingPlaybackStart {
+ /** Records a new start and returns its id for `settle`. */
+ begin(owner: TOwner): number;
+ /** Clears the flag if this start is still the latest one. */
+ settle(startId: number): void;
+ /** Reactive: whether the latest start belongs to this owner. */
+ isPendingFor(owner: TOwner): boolean;
+}
+
+export function createPendingPlaybackStart<
+ TOwner,
+>(): PendingPlaybackStart {
+ const pending = signal<{ startId: number; owner: TOwner } | null>(null);
+ let sequence = 0;
+
+ return {
+ begin(owner) {
+ const startId = ++sequence;
+ pending.set({ startId, owner });
+ return startId;
+ },
+ settle(startId) {
+ if (pending()?.startId === startId) {
+ pending.set(null);
+ }
+ },
+ isPendingFor(owner) {
+ const current = pending();
+ return current !== null && current.owner === owner;
+ },
+ };
+}
diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts
index 79202c9ae..d72da4e61 100644
--- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts
+++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts
@@ -1,5 +1,6 @@
import { InjectionToken, Signal } from '@angular/core';
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
+import type { PortalWatchState } from './portal-watch-state';
export type PortalCatalogProvider = 'xtream' | 'stalker';
@@ -21,10 +22,14 @@ export interface PortalCatalogPlaylistMeta {
origin?: string;
}
+/**
+ * What a catalog card shows about playback: a percent for the movie
+ * progress capsule, and one shared watch state for the corner badge
+ * (`resolvePortalWatchState` / `resolvePortalSeriesWatchState`).
+ */
export interface PortalCatalogItemProgress {
progress?: number;
- isWatched?: boolean;
- hasSeriesProgress?: boolean;
+ watchState?: PortalWatchState;
}
export interface PortalCatalogFacade<
@@ -115,7 +120,10 @@ export interface StalkerPortalCatalogFacade<
macAddress: string,
cmd: string,
series?: number,
- linkFlags?: { use_http_tmp_link?: unknown; use_load_balancing?: unknown }
+ linkFlags?: {
+ use_http_tmp_link?: unknown;
+ use_load_balancing?: unknown;
+ }
): Promise;
resolveVodPlayback(
cmd?: string,
diff --git a/libs/portal/shared/util/src/lib/portal-watch-state.ts b/libs/portal/shared/util/src/lib/portal-watch-state.ts
new file mode 100644
index 000000000..d3912c57f
--- /dev/null
+++ b/libs/portal/shared/util/src/lib/portal-watch-state.ts
@@ -0,0 +1,78 @@
+import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
+import { getPortalPlaybackProgressPercent } from './portal-playback-positions';
+
+/**
+ * One watch state for every catalog card, movie or series, on every portal.
+ *
+ * - `watched`: the position row reached the completion threshold, whether
+ * playback got there or the user marked it by hand.
+ * - `in-progress`: something was started but not finished. For a series it
+ * means at least one episode has a row — the catalog list cannot know how
+ * many episodes a series has, so a series never reaches `watched` here.
+ * - `unwatched`: no row at all.
+ */
+export type PortalWatchState = 'unwatched' | 'in-progress' | 'watched';
+
+/** Progress at or above which a movie or episode counts as watched. */
+export const PORTAL_WATCHED_PROGRESS_PERCENT = 90;
+
+export function watchStateFromProgressPercent(
+ percent: number
+): PortalWatchState {
+ if (percent >= PORTAL_WATCHED_PROGRESS_PERCENT) {
+ return 'watched';
+ }
+ return percent > 0 ? 'in-progress' : 'unwatched';
+}
+
+export function resolvePortalWatchState(
+ position: PlaybackPositionData | null | undefined
+): PortalWatchState {
+ return watchStateFromProgressPercent(
+ getPortalPlaybackProgressPercent(position)
+ );
+}
+
+/**
+ * Series catalog cards only know whether ANY episode carries a row; the
+ * total episode count is not part of the list payload on either portal, so
+ * the strongest truthful answer is "started".
+ */
+export function resolvePortalSeriesWatchState(
+ hasAnyEpisodeProgress: boolean
+): PortalWatchState {
+ return hasAnyEpisodeProgress ? 'in-progress' : 'unwatched';
+}
+
+/** Full-progress position row that marks one movie as watched. */
+export function buildWatchedVodPosition(options: {
+ playlistId: string;
+ contentXtreamId: number;
+ /** Runtime the detail view knows (Xtream `duration_secs`); may be absent. */
+ durationSeconds?: number | null;
+ /** The row already stored, whose real duration beats any metadata guess. */
+ currentPosition?: PlaybackPositionData | null;
+}): PlaybackPositionData {
+ // A watched row is "position === duration", so any positive duration
+ // works; 1 s is the same fallback the episode toggle uses when the
+ // provider states no runtime at all.
+ const duration =
+ positiveOrNull(options.currentPosition?.durationSeconds) ??
+ positiveOrNull(options.durationSeconds) ??
+ 1;
+
+ return {
+ contentXtreamId: options.contentXtreamId,
+ contentType: 'vod',
+ positionSeconds: duration,
+ durationSeconds: duration,
+ playlistId: options.playlistId,
+ updatedAt: new Date().toISOString(),
+ };
+}
+
+function positiveOrNull(value: number | null | undefined): number | null {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
+ ? value
+ : null;
+}
diff --git a/libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts b/libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts
new file mode 100644
index 000000000..47af81167
--- /dev/null
+++ b/libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts
@@ -0,0 +1,293 @@
+import { signal } from '@angular/core';
+import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
+import {
+ buildWatchedVodPosition,
+ resolvePortalSeriesWatchState,
+ resolvePortalWatchState,
+ watchStateFromProgressPercent,
+} from './portal-watch-state';
+import {
+ VodWatchedToggleFeedback,
+ createVodWatchedToggle,
+} from './vod-watched-toggle';
+
+const PLAYLIST = 'playlist-1';
+
+function row(
+ positionSeconds: number,
+ durationSeconds = 5400
+): PlaybackPositionData {
+ return {
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ contentType: 'vod',
+ positionSeconds,
+ durationSeconds,
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ };
+}
+
+describe('portal watch state', () => {
+ it('maps progress onto the three catalog states at the 90% threshold', () => {
+ expect(watchStateFromProgressPercent(0)).toBe('unwatched');
+ expect(watchStateFromProgressPercent(1)).toBe('in-progress');
+ expect(watchStateFromProgressPercent(89)).toBe('in-progress');
+ expect(watchStateFromProgressPercent(90)).toBe('watched');
+ expect(watchStateFromProgressPercent(100)).toBe('watched');
+ });
+
+ it('resolves a position row the same way the Resume rule does', () => {
+ expect(resolvePortalWatchState(null)).toBe('unwatched');
+ expect(resolvePortalWatchState(row(0))).toBe('unwatched');
+ expect(resolvePortalWatchState(row(600))).toBe('in-progress');
+ expect(resolvePortalWatchState(row(5000))).toBe('watched');
+ // No duration means no percent — never "watched" by accident.
+ expect(resolvePortalWatchState(row(5000, 0))).toBe('unwatched');
+ });
+
+ it('never reports a series as fully watched from the catalog list', () => {
+ expect(resolvePortalSeriesWatchState(true)).toBe('in-progress');
+ expect(resolvePortalSeriesWatchState(false)).toBe('unwatched');
+ });
+
+ it('builds a full-progress row preferring the stored duration', () => {
+ const stored = buildWatchedVodPosition({
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ durationSeconds: 7200,
+ currentPosition: row(600, 5400),
+ });
+ expect(stored).toMatchObject({
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ contentType: 'vod',
+ positionSeconds: 5400,
+ durationSeconds: 5400,
+ });
+
+ const fromMetadata = buildWatchedVodPosition({
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ durationSeconds: 7200,
+ currentPosition: null,
+ });
+ expect(fromMetadata.positionSeconds).toBe(7200);
+ expect(fromMetadata.durationSeconds).toBe(7200);
+
+ const unknown = buildWatchedVodPosition({
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ durationSeconds: 0,
+ currentPosition: row(600, 0),
+ });
+ expect(unknown.positionSeconds).toBe(1);
+ expect(unknown.durationSeconds).toBe(1);
+ expect(resolvePortalWatchState(unknown)).toBe('watched');
+ });
+});
+
+describe('createVodWatchedToggle', () => {
+ function setup(initial: PlaybackPositionData | null) {
+ const position = signal(initial);
+ const playingNow = signal(false);
+ const feedback: VodWatchedToggleFeedback[] = [];
+ const persisted: string[] = [];
+ const playbackPositions = {
+ savePlaybackPositionOrThrow: jest.fn().mockResolvedValue(undefined),
+ clearPlaybackPositionOrThrow: jest
+ .fn()
+ .mockResolvedValue(undefined),
+ };
+ const logger = { error: jest.fn() };
+ const toggle = createVodWatchedToggle({
+ playbackPositions,
+ position,
+ applyPosition: (next) => position.set(next),
+ playingNow,
+ notify: (kind) => feedback.push(kind),
+ onPersisted: (playlistId) => persisted.push(playlistId),
+ logger,
+ });
+ const target = (stillCurrent = () => true) => ({
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ durationSeconds: 5400,
+ stillCurrent,
+ });
+ return {
+ toggle,
+ position,
+ playingNow,
+ feedback,
+ persisted,
+ playbackPositions,
+ logger,
+ target,
+ };
+ }
+
+ it('marks an unwatched movie by writing a full-progress row', async () => {
+ const t = setup(null);
+ expect(t.toggle.isWatched()).toBe(false);
+
+ await expect(t.toggle.toggle(t.target())).resolves.toBe(true);
+
+ expect(
+ t.playbackPositions.savePlaybackPositionOrThrow
+ ).toHaveBeenCalledWith(
+ PLAYLIST,
+ expect.objectContaining({
+ contentXtreamId: 42,
+ contentType: 'vod',
+ positionSeconds: 5400,
+ durationSeconds: 5400,
+ })
+ );
+ expect(t.toggle.isWatched()).toBe(true);
+ expect(t.feedback).toEqual(['marked']);
+ expect(t.persisted).toEqual([PLAYLIST]);
+ });
+
+ it('unmarks a watched movie by clearing its row', async () => {
+ const t = setup(row(5400));
+ expect(t.toggle.isWatched()).toBe(true);
+
+ await expect(t.toggle.toggle(t.target())).resolves.toBe(true);
+
+ expect(
+ t.playbackPositions.clearPlaybackPositionOrThrow
+ ).toHaveBeenCalledWith(PLAYLIST, 42, 'vod');
+ expect(t.position()).toBeNull();
+ expect(t.toggle.isWatched()).toBe(false);
+ expect(t.feedback).toEqual(['unmarked']);
+ });
+
+ it('keeps the rendered row and reports when the write is refused', async () => {
+ const t = setup(row(600));
+ t.playbackPositions.savePlaybackPositionOrThrow.mockRejectedValue(
+ new Error('disk full')
+ );
+
+ await expect(t.toggle.toggle(t.target())).resolves.toBe(false);
+
+ expect(t.position()).toEqual(row(600));
+ expect(t.feedback).toEqual(['failed']);
+ expect(t.persisted).toEqual([]);
+ expect(t.logger.error).toHaveBeenCalled();
+ expect(t.toggle.busy()).toBe(false);
+ });
+
+ it('logs a failed post-write refresh without failing the toggle', async () => {
+ const position = signal(null);
+ const logger = { error: jest.fn() };
+ const toggle = createVodWatchedToggle({
+ playbackPositions: {
+ savePlaybackPositionOrThrow: jest
+ .fn()
+ .mockResolvedValue(undefined),
+ clearPlaybackPositionOrThrow: jest
+ .fn()
+ .mockResolvedValue(undefined),
+ },
+ position,
+ applyPosition: (next) => position.set(next),
+ notify: jest.fn(),
+ onPersisted: () => Promise.reject(new Error('ipc down')),
+ logger,
+ });
+
+ await expect(
+ toggle.toggle({
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ stillCurrent: () => true,
+ })
+ ).resolves.toBe(true);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(toggle.isWatched()).toBe(true);
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Refresh after watched toggle failed',
+ expect.any(Error)
+ );
+ });
+
+ it('stays disabled while playback could overwrite the row', async () => {
+ const t = setup(null);
+ t.playingNow.set(true);
+
+ expect(t.toggle.enabled()).toBe(false);
+ await expect(t.toggle.toggle(t.target())).resolves.toBe(false);
+ expect(
+ t.playbackPositions.savePlaybackPositionOrThrow
+ ).not.toHaveBeenCalled();
+ });
+
+ it('stays disabled until the stored row is in hand', async () => {
+ const position = signal(null);
+ const positionReady = signal(false);
+ const save = jest.fn().mockResolvedValue(undefined);
+ const toggle = createVodWatchedToggle({
+ playbackPositions: {
+ savePlaybackPositionOrThrow: save,
+ clearPlaybackPositionOrThrow: jest
+ .fn()
+ .mockResolvedValue(undefined),
+ },
+ position,
+ applyPosition: (next) => position.set(next),
+ positionReady,
+ notify: jest.fn(),
+ });
+ const target = {
+ playlistId: PLAYLIST,
+ contentXtreamId: 42,
+ stillCurrent: () => true,
+ };
+
+ expect(toggle.enabled()).toBe(false);
+ await expect(toggle.toggle(target)).resolves.toBe(false);
+ expect(save).not.toHaveBeenCalled();
+
+ positionReady.set(true);
+ expect(toggle.enabled()).toBe(true);
+ await expect(toggle.toggle(target)).resolves.toBe(true);
+ });
+
+ it('serializes overlapping toggles', async () => {
+ const t = setup(null);
+ let release!: () => void;
+ t.playbackPositions.savePlaybackPositionOrThrow.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ })
+ );
+
+ const first = t.toggle.toggle(t.target());
+ expect(t.toggle.busy()).toBe(true);
+ expect(t.toggle.enabled()).toBe(false);
+ await expect(t.toggle.toggle(t.target())).resolves.toBe(false);
+
+ release();
+ await expect(first).resolves.toBe(true);
+ expect(
+ t.playbackPositions.savePlaybackPositionOrThrow
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not patch the page or announce after the movie changed', async () => {
+ const t = setup(null);
+
+ await expect(t.toggle.toggle(t.target(() => false))).resolves.toBe(
+ true
+ );
+
+ expect(
+ t.playbackPositions.savePlaybackPositionOrThrow
+ ).toHaveBeenCalledTimes(1);
+ expect(t.position()).toBeNull();
+ expect(t.feedback).toEqual([]);
+ // The row did land for that playlist, so its badges still refresh.
+ expect(t.persisted).toEqual([PLAYLIST]);
+ });
+});
diff --git a/libs/portal/shared/util/src/lib/vod-watched-toggle.ts b/libs/portal/shared/util/src/lib/vod-watched-toggle.ts
new file mode 100644
index 000000000..582f1aea4
--- /dev/null
+++ b/libs/portal/shared/util/src/lib/vod-watched-toggle.ts
@@ -0,0 +1,154 @@
+import { Signal, computed, signal } from '@angular/core';
+import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
+import type { Logger } from './logger';
+import {
+ PortalPlaybackPositions,
+ isPortalPlaybackWatched,
+} from './portal-playback-positions';
+import { buildWatchedVodPosition } from './portal-watch-state';
+
+export type VodWatchedToggleFeedback = 'marked' | 'unmarked' | 'failed';
+
+export interface VodWatchedToggleTarget {
+ playlistId: string;
+ contentXtreamId: number;
+ /** Runtime the detail view knows; the stored row's duration wins over it. */
+ durationSeconds?: number | null;
+ /**
+ * Whether the page still shows this movie once the write returns. Detail
+ * hosts are reused across navigations and ids collide across playlists,
+ * so a late completion must neither patch the next movie's row nor show
+ * its feedback there. The write itself is safe: it carries its own ids.
+ */
+ stillCurrent: () => boolean;
+}
+
+export interface VodWatchedToggleConfig {
+ playbackPositions: Pick<
+ PortalPlaybackPositions,
+ 'savePlaybackPositionOrThrow' | 'clearPlaybackPositionOrThrow'
+ >;
+ /** The movie's row as the page shows it. */
+ position: Signal;
+ /** Replaces that row after a confirmed write. */
+ applyPosition: (position: PlaybackPositionData | null) => void;
+ /**
+ * Playback whose next position tick would overwrite a just-written row
+ * (inline player mounted, external session live or launching). While it
+ * is true the toggle stays disabled rather than silently flipping back.
+ */
+ playingNow?: Signal;
+ /**
+ * Whether `position` is the stored row rather than a placeholder: until
+ * the host's read lands the button would offer the wrong direction
+ * (re-marking a watched movie, or clearing a row it never saw).
+ */
+ positionReady?: Signal;
+ notify: (feedback: VodWatchedToggleFeedback) => void;
+ /**
+ * Runs after a confirmed write, e.g. to refresh catalog badges. Its
+ * failure is logged and swallowed: the mutation it follows is already
+ * confirmed, so it must neither reject the toggle nor escape unhandled.
+ */
+ onPersisted?: (playlistId: string) => void | Promise;
+ logger?: Pick;
+}
+
+export interface VodWatchedToggle {
+ isWatched: Signal;
+ busy: Signal;
+ enabled: Signal;
+ /** Resolves true when the write landed, false when nothing changed. */
+ toggle(target: VodWatchedToggleTarget): Promise;
+}
+
+/**
+ * Manual "watched" toggle for a movie, shared by the Xtream and Stalker
+ * detail views. Marking writes a full-progress position row (the same shape
+ * playback itself leaves behind, so every catalog badge and Resume rule keeps
+ * working unchanged); unmarking deletes the row, which also forgets the
+ * resume point — the same trade the episode toggle makes.
+ *
+ * Both writes go through the strict, rejecting persistence boundary: the
+ * row on screen changes only after the write is confirmed, never before.
+ */
+export function createVodWatchedToggle(
+ config: VodWatchedToggleConfig
+): VodWatchedToggle {
+ const busy = signal(false);
+ const isWatched = computed(() =>
+ isPortalPlaybackWatched(config.position())
+ );
+ const enabled = computed(
+ () =>
+ !busy() &&
+ !(config.playingNow?.() ?? false) &&
+ (config.positionReady?.() ?? true)
+ );
+
+ async function toggle(target: VodWatchedToggleTarget): Promise {
+ if (!enabled() || !target.playlistId) {
+ return false;
+ }
+
+ const markWatched = !isWatched();
+ busy.set(true);
+ try {
+ let next: PlaybackPositionData | null = null;
+ if (markWatched) {
+ next = buildWatchedVodPosition({
+ playlistId: target.playlistId,
+ contentXtreamId: target.contentXtreamId,
+ durationSeconds: target.durationSeconds,
+ currentPosition: config.position(),
+ });
+ await config.playbackPositions.savePlaybackPositionOrThrow(
+ target.playlistId,
+ next
+ );
+ } else {
+ await config.playbackPositions.clearPlaybackPositionOrThrow(
+ target.playlistId,
+ target.contentXtreamId,
+ 'vod'
+ );
+ }
+
+ void Promise.resolve()
+ .then(() => config.onPersisted?.(target.playlistId))
+ .catch((error) =>
+ config.logger?.error(
+ 'Refresh after watched toggle failed',
+ error
+ )
+ );
+ if (!target.stillCurrent()) {
+ return true;
+ }
+ config.applyPosition(next);
+ config.notify(markWatched ? 'marked' : 'unmarked');
+ return true;
+ } catch (error) {
+ // Nothing was confirmed persisted — keep the rendered state.
+ config.logger?.error('Watched toggle failed', error);
+ if (target.stillCurrent()) {
+ config.notify('failed');
+ }
+ return false;
+ } finally {
+ busy.set(false);
+ }
+ }
+
+ return { isWatched, busy, enabled, toggle };
+}
+
+/** Translation keys behind each feedback kind, shared by every host. */
+export const VOD_WATCHED_FEEDBACK_KEYS: Record<
+ VodWatchedToggleFeedback,
+ string
+> = {
+ marked: 'XTREAM.MOVIE_MARKED_WATCHED',
+ unmarked: 'XTREAM.MOVIE_MARKED_UNWATCHED',
+ failed: 'XTREAM.MOVIE_WATCH_UPDATE_FAILED',
+};
diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html
index 39c85e94a..728a260e4 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html
+++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html
@@ -15,9 +15,14 @@
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
[providerOnly]="providerOnly()"
+ [isWatched]="watchedToggle.isWatched()"
+ [watchedToggleBusy]="watchedToggle.busy()"
+ [watchedToggleReady]="positionLoaded()"
+ [playbackStartPending]="playbackStartPending()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
+ (watchedToggled)="onVodWatchedToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts
index 38af4f584..95b26effb 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts
@@ -52,9 +52,14 @@ class StubVodDetailsComponent {
readonly playbackPosition = input(null);
readonly inlinePlayback = input(null);
readonly externalPlayback = input(null);
+ readonly isWatched = input(false);
+ readonly watchedToggleBusy = input(false);
+ readonly watchedToggleReady = input(true);
+ readonly playbackStartPending = input(false);
readonly playClicked = output();
readonly resumeClicked = output();
readonly favoriteToggled = output();
+ readonly watchedToggled = output<{ item: unknown; watched: boolean }>();
readonly downloadRequested = output();
readonly backClicked = output();
readonly inlineTimeUpdated = output();
@@ -84,6 +89,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
const contentType = signal<'vod' | 'series'>('vod');
const catalogPlaylist = signal({ id: 'stalker-1' });
const snackBar = { open: jest.fn() };
+ const refreshPositions = jest.fn().mockResolvedValue(undefined);
+ const getPlaybackPosition = jest.fn().mockResolvedValue(null);
+ const savePlaybackPositionOrThrow = jest.fn().mockResolvedValue(undefined);
const routerMock = { navigateByUrl: jest.fn() };
const locationMock = { back: jest.fn() };
const originalHistoryState = window.history.state;
@@ -104,6 +112,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
catalogPlaylist.set({ id: 'stalker-1' });
snackBar.open.mockReset();
+ refreshPositions.mockClear();
+ getPlaybackPosition.mockReset().mockResolvedValue(null);
+ savePlaybackPositionOrThrow.mockClear().mockResolvedValue(undefined);
routerMock.navigateByUrl.mockReset();
locationMock.back.mockReset();
@@ -118,13 +129,18 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
playlist: catalogPlaylist,
clearSelectedItem: jest.fn(),
resolveVodPlayback,
+ refreshPositions,
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
- getPlaybackPosition: jest.fn().mockResolvedValue(null),
+ getPlaybackPosition,
savePlaybackPosition: jest.fn(),
+ savePlaybackPositionOrThrow,
+ clearPlaybackPositionOrThrow: jest
+ .fn()
+ .mockResolvedValue(undefined),
},
},
{
@@ -196,6 +212,61 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
);
});
+ it('marks the open movie watched from the child toggle and refreshes catalog badges', async () => {
+ await fixture.whenStable();
+ const child = fixture.debugElement.query(
+ By.directive(StubVodDetailsComponent)
+ ).componentInstance as StubVodDetailsComponent;
+ expect(child.isWatched()).toBe(false);
+
+ child.watchedToggled.emit({ item: child.item(), watched: true });
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(savePlaybackPositionOrThrow).toHaveBeenCalledWith(
+ 'stalker-1',
+ expect.objectContaining({ contentXtreamId: 42, contentType: 'vod' })
+ );
+ expect(refreshPositions).toHaveBeenCalledWith('stalker-1');
+ expect(child.isWatched()).toBe(true);
+ expect(snackBar.open).toHaveBeenCalledWith(
+ 'XTREAM.MOVIE_MARKED_WATCHED',
+ undefined,
+ expect.anything()
+ );
+ });
+
+ it('blocks the toggle until the initial position read lands, then honours it', async () => {
+ let resolveRead!: (value: null) => void;
+ getPlaybackPosition.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveRead = resolve;
+ })
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const child = fixture.debugElement.query(
+ By.directive(StubVodDetailsComponent)
+ ).componentInstance as StubVodDetailsComponent;
+ expect(child.watchedToggleReady()).toBe(false);
+
+ // Before the row is known the direction would be a guess: refused.
+ child.watchedToggled.emit({ item: child.item(), watched: true });
+ await fixture.whenStable();
+ expect(savePlaybackPositionOrThrow).not.toHaveBeenCalled();
+
+ resolveRead(null);
+ await fixture.whenStable();
+ fixture.detectChanges();
+ expect(child.watchedToggleReady()).toBe(true);
+
+ child.watchedToggled.emit({ item: child.item(), watched: true });
+ await fixture.whenStable();
+ fixture.detectChanges();
+ expect(savePlaybackPositionOrThrow).toHaveBeenCalledTimes(1);
+ expect(child.isWatched()).toBe(true);
+ });
+
it('keeps provider-only presentation disabled for a regular VOD open', async () => {
selectedItem.set({
id: '99',
@@ -241,6 +312,70 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
}
);
+ it('blocks the watched toggle while a Play is still resolving', async () => {
+ let resolve!: (value: { streamUrl: string }) => void;
+ resolveVodPlayback.mockReturnValueOnce(
+ new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ })
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const child = fixture.debugElement.query(
+ By.directive(StubVodDetailsComponent)
+ ).componentInstance as StubVodDetailsComponent;
+ expect(child.playbackStartPending()).toBe(false);
+
+ fixture.componentInstance.onVodPlay({
+ type: 'stalker',
+ cmd: '/media/42',
+ data: selectedItem(),
+ } as never);
+ fixture.detectChanges();
+ expect(child.playbackStartPending()).toBe(true);
+ expect(fixture.componentInstance.watchedToggle.enabled()).toBe(false);
+
+ // A toggle in this window is refused, so nothing is written.
+ child.watchedToggled.emit({ item: child.item(), watched: true });
+ await fixture.whenStable();
+ expect(savePlaybackPositionOrThrow).not.toHaveBeenCalled();
+
+ resolve({ streamUrl: 'https://portal.example/movie.mpg' });
+ await fixture.whenStable();
+ fixture.detectChanges();
+ expect(child.playbackStartPending()).toBe(false);
+ });
+
+ it('does not carry a pending start over to the next selected movie', async () => {
+ let resolve!: (value: { streamUrl: string }) => void;
+ resolveVodPlayback.mockReturnValueOnce(
+ new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ })
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.componentInstance.onVodPlay({
+ type: 'stalker',
+ cmd: '/media/42',
+ data: selectedItem(),
+ } as never);
+ fixture.detectChanges();
+ expect(fixture.componentInstance.playbackStartPending()).toBe(true);
+
+ selectedItem.set({
+ id: '99',
+ cmd: '/media/99',
+ info: { name: 'Replacement movie' },
+ });
+ fixture.detectChanges();
+ expect(fixture.componentInstance.playbackStartPending()).toBe(false);
+
+ resolve({ streamUrl: 'https://stale.example/movie.mpg' });
+ await fixture.whenStable();
+ expect(fixture.componentInstance.playbackStartPending()).toBe(false);
+ });
+
it('does not mount a VOD resolution after the catalog owner changes', async () => {
let resolve!: (value: { streamUrl: string }) => void;
resolveVodPlayback.mockReturnValueOnce(
diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts
index 96fffc7dd..34c038f54 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts
@@ -13,13 +13,14 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
- consumeStalkerReturnMarker,
- resolveStalkerBackNavigation,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
- createLogger,
+ consumeStalkerReturnMarker,
createInlinePlaybackPositionWriter,
+ createLogger,
+ createPendingPlaybackStart,
+ resolveStalkerBackNavigation,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
@@ -50,6 +51,7 @@ import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { startStalkerVodDownload } from './stalker-vod-download';
+import { createStalkerVodWatchedToggle } from '../stalker-vod-watched-toggle';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
@@ -108,6 +110,18 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
null
);
private unsubscribePositionUpdates: (() => void) | null = null;
+ private positionLoadGeneration = 0;
+ /** The stored row is in hand (not the placeholder shown while reading). */
+ readonly positionLoaded = signal(false);
+ /**
+ * The start still waiting on the portal between the click and playback,
+ * keyed by its owner: a stale resolution for the previous movie must not
+ * hold the next movie's toggle hostage.
+ */
+ private readonly pendingStart = createPendingPlaybackStart();
+ readonly playbackStartPending = computed(() =>
+ this.pendingStart.isPendingFor(this.playbackOwnerKey())
+ );
readonly isSeriesDetail = computed(() => {
const item = this.selectedItem();
@@ -134,6 +148,33 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
() => this.selectedVodPosition()?.positionSeconds ?? null
);
+ /** Manual watched toggle; the child gates it on live playback itself. */
+ readonly watchedToggle = createStalkerVodWatchedToggle({
+ owner: () => {
+ const playlistId = this.catalog.playlist()?.id;
+ const vodId = Number(this.selectedItem()?.id);
+ return playlistId && Number.isFinite(vodId)
+ ? { playlistId, vodId }
+ : null;
+ },
+ playbackPositions: this.playbackPositions,
+ position: this.selectedVodPosition,
+ playingNow: computed(
+ () => this.inlinePlayback() !== null || this.playbackStartPending()
+ ),
+ positionReady: this.positionLoaded,
+ applyPosition: (position) => {
+ // A read still in flight started from the pre-write row; letting
+ // it land would revert the toggle it never saw.
+ this.positionLoadGeneration++;
+ this.selectedVodPosition.set(position);
+ },
+ snackBar: this.snackBar,
+ translateService: this.translateService,
+ logger: this.logger,
+ onPersisted: (playlistId) => this.catalog.refreshPositions(playlistId),
+ });
+
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.catalog.playlist()?.id,
@@ -228,6 +269,10 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
});
}
+ onVodWatchedToggled(event: { item: VodDetailsItem }): void {
+ void this.watchedToggle.toggleItem(event.item);
+ }
+
onVodBack(): void {
const back = resolveStalkerBackNavigation(
window.history.state,
@@ -320,6 +365,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
playlistId: string,
vodId: number
): Promise {
+ const generation = ++this.positionLoadGeneration;
+ this.positionLoaded.set(false);
if (Number.isNaN(vodId)) {
this.selectedVodPosition.set(null);
return;
@@ -330,7 +377,17 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
vodId,
'vod'
);
+ // Only the newest read for the item still on screen may land: an
+ // older one would revert a watched toggle or a later selection.
+ if (
+ generation !== this.positionLoadGeneration ||
+ this.catalog.playlist()?.id !== playlistId ||
+ Number(this.selectedItem()?.id) !== vodId
+ ) {
+ return;
+ }
this.selectedVodPosition.set(position ?? null);
+ this.positionLoaded.set(true);
}
private async startStalkerVodPlayback(
@@ -345,6 +402,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer();
if (usesEmbeddedPlayer && !sessionKey) return;
+ const startId = this.pendingStart.begin(ownerKey);
try {
const playback = await this.catalog.resolveVodPlayback(
cmd,
@@ -384,6 +442,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
+ } finally {
+ this.pendingStart.settle(startId);
}
}
}
diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts
index 33601663f..9913d02a9 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts
@@ -148,7 +148,7 @@ describe('StalkerCatalogFacadeService', () => {
expect(
service.getItemProgress({ id: '42', is_series: isSeries })
- ).toEqual({ hasSeriesProgress: false });
+ ).toEqual({ watchState: 'unwatched' });
}
);
@@ -168,7 +168,7 @@ describe('StalkerCatalogFacadeService', () => {
);
expect(service.getItemProgress(item)).toEqual({
progress: 0,
- isWatched: false,
+ watchState: 'unwatched',
});
}
);
@@ -200,6 +200,25 @@ describe('StalkerCatalogFacadeService', () => {
);
});
+ it('reports a fully played movie as watched on its catalog card', async () => {
+ const service = TestBed.inject(StalkerCatalogFacadeService);
+ await Promise.resolve();
+
+ playbackUpdateHandler?.({
+ playlistId: playlist._id,
+ contentXtreamId: 17359,
+ contentType: 'vod',
+ positionSeconds: 5400,
+ durationSeconds: 5400,
+ });
+ await Promise.resolve();
+
+ expect(service.getItemProgress({ id: '17359' })).toEqual({
+ progress: 100,
+ watchState: 'watched',
+ });
+ });
+
it('ignores external playback updates for other playlists', async () => {
TestBed.inject(StalkerCatalogFacadeService);
await Promise.resolve();
@@ -218,9 +237,9 @@ describe('StalkerCatalogFacadeService', () => {
it('splits loading into the initial skeleton and the append tail by portal page', () => {
const service = TestBed.inject(StalkerCatalogFacadeService);
- const loading = stalkerStoreMock['isPaginatedContentLoading'] as ReturnType<
- typeof signal
- >;
+ const loading = stalkerStoreMock[
+ 'isPaginatedContentLoading'
+ ] as ReturnType>;
const page = stalkerStoreMock['page'] as ReturnType<
typeof signal
>;
@@ -237,9 +256,9 @@ describe('StalkerCatalogFacadeService', () => {
it('guards loadMore behind loading, append errors, and hasMore', () => {
const service = TestBed.inject(StalkerCatalogFacadeService);
- const loading = stalkerStoreMock['isPaginatedContentLoading'] as ReturnType<
- typeof signal
- >;
+ const loading = stalkerStoreMock[
+ 'isPaginatedContentLoading'
+ ] as ReturnType>;
const hasMore = stalkerStoreMock['hasMoreContent'] as ReturnType<
typeof signal
>;
@@ -294,9 +313,9 @@ describe('StalkerCatalogFacadeService', () => {
// The route provider (and this facade) survives a same-config portal
// switch — the identity must include the playlist.
const service = TestBed.inject(StalkerCatalogFacadeService);
- const currentPlaylist = stalkerStoreMock['currentPlaylist'] as ReturnType<
- typeof signal<{ _id: string } | undefined>
- >;
+ const currentPlaylist = stalkerStoreMock[
+ 'currentPlaylist'
+ ] as ReturnType>;
service.saveScrollPosition(420);
diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts
index 31f6df46f..ebfcc5323 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts
@@ -22,23 +22,12 @@ import {
PORTAL_CATALOG_FACADE,
PORTAL_PLAYBACK_POSITIONS,
StalkerPortalCatalogFacade,
+ getPortalPlaybackProgressPercent,
+ resolvePortalSeriesWatchState,
+ resolvePortalWatchState,
} from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
-function calculateProgress(position: PlaybackPositionData | undefined): number {
- if (!position || !position.durationSeconds) {
- return 0;
- }
-
- const percent = (position.positionSeconds / position.durationSeconds) * 100;
-
- if (position.positionSeconds > 10 && percent < 1) {
- return 1;
- }
-
- return Math.min(100, Math.round(percent));
-}
-
@Injectable()
export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
Record,
@@ -283,20 +272,16 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
this.contentType() === 'series' ||
isStalkerSeriesFlag(item.is_series);
- if (hasSeriesProgress) {
- return { hasSeriesProgress: true };
+ if (hasSeriesProgress || isSeries) {
+ return {
+ watchState: resolvePortalSeriesWatchState(hasSeriesProgress),
+ };
}
- if (isSeries) {
- return { hasSeriesProgress: false };
- }
-
- const progress = calculateProgress(
- this.stalkerPositions().get(`vod_${numericId}`)
- );
+ const position = this.stalkerPositions().get(`vod_${numericId}`);
return {
- progress,
- isWatched: progress >= 90,
+ progress: getPortalPlaybackProgressPercent(position),
+ watchState: resolvePortalWatchState(position),
};
}
diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts
index 86d328a7f..083c99f03 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts
@@ -21,8 +21,12 @@ import {
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
-import { PlaylistsService } from '@iptvnator/services';
import {
+ PlaybackPositionRuntimeBridgeService,
+ PlaylistsService,
+} from '@iptvnator/services';
+import {
+ PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
VodDetailsItem,
@@ -59,10 +63,18 @@ class StubStalkerInlineDetailComponent {
readonly playbackPosition = input(null);
readonly inlinePlayback = input(null);
readonly externalPlayback = input(null);
+ readonly isWatched = input(false);
+ readonly watchedToggleBusy = input(false);
+ readonly watchedToggleReady = input(true);
+ readonly playbackStartPending = input(false);
readonly backClicked = output();
readonly playClicked = output();
readonly resumeClicked = output();
readonly favoriteToggled = output();
+ readonly watchedToggled = output<{
+ item: VodDetailsItem;
+ watched: boolean;
+ }>();
readonly inlineTimeUpdated = output();
readonly inlinePlaybackClosed = output();
readonly streamUrlCopied = output();
@@ -99,12 +111,16 @@ describe('StalkerCollectionDetailComponent', () => {
};
let playbackPositions: {
savePlaybackPosition: jest.Mock;
+ savePlaybackPositionOrThrow: jest.Mock;
+ clearPlaybackPositionOrThrow: jest.Mock;
getPlaybackPosition: jest.Mock;
getSeriesPlaybackPositions: jest.Mock;
getAllPlaybackPositions: jest.Mock;
clearPlaybackPosition: jest.Mock;
};
let snackBar: { open: jest.Mock };
+ let playbackUpdateHandler:
+ ((data: PlaybackPositionData) => void) | undefined;
let playlistsService: { getPlaylistById: jest.Mock };
let routerNavigate: jest.Mock;
@@ -153,12 +169,15 @@ describe('StalkerCollectionDetailComponent', () => {
};
playbackPositions = {
savePlaybackPosition: jest.fn(),
+ savePlaybackPositionOrThrow: jest.fn(async () => undefined),
+ clearPlaybackPositionOrThrow: jest.fn(async () => undefined),
getPlaybackPosition: jest.fn(async () => null),
getSeriesPlaybackPositions: jest.fn(),
getAllPlaybackPositions: jest.fn(),
clearPlaybackPosition: jest.fn(),
};
snackBar = { open: jest.fn() };
+ playbackUpdateHandler = undefined;
playlistsService = {
getPlaylistById: jest.fn((playlistId: string) =>
of({ ...playlist, _id: playlistId })
@@ -213,6 +232,17 @@ describe('StalkerCollectionDetailComponent', () => {
onDefaultLangChange: new Subject(),
},
},
+ {
+ provide: PlaybackPositionRuntimeBridgeService,
+ useValue: {
+ onPlaybackPositionUpdate: jest.fn(
+ (handler: (data: PlaybackPositionData) => void) => {
+ playbackUpdateHandler = handler;
+ return () => undefined;
+ }
+ ),
+ },
+ },
{
provide: PlaylistsService,
useValue: {
@@ -549,6 +579,260 @@ describe('StalkerCollectionDetailComponent', () => {
expect(snackBar.open).not.toHaveBeenCalled();
});
+ it('marks a collection movie watched under its owning playlist', async () => {
+ const sourceItem = {
+ id: '1701',
+ title: 'Collection Movie',
+ category_id: 'vod',
+ cmd: '/media/file_1701.mpg',
+ info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
+ };
+ fixture.componentRef.setInput(
+ 'item',
+ buildCollectionItem({
+ contentType: 'movie',
+ categoryId: 'vod',
+ stalkerItem: sourceItem,
+ })
+ );
+ await settleDetail(fixture);
+ await settleDetail(fixture);
+
+ const detail = fixture.debugElement.query(
+ By.directive(StubStalkerInlineDetailComponent)
+ ).componentInstance as StubStalkerInlineDetailComponent;
+ expect(detail.isWatched()).toBe(false);
+
+ detail.watchedToggled.emit({
+ item: createStalkerVodItem(sourceItem, playlist._id),
+ watched: true,
+ });
+ await settleDetail(fixture);
+
+ expect(
+ playbackPositions.savePlaybackPositionOrThrow
+ ).toHaveBeenCalledWith(
+ 'stalker-1',
+ expect.objectContaining({
+ contentXtreamId: 1701,
+ contentType: 'vod',
+ })
+ );
+ expect(detail.isWatched()).toBe(true);
+ expect(snackBar.open).toHaveBeenCalledWith(
+ 'XTREAM.MOVIE_MARKED_WATCHED',
+ undefined,
+ expect.anything()
+ );
+ });
+
+ it('blocks the watched toggle while a collection Play is still resolving', async () => {
+ const sourceItem = {
+ id: '1701',
+ title: 'Collection Movie',
+ category_id: 'vod',
+ cmd: '/media/file_1701.mpg',
+ info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
+ };
+ let resolve!: (value: ResolvedPortalPlayback) => void;
+ stalkerStore.resolveVodPlayback.mockReturnValueOnce(
+ new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ })
+ );
+ fixture.componentRef.setInput(
+ 'item',
+ buildCollectionItem({
+ contentType: 'movie',
+ categoryId: 'vod',
+ stalkerItem: sourceItem,
+ })
+ );
+ await settleDetail(fixture);
+ await settleDetail(fixture);
+ const detail = fixture.debugElement.query(
+ By.directive(StubStalkerInlineDetailComponent)
+ ).componentInstance as StubStalkerInlineDetailComponent;
+
+ fixture.componentInstance.onVodPlay(
+ createStalkerVodItem(sourceItem, playlist._id)
+ );
+ fixture.detectChanges();
+ expect(detail.playbackStartPending()).toBe(true);
+
+ detail.watchedToggled.emit({
+ item: createStalkerVodItem(sourceItem, playlist._id),
+ watched: true,
+ });
+ await settleDetail(fixture);
+ expect(
+ playbackPositions.savePlaybackPositionOrThrow
+ ).not.toHaveBeenCalled();
+
+ resolve({ streamUrl: 'https://streams.test/a.mp4', title: 'x' });
+ await settleDetail(fixture);
+ expect(detail.playbackStartPending()).toBe(false);
+ });
+
+ it('does not carry a pending collection start over to the next item', async () => {
+ const sourceItem = {
+ id: '1701',
+ title: 'Collection Movie',
+ category_id: 'vod',
+ cmd: '/media/file_1701.mpg',
+ info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
+ };
+ let resolve!: (value: ResolvedPortalPlayback) => void;
+ stalkerStore.resolveVodPlayback.mockReturnValueOnce(
+ new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ })
+ );
+ fixture.componentRef.setInput(
+ 'item',
+ buildCollectionItem({
+ contentType: 'movie',
+ categoryId: 'vod',
+ stalkerItem: sourceItem,
+ })
+ );
+ await settleDetail(fixture);
+ await settleDetail(fixture);
+ fixture.componentInstance.onVodPlay(
+ createStalkerVodItem(sourceItem, playlist._id)
+ );
+ fixture.detectChanges();
+ expect(fixture.componentInstance.playback.playbackStartPending()).toBe(
+ true
+ );
+
+ fixture.componentRef.setInput(
+ 'item',
+ buildCollectionItem({
+ uid: 'stalker::stalker-1::item-2',
+ stalkerId: 'item-2',
+ contentType: 'movie',
+ categoryId: 'vod',
+ stalkerItem: { ...sourceItem, id: '1702' },
+ })
+ );
+ await settleDetail(fixture);
+ expect(fixture.componentInstance.playback.playbackStartPending()).toBe(
+ false
+ );
+
+ resolve({ streamUrl: 'https://streams.test/a.mp4', title: 'x' });
+ await settleDetail(fixture);
+ expect(fixture.componentInstance.playback.playbackStartPending()).toBe(
+ false
+ );
+ });
+
+ it('mirrors an external player position into the collection row', async () => {
+ const sourceItem = {
+ id: '1701',
+ title: 'Collection Movie',
+ category_id: 'vod',
+ cmd: '/media/file_1701.mpg',
+ info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
+ };
+ playbackPositions.getPlaybackPosition.mockResolvedValueOnce({
+ playlistId: 'stalker-1',
+ contentXtreamId: 1701,
+ contentType: 'vod',
+ positionSeconds: 5400,
+ durationSeconds: 5400,
+ });
+ fixture.componentRef.setInput(
+ 'item',
+ buildCollectionItem({
+ contentType: 'movie',
+ categoryId: 'vod',
+ stalkerItem: sourceItem,
+ })
+ );
+ await settleDetail(fixture);
+ await settleDetail(fixture);
+ const detail = fixture.debugElement.query(
+ By.directive(StubStalkerInlineDetailComponent)
+ ).componentInstance as StubStalkerInlineDetailComponent;
+ expect(detail.isWatched()).toBe(true);
+
+ // MPV replays the movie from the start; a foreign item's tick is ignored.
+ playbackUpdateHandler?.({
+ playlistId: 'stalker-1',
+ contentXtreamId: 9999,
+ contentType: 'vod',
+ positionSeconds: 10,
+ durationSeconds: 5400,
+ });
+ playbackUpdateHandler?.({
+ playlistId: 'stalker-1',
+ contentXtreamId: 1701,
+ contentType: 'vod',
+ positionSeconds: 1080,
+ durationSeconds: 5400,
+ });
+ await settleDetail(fixture);
+
+ expect(detail.isWatched()).toBe(false);
+ expect(detail.playbackPosition()).toBe(1080);
+ expect(detail.watchedToggleReady()).toBe(true);
+ });
+
+ it('blocks the collection toggle until the initial position read lands', async () => {
+ let resolveRead!: (value: null) => void;
+ playbackPositions.getPlaybackPosition.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveRead = resolve;
+ })
+ );
+ const sourceItem = {
+ id: '1701',
+ title: 'Collection Movie',
+ category_id: 'vod',
+ cmd: '/media/file_1701.mpg',
+ info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
+ };
+ fixture.componentRef.setInput(
+ 'item',
+ buildCollectionItem({
+ contentType: 'movie',
+ categoryId: 'vod',
+ stalkerItem: sourceItem,
+ })
+ );
+ await settleDetail(fixture);
+ await settleDetail(fixture);
+
+ const detail = fixture.debugElement.query(
+ By.directive(StubStalkerInlineDetailComponent)
+ ).componentInstance as StubStalkerInlineDetailComponent;
+ expect(detail.watchedToggleReady()).toBe(false);
+ detail.watchedToggled.emit({
+ item: createStalkerVodItem(sourceItem, playlist._id),
+ watched: true,
+ });
+ await settleDetail(fixture);
+ expect(
+ playbackPositions.savePlaybackPositionOrThrow
+ ).not.toHaveBeenCalled();
+
+ resolveRead(null);
+ await settleDetail(fixture);
+ expect(detail.watchedToggleReady()).toBe(true);
+
+ detail.watchedToggled.emit({
+ item: createStalkerVodItem(sourceItem, playlist._id),
+ watched: true,
+ });
+ await settleDetail(fixture);
+ expect(
+ playbackPositions.savePlaybackPositionOrThrow
+ ).toHaveBeenCalledTimes(1);
+ expect(detail.isWatched()).toBe(true);
+ });
+
it('does not load VOD playback position when the playlist id is missing', async () => {
const playlistsService = TestBed.inject(PlaylistsService) as {
getPlaylistById: jest.Mock;
diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts
index ad358096f..599159a6f 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts
@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
+ DestroyRef,
computed,
effect,
forwardRef,
@@ -36,7 +37,10 @@ import {
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
-import { PlaylistsService } from '@iptvnator/services';
+import {
+ PlaybackPositionRuntimeBridgeService,
+ PlaylistsService,
+} from '@iptvnator/services';
import { Playlist, VodDetailsItem } from '@iptvnator/shared/interfaces';
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
@@ -68,10 +72,15 @@ import {
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
+ [isWatched]="playback.watchedToggle.isWatched()"
+ [watchedToggleBusy]="playback.watchedToggle.busy()"
+ [watchedToggleReady]="playback.positionLoaded()"
+ [playbackStartPending]="playback.playbackStartPending()"
(backClicked)="closeRequested.emit()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
+ (watchedToggled)="playback.toggleSelectedVodWatched($event)"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
@@ -127,7 +136,7 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
vodDetailsItem: () => this.vodDetailsItem(),
});
- private readonly playback = new StalkerCollectionPlaybackController({
+ readonly playback = new StalkerCollectionPlaybackController({
item: () => this.item(),
stalkerStore: this.stalkerStore,
playbackPositions: this.playbackPositions,
@@ -169,6 +178,13 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
private currentPlaybackOwnerKey = '';
constructor() {
+ const unsubscribePositionUpdates = inject(
+ PlaybackPositionRuntimeBridgeService
+ ).onPlaybackPositionUpdate((data) =>
+ this.playback.applyRuntimePosition(data)
+ );
+ inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.());
+
effect(() => {
this.portalFavorites.value();
this.favorites.sync();
diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts
index 8520c6cdc..e53c547ab 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts
@@ -19,6 +19,7 @@ import {
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
+import { createStalkerVodWatchedToggle } from './stalker-vod-watched-toggle';
interface StalkerCollectionPlaybackOwner {
readonly sourceId: string;
@@ -60,6 +61,36 @@ export class StalkerCollectionPlaybackController {
private readonly vodPlayback: StalkerVodPlaybackController;
+ readonly playbackStartPending = computed(() =>
+ this.vodPlayback.playbackStartPending()
+ );
+ readonly positionLoaded = computed(() => this.vodPlayback.positionLoaded());
+
+ /** Manual watched toggle; the child gates it on live playback itself. */
+ readonly watchedToggle = createStalkerVodWatchedToggle({
+ owner: () => {
+ const owner = this.playbackOwner();
+ return owner
+ ? { playlistId: owner.sourceId, vodId: Number(owner.contentId) }
+ : null;
+ },
+ playbackPositions: this.config.playbackPositions,
+ position: this.selectedVodPosition,
+ playingNow: computed(
+ () => this.inlinePlayback() !== null || this.playbackStartPending()
+ ),
+ positionReady: this.positionLoaded,
+ applyPosition: (position) => {
+ // A read still in flight started from the pre-write row; letting
+ // it land would revert the toggle it never saw.
+ this.vodPlayback.discardPendingPositionLoad();
+ this.selectedVodPosition.set(position);
+ },
+ snackBar: this.config.snackBar,
+ translateService: this.config.translateService,
+ logger: this.config.logger,
+ });
+
constructor(
private readonly config: StalkerCollectionPlaybackControllerConfig
) {
@@ -126,10 +157,38 @@ export class StalkerCollectionPlaybackController {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
+ /**
+ * Mirrors an external player's position into the row while it owns
+ * this item. Persistence already has it; without this the row shown
+ * here (and the watched toggle's direction) would stay at what the
+ * one-time read returned before MPV/VLC played.
+ */
+ applyRuntimePosition(data: PlaybackPositionData): void {
+ const owner = this.playbackOwner();
+ if (
+ !owner ||
+ data.contentType !== 'vod' ||
+ data.playlistId !== owner.sourceId ||
+ String(data.contentXtreamId) !== owner.contentId
+ ) {
+ return;
+ }
+ // A live tick is newer than any stored read still in flight.
+ this.vodPlayback.discardPendingPositionLoad();
+ this.selectedVodPosition.set(data);
+ this.vodPlayback.positionLoaded.set(true);
+ }
+
clearSelectedVodPosition(): void {
+ this.vodPlayback.discardPendingPositionLoad();
+ this.vodPlayback.positionLoaded.set(false);
this.selectedVodPosition.set(null);
}
+ toggleSelectedVodWatched(event: { item: VodDetailsItem }): void {
+ void this.watchedToggle.toggleItem(event.item);
+ }
+
private async startVodPlayback(
cmd?: string,
title?: string,
diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html
index 00997ac87..f45dde9a2 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html
+++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html
@@ -22,9 +22,14 @@
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback()"
[providerOnly]="providerOnly()"
+ [isWatched]="isWatched()"
+ [watchedToggleBusy]="watchedToggleBusy()"
+ [watchedToggleReady]="watchedToggleReady()"
+ [playbackStartPending]="playbackStartPending()"
(playClicked)="onPlayClicked($event)"
(resumeClicked)="onResumeClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
+ (watchedToggled)="onWatchedToggled($event)"
(backClicked)="onBackClicked()"
(inlineTimeUpdated)="onInlineTimeUpdated($event)"
(inlinePlaybackClosed)="onInlinePlaybackClosed()"
diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts
index f892b3598..eab5d0668 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts
@@ -24,9 +24,14 @@ class StubVodDetailsComponent {
readonly playbackPosition = input(null);
readonly inlinePlayback = input(null);
readonly externalPlayback = input(null);
+ readonly isWatched = input(false);
+ readonly watchedToggleBusy = input(false);
+ readonly watchedToggleReady = input(true);
+ readonly playbackStartPending = input(false);
readonly playClicked = output();
readonly resumeClicked = output();
readonly favoriteToggled = output();
+ readonly watchedToggled = output<{ item: unknown; watched: boolean }>();
readonly backClicked = output();
readonly inlineTimeUpdated = output();
readonly inlinePlaybackClosed = output();
@@ -91,6 +96,33 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
expect(child.playbackSessionKey()).toBe('collection-owned-vod-key');
});
+ it('relays the watched state and toggle of regular VOD details', async () => {
+ fixture.componentRef.setInput('categoryId', 'vod');
+ fixture.componentRef.setInput('vodDetailsItem', VOD_ITEM);
+ fixture.componentRef.setInput('isWatched', true);
+ fixture.componentRef.setInput('watchedToggleBusy', true);
+ fixture.componentRef.setInput('watchedToggleReady', false);
+ fixture.componentRef.setInput('playbackStartPending', true);
+ const watchedToggled = jest.fn();
+ fixture.componentInstance.watchedToggled.subscribe(watchedToggled);
+ await fixture.whenStable();
+
+ const child = fixture.debugElement.query(
+ By.directive(StubVodDetailsComponent)
+ ).componentInstance as StubVodDetailsComponent;
+ expect(child.isWatched()).toBe(true);
+ expect(child.watchedToggleBusy()).toBe(true);
+ expect(child.watchedToggleReady()).toBe(false);
+ expect(child.playbackStartPending()).toBe(true);
+
+ child.watchedToggled.emit({ item: VOD_ITEM, watched: false });
+
+ expect(watchedToggled).toHaveBeenCalledWith({
+ item: VOD_ITEM,
+ watched: false,
+ });
+ });
+
it.each([
['embedded VOD series', 'vod', { id: '42', series: [1] }, false],
['Ministra VOD series', 'vod', { id: '42' }, true],
diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts
index 10fe38211..7980314d2 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts
@@ -42,6 +42,10 @@ export class StalkerInlineDetailComponent {
readonly inlinePlayback = input(null);
readonly externalPlayback = input(null);
readonly providerOnly = input(false);
+ readonly isWatched = input(false);
+ readonly watchedToggleBusy = input(false);
+ readonly watchedToggleReady = input(true);
+ readonly playbackStartPending = input(false);
readonly backClicked = output();
readonly playClicked = output();
@@ -53,6 +57,10 @@ export class StalkerInlineDetailComponent {
item: VodDetailsItem;
isFavorite: boolean;
}>();
+ readonly watchedToggled = output<{
+ item: VodDetailsItem;
+ watched: boolean;
+ }>();
readonly inlineTimeUpdated = output<{
currentTime: number;
duration: number;
@@ -78,6 +86,10 @@ export class StalkerInlineDetailComponent {
this.favoriteToggled.emit(event);
}
+ onWatchedToggled(event: { item: VodDetailsItem; watched: boolean }) {
+ this.watchedToggled.emit(event);
+ }
+
onInlineTimeUpdated(event: { currentTime: number; duration: number }) {
this.inlineTimeUpdated.emit(event);
}
diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts
index 94c28bd8e..36ba0ae1f 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts
@@ -1,10 +1,11 @@
-import type { WritableSignal } from '@angular/core';
+import { type WritableSignal, computed, signal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
-import type {
- Logger,
- PortalPlaybackPositions,
- PortalPlayer,
+import {
+ type Logger,
+ type PortalPlaybackPositions,
+ type PortalPlayer,
+ createPendingPlaybackStart,
} from '@iptvnator/portal/shared/util';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
@@ -25,6 +26,17 @@ interface StalkerVodPlaybackControllerConfig {
}
export class StalkerVodPlaybackController {
+ /**
+ * The start still waiting on the portal between the click and playback,
+ * keyed by its owner: a stale resolution for the previous item must not
+ * hold the next item's watched toggle hostage.
+ */
+ private readonly pendingStart = createPendingPlaybackStart<
+ string | undefined
+ >();
+ readonly playbackStartPending = computed(() =>
+ this.pendingStart.isPendingFor(this.config.playbackOwnerKey?.())
+ );
private lastInlineSaveTime = 0;
private loadSelectedVodPositionRequestId = 0;
private playbackRequestId = 0;
@@ -37,6 +49,7 @@ export class StalkerVodPlaybackController {
const requestId = ++this.playbackRequestId;
const usesEmbeddedPlayer = this.config.portalPlayer.isEmbeddedPlayer();
const playbackOwnerKey = this.config.playbackOwnerKey?.();
+ const startId = this.pendingStart.begin(playbackOwnerKey);
try {
const playback = await resolvePlayback();
if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) {
@@ -70,14 +83,29 @@ export class StalkerVodPlaybackController {
this.config.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
+ } finally {
+ this.pendingStart.settle(startId);
}
}
+ /** Retires a stored-position read still in flight (a row was written since). */
+ discardPendingPositionLoad(): void {
+ this.loadSelectedVodPositionRequestId++;
+ }
+
+ /**
+ * Whether `selectedVodPosition` is the stored row rather than the
+ * placeholder shown while the read is in flight. Fails closed: a read
+ * that never lands keeps the watched toggle off rather than guessing.
+ */
+ readonly positionLoaded = signal(false);
+
async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise {
const requestId = ++this.loadSelectedVodPositionRequestId;
+ this.positionLoaded.set(false);
if (!playlistId || !Number.isFinite(vodId)) {
this.config.selectedVodPosition.set(null);
@@ -95,6 +123,7 @@ export class StalkerVodPlaybackController {
}
this.config.selectedVodPosition.set(position ?? null);
+ this.positionLoaded.set(true);
}
handleInlineTimeUpdate(event: {
diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.spec.ts
new file mode 100644
index 000000000..e6d47a453
--- /dev/null
+++ b/libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.spec.ts
@@ -0,0 +1,96 @@
+import { signal } from '@angular/core';
+import type { MatSnackBar } from '@angular/material/snack-bar';
+import type { TranslateService } from '@ngx-translate/core';
+import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util';
+import {
+ PlaybackPositionData,
+ createStalkerVodItem,
+} from '@iptvnator/shared/interfaces';
+import {
+ StalkerVodWatchedOwner,
+ createStalkerVodWatchedToggle,
+} from './stalker-vod-watched-toggle';
+
+describe('createStalkerVodWatchedToggle', () => {
+ function setup(owner: StalkerVodWatchedOwner | null) {
+ const ownerSignal = signal(owner);
+ const position = signal(null);
+ const save = jest.fn().mockResolvedValue(undefined);
+ const snackBar = { open: jest.fn() };
+ const toggle = createStalkerVodWatchedToggle({
+ owner: () => ownerSignal(),
+ playbackPositions: {
+ savePlaybackPositionOrThrow: save,
+ clearPlaybackPositionOrThrow: jest
+ .fn()
+ .mockResolvedValue(undefined),
+ } as unknown as PortalPlaybackPositions,
+ position,
+ applyPosition: (next) => position.set(next),
+ playingNow: signal(false),
+ positionReady: signal(true),
+ snackBar: snackBar as unknown as MatSnackBar,
+ translateService: {
+ instant: (key: string) => key,
+ } as unknown as TranslateService,
+ logger: { error: jest.fn() } as never,
+ });
+ return { toggle, ownerSignal, position, save, snackBar };
+ }
+
+ const item = (id: string, playlistId = 'stalker-1') =>
+ createStalkerVodItem(
+ { id, cmd: `/media/${id}`, info: { name: 'Movie' } } as never,
+ playlistId
+ );
+
+ it('toggles the item the host currently owns and announces it', async () => {
+ const t = setup({ playlistId: 'stalker-1', vodId: 42 });
+
+ await expect(t.toggle.toggleItem(item('42'))).resolves.toBe(true);
+
+ expect(t.save).toHaveBeenCalledWith(
+ 'stalker-1',
+ expect.objectContaining({ contentXtreamId: 42, contentType: 'vod' })
+ );
+ expect(t.toggle.isWatched()).toBe(true);
+ expect(t.snackBar.open).toHaveBeenCalledWith(
+ 'XTREAM.MOVIE_MARKED_WATCHED',
+ undefined,
+ expect.anything()
+ );
+ });
+
+ it('refuses an item that is not the owner on screen', async () => {
+ const t = setup({ playlistId: 'stalker-1', vodId: 42 });
+
+ // The input moved on to 42 while 41 is still rendered.
+ await expect(t.toggle.toggleItem(item('41'))).resolves.toBe(false);
+ // Same id from another playlist is a different row entirely.
+ await expect(
+ t.toggle.toggleItem(item('42', 'stalker-2'))
+ ).resolves.toBe(false);
+ t.ownerSignal.set(null);
+ await expect(t.toggle.toggleItem(item('42'))).resolves.toBe(false);
+
+ expect(t.save).not.toHaveBeenCalled();
+ });
+
+ it('does not patch the row after the owner changed mid-write', async () => {
+ const t = setup({ playlistId: 'stalker-1', vodId: 42 });
+ let release!: () => void;
+ t.save.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ })
+ );
+
+ const pending = t.toggle.toggleItem(item('42'));
+ t.ownerSignal.set({ playlistId: 'stalker-1', vodId: 43 });
+ release();
+ await expect(pending).resolves.toBe(true);
+
+ expect(t.position()).toBeNull();
+ expect(t.snackBar.open).not.toHaveBeenCalled();
+ });
+});
diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.ts
new file mode 100644
index 000000000..e04af86fa
--- /dev/null
+++ b/libs/portal/stalker/feature/src/lib/stalker-vod-watched-toggle.ts
@@ -0,0 +1,95 @@
+import type { Signal } from '@angular/core';
+import type { MatSnackBar } from '@angular/material/snack-bar';
+import type { TranslateService } from '@ngx-translate/core';
+import {
+ type Logger,
+ type PortalPlaybackPositions,
+ VOD_WATCHED_FEEDBACK_KEYS,
+ type VodWatchedToggle,
+ createVodWatchedToggle,
+} from '@iptvnator/portal/shared/util';
+import type {
+ PlaybackPositionData,
+ VodDetailsItem,
+} from '@iptvnator/shared/interfaces';
+
+/** The movie the host currently shows; null while nothing is open. */
+export interface StalkerVodWatchedOwner {
+ playlistId: string;
+ vodId: number;
+}
+
+export interface StalkerVodWatchedToggle extends VodWatchedToggle {
+ /** Toggles the item the child emitted, scoped to the owner on screen. */
+ toggleItem(item: VodDetailsItem): Promise;
+}
+
+export interface StalkerVodWatchedToggleConfig {
+ owner: () => StalkerVodWatchedOwner | null;
+ playbackPositions: PortalPlaybackPositions;
+ position: Signal;
+ applyPosition: (position: PlaybackPositionData | null) => void;
+ playingNow: Signal;
+ positionReady: Signal;
+ snackBar: MatSnackBar;
+ translateService: TranslateService;
+ logger: Logger;
+ onPersisted?: (playlistId: string) => void | Promise;
+}
+
+/**
+ * The shared movie watched toggle with Stalker's snackbar feedback wired
+ * in — one place for the routed catalog detail and the collection detail.
+ */
+export function createStalkerVodWatchedToggle(
+ config: StalkerVodWatchedToggleConfig
+): StalkerVodWatchedToggle {
+ const toggle = createVodWatchedToggle({
+ playbackPositions: config.playbackPositions,
+ position: config.position,
+ applyPosition: config.applyPosition,
+ playingNow: config.playingNow,
+ positionReady: config.positionReady,
+ notify: (feedback) =>
+ config.snackBar.open(
+ config.translateService.instant(
+ VOD_WATCHED_FEEDBACK_KEYS[feedback]
+ ),
+ undefined,
+ { duration: 5000 }
+ ),
+ onPersisted: config.onPersisted,
+ logger: config.logger,
+ });
+
+ return {
+ ...toggle,
+ toggleItem(item) {
+ const owner = config.owner();
+ // The owner follows the host's input at once while the previous
+ // item stays rendered until its detail is prepared; a click in
+ // that window must not pair the new playlist with the old id.
+ if (
+ item.type !== 'stalker' ||
+ !owner ||
+ item.playlistId !== owner.playlistId ||
+ Number(item.data.id) !== owner.vodId
+ ) {
+ return Promise.resolve(false);
+ }
+ return toggle.toggle({
+ playlistId: owner.playlistId,
+ contentXtreamId: owner.vodId,
+ // Hosts are reused across movies: a late completion must
+ // neither patch the next movie's row nor announce there.
+ stillCurrent: () => {
+ const current = config.owner();
+ return (
+ current?.playlistId === owner.playlistId &&
+ current.vodId === owner.vodId
+ );
+ },
+ });
+ },
+ };
+}
diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts
index 7974c3cc4..63c39333f 100644
--- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts
+++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts
@@ -441,6 +441,46 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
});
});
+ it('reports a start as pending until the previous player is closed', async () => {
+ const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
+ activeSession.set(launched);
+ const closing = deferred();
+ closeSession.mockReturnValueOnce(closing.promise);
+
+ const pending = service.startResolvedPlayback({
+ streamUrl: 'https://example.com/second.mkv',
+ title: 'Second movie',
+ contentInfo: launched.contentInfo,
+ });
+ expect(service.playbackStartPending()).toBe(true);
+
+ closing.resolve();
+ await pending;
+
+ expect(service.playbackStartPending()).toBe(false);
+ });
+
+ it('does not carry a pending start over to the next routed movie', async () => {
+ const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
+ activeSession.set(launched);
+ const closing = deferred();
+ closeSession.mockReturnValueOnce(closing.promise);
+
+ const pending = service.startResolvedPlayback({
+ streamUrl: 'https://example.com/second.mkv',
+ title: 'Second movie',
+ contentInfo: launched.contentInfo,
+ });
+ expect(service.playbackStartPending()).toBe(true);
+
+ routeVodId.set(ROUTE_VOD_ID + 1);
+ expect(service.playbackStartPending()).toBe(false);
+
+ closing.resolve();
+ await pending;
+ expect(service.playbackStartPending()).toBe(false);
+ });
+
describe('stored position loads', () => {
it('drops a result after the route changes without starting another load', async () => {
const oldLoad = deferred();
@@ -492,6 +532,49 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(service.routePlaybackPosition()?.positionSeconds).toBe(300);
});
+ it('reports the row as loaded only once the read for the current route lands', async () => {
+ const load = deferred();
+ getPlaybackPosition.mockReturnValueOnce(load.promise);
+ expect(service.positionLoaded()).toBe(false);
+
+ const pending = service.loadPosition(ROUTE_PLAYLIST, ROUTE_VOD_ID);
+ expect(service.positionLoaded()).toBe(false);
+ load.resolve(null);
+ await pending;
+ expect(service.positionLoaded()).toBe(true);
+
+ // A stale answer for a route that moved on never flips it back on.
+ const stale = deferred();
+ getPlaybackPosition.mockReturnValueOnce(stale.promise);
+ const stalePending = service.loadPosition(
+ ROUTE_PLAYLIST,
+ ROUTE_VOD_ID
+ );
+ expect(service.positionLoaded()).toBe(false);
+ routeVodId.set(ROUTE_VOD_ID + 1);
+ stale.resolve(null);
+ await stalePending;
+ expect(service.positionLoaded()).toBe(false);
+ });
+
+ it('drops a pending result once a row was written since the read started', async () => {
+ const oldLoad = deferred();
+ getPlaybackPosition.mockReturnValueOnce(oldLoad.promise);
+ const pending = service.loadPosition(ROUTE_PLAYLIST, ROUTE_VOD_ID);
+
+ // The manual watched toggle writes and applies a row meanwhile.
+ service.discardPendingPositionLoads();
+ const watched = positionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID, 5400);
+ service.routePlaybackPosition.set(watched);
+ service.vodPlaybackPosition.set(watched);
+
+ oldLoad.resolve(null);
+ await pending;
+
+ expect(service.routePlaybackPosition()).toEqual(watched);
+ expect(service.vodPlaybackPosition()).toEqual(watched);
+ });
+
it('does not publish a pending result after destruction', async () => {
const oldLoad = deferred();
getPlaybackPosition.mockReturnValueOnce(oldLoad.promise);
diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts
index 0f3241f47..62529c89f 100644
--- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts
+++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts
@@ -13,7 +13,7 @@ import {
createExternalPlaybackButtonState,
createInlinePlaybackPositionWriter,
createLogger,
- getPortalPlaybackProgressPercent,
+ createPendingPlaybackStart,
} from '@iptvnator/portal/shared/util';
import {
resolveXtreamVodPlaybackSource,
@@ -40,7 +40,7 @@ import {
} from './vod-details-external-launch-owner';
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
-import { formatPlaybackPosition } from './vod-primary-action-position';
+import { isResumablePosition } from './vod-primary-action-position';
export interface VodDetailsPlaybackBindings {
/** Current vod id resolved from the route */
@@ -125,10 +125,6 @@ export class VodDetailsPlaybackService {
);
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
- readonly vodPlaybackProgress = computed(() =>
- getPortalPlaybackProgressPercent(this.vodPlaybackPosition())
- );
-
/** Mirrors an incoming position into the route's row when it owns it. */
private trackPosition(position: PlaybackPositionData | null): void {
this.vodPlaybackPosition.set(position);
@@ -146,17 +142,9 @@ export class VodDetailsPlaybackService {
}
/** Whether the ROUTE copy has somewhere to resume from. */
- readonly hasPlaybackPosition = computed(() => {
- const progress = getPortalPlaybackProgressPercent(
- this.routePlaybackPosition()
- );
- const inProgress = progress > 0 && progress < 90;
- this.logger.debug('hasPlaybackPosition check', {
- vodId: this.bindings()?.vodId(),
- inProgress,
- });
- return inProgress;
- });
+ readonly hasPlaybackPosition = computed(() =>
+ isResumablePosition(this.routePlaybackPosition())
+ );
constructor() {
const unsubscribePositionUpdates =
@@ -306,11 +294,9 @@ export class VodDetailsPlaybackService {
}
stopExternalPlayback(): Promise {
- return this.externalPlayback.closeSession(this.matchedExternalPlayback());
- }
-
- formatPosition(): string {
- return formatPlaybackPosition(this.routePlaybackPosition());
+ return this.externalPlayback.closeSession(
+ this.matchedExternalPlayback()
+ );
}
closeInlinePlayer(): void {
@@ -357,8 +343,21 @@ export class VodDetailsPlaybackService {
);
}
+ /**
+ * Retires every stored-position read still in flight. A row written
+ * since (the manual watched toggle) must not be overwritten by the older
+ * answer that read started from.
+ */
+ discardPendingPositionLoads(): void {
+ this.positionLoadGeneration++;
+ }
+
+ /** The route copy's row is in hand; fails closed while a read is in flight. */
+ readonly positionLoaded = signal(false);
+
async loadPosition(playlistId: string, vodId: number): Promise {
const generation = ++this.positionLoadGeneration;
+ this.positionLoaded.set(false);
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
@@ -375,6 +374,7 @@ export class VodDetailsPlaybackService {
this.vodPlaybackPosition.set(position);
this.routePlaybackPosition.set(position);
+ this.positionLoaded.set(true);
}
private addToRecentlyViewed(): void {
@@ -410,6 +410,19 @@ export class VodDetailsPlaybackService {
private launchedExternallyGeneration = 0;
private readonly externalLaunchGeneration = signal(null);
+ /**
+ * Starts still between the click and `inlinePlayback` / the external
+ * launch (closing the previous player is a round-trip). The watched
+ * toggle waits them out: a row written inside that window would be
+ * overwritten by the new player's first position tick.
+ */
+ private readonly pendingStart = createPendingPlaybackStart<
+ number | undefined
+ >();
+ readonly playbackStartPending = computed(() =>
+ this.pendingStart.isPendingFor(this.bindings()?.vodId())
+ );
+
async startResolvedPlayback(
playback: ResolvedPortalPlayback,
isCurrent: () => boolean = () => true
@@ -430,31 +443,37 @@ export class VodDetailsPlaybackService {
}
const generation = ++this.startGeneration;
+ const startId = this.pendingStart.begin(this.bindings()?.vodId());
+ try {
+ // A switch REPLACES what is playing. With MPV or VLC and instance
+ // reuse off, the backend spawns a second detached player
+ // otherwise — both sources keep running and Stop owns only the
+ // newer one.
+ const previousPlayerClosed = await closeRunningExternalSession(
+ runningSession,
+ (session) => this.externalPlayback.closeSession(session),
+ (message, error) => this.logger.warn(message, error)
+ );
- // A switch REPLACES what is playing. With MPV or VLC and instance
- // reuse off, the backend spawns a second detached player otherwise —
- // both sources keep running and Stop owns only the newer one.
- const previousPlayerClosed = await closeRunningExternalSession(
- runningSession,
- (session) => this.externalPlayback.closeSession(session),
- (message, error) => this.logger.warn(message, error)
- );
+ // Closing is a round-trip, and a second pick across it would
+ // otherwise reach this line too: both would have seen the same
+ // session, closed it once, and then launched independently — two
+ // detached players again, the older one holding a source the user
+ // has moved on from.
+ if (
+ !previousPlayerClosed ||
+ generation !== this.startGeneration ||
+ !isCurrent()
+ ) {
+ return false;
+ }
- // Closing is a round-trip, and a second pick across it would otherwise
- // reach this line too: both would have seen the same session, closed
- // it once, and then launched independently — two detached players
- // again, the older one holding a source the user has moved on from.
- if (
- !previousPlayerClosed ||
- generation !== this.startGeneration ||
- !isCurrent()
- ) {
- return false;
+ // Same movie, different source: still a view.
+ this.addToRecentlyViewed();
+ return await this.applyPlayback(playback, isCurrent);
+ } finally {
+ this.pendingStart.settle(startId);
}
-
- // Same movie, different source: still a view.
- this.addToRecentlyViewed();
- return await this.applyPlayback(playback, isCurrent);
}
private startPlayback(playback: ResolvedPortalPlayback): Promise {
@@ -483,11 +502,7 @@ export class VodDetailsPlaybackService {
this.closeInlinePlayer();
this.claimExternalLaunch(playback, generation);
const launch = this.portalPlayer.openResolvedPlayback(playback, true);
- return await this.settleExternalLaunch(
- generation,
- isCurrent,
- launch
- );
+ return await this.settleExternalLaunch(generation, isCurrent, launch);
}
private claimExternalLaunch(
@@ -514,8 +529,7 @@ export class VodDetailsPlaybackService {
error
),
clearPending: () => this.clearExternalLaunchPending(generation),
- clearOwnership: () =>
- this.clearExternalLaunchOwnership(generation),
+ clearOwnership: () => this.clearExternalLaunchOwnership(generation),
});
}
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 618a9b972..019500ff6 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
@@ -101,6 +101,8 @@ describe('VodDetailsRouteComponent fallback actions', () => {
const playDownload = jest.fn().mockResolvedValue(undefined);
const revealFile = jest.fn().mockResolvedValue({ success: true });
const toggleFavorite = jest.fn();
+ const savePlaybackPositionOrThrow = jest.fn().mockResolvedValue(undefined);
+ const loadAllPositions = jest.fn().mockResolvedValue(undefined);
const sparseItem = (): SparseVodItem => ({
info: [],
stream_id: 650020,
@@ -197,6 +199,8 @@ describe('VodDetailsRouteComponent fallback actions', () => {
playDownload.mockClear();
revealFile.mockClear();
toggleFavorite.mockClear();
+ savePlaybackPositionOrThrow.mockClear().mockResolvedValue(undefined);
+ loadAllPositions.mockClear();
await TestBed.configureTestingModule({
imports: [VodDetailsRouteComponent],
providers: [
@@ -247,6 +251,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
constructVodStreamUrl,
addRecentItem: jest.fn(),
backfillContentMetadata: jest.fn(),
+ loadAllPositions,
},
},
{
@@ -289,6 +294,10 @@ describe('VodDetailsRouteComponent fallback actions', () => {
useValue: {
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn(),
+ savePlaybackPositionOrThrow,
+ clearPlaybackPositionOrThrow: jest
+ .fn()
+ .mockResolvedValue(undefined),
},
},
{
@@ -353,6 +362,36 @@ describe('VodDetailsRouteComponent fallback actions', () => {
expect(fixture.componentInstance.selectedCatalogItem()).toBeNull();
expect(fixture.componentInstance.selectedCategory()).toBeNull();
});
+ it('marks the movie watched from the action row and refreshes catalog badges', async () => {
+ selectedItem.set(richItem());
+ fixture.detectChanges();
+ const host = fixture.nativeElement as HTMLElement;
+ const toggle = host.querySelector(
+ '[data-testid="vod-watched-toggle"]'
+ );
+ expect(toggle).not.toBeNull();
+ expect(toggle?.getAttribute('aria-pressed')).toBe('false');
+
+ toggle?.click();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(savePlaybackPositionOrThrow).toHaveBeenCalledWith(
+ 'playlist-1',
+ expect.objectContaining({
+ contentXtreamId: 650020,
+ contentType: 'vod',
+ })
+ );
+ expect(loadAllPositions).toHaveBeenCalledWith('playlist-1');
+ expect(
+ host
+ .querySelector('[data-testid="vod-watched-toggle"]')
+ ?.getAttribute('aria-pressed')
+ ).toBe('true');
+ // A watched copy reads Play, never "Resume" from its final seconds.
+ expect(host.querySelector('.play-btn--resume')).toBeNull();
+ });
it('starts playable sparse VOD inline with catalog presentation', () => {
const item = sparseItem();
selectedItem.set(item);
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 407855726..ec826e9e7 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
@@ -96,6 +96,24 @@
isFavorite() ? 'favorite' : 'favorite_border'
}}
+ @let watchedLabel =
+ (isWatched() ? 'XTREAM.MARK_UNWATCHED' : 'XTREAM.MARK_WATCHED')
+ | translate;
+
@if (isElectron() && !providerOnly()) {
@if (isDownloaded()) {
}
+
+ @let watchedLabel =
+ (isWatched() ? 'XTREAM.MARK_UNWATCHED' : 'XTREAM.MARK_WATCHED')
+ | translate;
+
+
@if (isElectron() && !providerOnly()) {
@if (isDownloaded()) {
diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts
index 157a94e73..0a1dc6b00 100644
--- a/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts
+++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts
@@ -148,10 +148,12 @@ describe('VodDetailsComponent offline playback', () => {
playbackPosition = null,
externalPlayback = null,
providerOnly = false,
+ isWatched = false,
}: {
playbackPosition?: number | null;
externalPlayback?: ExternalPlayerSession | null;
providerOnly?: boolean;
+ isWatched?: boolean;
} = {}) => {
fixture = TestBed.createComponent(VodDetailsComponent);
fixture.componentRef.setInput('item', STALKER_VOD);
@@ -162,6 +164,7 @@ describe('VodDetailsComponent offline playback', () => {
fixture.componentRef.setInput('playbackPosition', playbackPosition);
fixture.componentRef.setInput('externalPlayback', externalPlayback);
fixture.componentRef.setInput('providerOnly', providerOnly);
+ fixture.componentRef.setInput('isWatched', isWatched);
playClicked = jest.fn();
resumeClicked = jest.fn();
fixture.componentInstance.playClicked.subscribe(playClicked);
@@ -312,6 +315,8 @@ describe('VodDetailsComponent offline playback', () => {
PLAY: 'Play',
RESUME: 'Resume',
RESTART: 'Restart',
+ MARK_WATCHED: 'Mark as Watched',
+ MARK_UNWATCHED: 'Mark as Unwatched',
},
PORTALS: {
ADD_TO_FAVORITES: 'Add to favorites',
@@ -494,4 +499,99 @@ describe('VodDetailsComponent offline playback', () => {
expect(playClicked).toHaveBeenCalledWith(STALKER_VOD);
expect(playDownload).not.toHaveBeenCalled();
});
+
+ describe('manual watched toggle', () => {
+ const watchedButton = (): HTMLButtonElement =>
+ fixture.nativeElement.querySelector(
+ '[data-test-id="vod-watched-toggle"]'
+ ) as HTMLButtonElement;
+
+ it('offers Mark as Watched and emits the desired state', async () => {
+ await render({ playbackPosition: 600 });
+ const watchedToggled = jest.fn();
+ fixture.componentInstance.watchedToggled.subscribe(watchedToggled);
+
+ expect(buttonText(watchedButton())).toContain('Mark as Watched');
+ expect(watchedButton().getAttribute('aria-pressed')).toBe('false');
+ expect(buttonText(primaryButton())).toContain('Resume');
+
+ watchedButton().click();
+
+ expect(watchedToggled).toHaveBeenCalledWith({
+ item: STALKER_VOD,
+ watched: true,
+ });
+ });
+
+ it('shows Play instead of Resume once the movie is watched', async () => {
+ await render({ playbackPosition: 5400, isWatched: true });
+ const watchedToggled = jest.fn();
+ fixture.componentInstance.watchedToggled.subscribe(watchedToggled);
+
+ expect(buttonText(primaryButton())).toContain('Play');
+ expect(buttonText(primaryButton())).not.toContain('Resume');
+ expect(findButtonWithText('Restart')).toBeUndefined();
+ expect(buttonText(watchedButton())).toContain('Mark as Unwatched');
+ expect(watchedButton().getAttribute('aria-pressed')).toBe('true');
+ expect(watchedButton().classList).toContain('favorite-btn--active');
+
+ watchedButton().click();
+
+ expect(watchedToggled).toHaveBeenCalledWith({
+ item: STALKER_VOD,
+ watched: false,
+ });
+ });
+
+ it.each([
+ [
+ 'the inline player is mounted',
+ () =>
+ fixture.componentRef.setInput('inlinePlayback', {
+ streamUrl: 'https://portal.example/movie.mp4',
+ title: 'Catalog Movie',
+ }),
+ ],
+ [
+ 'an external session owns the movie',
+ () =>
+ fixture.componentRef.setInput(
+ 'externalPlayback',
+ MATCHING_MPV_SESSION
+ ),
+ ],
+ [
+ 'an external launch is pending',
+ () =>
+ fixture.componentRef.setInput(
+ 'externalPlayback',
+ MATCHING_LAUNCHING_MPV_SESSION
+ ),
+ ],
+ [
+ 'a watched write is in flight',
+ () => fixture.componentRef.setInput('watchedToggleBusy', true),
+ ],
+ [
+ 'the stored position has not loaded yet',
+ () =>
+ fixture.componentRef.setInput('watchedToggleReady', false),
+ ],
+ [
+ 'a playback start is still resolving',
+ () =>
+ fixture.componentRef.setInput('playbackStartPending', true),
+ ],
+ ])('disables the toggle while %s', async (_label, arrange) => {
+ await render();
+ const watchedToggled = jest.fn();
+ fixture.componentInstance.watchedToggled.subscribe(watchedToggled);
+ arrange();
+ fixture.detectChanges();
+
+ expect(watchedButton().disabled).toBe(true);
+ fixture.componentInstance.toggleWatched();
+ expect(watchedToggled).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
index c45d592a9..5b804f61e 100644
--- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
+++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
@@ -102,6 +102,21 @@ export class VodDetailsComponent {
/** Provider detail handoff hides local/download presentation only. */
readonly providerOnly = input(false);
+ /** The host's verdict on the position row (≥90% or marked by hand). */
+ readonly isWatched = input(false);
+
+ /** A watched write is in flight; the toggle waits for it. */
+ readonly watchedToggleBusy = input(false);
+
+ /** The host has the stored row in hand (`playbackPosition` is not a placeholder). */
+ readonly watchedToggleReady = input(true);
+
+ /**
+ * A Play/Resume is still resolving with the portal: nothing plays yet,
+ * but the player about to start would overwrite a row written now.
+ */
+ readonly playbackStartPending = input(false);
+
// ============ Outputs ============
/** Emitted when play button is clicked */
@@ -119,6 +134,12 @@ export class VodDetailsComponent {
isFavorite: boolean;
}>();
+ /** Emitted when the manual watched toggle is clicked (desired state) */
+ readonly watchedToggled = output<{
+ item: VodDetailsItem;
+ watched: boolean;
+ }>();
+
/** Emitted when back button is clicked */
readonly backClicked = output();
@@ -197,10 +218,13 @@ export class VodDetailsComponent {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
- /** Whether there's a playback position to resume from */
+ /**
+ * Whether there's a playback position to resume from. A watched movie
+ * shows Play, not "Resume 1:32:00" from its final seconds.
+ */
readonly hasPlaybackPosition = computed(() => {
const pos = this.playbackPosition();
- return pos !== null && pos > 0;
+ return pos !== null && pos > 0 && !this.isWatched();
});
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
@@ -293,6 +317,31 @@ export class VodDetailsComponent {
}
}
+ /**
+ * The manual watched toggle stays off while playback owns the row: the
+ * player persists its position every ~15 s and would overwrite a
+ * just-written full-progress row, silently flipping the movie back.
+ */
+ readonly canToggleWatched = computed(
+ () =>
+ !this.watchedToggleBusy() &&
+ this.watchedToggleReady() &&
+ !this.playbackStartPending() &&
+ this.inlinePlayback() === null &&
+ this.matchedExternalPlayback() === null &&
+ !this.isExternalLaunchPending()
+ );
+
+ toggleWatched(): void {
+ if (!this.canToggleWatched()) {
+ return;
+ }
+ this.watchedToggled.emit({
+ item: this.item(),
+ watched: !this.isWatched(),
+ });
+ }
+
/** Handle favorite toggle - emits the desired new state */
toggleFavorite(): void {
this.favoriteToggled.emit({
diff --git a/libs/ui/styles/_detail-view-actions.scss b/libs/ui/styles/_detail-view-actions.scss
index 71cfae339..3335f83f6 100644
--- a/libs/ui/styles/_detail-view-actions.scss
+++ b/libs/ui/styles/_detail-view-actions.scss
@@ -74,6 +74,24 @@
background: rgba(255, 91, 110, 0.24);
}
}
+
+ // The watched toggle shares the ghost surface but reads green when
+ // active, like the catalog's watched badge.
+ &.watched-btn.favorite-btn--active {
+ background: rgba(74, 222, 128, 0.16);
+ border-color: rgba(74, 222, 128, 0.4);
+ color: #4ade80;
+
+ &:hover {
+ background: rgba(74, 222, 128, 0.24);
+ }
+ }
+
+ &.watched-btn:disabled {
+ opacity: 0.55;
+ cursor: default;
+ pointer-events: none;
+ }
}
@mixin download-button-state {
@@ -174,6 +192,21 @@
animation: icon-action-pulse 180ms ease;
}
+ // Watched: the same green as the catalog badge, filled glyph.
+ &--watched.is-active {
+ color: #4ade80;
+
+ &:hover {
+ color: #6ee7a0;
+ }
+ }
+
+ &--watched:disabled {
+ opacity: 0.55;
+ cursor: default;
+ pointer-events: none;
+ }
+
// Downloaded: green check; the click now opens the file's location.
&--done {
color: #4ade80;