feat(dashboard): detail-first Continue Watching cards with quick actions (#1469)

* feat(dashboard): detail-first continue watching cards with quick actions (#1441)

Continue Watching cards now open the detail page on click like movie
cards; resuming the saved episode, marking it watched, and removing the
entry from history move into a per-card ⋮ menu. Series details land on
the earliest season with unwatched episodes (or the latest once all are
watched) instead of always season 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): address review findings and season auto-select regressions

- A session's own watched toggles no longer re-resolve the selected
  season when the positions map first fills — marking season 1 watched
  used to jump the view to season 2 (CI regression in the web and
  Electron season-watched-toggle E2Es).
- The all-watched season fallback skips loaded-but-empty seasons and
  picks the latest season that has episodes (Greptile P1).
- Mark as Watched uses the strict failure-propagating save boundary
  (Codex P2), and both card mutations surface persistence failures via
  a snackbar with the new WORKSPACE.DASHBOARD.ACTION_FAILED key in all
  19 languages (Greptile P2).
- Season E2Es now assert the intended post-reload behavior: the fresh
  mount lands on the earliest unwatched season while season 1 keeps its
  watched state behind its tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-23 08:06:46 +02:00
1 parent 5b2eb515d1
commit 72727a5dfa
36 files changed
+722 -44

No files matched your search

@@ -0,0 +1,10 @@
---
type: feature
area: dashboard
issues: [1441]
---
Continue Watching cards on the dashboard now open the detail page on click,
just like movie cards — no more accidental episode playback. Resuming the
saved episode moved to a new ⋮ menu on each card, next to "Mark as Watched"
and "Remove from history".
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: portals
issues: [1441]
---
Opening a series now lands on the season you'd actually continue: the
earliest season with unwatched episodes, or the latest season once
everything is watched — instead of always starting at season 1.
+2 -2
View File
@@ -1354,9 +1354,9 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite.
- 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 → first) 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)
- 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`
- Dashboard hero/Continue Watching clicks 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. Ordinary global-recent grid clicks remain detail-only.
- 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")
**VOD Multi-Source** (alternative sources for a movie):
@@ -223,20 +223,32 @@ test.describe('Electron Season Watched Toggle', () => {
const restartedWatchedCards = app.mainWindow.locator(
'.episode-card--watched'
);
// With season 1 completed, the fresh mount auto-selects the
// earliest season with unwatched episodes (issue #1441): season 2
// opens unwatched while season 1's tab keeps the check.
await expect(restartedToggle).toBeVisible({ timeout: 20_000 });
await expect(restartedToggle).toContainText(
'Mark season as unwatched'
`Mark season as watched (${seasonEpisodeCount})`
);
await expect(restartedWatchedCards).toHaveCount(0, {
timeout: 10_000,
});
const restartedSeasonTabs =
app.mainWindow.locator('.season-tabs__pill');
await expect(
restartedSeasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself was re-read from SQLite fully watched.
await restartedSeasonTabs.first().click();
await expect(restartedToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
);
await expect(restartedWatchedCards).toHaveCount(
seasonEpisodeCount,
{ timeout: 10_000 }
);
await expect(
app.mainWindow
.locator('.season-tabs__pill')
.first()
.locator('.season-tabs__done')
).toBeVisible();
// Unmark: the clear batch removes every row again, in the DOM and
// in SQLite.
+12 -3
View File
@@ -754,15 +754,24 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
);
// PWA persistence: positions live in localStorage, so a reload of the
// detail route must come back fully watched.
// detail route must come back fully watched. With season 1 completed the
// fresh mount auto-selects the earliest season with unwatched episodes
// (issue #1441), so season 2 opens while season 1's tab keeps the check.
await page.reload();
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
await expect(seasonToggle).toContainText('Mark season as unwatched');
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await expect(watchedCards).toHaveCount(0, { timeout: 10_000 });
await expect(
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself still holds the 8 persisted rows.
await seasonTabs.first().click();
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
// Second click clears every episode's position again.
await seasonToggle.click();
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "ترتيب الخادم",
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
"DASHBOARD": {
"ACTION_FAILED": "تعذر إكمال الإجراء. يرجى المحاولة مرة أخرى.",
"CONTINUE_WATCHING": "متابعة المشاهدة",
"LIVE_FAVORITES": "يُبث الآن على المفضلة لديك",
"LIVE_RECENT": "متابعة البث المباشر",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "ترتيب السيرفر",
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
"DASHBOARD": {
"ACTION_FAILED": "ما قدرناش نكملو العملية. عاود حاول مرة أخرى.",
"CONTINUE_WATCHING": "كمل المشاهدة",
"LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك",
"LIVE_RECENT": "كمل مع المباشر",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Парадак сервера",
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
"DASHBOARD": {
"ACTION_FAILED": "Не атрымалася выканаць дзеянне. Паспрабуйце яшчэ раз.",
"CONTINUE_WATCHING": "Працягнуць прагляд",
"LIVE_FAVORITES": "Цяпер у эфіры на выбраных",
"LIVE_RECENT": "Працягнуць прагляд эфіру",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Serverreihenfolge",
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
"DASHBOARD": {
"ACTION_FAILED": "Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.",
"CONTINUE_WATCHING": "Weiter schauen",
"LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten",
"LIVE_RECENT": "Mit Live-TV weiterschauen",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Σειρά διακομιστή",
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
"DASHBOARD": {
"ACTION_FAILED": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας. Δοκιμάστε ξανά.",
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας",
"LIVE_RECENT": "Συνέχιση με ζωντανή TV",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Server sorting",
"CATEGORY_SORT_ARIA": "Sort categories",
"DASHBOARD": {
"ACTION_FAILED": "The action could not be completed. Please try again.",
"CONTINUE_WATCHING": "Continue Watching",
"LIVE_FAVORITES": "Live now on your favorites",
"LIVE_RECENT": "Continue with live TV",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Orden del servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorías",
"DASHBOARD": {
"ACTION_FAILED": "No se pudo completar la acción. Inténtalo de nuevo.",
"CONTINUE_WATCHING": "Continuar viendo",
"LIVE_FAVORITES": "Ahora en vivo en tus favoritos",
"LIVE_RECENT": "Continuar con TV en vivo",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Ordre du serveur",
"CATEGORY_SORT_ARIA": "Trier les catégories",
"DASHBOARD": {
"ACTION_FAILED": "L'action n'a pas pu être effectuée. Veuillez réessayer.",
"CONTINUE_WATCHING": "Reprendre la lecture",
"LIVE_FAVORITES": "En direct sur vos favoris",
"LIVE_RECENT": "Reprendre la TV en direct",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Kiszolgálóoldali rendezés",
"CATEGORY_SORT_ARIA": "Kategóriák rendezése",
"DASHBOARD": {
"ACTION_FAILED": "A műveletet nem sikerült végrehajtani. Próbáld újra.",
"CONTINUE_WATCHING": "Lejátszás folytatása",
"LIVE_FAVORITES": "Most a kedvenc csatornákon",
"LIVE_RECENT": "Élő tévénézés folytatása",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Ordine del server",
"CATEGORY_SORT_ARIA": "Ordina categorie",
"DASHBOARD": {
"ACTION_FAILED": "Impossibile completare l'azione. Riprova.",
"CONTINUE_WATCHING": "Continua a guardare",
"LIVE_FAVORITES": "Live ora sui tuoi preferiti",
"LIVE_RECENT": "Continua con la TV live",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "サーバー順",
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
"DASHBOARD": {
"ACTION_FAILED": "操作を完了できませんでした。もう一度お試しください。",
"CONTINUE_WATCHING": "視聴を再開",
"LIVE_FAVORITES": "お気に入りで現在放送中",
"LIVE_RECENT": "ライブTVの視聴を再開",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "서버 순서",
"CATEGORY_SORT_ARIA": "카테고리 정렬",
"DASHBOARD": {
"ACTION_FAILED": "작업을 완료할 수 없습니다. 다시 시도해 주세요.",
"CONTINUE_WATCHING": "이어서 시청",
"LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다",
"LIVE_RECENT": "라이브 TV 이어서 시청",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Servervolgorde",
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
"DASHBOARD": {
"ACTION_FAILED": "De actie kon niet worden voltooid. Probeer het opnieuw.",
"CONTINUE_WATCHING": "Verder kijken",
"LIVE_FAVORITES": "Nu live op je favorieten",
"LIVE_RECENT": "Verder kijken met live tv",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Kolejność serwera",
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
"DASHBOARD": {
"ACTION_FAILED": "Nie udało się wykonać akcji. Spróbuj ponownie.",
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"LIVE_FAVORITES": "Na żywo w Twoich ulubionych",
"LIVE_RECENT": "Kontynuuj oglądanie na żywo",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Ordem do servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorias",
"DASHBOARD": {
"ACTION_FAILED": "Não foi possível concluir a ação. Tente novamente.",
"CONTINUE_WATCHING": "Continuar assistindo",
"LIVE_FAVORITES": "Ao vivo agora nos seus favoritos",
"LIVE_RECENT": "Continuar com TV ao vivo",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Порядок сервера",
"CATEGORY_SORT_ARIA": "Сортировать категории",
"DASHBOARD": {
"ACTION_FAILED": "Не удалось выполнить действие. Попробуйте ещё раз.",
"CONTINUE_WATCHING": "Продолжить просмотр",
"LIVE_FAVORITES": "Сейчас в эфире на избранных каналах",
"LIVE_RECENT": "Продолжить просмотр прямого эфира",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "Sunucu sıralaması",
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
"DASHBOARD": {
"ACTION_FAILED": "İşlem tamamlanamadı. Lütfen tekrar deneyin.",
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"LIVE_FAVORITES": "Favorilerinizde şu an canlı",
"LIVE_RECENT": "Canlı TV ile devam et",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "服务器排序",
"CATEGORY_SORT_ARIA": "排序分类",
"DASHBOARD": {
"ACTION_FAILED": "无法完成该操作。请重试。",
"CONTINUE_WATCHING": "继续观看",
"LIVE_FAVORITES": "收藏频道正在播放",
"LIVE_RECENT": "继续观看直播",
+1
View File
@@ -1310,6 +1310,7 @@
"SORT_SERVER": "伺服器排序",
"CATEGORY_SORT_ARIA": "排序分類",
"DASHBOARD": {
"ACTION_FAILED": "無法完成該操作。請重試。",
"CONTINUE_WATCHING": "繼續觀看",
"LIVE_FAVORITES": "我的最愛正在直播",
"LIVE_RECENT": "繼續觀看直播電視",
@@ -250,7 +250,12 @@ Season navigation inside `SeasonContainerComponent` uses season tabs
(`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old
seasons-grid + "Back to seasons" level. A season is auto-selected
(inline-playing episode's season → most recently updated in-progress
episode's season → first) and the auto-selection emits `seasonSelected`,
episode's season → earliest season with unwatched episodes → latest
non-empty season; Stalker lazy-VOD series with unhydrated seasons pin
the fallback to the first season because their watched state is unknown,
and the empty→loaded positions flip caused by the session's own watched
toggles never re-resolves the selection) and the
auto-selection emits `seasonSelected`,
so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch,
TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open
without a click. Switching tabs never stops playback; a "back to playing
@@ -23,12 +23,15 @@ Related:
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
Stalker movies/series into the matching global collection route with detail
pre-opened.
- Dashboard Continue Watching handoffs for Xtream series may additionally carry
a one-shot season/episode resume target. The collection-owned Xtream detail
consumes it after its episode positions load and starts that exact episode;
opening the series from the collection grid itself remains detail-only. If the
positions load fails, the target stays unconsumed and the handoff degrades to
detail-only rather than starting the episode at offset zero.
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume
episode" ⋮ action for Xtream series carry a one-shot season/episode resume
target. The collection-owned Xtream detail consumes it after its episode
positions load and starts that exact episode; the cards' default click is
detail-only (movie-like), as is opening the series from the collection grid
itself. If the positions load fails, the target stays unconsumed and the
handoff degrades to detail-only rather than starting the episode at offset
zero. Continue Watching cards also expose "Mark as Watched" (maxes out the
tracked position row) and "Remove from history" in the same ⋮ menu.
- Ready Download Manager cards open one of the three focused
`downloads/:downloadId` routes. These local details hide the workspace
context panel, play only finalized local files, and show only locally
@@ -53,7 +53,15 @@ export interface CollectionViewState {
export function getRecentItemNavigation(
item: PortalRecentItem,
seriesResume?: SeriesResumeTarget | null
seriesResume?: SeriesResumeTarget | null,
options?: {
/**
* Use `seriesResume` only to rewrite an episode-keyed recent row to
* its parent series identity, without carrying the auto-play resume
* handoff in the navigation state (detail-only clicks, issue #1441).
*/
resumeIdentityOnly?: boolean;
}
): WorkspaceNavigationTarget {
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
@@ -85,7 +93,9 @@ export function getRecentItemNavigation(
return buildGlobalCollectionDetailNavigationTarget(
'recent',
detailItem,
item.type === 'series' ? seriesResume : null
item.type === 'series' && !options?.resumeIdentityOnly
? seriesResume
: null
);
}
@@ -0,0 +1,208 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { DownloadsService } from '@iptvnator/services';
import { SeasonContainerComponent } from './season-container.component';
// Coverage for the default-season fallback (issue #1441): earliest season
// with unwatched episodes, or the latest season once everything loaded is
// watched. The higher-priority auto-select rules (playing episode's season,
// most recent in-progress season) live in season-container.component.spec.ts,
// which sits at the max-lines cap.
function createEpisode(
overrides: Partial<XtreamSerieEpisode> = {}
): XtreamSerieEpisode {
return {
id: '101',
episode_num: 1,
title: 'Pilot',
container_extension: 'mp4',
info: { duration: '45 min' },
custom_sid: '',
added: '',
season: 1,
direct_source: '',
...overrides,
} as XtreamSerieEpisode;
}
const watchedPosition = (contentXtreamId: number) => ({
contentXtreamId,
contentType: 'episode' as const,
positionSeconds: 2700,
durationSeconds: 2700,
updatedAt: '2026-07-01T00:00:00.000Z',
});
describe('SeasonContainerComponent default-season fallback', () => {
let fixture: ComponentFixture<SeasonContainerComponent>;
let component: SeasonContainerComponent;
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>
) => {
fixture.componentRef.setInput('seasons', seasons);
fixture.componentRef.setInput('seriesId', 20);
fixture.componentRef.setInput('playlistId', 'playlist-1');
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
SeasonContainerComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: DownloadsService,
useValue: {
isAvailable: signal(false),
hasAuthoritativeDownloadList: signal(false),
hasLoadedDownloads: signal(false),
downloads: signal([]),
},
},
{ provide: MatDialog, useValue: { open: jest.fn() } },
SeasonDownloadCoordinator,
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
}).compileComponents();
fixture = TestBed.createComponent(SeasonContainerComponent);
component = fixture.componentInstance;
});
it('auto-selects the earliest season with unwatched episodes when nothing is in progress', () => {
// Season 1 fully watched, seasons 2 and 3 untouched → land on 2.
fixture.componentRef.setInput(
'playbackPositions',
new Map([[101, watchedPosition(101)]])
);
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
'3': [createEpisode({ id: '301', season: 3 })],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('2');
});
it('auto-selects the latest season once every loaded episode is watched', () => {
fixture.componentRef.setInput(
'playbackPositions',
new Map([
[101, watchedPosition(101)],
[201, watchedPosition(201)],
])
);
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('2');
});
it('keeps the first season without positions', () => {
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('1');
});
it('keeps the first-season fallback while lazy seasons are still unloaded', () => {
// Stalker lazy VOD: unhydrated seasons have unknown watched state,
// so a watched season 1 must not push the selection past them.
fixture.componentRef.setInput('hasUnloadedSeasons', true);
fixture.componentRef.setInput(
'playbackPositions',
new Map([[101, watchedPosition(101)]])
);
setRequiredInputs({
'1': [createEpisode()],
'2': [],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('1');
});
it('skips loaded-but-empty seasons in the all-watched fallback', () => {
// A trailing empty season is a valid Stalker answer; the latest
// season WITH episodes wins over it.
fixture.componentRef.setInput(
'playbackPositions',
new Map([
[101, watchedPosition(101)],
[201, watchedPosition(201)],
])
);
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
'3': [],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('2');
});
it('keeps the selected season when a local watched toggle fills the positions map', () => {
// Marking season 1 watched on a fresh profile flips the positions
// map empty→non-empty; that echo of the user's own action must not
// re-resolve the selection and jump to season 2 (issue #1441 CI
// regression).
const episode = createEpisode();
setRequiredInputs({
'1': [episode],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('1');
component.toggleWatched(new Event('click'), episode);
fixture.componentRef.setInput(
'playbackPositions',
new Map([[101, watchedPosition(101)]])
);
fixture.detectChanges();
expect(component.selectedSeason()).toBe('1');
});
it('still prefers an in-progress season over the unwatched fallback', () => {
// Season 1 has an in-progress episode; season 2 is unwatched — the
// resume rule outranks the fallback.
fixture.componentRef.setInput(
'playbackPositions',
new Map([
[
101,
{
...watchedPosition(101),
positionSeconds: 500,
},
],
])
);
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('1');
});
});
@@ -144,10 +144,14 @@ export class SeasonContainerComponent implements OnInit {
/**
* Selected season. Auto-resolves when the season key set changes or when
* playback positions first arrive (priority: inline-playing episode's
* season → most recently updated in-progress episode's season → first
* season); user tab clicks write to it and stick until the auto-select
* season → most recently updated in-progress episode's season → earliest
* season with unwatched episodes → latest season, with unhydrated
* lazy-VOD seasons pinning the fallback to the first season); user tab
* clicks write to it and stick until the auto-select
* key changes. Ongoing position saves do not reset the selection — only
* the empty→loaded transition of the positions map does.
* the empty→loaded transition of the positions map does, and even that
* is ignored once this session toggled watched state itself (the flip is
* then an echo of the local action, not an initial load).
*/
readonly selectedSeason = signal<string | undefined>(undefined);
@@ -163,6 +167,14 @@ export class SeasonContainerComponent implements OnInit {
}`
);
private lastAutoSelectKey: string | null = null;
private lastAutoSelectSeasonSet: string | null = null;
/**
* True once this session toggled watched state itself. From then on an
* empty↔loaded flip of the positions map is the echo of that action, not
* an async initial load — re-resolving on it would yank the user off the
* season they just marked (e.g. all-watched season 1 → jump to season 2).
*/
private hasLocalWatchedMutation = false;
/**
* Show thumbnails in the list view only when episodes have genuinely
@@ -210,10 +222,14 @@ export class SeasonContainerComponent implements OnInit {
activeEpisodeId: this.activeEpisodeId,
openingEpisodeId: this.openingEpisodeId,
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
emitSeasonToggle: (request) =>
this.seasonPlaybackToggleRequested.emit(request),
emitSeriesToggle: (request) =>
this.seriesPlaybackToggleRequested.emit(request),
emitSeasonToggle: (request) => {
this.hasLocalWatchedMutation = true;
this.seasonPlaybackToggleRequested.emit(request);
},
emitSeriesToggle: (request) => {
this.hasLocalWatchedMutation = true;
this.seriesPlaybackToggleRequested.emit(request);
},
});
effect(() => {
@@ -221,7 +237,19 @@ export class SeasonContainerComponent implements OnInit {
if (key === this.lastAutoSelectKey) {
return;
}
const seasonSet = untracked(() =>
this.sortedSeasonKeys().join('|')
);
const seasonSetUnchanged =
seasonSet === this.lastAutoSelectSeasonSet;
this.lastAutoSelectKey = key;
this.lastAutoSelectSeasonSet = seasonSet;
// A positions-emptiness flip after a local watched toggle keeps
// the current selection; only the async initial positions load
// (or a season-set change) re-resolves the season.
if (seasonSetUnchanged && this.hasLocalWatchedMutation) {
return;
}
this.selectedSeason.set(untracked(() => this.resolveAutoSeason()));
});
@@ -313,6 +341,7 @@ export class SeasonContainerComponent implements OnInit {
this.logger.warn('Cannot toggle watched: no playlist ID');
return;
}
this.hasLocalWatchedMutation = true;
const contentXtreamId = this.getEpisodeContentId(episode);
const currentPosition = this.getEpisodePosition(episode);
@@ -410,7 +439,36 @@ export class SeasonContainerComponent implements OnInit {
}
const resumeSeason = this.findMostRecentInProgressSeason();
return resumeSeason ?? keys[0];
return resumeSeason ?? this.resolveDefaultSeason(keys);
}
/**
* Fallback when nothing is playing or in progress: the earliest season
* with unwatched episodes, or — once everything loaded is watched — the
* latest non-empty season, where new episodes land (issue #1441).
* Loaded-but-empty seasons (a valid Stalker answer) are never picked over
* one that has episodes. Stalker lazy-VOD series with unhydrated seasons
* keep the first season: their watched state is unknown, so skipping
* past them would be a guess.
*/
private resolveDefaultSeason(keys: readonly string[]): string {
if (this.hasUnloadedSeasons()) {
return keys[0];
}
const episodeCounts = this.episodeCounts();
const watchedCounts = this.watchedCounts();
const firstUnwatched = keys.find((key) => {
const total = episodeCounts[key] ?? 0;
return total > 0 && (watchedCounts[key] ?? 0) < total;
});
if (firstUnwatched) {
return firstUnwatched;
}
const latestWithEpisodes = [...keys]
.reverse()
.find((key) => (episodeCounts[key] ?? 0) > 0);
return latestWithEpisodes ?? keys[0];
}
private findMostRecentInProgressSeason(): string | null {
@@ -56,8 +56,10 @@ import {
getGlobalFavoriteLink as getGlobalFavoriteLinkUtil,
getGlobalFavoriteNavigationState as getGlobalFavoriteNavigationStateUtil,
getPlaylistLink as getPlaylistLinkUtil,
getRecentItemDetailNavigationState as getRecentItemDetailNavigationStateUtil,
getRecentItemLink as getRecentItemLinkUtil,
getRecentItemNavigationState as getRecentItemNavigationStateUtil,
getRecentItemResumeNavigation as getRecentItemResumeNavigationUtil,
getRecentlyAddedLink as getRecentlyAddedLinkUtil,
getRecentlyAddedNavigationState as getRecentlyAddedNavigationStateUtil,
isTypeInKind as isTypeInKindUtil,
@@ -899,6 +901,52 @@ export class DashboardDataService {
);
}
getRecentItemDetailNavigationState(
item: GlobalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemDetailNavigationStateUtil(
item,
this.getPlaybackPositionForItem(item)
);
}
getRecentItemResumeNavigation(
item: GlobalRecentItem
): WorkspaceNavigationTarget | null {
return getRecentItemResumeNavigationUtil(
item,
this.getPlaybackPositionForItem(item)
);
}
/**
* Mark the item's tracked position as fully watched by maxing out the
* existing row. Reusing the stored row keeps the provider-specific
* identity fields (Stalker scoped episode ids included) intact; without
* a row or a known duration there is nothing truthful to write, so the
* action reports false and the caller hides/ignores it. Uses the strict
* save boundary and rethrows persistence failures — the non-strict save
* swallows them, and reloading an unchanged position while reporting
* success would silently lie to the user.
*/
async markRecentItemWatched(item: GlobalRecentItem): Promise<boolean> {
const position = this.getPlaybackPositionForItem(item);
if (!position?.durationSeconds || position.durationSeconds <= 0) {
return false;
}
await this.playbackPositions.savePlaybackPositionOrThrow(
item.playlist_id,
{
...position,
positionSeconds: position.durationSeconds,
updatedAt: new Date().toISOString(),
}
);
await this.reloadPlaybackPositions();
return true;
}
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
if (item.source === 'xtream') {
if (this.hasPortalActivityStorage) {
@@ -2,7 +2,11 @@ import type {
PlaybackPositionData,
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import { getRecentItemNavigationState } from './dashboard-navigation.util';
import {
getRecentItemDetailNavigationState,
getRecentItemNavigationState,
getRecentItemResumeNavigation,
} from './dashboard-navigation.util';
const recentSeries: PortalRecentItem = {
id: 200,
@@ -33,7 +37,7 @@ function episodePosition(
type RecentNavigationState = {
openCollectionDetailItem?: {
item?: unknown;
item?: { uid?: string; xtreamId?: number };
seriesResume?: unknown;
};
};
@@ -66,3 +70,67 @@ describe('getRecentItemNavigationState series resume target', () => {
expect(state.openCollectionDetailItem?.item).toBeDefined();
});
});
describe('getRecentItemDetailNavigationState', () => {
it('never carries a resume target, even for an in-progress series', () => {
// Continue Watching default click is detail-only (issue #1441) —
// resuming is an explicit card action instead.
const state = getRecentItemDetailNavigationState(
recentSeries,
episodePosition()
) as RecentNavigationState;
expect(state.openCollectionDetailItem?.seriesResume).toBeUndefined();
expect(state.openCollectionDetailItem?.item).toBeDefined();
});
it('still rewrites an episode-keyed row to the parent series identity', () => {
// Legacy recent rows can carry the EPISODE id in xtream_id; the
// position names the parent series, and the detail must target it
// even without the resume handoff — including for watched rows.
const state = getRecentItemDetailNavigationState(
{ ...recentSeries, xtream_id: 4007 },
episodePosition({ positionSeconds: 1800 })
) as RecentNavigationState;
expect(state.openCollectionDetailItem?.item?.xtreamId).toBe(4000);
expect(state.openCollectionDetailItem?.seriesResume).toBeUndefined();
});
});
describe('getRecentItemResumeNavigation', () => {
it('builds a full navigation target for an in-progress episode', () => {
const navigation = getRecentItemResumeNavigation(
recentSeries,
episodePosition()
);
expect(navigation?.link).toEqual(['/workspace', 'global-recent']);
const state = navigation?.state as RecentNavigationState;
expect(state.openCollectionDetailItem?.seriesResume).toEqual({
seriesXtreamId: 4000,
contentXtreamId: 4007,
seasonNumber: 3,
episodeNumber: 7,
});
});
it('returns null for watched rows', () => {
expect(
getRecentItemResumeNavigation(
recentSeries,
episodePosition({ positionSeconds: 1800 })
)
).toBeNull();
});
it('returns null for non-Xtream sources and missing positions', () => {
expect(
getRecentItemResumeNavigation(
{ ...recentSeries, source: 'stalker' },
episodePosition()
)
).toBeNull();
expect(getRecentItemResumeNavigation(recentSeries, null)).toBeNull();
});
});
@@ -70,7 +70,60 @@ export function getRecentItemNavigationState(
).state;
}
function buildRecentSeriesResumeTarget(
/**
* Detail-only navigation state: never carries a series resume target, so the
* handoff opens the detail page without auto-playing. Used by the Continue
* Watching cards' default click (movie-like behavior, issue #1441); the hero
* CTA and the cards' explicit Resume action keep the resume handoff. The
* position still matters here: a legacy episode-keyed recent row needs its
* identity rewritten to the parent series the position names, or the detail
* would target the episode id.
*/
export function getRecentItemDetailNavigationState(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(
item,
buildRecentSeriesIdentityTarget(item, playbackPosition),
{ resumeIdentityOnly: true }
).state;
}
/**
* Full navigation target carrying the one-shot resume handoff, or null when
* the item/position cannot produce one (non-Xtream, non-series, watched or
* coordinate-less rows). Powers the card's explicit "Resume episode" action.
*/
export function getRecentItemResumeNavigation(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): WorkspaceNavigationTarget | null {
const resumeTarget = buildRecentSeriesResumeTarget(item, playbackPosition);
return resumeTarget ? getRecentItemNavigation(item, resumeTarget) : null;
}
export function buildRecentSeriesResumeTarget(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): SeriesResumeTarget | null {
// A watched row is a completion marker (natural finish or a manual/bulk
// "mark watched"), not resumable progress — auto-playing it would start
// the episode at its end. Detail-only handoff lets the series page's
// quick-start pick the first unwatched episode instead.
if (isPortalPlaybackWatched(playbackPosition)) {
return null;
}
return buildRecentSeriesIdentityTarget(item, playbackPosition);
}
/**
* Series coordinates named by an episode position, without the watched
* guard: even a finished episode still identifies its parent series, which
* the detail-only click needs for the episode-keyed-row identity rewrite.
*/
function buildRecentSeriesIdentityTarget(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): SeriesResumeTarget | null {
@@ -82,14 +135,6 @@ function buildRecentSeriesResumeTarget(
return null;
}
// A watched row is a completion marker (natural finish or a manual/bulk
// "mark watched"), not resumable progress — auto-playing it would start
// the episode at its end. Detail-only handoff lets the series page's
// quick-start pick the first unwatched episode instead.
if (isPortalPlaybackWatched(playbackPosition)) {
return null;
}
// Episode-keyed recent rows make item.xtream_id an episode id, so only a
// position row that names its parent series can produce a resume target;
// legacy rows without seriesXtreamId stay detail-only.
@@ -68,6 +68,50 @@ export function buildDashboardSourceActions(
return actions;
}
export type DashboardContinueWatchingActionId =
| 'resume'
| 'mark-watched'
| 'remove-from-history';
/**
* ⋮ menu for Continue Watching cards (issue #1441). The default card click is
* detail-only (movie-like); the explicit Resume action carries the one-shot
* auto-play handoff instead. Reuses existing translation keys so no new
* strings are needed across the language files.
*/
export function buildDashboardContinueWatchingActions(options: {
canResume: boolean;
canMarkWatched: boolean;
}): DashboardRailAction[] {
const actions: DashboardRailAction[] = [];
if (options.canResume) {
actions.push({
id: 'resume',
icon: 'play_arrow',
labelKey: 'XTREAM.RESUME_EPISODE',
});
}
if (options.canMarkWatched) {
actions.push({
id: 'mark-watched',
icon: 'check_circle',
labelKey: 'XTREAM.MARK_WATCHED',
});
}
actions.push({
id: 'remove-from-history',
icon: 'delete',
labelKey: 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY',
destructive: true,
separatorBefore: actions.length > 0,
});
return actions;
}
export function liveRailTitleKeyForSource(
source: 'favorites' | 'recent'
): string {
@@ -215,6 +215,7 @@
[seeAllLink]="['/workspace/global-recent']"
[seeAllState]="continueWatchingSeeAllState()"
[testId]="'dashboard-continue-watching-rail'"
(actionSelected)="onContinueWatchingActionSelected($event)"
/>
}
@@ -2,6 +2,7 @@ import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DEFAULT_DASHBOARD_RAILS_SETTINGS } from '@iptvnator/shared/interfaces';
import {
buildDashboardCollectionViewState,
buildDashboardContinueWatchingActions,
buildDashboardRailSeeAllState,
buildDashboardSourceActions,
liveRailTitleKeyForSource,
@@ -96,6 +97,51 @@ describe('buildDashboardSourceActions', () => {
});
});
describe('buildDashboardContinueWatchingActions', () => {
const actionIds = (options: {
canResume: boolean;
canMarkWatched: boolean;
}) =>
buildDashboardContinueWatchingActions(options).map(
(action) => action.id
);
it('exposes resume, mark-watched, and remove for a resumable series', () => {
expect(actionIds({ canResume: true, canMarkWatched: true })).toEqual([
'resume',
'mark-watched',
'remove-from-history',
]);
});
it('keeps only remove when the card has no resumable or markable position', () => {
const actions = buildDashboardContinueWatchingActions({
canResume: false,
canMarkWatched: false,
});
expect(actions.map((action) => action.id)).toEqual([
'remove-from-history',
]);
// Sole entry → no leading separator above it.
expect(actions[0].separatorBefore).toBe(false);
expect(actions[0].destructive).toBe(true);
});
it('separates the destructive remove entry from the content actions', () => {
const actions = buildDashboardContinueWatchingActions({
canResume: false,
canMarkWatched: true,
});
expect(actions.map((action) => action.id)).toEqual([
'mark-watched',
'remove-from-history',
]);
expect(actions[1].separatorBefore).toBe(true);
});
});
describe('resolveDashboardHeroArtwork', () => {
const item = {
title: 'Broken Hero',
@@ -20,7 +20,8 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
@@ -78,6 +79,7 @@ import {
} from './dashboard-playback.utils';
import {
buildDashboardCollectionViewState,
buildDashboardContinueWatchingActions,
buildDashboardRailSeeAllState,
buildDashboardSourceActions,
liveRailTitleKeyForSource,
@@ -87,7 +89,10 @@ import {
SKELETON_CARDS_PER_RAIL,
SKELETON_RAILS,
} from './dashboard-rail.utils';
import type { DashboardSourceActionId } from './dashboard-rail.utils';
import type {
DashboardContinueWatchingActionId,
DashboardSourceActionId,
} from './dashboard-rail.utils';
@Component({
selector: 'lib-workspace-dashboard-rails',
@@ -114,6 +119,7 @@ export class WorkspaceDashboardRailsComponent {
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
@@ -538,6 +544,63 @@ export class WorkspaceDashboardRailsComponent {
}
}
onContinueWatchingActionSelected(
selection: DashboardRailActionSelection
): void {
const item = this.data
.globalRecentVodItems()
.find(
(candidate) =>
this.recentCardId(candidate) === selection.card.id
);
if (!item) {
return;
}
switch (selection.action.id as DashboardContinueWatchingActionId) {
case 'resume':
this.resumeRecentItem(item);
break;
case 'mark-watched':
this.runContinueWatchingMutation(() =>
this.data.markRecentItemWatched(item)
);
break;
case 'remove-from-history':
this.runContinueWatchingMutation(() =>
this.data.removeGlobalRecentItem(item)
);
break;
}
}
private resumeRecentItem(item: GlobalRecentItem): void {
const navigation = this.data.getRecentItemResumeNavigation(item);
if (!navigation) {
return;
}
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
/**
* A rejected persistence write leaves the card unchanged, so tell the
* user instead of failing silently with an unhandled rejection.
*/
private runContinueWatchingMutation(
mutation: () => Promise<unknown>
): void {
mutation().catch(() => {
this.snackBar.open(
this.t('WORKSPACE.DASHBOARD.ACTION_FAILED'),
undefined,
{ duration: 5000 }
);
});
}
private enrichLiveCards(
cards: readonly DashboardRailCard[]
): DashboardRailCard[] {
@@ -597,7 +660,7 @@ export class WorkspaceDashboardRailsComponent {
)
: null;
return {
id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`,
id: this.recentCardId(item),
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
imageUrl: item.poster_url,
@@ -605,12 +668,32 @@ export class WorkspaceDashboardRailsComponent {
contentType: item.type,
epgLookupKey: item.epg_lookup_key,
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
// Default click is detail-only for every card — an in-progress
// series no longer auto-plays on click (issue #1441); resuming
// moved to the ⋮ menu below. The hero CTA keeps the resume state.
state: this.data.getRecentItemDetailNavigationState(item),
watchProgress,
episodeBadge,
...(item.type === 'movie' || item.type === 'series'
? {
actions: buildDashboardContinueWatchingActions({
canResume:
this.data.getRecentItemResumeNavigation(item) !==
null,
canMarkWatched:
!!position?.durationSeconds &&
position.durationSeconds > 0 &&
!isPortalPlaybackWatched(position),
}),
}
: {}),
};
}
private recentCardId(item: GlobalRecentItem): string {
return `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`;
}
private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard {
return {
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,