mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(dashboard): show S·E badge on Continue Watching series cards
For series in the Continue Watching rail, surface which episode the user left off on as a small "S2·E5" chip next to the card subtitle. Sources the season/episode straight off the matched PlaybackPositionData so the information is always in sync with the resume target. Fixes a latent bug in the same patch: getPlaybackPositionForItem could not resolve series whose recent_items row carried the series id (the landing-page path), because playback_positions are keyed by the episode id. The lookup now matches both shapes (contentXtreamId === xtreamId OR seriesXtreamId === xtreamId) and prefers the most recently updated episode, which also restores the resume progress bar that had been silently dropping out for series. Localised in all 17 non-English locales — most use language-specific short forms (St·F in German, T·E in Spanish/Portuguese, С·Э in Russian/Belarusian, 시즌N N화 in Korean) rather than the English S·E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6891a1ca30
commit
8091dec074
24 files changed
+193
-13
No files matched your search
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "مباشر",
|
||||
"TYPE_MOVIE": "فيلم",
|
||||
"TYPE_SERIES": "مسلسل",
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "لم تتم المزامنة بعد"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "مباشر",
|
||||
"TYPE_MOVIE": "فيلم",
|
||||
"TYPE_SERIES": "سلسلة",
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "مازال ما تزامنش"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Прамы эфір",
|
||||
"TYPE_MOVIE": "Фільм",
|
||||
"TYPE_SERIES": "Серыял",
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Яшчэ не сінхранізавана"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "St{{season}} · F{{episode}}",
|
||||
"NOT_YET_SYNCED": "Noch nicht synchronisiert"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Ζωντανά",
|
||||
"TYPE_MOVIE": "Ταινία",
|
||||
"TYPE_SERIES": "Σειρά",
|
||||
"SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}",
|
||||
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Movie",
|
||||
"TYPE_SERIES": "Series",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Not yet synced"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "En vivo",
|
||||
"TYPE_MOVIE": "Película",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Aún no sincronizado"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Direct",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Série",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Pas encore synchronisé"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Non ancora sincronizzato"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "ライブ",
|
||||
"TYPE_MOVIE": "映画",
|
||||
"TYPE_SERIES": "シリーズ",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}",
|
||||
"NOT_YET_SYNCED": "未同期"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "라이브",
|
||||
"TYPE_MOVIE": "영화",
|
||||
"TYPE_SERIES": "시리즈",
|
||||
"SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화",
|
||||
"NOT_YET_SYNCED": "아직 동기화되지 않음"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Series",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·A{{episode}}",
|
||||
"NOT_YET_SYNCED": "Nog niet gesynchroniseerd"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Na żywo",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serial",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·O{{episode}}",
|
||||
"NOT_YET_SYNCED": "Jeszcze niezsynchronizowane"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Ao vivo",
|
||||
"TYPE_MOVIE": "Filme",
|
||||
"TYPE_SERIES": "Série",
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Ainda não sincronizado"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "ТВ",
|
||||
"TYPE_MOVIE": "Фильм",
|
||||
"TYPE_SERIES": "Сериал",
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Еще не синхронизировано"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "Canlı",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Dizi",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·B{{episode}}",
|
||||
"NOT_YET_SYNCED": "Henüz eşitlenmedi"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "直播",
|
||||
"TYPE_MOVIE": "电影",
|
||||
"TYPE_SERIES": "剧集",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -872,6 +872,7 @@
|
||||
"TYPE_LIVE": "直播",
|
||||
"TYPE_MOVIE": "電影",
|
||||
"TYPE_SERIES": "影集",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -619,6 +619,87 @@ describe('DashboardDataService', () => {
|
||||
const seriesPos = service.getPlaybackPositionForItem(series!);
|
||||
expect(seriesPos?.contentType).toBe('episode');
|
||||
expect(seriesPos?.positionSeconds).toBe(720);
|
||||
// Season/episode metadata travels with the position so cards can
|
||||
// render an "S2 · E5" badge without an extra round-trip.
|
||||
expect(seriesPos?.seasonNumber).toBe(2);
|
||||
expect(seriesPos?.episodeNumber).toBe(4);
|
||||
});
|
||||
|
||||
it('resolves the latest episode position for series whose recent_items row carries the series id', async () => {
|
||||
// The Xtream series landing page records the recent item with the
|
||||
// SERIES id, while each played episode saves its position keyed by
|
||||
// the EPISODE id (with seriesXtreamId pointing back). The lookup
|
||||
// must follow seriesXtreamId so both card shapes show progress.
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 200,
|
||||
category_id: 30,
|
||||
title: 'Shadow Bay',
|
||||
rating: '7.5',
|
||||
viewed_at: '2026-05-01T09:00:00.000Z',
|
||||
poster_url: 'https://example.com/shadow-bay.png',
|
||||
xtream_id: 4000, // series id
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-C',
|
||||
playlist_name: 'Xtream C',
|
||||
},
|
||||
]);
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: 'xtream-C',
|
||||
title: 'Xtream C',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://c.example.com',
|
||||
},
|
||||
]);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
(playlistId: string) => {
|
||||
if (playlistId === 'xtream-C') {
|
||||
return Promise.resolve([
|
||||
// Earlier episode — should NOT win even if it also
|
||||
// matches the series, because the newer one is more
|
||||
// relevant for "where you left off".
|
||||
{
|
||||
contentXtreamId: 4001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 4000,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 60,
|
||||
durationSeconds: 1800,
|
||||
playlistId,
|
||||
updatedAt: '2026-04-25T12:00:00.000Z',
|
||||
} as PlaybackPositionData,
|
||||
{
|
||||
contentXtreamId: 4007,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 4000,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 7,
|
||||
positionSeconds: 540,
|
||||
durationSeconds: 1800,
|
||||
playlistId,
|
||||
updatedAt: '2026-05-01T09:30:00.000Z',
|
||||
} as PlaybackPositionData,
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
const series = service
|
||||
.globalRecentVodItems()
|
||||
.find((item) => item.title === 'Shadow Bay');
|
||||
const position = service.getPlaybackPositionForItem(series!);
|
||||
expect(position?.contentType).toBe('episode');
|
||||
expect(position?.seasonNumber).toBe(3);
|
||||
expect(position?.episodeNumber).toBe(7);
|
||||
});
|
||||
|
||||
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
|
||||
|
||||
@@ -248,8 +248,6 @@ export class DashboardDataService {
|
||||
if (item.type !== 'movie' && item.type !== 'series') {
|
||||
return null;
|
||||
}
|
||||
const positionType: 'vod' | 'episode' =
|
||||
item.type === 'series' ? 'episode' : 'vod';
|
||||
const xtreamId =
|
||||
typeof item.xtream_id === 'number'
|
||||
? item.xtream_id
|
||||
@@ -257,12 +255,41 @@ export class DashboardDataService {
|
||||
if (!Number.isFinite(xtreamId)) {
|
||||
return null;
|
||||
}
|
||||
const key = playbackPositionMapKey(
|
||||
item.playlist_id,
|
||||
xtreamId,
|
||||
positionType
|
||||
);
|
||||
return this.playbackPositionsMap().get(key) ?? null;
|
||||
|
||||
if (item.type === 'movie') {
|
||||
const key = playbackPositionMapKey(
|
||||
item.playlist_id,
|
||||
xtreamId,
|
||||
'vod'
|
||||
);
|
||||
return this.playbackPositionsMap().get(key) ?? null;
|
||||
}
|
||||
|
||||
// Series recent_items rows carry either the series id (the series
|
||||
// landing page writes the series itself) or, for direct-play flows,
|
||||
// the episode id. Match both shapes and prefer the most recently
|
||||
// updated episode position so the card can show resume progress and
|
||||
// a "S2 · E5" badge regardless of how the row was originally saved.
|
||||
let best: PlaybackPositionData | null = null;
|
||||
for (const position of this.playbackPositionsMap().values()) {
|
||||
if (position.contentType !== 'episode') continue;
|
||||
if (
|
||||
position.playlistId &&
|
||||
position.playlistId !== item.playlist_id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const matchesEpisode = position.contentXtreamId === xtreamId;
|
||||
const matchesSeries = position.seriesXtreamId === xtreamId;
|
||||
if (!matchesEpisode && !matchesSeries) continue;
|
||||
if (
|
||||
!best ||
|
||||
(position.updatedAt ?? '') > (best.updatedAt ?? '')
|
||||
) {
|
||||
best = position;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,11 +189,18 @@
|
||||
<span class="rail__card-title">{{
|
||||
card.title
|
||||
}}</span>
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
<span class="rail__card-meta-row">
|
||||
@if (card.episodeBadge) {
|
||||
<span class="rail__card-episode">{{
|
||||
card.episodeBadge
|
||||
}}</span>
|
||||
}
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -350,7 +350,34 @@
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.rail__card-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail__card-episode {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color, #2f86ff) 14%,
|
||||
transparent
|
||||
);
|
||||
color: var(--app-selection-color, #2f86ff);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rail__card-subtitle {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
|
||||
@@ -54,6 +54,13 @@ export interface DashboardRailCard {
|
||||
* position leave this unset.
|
||||
*/
|
||||
watchProgress?: number | null;
|
||||
|
||||
/**
|
||||
* Localised "S{n} · E{n}" badge for series with a tracked episode
|
||||
* position. Renders as a small chip next to the card subtitle so the
|
||||
* user can see which episode they were on without opening the show.
|
||||
*/
|
||||
episodeBadge?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -615,6 +615,18 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const watchProgress = playbackProgressPercent(position);
|
||||
const episodeBadge =
|
||||
item.type === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`,
|
||||
title: item.title,
|
||||
@@ -625,6 +637,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
link: this.data.getRecentItemLink(item),
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
watchProgress,
|
||||
episodeBadge,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user