mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(portal): mark movies as watched from the detail page (#1605)
This commit is contained in:
1 parent
e9eca1c386
commit
7f724494b9
57 files changed
+2420
-122
No files matched your search
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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": "البيانات الوصفية للبوابة غير متاحة",
|
||||
|
||||
@@ -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": "البيانات الوصفية ديال البوابة ما متوفراش",
|
||||
|
||||
@@ -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": "Метаданыя партала недаступныя",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ポータルのメタデータは利用できません",
|
||||
|
||||
@@ -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": "포털 메타데이터를 사용할 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Метаданные портала недоступны",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "门户元数据不可用",
|
||||
|
||||
@@ -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": "無法取得入口網站中繼資料",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }
|
||||
"
|
||||
>
|
||||
<mat-icon>history</mat-icon>
|
||||
@@ -181,16 +182,22 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
|
||||
status as text for AT users -->
|
||||
<span class="visually-hidden">{{
|
||||
catchupLabelKey(i)
|
||||
| translate: { days: catchupDays(i) }
|
||||
| translate
|
||||
: { days: catchupDays(i) }
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (i.isWatched) {
|
||||
@if (i.watchState === 'watched') {
|
||||
<app-watched-badge
|
||||
[isWatched]="true"
|
||||
icon="check_circle"
|
||||
/>
|
||||
} @else if (i.hasSeriesProgress) {
|
||||
} @else if (
|
||||
i.watchState === 'in-progress' && !i.progress
|
||||
) {
|
||||
<!-- Started with no percent to draw (a series):
|
||||
the eye says "touched", the capsule above
|
||||
already says it for a movie. -->
|
||||
<app-watched-badge
|
||||
[isWatched]="true"
|
||||
icon="remove_red_eye"
|
||||
|
||||
@@ -39,3 +39,6 @@ export * from './lib/external-playback-button-state';
|
||||
export * from './lib/inline-playback-position-writer';
|
||||
export * from './lib/downloads/download-metadata-snapshot';
|
||||
export * from './lib/downloads/episode-download-identity';
|
||||
export * from './lib/portal-watch-state';
|
||||
export * from './lib/vod-watched-toggle';
|
||||
export * from './lib/pending-playback-start';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from '@angular/core';
|
||||
import { createPendingPlaybackStart } from './pending-playback-start';
|
||||
|
||||
describe('createPendingPlaybackStart', () => {
|
||||
it('is pending only for the owner of the latest start', () => {
|
||||
const tracker = createPendingPlaybackStart<string>();
|
||||
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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<TOwner> {
|
||||
/** 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<TOwner> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string>;
|
||||
resolveVodPlayback(
|
||||
cmd?: string,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PlaybackPositionData | null>(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<PlaybackPositionData | null>(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<PlaybackPositionData | null>(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<void>((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]);
|
||||
});
|
||||
});
|
||||
@@ -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<PlaybackPositionData | null>;
|
||||
/** 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<boolean>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
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<void>;
|
||||
logger?: Pick<Logger, 'error'>;
|
||||
}
|
||||
|
||||
export interface VodWatchedToggle {
|
||||
isWatched: Signal<boolean>;
|
||||
busy: Signal<boolean>;
|
||||
enabled: Signal<boolean>;
|
||||
/** Resolves true when the write landed, false when nothing changed. */
|
||||
toggle(target: VodWatchedToggleTarget): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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',
|
||||
};
|
||||
+5
@@ -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)"
|
||||
|
||||
+136
-1
@@ -52,9 +52,14 @@ class StubVodDetailsComponent {
|
||||
readonly playbackPosition = input<number | null>(null);
|
||||
readonly inlinePlayback = input<unknown>(null);
|
||||
readonly externalPlayback = input<unknown>(null);
|
||||
readonly isWatched = input(false);
|
||||
readonly watchedToggleBusy = input(false);
|
||||
readonly watchedToggleReady = input(true);
|
||||
readonly playbackStartPending = input(false);
|
||||
readonly playClicked = output<unknown>();
|
||||
readonly resumeClicked = output<unknown>();
|
||||
readonly favoriteToggled = output<unknown>();
|
||||
readonly watchedToggled = output<{ item: unknown; watched: boolean }>();
|
||||
readonly downloadRequested = output<unknown>();
|
||||
readonly backClicked = output<void>();
|
||||
readonly inlineTimeUpdated = output<unknown>();
|
||||
@@ -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<null>((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(
|
||||
|
||||
+63
-3
@@ -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<string>();
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>
|
||||
>;
|
||||
const loading = stalkerStoreMock[
|
||||
'isPaginatedContentLoading'
|
||||
] as ReturnType<typeof signal<boolean>>;
|
||||
const page = stalkerStoreMock['page'] as ReturnType<
|
||||
typeof signal<number>
|
||||
>;
|
||||
@@ -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<boolean>
|
||||
>;
|
||||
const loading = stalkerStoreMock[
|
||||
'isPaginatedContentLoading'
|
||||
] as ReturnType<typeof signal<boolean>>;
|
||||
const hasMore = stalkerStoreMock['hasMoreContent'] as ReturnType<
|
||||
typeof signal<boolean>
|
||||
>;
|
||||
@@ -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<typeof signal<{ _id: string } | undefined>>;
|
||||
|
||||
service.saveScrollPosition(420);
|
||||
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly externalPlayback = input<unknown>(null);
|
||||
readonly isWatched = input(false);
|
||||
readonly watchedToggleBusy = input(false);
|
||||
readonly watchedToggleReady = input(true);
|
||||
readonly playbackStartPending = input(false);
|
||||
readonly backClicked = output<void>();
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
readonly resumeClicked = output<unknown>();
|
||||
readonly favoriteToggled = output<unknown>();
|
||||
readonly watchedToggled = output<{
|
||||
item: VodDetailsItem;
|
||||
watched: boolean;
|
||||
}>();
|
||||
readonly inlineTimeUpdated = output<unknown>();
|
||||
readonly inlinePlaybackClosed = output<void>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
@@ -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<ResolvedPortalPlayback>((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<ResolvedPortalPlayback>((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<null>((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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
@@ -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()"
|
||||
|
||||
+32
@@ -24,9 +24,14 @@ class StubVodDetailsComponent {
|
||||
readonly playbackPosition = input<number | null>(null);
|
||||
readonly inlinePlayback = input<unknown>(null);
|
||||
readonly externalPlayback = input<unknown>(null);
|
||||
readonly isWatched = input(false);
|
||||
readonly watchedToggleBusy = input(false);
|
||||
readonly watchedToggleReady = input(true);
|
||||
readonly playbackStartPending = input(false);
|
||||
readonly playClicked = output<unknown>();
|
||||
readonly resumeClicked = output<unknown>();
|
||||
readonly favoriteToggled = output<unknown>();
|
||||
readonly watchedToggled = output<{ item: unknown; watched: boolean }>();
|
||||
readonly backClicked = output<void>();
|
||||
readonly inlineTimeUpdated = output<unknown>();
|
||||
readonly inlinePlaybackClosed = output<void>();
|
||||
@@ -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],
|
||||
|
||||
+12
@@ -42,6 +42,10 @@ export class StalkerInlineDetailComponent {
|
||||
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
|
||||
readonly providerOnly = input(false);
|
||||
readonly isWatched = input(false);
|
||||
readonly watchedToggleBusy = input(false);
|
||||
readonly watchedToggleReady = input(true);
|
||||
readonly playbackStartPending = input(false);
|
||||
|
||||
readonly backClicked = output<void>();
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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: {
|
||||
|
||||
@@ -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<PlaybackPositionData | null>(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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
export interface StalkerVodWatchedToggleConfig {
|
||||
owner: () => StalkerVodWatchedOwner | null;
|
||||
playbackPositions: PortalPlaybackPositions;
|
||||
position: Signal<PlaybackPositionData | null>;
|
||||
applyPosition: (position: PlaybackPositionData | null) => void;
|
||||
playingNow: Signal<boolean>;
|
||||
positionReady: Signal<boolean>;
|
||||
snackBar: MatSnackBar;
|
||||
translateService: TranslateService;
|
||||
logger: Logger;
|
||||
onPersisted?: (playlistId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>();
|
||||
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<void>();
|
||||
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<PlaybackPositionData | null>();
|
||||
@@ -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<PlaybackPositionData | null>();
|
||||
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<PlaybackPositionData | null>();
|
||||
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<PlaybackPositionData | null>();
|
||||
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<PlaybackPositionData | null>();
|
||||
getPlaybackPosition.mockReturnValueOnce(oldLoad.promise);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<number | null>(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<boolean> {
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLButtonElement>(
|
||||
'[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);
|
||||
|
||||
@@ -96,6 +96,24 @@
|
||||
isFavorite() ? 'favorite' : 'favorite_border'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
@let watchedLabel =
|
||||
(isWatched() ? 'XTREAM.MARK_UNWATCHED' : 'XTREAM.MARK_WATCHED')
|
||||
| translate;
|
||||
<button
|
||||
type="button"
|
||||
class="icon-action-btn icon-action-btn--watched"
|
||||
[class.is-active]="isWatched()"
|
||||
[disabled]="!canToggleWatched()"
|
||||
(click)="toggleWatched(playableItem)"
|
||||
[matTooltip]="watchedLabel"
|
||||
[attr.aria-label]="watchedLabel"
|
||||
[attr.aria-pressed]="isWatched()"
|
||||
data-testid="vod-watched-toggle"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isWatched() ? 'check_circle' : 'check_circle_outline'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
@if (isElectron() && !providerOnly()) {
|
||||
@if (isDownloaded()) {
|
||||
<button
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service';
|
||||
import { VodDetailsDownloadsService } from './vod-details-downloads.service';
|
||||
import { VodDetailsWatchedService } from './vod-details-watched.service';
|
||||
import { VodDetailsSimilarService } from './vod-details-similar.service';
|
||||
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
|
||||
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
@@ -111,6 +112,7 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null {
|
||||
VodDetailsMultiSourceUiService,
|
||||
VodDetailsSimilarService,
|
||||
VodDetailsDownloadsService,
|
||||
VodDetailsWatchedService,
|
||||
],
|
||||
imports: [
|
||||
DetailActionsTemplateDirective,
|
||||
@@ -145,6 +147,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
private readonly msUi = inject(VodDetailsMultiSourceUiService);
|
||||
private readonly similar = inject(VodDetailsSimilarService);
|
||||
private readonly downloads = inject(VodDetailsDownloadsService);
|
||||
private readonly watched = inject(VodDetailsWatchedService);
|
||||
private readonly logger = createLogger('VodDetailsRoute');
|
||||
/** `playlistId:vodId` of the last initialized detail view */
|
||||
private readonly lastInitKey = signal<string | null>(null);
|
||||
@@ -166,6 +169,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
readonly isElectron = this.downloadsService.isAvailable;
|
||||
|
||||
readonly isFavorite = this.xtreamStore.isFavorite;
|
||||
readonly isWatched = this.watched.isWatched;
|
||||
readonly canToggleWatched = this.watched.canToggle;
|
||||
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.xtreamStore.currentPlaylist()?.id;
|
||||
@@ -319,7 +324,6 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
readonly isExternalStopAction = this.playback.isExternalStopAction;
|
||||
readonly externalPrimaryButtonState =
|
||||
this.playback.externalPrimaryButtonState;
|
||||
readonly vodPlaybackProgress = this.playback.vodPlaybackProgress;
|
||||
|
||||
readonly hasPlaybackPosition = this.msUi.hasPlaybackPosition;
|
||||
|
||||
@@ -449,6 +453,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
this.initializeVodDetails(playlistId, vodId);
|
||||
});
|
||||
|
||||
this.watched.bind(this.selectedVodId);
|
||||
|
||||
registerContentMetadataBackfill({
|
||||
store: this.xtreamStore,
|
||||
contentType: 'movie',
|
||||
@@ -658,6 +664,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
toggleWatched(vodItem: XtreamVodDetails | null): Promise<boolean> {
|
||||
return this.watched.toggleWatched(vodItem);
|
||||
}
|
||||
|
||||
getBackdropUrl(info: XtreamVodInfo): string | undefined {
|
||||
return info.backdrop_path?.[0];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamVodDetails,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
import { VodDetailsWatchedService } from './vod-details-watched.service';
|
||||
|
||||
const PLAYLIST = 'playlist-1';
|
||||
const VOD_ID = 650020;
|
||||
|
||||
const vodItem = (): XtreamVodDetails =>
|
||||
({
|
||||
info: {
|
||||
name: 'Metadata movie',
|
||||
duration_secs: 5400,
|
||||
},
|
||||
movie_data: {
|
||||
stream_id: VOD_ID,
|
||||
name: 'Metadata movie',
|
||||
container_extension: 'mp4',
|
||||
},
|
||||
}) as unknown as XtreamVodDetails;
|
||||
|
||||
const watchedRow = (): PlaybackPositionData => ({
|
||||
playlistId: PLAYLIST,
|
||||
contentXtreamId: VOD_ID,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 5400,
|
||||
durationSeconds: 5400,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
describe('VodDetailsWatchedService', () => {
|
||||
const routePlaybackPosition = signal<PlaybackPositionData | null>(null);
|
||||
const vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
|
||||
const inlinePlayback = signal<unknown>(null);
|
||||
const matchedExternalPlayback = signal<unknown>(null);
|
||||
const isExternalLaunchPending = signal(false);
|
||||
const playbackStartPending = signal(false);
|
||||
const positionLoaded = signal(true);
|
||||
const currentPlaylist = signal<{ id: string } | null>({ id: PLAYLIST });
|
||||
const routeVodId = signal(VOD_ID);
|
||||
const loadAllPositions = jest.fn().mockResolvedValue(undefined);
|
||||
const discardPendingPositionLoads = jest.fn();
|
||||
const savePlaybackPositionOrThrow = jest.fn().mockResolvedValue(undefined);
|
||||
const clearPlaybackPositionOrThrow = jest.fn().mockResolvedValue(undefined);
|
||||
const snackBarOpen = jest.fn();
|
||||
let service: VodDetailsWatchedService;
|
||||
|
||||
beforeEach(() => {
|
||||
routePlaybackPosition.set(null);
|
||||
vodPlaybackPosition.set(null);
|
||||
inlinePlayback.set(null);
|
||||
matchedExternalPlayback.set(null);
|
||||
isExternalLaunchPending.set(false);
|
||||
playbackStartPending.set(false);
|
||||
positionLoaded.set(true);
|
||||
currentPlaylist.set({ id: PLAYLIST });
|
||||
routeVodId.set(VOD_ID);
|
||||
loadAllPositions.mockClear();
|
||||
discardPendingPositionLoads.mockClear();
|
||||
savePlaybackPositionOrThrow.mockClear().mockResolvedValue(undefined);
|
||||
clearPlaybackPositionOrThrow.mockClear().mockResolvedValue(undefined);
|
||||
snackBarOpen.mockClear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
VodDetailsWatchedService,
|
||||
{
|
||||
provide: VodDetailsPlaybackService,
|
||||
useValue: {
|
||||
routePlaybackPosition,
|
||||
vodPlaybackPosition,
|
||||
inlinePlayback,
|
||||
matchedExternalPlayback,
|
||||
isExternalLaunchPending,
|
||||
playbackStartPending,
|
||||
positionLoaded,
|
||||
discardPendingPositionLoads,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: { currentPlaylist, loadAllPositions },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
savePlaybackPositionOrThrow,
|
||||
clearPlaybackPositionOrThrow,
|
||||
},
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: { open: snackBarOpen } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(VodDetailsWatchedService);
|
||||
service.bind(routeVodId);
|
||||
});
|
||||
|
||||
it('marks the route copy watched with the provider runtime and refreshes the catalog', async () => {
|
||||
expect(service.isWatched()).toBe(false);
|
||||
|
||||
await expect(service.toggleWatched(vodItem())).resolves.toBe(true);
|
||||
|
||||
expect(savePlaybackPositionOrThrow).toHaveBeenCalledWith(
|
||||
PLAYLIST,
|
||||
expect.objectContaining({
|
||||
contentXtreamId: VOD_ID,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 5400,
|
||||
durationSeconds: 5400,
|
||||
})
|
||||
);
|
||||
expect(service.isWatched()).toBe(true);
|
||||
// An in-flight stored read must not land over the written row.
|
||||
expect(discardPendingPositionLoads).toHaveBeenCalledTimes(1);
|
||||
expect(routePlaybackPosition()?.positionSeconds).toBe(5400);
|
||||
expect(vodPlaybackPosition()?.positionSeconds).toBe(5400);
|
||||
expect(loadAllPositions).toHaveBeenCalledWith(PLAYLIST);
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.MOVIE_MARKED_WATCHED',
|
||||
undefined,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('unmarks a watched movie by clearing its row', async () => {
|
||||
routePlaybackPosition.set(watchedRow());
|
||||
expect(service.isWatched()).toBe(true);
|
||||
|
||||
await expect(service.toggleWatched(vodItem())).resolves.toBe(true);
|
||||
|
||||
expect(clearPlaybackPositionOrThrow).toHaveBeenCalledWith(
|
||||
PLAYLIST,
|
||||
VOD_ID,
|
||||
'vod'
|
||||
);
|
||||
expect(routePlaybackPosition()).toBeNull();
|
||||
expect(service.isWatched()).toBe(false);
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.MOVIE_MARKED_UNWATCHED',
|
||||
undefined,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does not touch the page after the route moved to another movie', async () => {
|
||||
let release!: () => void;
|
||||
savePlaybackPositionOrThrow.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const pending = service.toggleWatched(vodItem());
|
||||
routeVodId.set(999);
|
||||
release();
|
||||
await expect(pending).resolves.toBe(true);
|
||||
|
||||
expect(routePlaybackPosition()).toBeNull();
|
||||
expect(snackBarOpen).not.toHaveBeenCalled();
|
||||
// The row landed for that playlist, so its grid still refreshes.
|
||||
expect(loadAllPositions).toHaveBeenCalledWith(PLAYLIST);
|
||||
});
|
||||
|
||||
it('does not reload the old playlist over the one the user moved to', async () => {
|
||||
let release!: () => void;
|
||||
savePlaybackPositionOrThrow.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const pending = service.toggleWatched(vodItem());
|
||||
currentPlaylist.set({ id: 'playlist-2' });
|
||||
release();
|
||||
await expect(pending).resolves.toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The store's position map belongs to playlist-2 now; reloading
|
||||
// playlist-1 into it would mislabel colliding ids.
|
||||
expect(loadAllPositions).not.toHaveBeenCalled();
|
||||
expect(routePlaybackPosition()).toBeNull();
|
||||
expect(snackBarOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays disabled while this movie is playing', () => {
|
||||
expect(service.canToggle()).toBe(true);
|
||||
inlinePlayback.set({ streamUrl: 'x' });
|
||||
expect(service.canToggle()).toBe(false);
|
||||
inlinePlayback.set(null);
|
||||
matchedExternalPlayback.set({ id: 'mpv' });
|
||||
expect(service.canToggle()).toBe(false);
|
||||
matchedExternalPlayback.set(null);
|
||||
isExternalLaunchPending.set(true);
|
||||
expect(service.canToggle()).toBe(false);
|
||||
isExternalLaunchPending.set(false);
|
||||
playbackStartPending.set(true);
|
||||
expect(service.canToggle()).toBe(false);
|
||||
playbackStartPending.set(false);
|
||||
positionLoaded.set(false);
|
||||
expect(service.canToggle()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses without a playlist or a positive vod id', async () => {
|
||||
currentPlaylist.set(null);
|
||||
await expect(service.toggleWatched(vodItem())).resolves.toBe(false);
|
||||
currentPlaylist.set({ id: PLAYLIST });
|
||||
routeVodId.set(NaN);
|
||||
await expect(service.toggleWatched(vodItem())).resolves.toBe(false);
|
||||
expect(savePlaybackPositionOrThrow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a refused write and keeps the rendered state', async () => {
|
||||
savePlaybackPositionOrThrow.mockRejectedValue(new Error('nope'));
|
||||
const errorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await expect(service.toggleWatched(vodItem())).resolves.toBe(false);
|
||||
|
||||
expect(service.isWatched()).toBe(false);
|
||||
expect(loadAllPositions).not.toHaveBeenCalled();
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.MOVIE_WATCH_UPDATE_FAILED',
|
||||
undefined,
|
||||
expect.anything()
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
VOD_WATCHED_FEEDBACK_KEYS,
|
||||
createLogger,
|
||||
createVodWatchedToggle,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XtreamVodDetails,
|
||||
getXtreamVodInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
|
||||
/**
|
||||
* Manual watched toggle for the Xtream VOD details route, wrapping the
|
||||
* shared helper with this page's row, playback ownership and feedback.
|
||||
*
|
||||
* The toggle acts on the ROUTE copy's row only: positions are keyed by
|
||||
* (playlist, stream), so a pinned alternative in another playlist keeps its
|
||||
* own state, exactly as playback would leave it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VodDetailsWatchedService {
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly playback = inject(VodDetailsPlaybackService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly logger = createLogger('VodDetailsWatched');
|
||||
|
||||
private readonly toggle = createVodWatchedToggle({
|
||||
playbackPositions: this.playbackPositions,
|
||||
position: this.playback.routePlaybackPosition,
|
||||
applyPosition: (position) => {
|
||||
// A read still in flight started from the pre-write row; letting
|
||||
// it land would revert the toggle it never saw.
|
||||
this.playback.discardPendingPositionLoads();
|
||||
this.playback.routePlaybackPosition.set(position);
|
||||
this.playback.vodPlaybackPosition.set(position);
|
||||
},
|
||||
// Inline mounted, or an external session live/launching for this
|
||||
// movie: its next position tick would overwrite the written row.
|
||||
playingNow: computed(
|
||||
() =>
|
||||
this.playback.inlinePlayback() !== null ||
|
||||
this.playback.matchedExternalPlayback() !== null ||
|
||||
this.playback.isExternalLaunchPending() ||
|
||||
this.playback.playbackStartPending()
|
||||
),
|
||||
positionReady: this.playback.positionLoaded,
|
||||
notify: (feedback) =>
|
||||
this.snackBar.open(
|
||||
this.translate.instant(VOD_WATCHED_FEEDBACK_KEYS[feedback]),
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
),
|
||||
// Catalog cards read the store's position map, which only the
|
||||
// once-per-playlist load and external-player pushes fill. That map is
|
||||
// global and latest-load-wins, so a write that lands after the user
|
||||
// moved to another playlist must not reload the old one over it.
|
||||
onPersisted: (playlistId) =>
|
||||
this.xtreamStore.currentPlaylist()?.id === playlistId
|
||||
? this.xtreamStore.loadAllPositions(playlistId)
|
||||
: undefined,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
readonly isWatched = this.toggle.isWatched;
|
||||
readonly canToggle = this.toggle.enabled;
|
||||
|
||||
/** The vod id the route currently shows; the host binds it once. */
|
||||
private readonly routeVodId = signal<Signal<number> | null>(null);
|
||||
|
||||
bind(routeVodId: Signal<number>): void {
|
||||
this.routeVodId.set(routeVodId);
|
||||
}
|
||||
|
||||
/** Flips the watched state of the movie the route currently shows. */
|
||||
async toggleWatched(vodItem: XtreamVodDetails | null): Promise<boolean> {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
const vodId = this.routeVodId()?.() ?? NaN;
|
||||
if (!playlistId || !Number.isFinite(vodId) || vodId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const info = vodItem ? getXtreamVodInfo(vodItem) : null;
|
||||
return this.toggle.toggle({
|
||||
playlistId,
|
||||
contentXtreamId: vodId,
|
||||
durationSeconds: info?.duration_secs,
|
||||
stillCurrent: () =>
|
||||
this.xtreamStore.currentPlaylist()?.id === playlistId &&
|
||||
this.routeVodId()?.() === vodId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
PortalCatalogSortMode,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalCatalogSortMode } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
XtreamPlaylistData,
|
||||
XtreamStore,
|
||||
@@ -79,7 +77,6 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
}),
|
||||
hasSeriesProgress: jest.fn().mockReturnValue(false),
|
||||
getProgressPercent: jest.fn().mockReturnValue(40),
|
||||
isWatched: jest.fn().mockReturnValue(false),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -110,7 +107,6 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
xtreamStore.setMinRating.mockClear();
|
||||
xtreamStore.hasSeriesProgress.mockClear();
|
||||
xtreamStore.getProgressPercent.mockClear();
|
||||
xtreamStore.isWatched.mockClear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -268,4 +264,36 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
|
||||
expect(xtreamStore.setMinRating).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps movie progress and series episode rows onto one watch state', () => {
|
||||
xtreamStore.getProgressPercent.mockReturnValue(40);
|
||||
expect(service.getItemProgress({ xtream_id: 1 })).toEqual({
|
||||
progress: 40,
|
||||
watchState: 'in-progress',
|
||||
});
|
||||
|
||||
xtreamStore.getProgressPercent.mockReturnValue(95);
|
||||
expect(service.getItemProgress({ xtream_id: 1 })).toEqual({
|
||||
progress: 95,
|
||||
watchState: 'watched',
|
||||
});
|
||||
|
||||
xtreamStore.getProgressPercent.mockReturnValue(0);
|
||||
expect(service.getItemProgress({ xtream_id: 1 })).toEqual({
|
||||
progress: 0,
|
||||
watchState: 'unwatched',
|
||||
});
|
||||
|
||||
// A series card only knows that some episode was started; the
|
||||
// catalog list cannot tell whether every episode is done.
|
||||
contentType.set('series');
|
||||
xtreamStore.hasSeriesProgress.mockReturnValue(true);
|
||||
expect(service.getItemProgress({ series_id: 7 })).toEqual({
|
||||
watchState: 'in-progress',
|
||||
});
|
||||
xtreamStore.hasSeriesProgress.mockReturnValue(false);
|
||||
expect(service.getItemProgress({ series_id: 7 })).toEqual({
|
||||
watchState: 'unwatched',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,14 @@ import {
|
||||
PortalCatalogPlaylistMeta,
|
||||
PortalCatalogSortMode,
|
||||
PORTAL_CATALOG_FACADE,
|
||||
resolvePortalSeriesWatchState,
|
||||
watchStateFromProgressPercent,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
|
||||
|
||||
const isValidSortMode = (
|
||||
mode: string | null
|
||||
): mode is PortalCatalogSortMode =>
|
||||
const isValidSortMode = (mode: string | null): mode is PortalCatalogSortMode =>
|
||||
mode === 'date-desc' ||
|
||||
mode === 'date-asc' ||
|
||||
mode === 'name-asc' ||
|
||||
@@ -172,13 +172,16 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
|
||||
|
||||
if (isSeries) {
|
||||
return {
|
||||
hasSeriesProgress: this.xtreamStore.hasSeriesProgress(itemId),
|
||||
watchState: resolvePortalSeriesWatchState(
|
||||
this.xtreamStore.hasSeriesProgress(itemId)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const progress = this.xtreamStore.getProgressPercent(itemId, 'vod');
|
||||
return {
|
||||
progress: this.xtreamStore.getProgressPercent(itemId, 'vod'),
|
||||
isWatched: this.xtreamStore.isWatched(itemId, 'vod'),
|
||||
progress,
|
||||
watchState: watchStateFromProgressPercent(progress),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,26 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Manual watched toggle -->
|
||||
@let watchedLabel =
|
||||
(isWatched() ? 'XTREAM.MARK_UNWATCHED' : 'XTREAM.MARK_WATCHED')
|
||||
| translate;
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-btn watched-btn"
|
||||
[class.favorite-btn--active]="isWatched()"
|
||||
[disabled]="!canToggleWatched()"
|
||||
[attr.aria-pressed]="isWatched()"
|
||||
[attr.aria-label]="watchedLabel"
|
||||
(click)="toggleWatched()"
|
||||
data-test-id="vod-watched-toggle"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isWatched() ? 'check_circle' : 'check_circle_outline'
|
||||
}}</mat-icon>
|
||||
{{ watchedLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Download buttons (Electron only) -->
|
||||
@if (isElectron() && !providerOnly()) {
|
||||
@if (isDownloaded()) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void>();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user