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

@@ -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.