Skip to content

Commit 762a3f6

Browse files
author
Arvin
committed
feat(continue-watching): show episode thumbnails
1 parent 75e7bc2 commit 762a3f6

14 files changed

Lines changed: 126 additions & 17 deletions

File tree

app/src/main/kotlin/com/arflix/tv/data/model/Models.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ data class MediaItem(
2222
val mediaType: MediaType = MediaType.MOVIE,
2323
val image: String = "",
2424
val backdrop: String? = null,
25+
// Episode-specific landscape artwork for Continue Watching cards. Keeping
26+
// it separate prevents episode stills from replacing series hero artwork.
27+
val episodeStill: String? = null,
2528
val progress: Int = 0,
2629
val isWatched: Boolean = false,
2730
val traktId: Int? = null,

app/src/main/kotlin/com/arflix/tv/data/repository/LauncherContinueWatchingRepository.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ class LauncherContinueWatchingRepository @Inject constructor(
195195
.setDescription(item.buildSubtitle())
196196
.setInternalProviderId(item.previewProgramId())
197197
.setPosterArtUri(item.posterPath?.takeIf { it.isNotBlank() }?.let(Uri::parse))
198-
.setThumbnailUri(item.backdropPath?.takeIf { it.isNotBlank() }?.let(Uri::parse))
198+
.setThumbnailUri((item.episodeStillPath ?: item.backdropPath)?.takeIf { it.isNotBlank() }?.let(Uri::parse))
199199
.setIntentUri(buildLaunchIntent(item).toUri(Intent.URI_INTENT_SCHEME).let(Uri::parse))
200200
.setWeight((Constants.MAX_CONTINUE_WATCHING - index).coerceAtLeast(1))
201201
.build()
@@ -212,7 +212,7 @@ class LauncherContinueWatchingRepository @Inject constructor(
212212
.setInternalProviderId(item.watchNextProgramId())
213213
.setIntentUri(buildLaunchIntent(item).toUri(Intent.URI_INTENT_SCHEME).let(Uri::parse))
214214
.setPosterArtUri(item.posterPath?.takeIf { it.isNotBlank() }?.let(Uri::parse))
215-
.setThumbnailUri(item.backdropPath?.takeIf { it.isNotBlank() }?.let(Uri::parse))
215+
.setThumbnailUri((item.episodeStillPath ?: item.backdropPath)?.takeIf { it.isNotBlank() }?.let(Uri::parse))
216216
.setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
217217
.setLastEngagementTimeUtcMillis(System.currentTimeMillis() - index)
218218

app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2523,18 +2523,19 @@ class TraktRepository @Inject constructor(
25232523
}.awaitAll()
25242524
}
25252525

2526-
/**
2527-
* Enrich a local Continue Watching item with TMDB data
2528-
* Matches the Trakt enrichment behavior: uses SHOW backdrop/overview, not episode
2529-
*/
2526+
/** Enrich Continue Watching with series metadata and episode-specific artwork. */
25302527
private suspend fun enrichLocalContinueWatchingItem(
25312528
item: ContinueWatchingItem,
25322529
seasonCache: java.util.concurrent.ConcurrentHashMap<Pair<Int, Int>, Deferred<com.arflix.tv.data.api.TmdbSeasonDetails?>> = java.util.concurrent.ConcurrentHashMap()
25332530
): ContinueWatchingItem = coroutineScope {
25342531
// Skip only when all Continue Watching metrics are already present.
25352532
val needsRuntime = item.durationSeconds <= 0L
25362533
val needsEpisodeCounts = item.mediaType == MediaType.TV && item.totalEpisodes <= 0
2537-
if (!needsRuntime && !needsEpisodeCounts && item.overview.isNotEmpty() && item.backdropPath?.startsWith("http") == true) {
2534+
val needsEpisodeArtwork = item.mediaType == MediaType.TV &&
2535+
item.season != null &&
2536+
item.episode != null &&
2537+
item.episodeStillPath.isNullOrBlank()
2538+
if (!needsRuntime && !needsEpisodeCounts && !needsEpisodeArtwork && item.overview.isNotEmpty() && item.backdropPath?.startsWith("http") == true) {
25382539
return@coroutineScope item
25392540
}
25402541

@@ -2546,7 +2547,9 @@ class TraktRepository @Inject constructor(
25462547
} catch (e: Exception) { AppLogger.e("TraktRepository", "Silently returning null", e); null }
25472548

25482549
// Get current season info for episode title and aired-episode counts.
2549-
val seasonDetails = if (item.season != null && item.episode != null && (item.episodeTitle.isNullOrEmpty() || needsEpisodeCounts)) {
2550+
val seasonDetails = if (item.season != null && item.episode != null &&
2551+
(item.episodeTitle.isNullOrEmpty() || needsEpisodeCounts || needsEpisodeArtwork)
2552+
) {
25502553
try {
25512554
val cacheKey = Pair(item.id, item.season)
25522555
val newDeferred = CompletableDeferred<com.arflix.tv.data.api.TmdbSeasonDetails?>()
@@ -2571,9 +2574,9 @@ class TraktRepository @Inject constructor(
25712574
} else null
25722575
val episodeInfo = seasonDetails?.episodes?.find { it.episodeNumber == item.episode }
25732576

2574-
// Use SHOW's backdrop and overview (like Trakt does), not episode's
25752577
val backdropUrl = details?.backdropPath?.let { "${Constants.BACKDROP_BASE_LARGE}$it" }
25762578
val posterUrl = details?.posterPath?.let { "${Constants.IMAGE_BASE}$it" }
2579+
val episodeStillUrl = episodeInfo?.stillPath?.let { "${Constants.IMAGE_BASE_LARGE}$it" }
25772580
val totalEpisodeCount = if (item.totalEpisodes > 0) {
25782581
item.totalEpisodes
25792582
} else {
@@ -2596,7 +2599,8 @@ class TraktRepository @Inject constructor(
25962599

25972600
item.copy(
25982601
overview = details?.overview ?: item.overview, // Show overview, not episode
2599-
backdropPath = backdropUrl ?: item.backdropPath, // Show backdrop, not episode still
2602+
backdropPath = backdropUrl ?: item.backdropPath,
2603+
episodeStillPath = episodeStillUrl ?: item.episodeStillPath,
26002604
posterPath = posterUrl ?: item.posterPath,
26012605
year = details?.firstAirDate?.take(4) ?: item.year,
26022606
tmdbRating = details?.voteAverage?.let { String.format(Locale.US, "%.1f", it) } ?: item.tmdbRating.orEmpty(),
@@ -4556,6 +4560,7 @@ data class ContinueWatchingItem(
45564560
val displayEpisode: Int? = episode,
45574561
val episodeTitle: String? = null,
45584562
val backdropPath: String? = null,
4563+
val episodeStillPath: String? = null,
45594564
val posterPath: String? = null,
45604565
val streamKey: String? = null,
45614566
val streamAddonId: String? = null,
@@ -4651,6 +4656,7 @@ data class ContinueWatchingItem(
46514656
progress = progress,
46524657
image = posterPath ?: backdropPath ?: "",
46534658
backdrop = backdropPath,
4659+
episodeStill = episodeStillPath,
46544660
badge = null,
46554661
budget = budget,
46564662
nextEpisode = nextEp,

app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,12 @@ fun MediaCard(
123123
// hovered tile animates its GIF. Regular media items keep the existing
124124
// behavior (landscape uses backdrop art, poster uses image).
125125
val isCollectionTile = item.status?.startsWith("collection:") == true
126+
val continueWatchingArtwork = item.episodeStill
127+
?.takeIf { showProgress && isLandscape && it.isNotBlank() }
126128
val baseImageUrl = if (isCollectionTile) {
127129
item.image.takeIf { it.isNotBlank() } ?: item.backdrop?.takeIf { it.isNotBlank() }
128130
} else if (isLandscape) {
129-
(item.backdrop ?: item.image).takeIf { it.isNotBlank() }
131+
(continueWatchingArtwork ?: item.backdrop ?: item.image).takeIf { it.isNotBlank() }
130132
} else {
131133
item.image.takeIf { it.isNotBlank() }
132134
}

app/src/main/kotlin/com/arflix/tv/ui/screens/home/ContinueWatchingRowReducer.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,21 @@ internal object ContinueWatchingRowReducer {
1414
val existing = existingCategory?.items?.firstOrNull {
1515
it.id == fresh.id && it.mediaType == fresh.mediaType
1616
}
17+
val episodeChanged = existing?.nextEpisode?.let { previous ->
18+
val next = fresh.nextEpisode
19+
next != null && (
20+
previous.seasonNumber != next.seasonNumber ||
21+
previous.episodeNumber != next.episodeNumber
22+
)
23+
} == true
1724
val merged = existing?.copy(
1825
title = fresh.title.ifBlank { existing.title },
1926
subtitle = fresh.subtitle,
2027
year = fresh.year.ifBlank { existing.year },
2128
duration = fresh.duration.ifBlank { existing.duration },
2229
image = fresh.image.ifBlank { existing.image },
2330
backdrop = fresh.backdrop ?: existing.backdrop,
31+
episodeStill = fresh.episodeStill ?: existing.episodeStill.takeUnless { episodeChanged },
2432
progress = fresh.progress,
2533
nextEpisode = fresh.nextEpisode,
2634
timeRemainingLabel = fresh.timeRemainingLabel,

app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,10 +527,12 @@ class HomeViewModel @Inject constructor(
527527
preferred: ContinueWatchingItem,
528528
fallback: ContinueWatchingItem
529529
): ContinueWatchingItem {
530+
val sameEpisode = preferred.season == fallback.season && preferred.episode == fallback.episode
530531
return preferred.copy(
531532
title = preferred.title.ifBlank { fallback.title },
532533
episodeTitle = preferred.episodeTitle ?: fallback.episodeTitle,
533534
backdropPath = preferred.backdropPath ?: fallback.backdropPath,
535+
episodeStillPath = preferred.episodeStillPath ?: fallback.episodeStillPath.takeIf { sameEpisode },
534536
posterPath = preferred.posterPath ?: fallback.posterPath,
535537
streamKey = preferred.streamKey ?: fallback.streamKey,
536538
streamAddonId = preferred.streamAddonId ?: fallback.streamAddonId,
@@ -551,6 +553,7 @@ class HomeViewModel @Inject constructor(
551553
private fun needsContinueWatchingArtworkRepair(item: ContinueWatchingItem): Boolean {
552554
return item.posterPath.isNullOrBlank() ||
553555
item.backdropPath.isNullOrBlank() ||
556+
(item.mediaType == MediaType.TV && item.season != null && item.episode != null && item.episodeStillPath.isNullOrBlank()) ||
554557
item.overview.isBlank() ||
555558
item.durationSeconds <= 0L
556559
}

app/src/test/kotlin/com/arflix/tv/data/repository/ContinueWatchingItemTest.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,41 @@ class ContinueWatchingItemTest {
4747
assertEquals("Continue S1E2 from 22:30", mediaItem.subtitle)
4848
assertEquals("22min left", mediaItem.timeRemainingLabel)
4949
}
50+
51+
@Test
52+
fun toMediaItem_keepsEpisodeStillSeparateFromSeriesArtwork() {
53+
val item = ContinueWatchingItem(
54+
id = 123,
55+
title = "Example Show",
56+
mediaType = MediaType.TV,
57+
progress = 25,
58+
season = 2,
59+
episode = 3,
60+
backdropPath = "https://images.example/show.jpg",
61+
episodeStillPath = "https://images.example/s02e03.jpg"
62+
)
63+
64+
val mediaItem = item.toMediaItem()
65+
66+
assertEquals("https://images.example/show.jpg", mediaItem.backdrop)
67+
assertEquals("https://images.example/s02e03.jpg", mediaItem.episodeStill)
68+
}
69+
70+
@Test
71+
fun toMediaItem_withoutEpisodeStillRetainsSeriesFallback() {
72+
val item = ContinueWatchingItem(
73+
id = 123,
74+
title = "Example Show",
75+
mediaType = MediaType.TV,
76+
progress = 25,
77+
season = 2,
78+
episode = 3,
79+
backdropPath = "https://images.example/show.jpg"
80+
)
81+
82+
val mediaItem = item.toMediaItem()
83+
84+
assertEquals("https://images.example/show.jpg", mediaItem.backdrop)
85+
assertNull(mediaItem.episodeStill)
86+
}
5087
}

app/src/test/kotlin/com/arflix/tv/ui/screens/home/ContinueWatchingRowReducerTest.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class ContinueWatchingRowReducerTest {
4343

4444
@Test
4545
fun `next episode update replaces the previous episode for the same show`() {
46-
val existing = tvItem(20, season = 1, episode = 4)
46+
val existing = tvItem(20, season = 1, episode = 4).copy(episodeStill = "episode-4")
4747
val next = tvItem(20, season = 1, episode = 5)
4848

4949
val categories = ContinueWatchingRowReducer.upsert(
@@ -53,6 +53,20 @@ class ContinueWatchingRowReducerTest {
5353

5454
assertEquals(1, categories.first().items.size)
5555
assertEquals(5, categories.first().items.single().nextEpisode?.episodeNumber)
56+
assertNull(categories.first().items.single().episodeStill)
57+
}
58+
59+
@Test
60+
fun `progress update for the same episode preserves its still`() {
61+
val existing = tvItem(20, season = 1, episode = 4).copy(episodeStill = "episode-4")
62+
val progressUpdate = tvItem(20, season = 1, episode = 4).copy(progress = 45)
63+
64+
val categories = ContinueWatchingRowReducer.upsert(
65+
listOf(Category("continue_watching", "Continue Watching", listOf(existing))),
66+
progressUpdate
67+
)
68+
69+
assertEquals("episode-4", categories.first().items.single().episodeStill)
5670
}
5771

5872
@Test

web/components/media/MediaCard.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: {
5454
// "Up next" chip instead; the bar stays for genuinely resumable items.
5555
const isUpNext = item.timeRemainingLabel === "Up next";
5656
const showProgress = !watched && !isUpNext && progress >= 1 && progress <= 94;
57+
const isContinueWatchingCard = isUpNext || showProgress || Boolean(item.timeRemainingLabel);
5758
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
5859
const suppressClickUntil = useRef(0);
5960
// CW/up-next items from Trakt arrive with no artwork, and a hydration that hit
@@ -62,7 +63,8 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: {
6263
const [fallbackArt, setFallbackArt] = useState<{ image: string; backdrop: string | null } | null>(null);
6364
const image = item.image || fallbackArt?.image || "";
6465
const backdrop = item.backdrop || fallbackArt?.backdrop || "";
65-
const artwork = effectivePosterMode ? (image || backdrop) : (backdrop || image);
66+
const episodeArtwork = isContinueWatchingCard ? item.episodeStill || "" : "";
67+
const artwork = effectivePosterMode ? (image || backdrop) : (episodeArtwork || backdrop || image);
6668
const year = item.releaseDate?.slice(0, 4) || item.year || (item.mediaType === "tv" ? "Series" : "Movie");
6769
const directMetadataId = item.tmdbId && item.tmdbId > 0 ? item.tmdbId : item.id > 0 ? item.id : null;
6870
const [metadataId, setMetadataId] = useState<number | null>(directMetadataId);
@@ -84,7 +86,7 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: {
8486
const triggerContextMenu = (posX?: number, posY?: number) => {
8587
openContextMenu({
8688
item,
87-
isContinueWatching: isUpNext || showProgress || Boolean(item.timeRemainingLabel),
89+
isContinueWatching: isContinueWatchingCard,
8890
position: posX !== undefined && posY !== undefined ? { x: posX, y: posY } : null
8991
});
9092
};

web/components/player/PlayerOverlay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,7 @@ function VideoPlayer({
11481148
duration_seconds: Math.round(video.duration),
11491149
position_seconds: Math.round(video.currentTime),
11501150
backdrop_path: item.backdrop?.replace(config.backdropBase, "") ?? null,
1151+
episode_still_path: item.episodeStill ?? null,
11511152
poster_path: item.image?.replace(config.imageBase, "") ?? null,
11521153
source: stream.addonName,
11531154
stream_addon_id: stream.addonId ?? null,

0 commit comments

Comments
 (0)