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;