diff --git a/.changes/dashboard-continue-watching-actions.md b/.changes/dashboard-continue-watching-actions.md new file mode 100644 index 000000000..edf1a0ced --- /dev/null +++ b/.changes/dashboard-continue-watching-actions.md @@ -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". diff --git a/.changes/portals-season-auto-select.md b/.changes/portals-season-auto-select.md new file mode 100644 index 000000000..8fe40b3ce --- /dev/null +++ b/.changes/portals-season-auto-select.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 6084f1fdc..4b9065f74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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): diff --git a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts index 301449f70..1bdf300d7 100644 --- a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts +++ b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts @@ -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. diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 8df41987a..fa61707a0 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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)', { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 96dd79bc0..02e1a6c58 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "ترتيب الخادم", "CATEGORY_SORT_ARIA": "ترتيب الفئات", "DASHBOARD": { + "ACTION_FAILED": "تعذر إكمال الإجراء. يرجى المحاولة مرة أخرى.", "CONTINUE_WATCHING": "متابعة المشاهدة", "LIVE_FAVORITES": "يُبث الآن على المفضلة لديك", "LIVE_RECENT": "متابعة البث المباشر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c19d29267..bbe6bf7b1 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "ترتيب السيرفر", "CATEGORY_SORT_ARIA": "ترتيب التصنيفات", "DASHBOARD": { + "ACTION_FAILED": "ما قدرناش نكملو العملية. عاود حاول مرة أخرى.", "CONTINUE_WATCHING": "كمل المشاهدة", "LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك", "LIVE_RECENT": "كمل مع المباشر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index a16a2fb97..c1740f917 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Парадак сервера", "CATEGORY_SORT_ARIA": "Сартаваць катэгорыі", "DASHBOARD": { + "ACTION_FAILED": "Не атрымалася выканаць дзеянне. Паспрабуйце яшчэ раз.", "CONTINUE_WATCHING": "Працягнуць прагляд", "LIVE_FAVORITES": "Цяпер у эфіры на выбраных", "LIVE_RECENT": "Працягнуць прагляд эфіру", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 85faf5100..e75bb72fb 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 55c8f33f3..dfe0bcf6e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Σειρά διακομιστή", "CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών", "DASHBOARD": { + "ACTION_FAILED": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας. Δοκιμάστε ξανά.", "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", "LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας", "LIVE_RECENT": "Συνέχιση με ζωντανή TV", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 4caeb987a..28b8a5727 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0124fc1e1..0b4dd72ad 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 92db1dffa..062879718 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 33094adae..047f2bcf8 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8b6b930c3..4d97e5374 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2484f389f..6cac2e8c8 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "サーバー順", "CATEGORY_SORT_ARIA": "カテゴリを並び替え", "DASHBOARD": { + "ACTION_FAILED": "操作を完了できませんでした。もう一度お試しください。", "CONTINUE_WATCHING": "視聴を再開", "LIVE_FAVORITES": "お気に入りで現在放送中", "LIVE_RECENT": "ライブTVの視聴を再開", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 3b9e32f43..be0dbbf34 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "서버 순서", "CATEGORY_SORT_ARIA": "카테고리 정렬", "DASHBOARD": { + "ACTION_FAILED": "작업을 완료할 수 없습니다. 다시 시도해 주세요.", "CONTINUE_WATCHING": "이어서 시청", "LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다", "LIVE_RECENT": "라이브 TV 이어서 시청", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c1af29454..c92d257cb 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 35b445c87..01282f6a9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index bea2bbc82..54b113a83 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c8d559642..d32072550 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "Порядок сервера", "CATEGORY_SORT_ARIA": "Сортировать категории", "DASHBOARD": { + "ACTION_FAILED": "Не удалось выполнить действие. Попробуйте ещё раз.", "CONTINUE_WATCHING": "Продолжить просмотр", "LIVE_FAVORITES": "Сейчас в эфире на избранных каналах", "LIVE_RECENT": "Продолжить просмотр прямого эфира", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 39f6cd0b4..6e2edfeab 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index a57414d44..3f3a1ed27 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "服务器排序", "CATEGORY_SORT_ARIA": "排序分类", "DASHBOARD": { + "ACTION_FAILED": "无法完成该操作。请重试。", "CONTINUE_WATCHING": "继续观看", "LIVE_FAVORITES": "收藏频道正在播放", "LIVE_RECENT": "继续观看直播", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2b6c312e2..1d1123e3b 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1310,6 +1310,7 @@ "SORT_SERVER": "伺服器排序", "CATEGORY_SORT_ARIA": "排序分類", "DASHBOARD": { + "ACTION_FAILED": "無法完成該操作。請重試。", "CONTINUE_WATCHING": "繼續觀看", "LIVE_FAVORITES": "我的最愛正在直播", "LIVE_RECENT": "繼續觀看直播電視", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 3bfea503b..27b64464b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index e45fbcb93..c6f48ef63 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -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 diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 4424b8d3c..0d6c42964 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -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 ); } diff --git a/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts b/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts new file mode 100644 index 000000000..c01ff7be4 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts @@ -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 { + 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; + let component: SeasonContainerComponent; + + const setRequiredInputs = ( + seasons: Record + ) => { + 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'); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 4cd6c5060..7cc9d8638 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -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(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 { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 71cb19fe5..cdd47daf2 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 { + 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 { if (item.source === 'xtream') { if (this.hasPortalActivityStorage) { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts index d3ef2f20b..8e16e73d4 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts @@ -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(); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts index 845daf01e..56b025258 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts @@ -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. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts index f84ec2dee..5e079f575 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts @@ -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 { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 8d725573e..a4beb0101 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -215,6 +215,7 @@ [seeAllLink]="['/workspace/global-recent']" [seeAllState]="continueWatchingSeeAllState()" [testId]="'dashboard-continue-watching-rail'" + (actionSelected)="onContinueWatchingActionSelected($event)" /> } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 80ffce6cd..0b34f85ac 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -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', diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index d185a330c..18ffa2c45 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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 + ): 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}`,