mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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.
|
||||
|
||||
Reference in new issue
Block a user