feat(ui): compact episode stills in the list view

When a season has genuinely distinct per-episode art (TMDB stills or
provider images), list rows show a 96x54 thumbnail with the episode
number riding on it instead of the number square. When every episode
repeats the same image (providers often send the series poster), the
plain number square stays — a column of identical posters is worse
than none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 20:34:36 +02:00
1 parent d4f6a8b1f7
commit 169925dbbe
4 files changed
+144 -3

No files matched your search

@@ -296,9 +296,36 @@
isEpisodePlayingInline(episode)
"
>
<div class="episode-list-item__number">
{{ episode.episode_num }}
</div>
@if (listThumbnailsEnabled()) {
<div class="episode-list-item__thumb">
@if (episodeInfo?.movie_image) {
<img
[src]="
episodeInfo?.movie_image ?? ''
"
(error)="
$event.target.src =
'./assets/images/default-episode.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<mat-icon
class="episode-list-item__thumb-placeholder"
>play_circle_outline</mat-icon
>
}
<span
class="episode-list-item__thumb-number"
>{{ episode.episode_num }}</span
>
</div>
} @else {
<div class="episode-list-item__number">
{{ episode.episode_num }}
</div>
}
<div class="episode-list-item__content">
<div class="episode-list-item__header">
<h4 class="episode-list-item__title">
@@ -582,6 +582,48 @@ app-season-tabs {
flex-shrink: 0;
}
// Compact 16:9 episode still (only when the season has distinct
// per-episode art); the episode number rides on the image.
&__thumb {
position: relative;
flex-shrink: 0;
width: 96px;
height: 54px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__thumb-placeholder {
color: var(--text-muted);
font-size: 22px;
width: 22px;
height: 22px;
}
&__thumb-number {
position: absolute;
bottom: 3px;
right: 3px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 0.65rem;
font-weight: 600;
line-height: 1.5;
padding: 0 5px;
border-radius: 3px;
}
&__content {
flex: 1;
min-width: 0;
@@ -330,6 +330,61 @@ describe('SeasonContainerComponent', () => {
).toBeNull();
});
it('shows list thumbnails when episode images are distinct', () => {
setRequiredInputs({
'1': [
createEpisode({
id: '101',
info: { plot: 'a', movie_image: 'still-1.jpg' } as never,
}),
createEpisode({
id: '102',
episode_num: 2,
info: { plot: 'b', movie_image: 'still-2.jpg' } as never,
}),
],
});
component.setViewMode('list');
fixture.detectChanges();
expect(component.listThumbnailsEnabled()).toBe(true);
expect(
fixture.nativeElement.querySelectorAll('.episode-list-item__thumb')
.length
).toBe(2);
expect(
fixture.nativeElement.querySelector('.episode-list-item__number')
).toBeNull();
});
it('keeps the number square when every episode repeats the same image', () => {
setRequiredInputs({
'1': [
createEpisode({
id: '101',
info: { plot: 'a', movie_image: 'poster.jpg' } as never,
}),
createEpisode({
id: '102',
episode_num: 2,
info: { plot: 'b', movie_image: 'poster.jpg' } as never,
}),
],
});
component.setViewMode('list');
fixture.detectChanges();
expect(component.listThumbnailsEnabled()).toBe(false);
expect(
fixture.nativeElement.querySelector('.episode-list-item__thumb')
).toBeNull();
expect(
fixture.nativeElement.querySelectorAll(
'.episode-list-item__number'
).length
).toBe(2);
});
it('renders the season description for the selected season', () => {
fixture.componentRef.setInput('seasonDescriptions', {
'1': 'Season one overview',
@@ -155,6 +155,23 @@ export class SeasonContainerComponent implements OnInit {
);
private lastAutoSelectKey: string | null = null;
/**
* Show thumbnails in the list view only when episodes have genuinely
* distinct stills (TMDB or per-episode provider art). When every episode
* carries the same image (providers often repeat the series poster) a
* column of identical pictures is worse than the plain number square.
*/
readonly listThumbnailsEnabled = computed(() => {
const episodes = this.selectedSeasonEpisodes();
const images = episodes
.map((episode) => this.getEpisodeInfo(episode)?.movie_image)
.filter((image): image is string => !!image);
if (images.length === 0) {
return false;
}
return episodes.length === 1 || new Set(images).size > 1;
});
readonly selectedSeasonDescription = computed(() => {
const selected = this.selectedSeason();
if (!selected) {