feat(portal): mark movies as watched from the detail page (#1605)

This commit is contained in:
4gray authored and GitHub committed 2026-09-14 21:05:19 +02:00
1 parent e9eca1c386
commit 7f724494b9
57 files changed
+2420 -122

No files matched your search

+10
View File
@@ -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.
+1
View File
@@ -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")
+3
View File
@@ -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": "البيانات الوصفية للبوابة غير متاحة",
+3
View File
@@ -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": "البيانات الوصفية ديال البوابة ما متوفراش",
+3
View File
@@ -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": "Метаданыя партала недаступныя",
+3
View File
@@ -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",
+3
View File
@@ -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": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "ポータルのメタデータは利用できません",
+3
View File
@@ -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": "포털 메타데이터를 사용할 수 없습니다",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "Метаданные портала недоступны",
+3
View File
@@ -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",
+3
View File
@@ -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": "门户元数据不可用",
+3
View File
@@ -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"
+3
View File
@@ -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',
};
@@ -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)"
@@ -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(
@@ -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,
@@ -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()"
@@ -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],
@@ -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({
+33
View File
@@ -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;