Skip to content

Commit f9cb196

Browse files
committed
fix(image): correct image loading strategy
1 parent 24a11d3 commit f9cb196

11 files changed

+129
-60
lines changed

src/components/common/list/ListItem.vue

+46-23
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
<script setup lang="ts">
22
import { NFlex, NImage, NSkeleton, NTime, NTimelineItem } from 'naive-ui';
33
4-
import { computed, type PropType, toRefs } from 'vue';
4+
import { computed, defineProps, type PropType, ref, toRefs, watch } from 'vue';
55
66
import type { ListScrollItem } from '~/components/common/list/ListScroll.model';
77
88
import PosterPlaceholder from '~/assets/images/poster-placholder.webp';
99
import ListItemPanel from '~/components/common/list/ListItemPanel.vue';
10-
import { useImageStore, useImageStoreRefs } from '~/stores/data/image.store';
10+
11+
import { useImageStore } from '~/stores/data/image.store';
1112
import { Colors } from '~/styles/colors.style';
12-
import { findClosestMatch } from '~/utils/math.utils';
1313
1414
const props = defineProps({
1515
item: {
@@ -23,7 +23,6 @@ const props = defineProps({
2323
poster: {
2424
type: String,
2525
required: false,
26-
default: PosterPlaceholder,
2726
},
2827
episode: {
2928
type: Boolean,
@@ -80,27 +79,32 @@ const sameYear = computed(() => date.value?.getFullYear() === year);
8079
const loading = computed(() => item?.value?.loading);
8180
8281
const { getImageUrl } = useImageStore();
83-
const { imageSizes } = useImageStoreRefs();
8482
85-
const imageSize = computed(() =>
86-
imageSizes.value?.poster?.length
87-
? findClosestMatch(200, imageSizes.value.poster)
88-
: 'original',
83+
const resolvedPoster = computed(() => item.value.poster?.value || poster?.value);
84+
const objectFit = computed(() =>
85+
resolvedPoster.value === PosterPlaceholder ? 'contain' : 'cover',
8986
);
9087
91-
const itemPoster = computed(() => {
92-
const media = item.value;
93-
if (media.poster) return media.poster;
94-
const query = media.getPosterQuery?.();
95-
if (query)
96-
return getImageUrl(
97-
episode.value ? query : { ...query, episode: undefined },
98-
imageSize.value,
99-
).value;
100-
return null;
101-
});
88+
const imgLoaded = ref(true);
89+
90+
const onLoad = () => {
91+
imgLoaded.value = true;
92+
};
93+
94+
const getPosters = (_item: ListScrollItem) => {
95+
imgLoaded.value = false;
96+
if (_item.poster?.value) return;
97+
if (!_item.poster) return;
98+
const query = _item.getPosterQuery?.();
99+
if (!query) return;
100+
if (!episode.value && _item.type === 'episode') {
101+
query.type = 'show';
102+
delete query.episode;
103+
}
104+
getImageUrl(query, 300, _item.poster);
105+
};
102106
103-
const resolvedPoster = computed(() => itemPoster.value || poster.value);
107+
watch(item, getPosters, { immediate: true, flush: 'post' });
104108
</script>
105109

106110
<template>
@@ -159,14 +163,20 @@ const resolvedPoster = computed(() => itemPoster.value || poster.value);
159163
<NImage
160164
alt="poster-image"
161165
class="poster"
166+
:class="{ episode, loading: !imgLoaded }"
167+
:object-fit="objectFit"
168+
width="100%"
162169
lazy
163170
preview-disabled
164171
:src="resolvedPoster"
165-
:fallback-src="PosterPlaceholder"
172+
:on-load="onLoad"
166173
/>
167174
<NImage
168175
alt="poster-image-fallback"
169176
class="poster placeholder"
177+
:class="{ episode }"
178+
object-fit="contain"
179+
width="100%"
170180
lazy
171181
preview-disabled
172182
:src="PosterPlaceholder"
@@ -265,10 +275,23 @@ const resolvedPoster = computed(() => itemPoster.value || poster.value);
265275
}
266276
267277
.poster {
268-
flex: 0 1 var(--poster-width, 5.3125rem);
278+
flex: 0 0 var(--poster-width, 5.3125rem);
269279
justify-content: center;
270280
width: var(--poster-width, 5.3125rem);
271281
height: var(--poster-height, 8rem);
282+
opacity: 1;
283+
transition: opacity 0.5s var(--n-bezier);
284+
will-change: opacity;
285+
286+
&.loading {
287+
opacity: 0;
288+
transition: opacity 0s;
289+
}
290+
291+
&.episode {
292+
flex: 0 0 var(--poster-width, 14.23rem);
293+
width: var(--poster-width, 14.23rem);
294+
}
272295
273296
&.placeholder {
274297
position: absolute;

src/components/common/list/ListItemPanel.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import { NEllipsis, NFlex, NSkeleton, NTag } from 'naive-ui';
33
4-
import { computed, type PropType, toRefs } from 'vue';
4+
import { computed, defineProps, type PropType, toRefs } from 'vue';
55
66
import type { ListScrollItem } from '~/components/common/list/ListScroll.model';
77

src/components/common/list/ListScroll.model.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export type ListScrollItem = {
5656
content?: string;
5757
tags?: ListScrollItemTag[];
5858

59-
poster?: string;
59+
poster?: Ref<string | undefined>;
6060
getPosterQuery?: () => ImageQuery | undefined;
6161

6262
loading?: boolean;

src/components/common/list/ListScroll.vue

+6
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const props = defineProps({
3939
type: Object as PropType<VirtualListProps>,
4040
required: false,
4141
},
42+
episode: {
43+
type: Boolean,
44+
required: false,
45+
},
4246
hideDate: {
4347
type: Boolean,
4448
required: false,
@@ -125,6 +129,7 @@ const onLoadMore = (payload: { page: number; pageCount: number; pageSize: number
125129
}"
126130
:padding-top="listOptions?.paddingTop ?? 60"
127131
:padding-bottom="listOptions?.paddingBottom ?? 32"
132+
:key-field="'id'"
128133
@scroll="onScrollHandler"
129134
@vue:updated="onUpdatedHandler"
130135
>
@@ -153,6 +158,7 @@ const onLoadMore = (payload: { page: number; pageCount: number; pageSize: number
153158
:index="item.index"
154159
:size="items.length"
155160
:hide-date="hideDate"
161+
:episode="episode"
156162
:hover="hoverDate === item.date?.current?.toDateString()"
157163
@on-hover="onHover"
158164
>

src/components/common/list/use-list-scroll.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { computed } from 'vue';
1+
import { computed, ref } from 'vue';
22

33
import type { Ref } from 'vue';
44

@@ -97,7 +97,8 @@ export const useListScroll = <D extends string, T extends ListScrollSourceItemWi
9797
if (!_item.type) _item.type = getType(item);
9898
if (!_item.title) _item.title = getTitle(item);
9999
if (!_item.content) _item.content = getContent(item);
100-
if (!_item.poster && !_item.getPosterQuery) _item.getPosterQuery = getPosterQuery(item, _item.type);
100+
if (!_item.poster) _item.poster = ref<string | undefined>(undefined);
101+
if (!_item.getPosterQuery) _item.getPosterQuery = getPosterQuery(item, _item.type);
101102
_item.date = getDate(item, array, index, dateFn);
102103

103104
return _item;

src/components/common/navbar/NavbarPageSizeSelect.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import { NIcon, NSelect, NTooltip, type SelectOption } from 'naive-ui';
33
4-
import { computed, onMounted, ref, toRefs, watch } from 'vue';
4+
import { computed, defineProps, onMounted, ref, toRefs, watch } from 'vue';
55
66
import IconChevron from '~/components/icons/IconChevron.vue';
77
import IconPage from '~/components/icons/IconPage.vue';

src/components/views/calendar/CalendarComponent.vue

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ watchUserChange({
5656
:items="list"
5757
:loading="loading"
5858
:scroll-threshold="300"
59+
episode
5960
@on-scroll="scrolled = true"
6061
@on-scroll-top="scrolled = false"
6162
>

src/services/trakt.service.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -240,22 +240,22 @@ export class TraktService {
240240

241241
static async calendar(query: TraktCalendarQuery, type: 'movies' | 'shows' = 'shows', variant?: 'new' | 'premieres' | 'finales') {
242242
if (type === 'movies') {
243-
const response = await this.traktClient.calendars.my.movies.cached(query);
243+
const response = await this.traktClient.calendars.my.movies.cached(query, { cache: 'reload' });
244244
return response.json();
245245
}
246246
if (variant === 'new') {
247-
const response = await this.traktClient.calendars.my.shows.new.cached(query);
247+
const response = await this.traktClient.calendars.my.shows.new.cached(query, { cache: 'reload' });
248248
return response.json();
249249
}
250250
if (variant === 'premieres') {
251-
const response = await this.traktClient.calendars.my.shows.premieres.cached(query);
251+
const response = await this.traktClient.calendars.my.shows.premieres.cached(query, { cache: 'reload' });
252252
return response.json();
253253
}
254254
if (variant === 'finales') {
255-
const response = await this.traktClient.calendars.my.shows.finales.cached(query);
255+
const response = await this.traktClient.calendars.my.shows.finales.cached(query, { cache: 'reload' });
256256
return response.json();
257257
}
258-
const response = await this.traktClient.calendars.my.shows.get.cached(query);
258+
const response = await this.traktClient.calendars.my.shows.get.cached(query, { cache: 'reload' });
259259
return response.json();
260260
}
261261
}

src/stores/data/calendar.store.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { debounceLoading, useLoadingPlaceholder } from '~/utils/store.utils';
1111

1212
export type CalendarItem = (TraktCalendarShow | TraktCalendarMovie | Record<never, never>) & {
1313
id: ListScrollItem['id'];
14-
type: ListScrollItem['type'];
14+
type?: ListScrollItem['type'];
1515
premiere?: 'season' | 'series' | 'mid_season';
1616
finale?: 'season' | 'series' | 'mid_season';
1717
day: 1 | 2 | 3 | 4 | 5 | 6 | 7;
@@ -94,7 +94,6 @@ export const useCalendarStore = defineStore('data.calendar', () => {
9494
return {
9595
...show,
9696
id: show.show.ids.trakt,
97-
type: 'show' as const,
9897
date,
9998
day: date.getDay(),
10099
premiere,
@@ -114,7 +113,6 @@ export const useCalendarStore = defineStore('data.calendar', () => {
114113
return {
115114
...movie,
116115
id: movie.movie.ids.trakt,
117-
type: 'movie' as const,
118116
date,
119117
day: date.getDay() as CalendarItem['day'],
120118
};

src/stores/data/image.store.ts

+61-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { defineStore, storeToRefs } from 'pinia';
22

3-
import { computed, reactive, ref } from 'vue';
3+
import { computed, reactive, type Ref, ref } from 'vue';
44

55
import type { TmdbConfiguration } from '~/models/tmdb/tmdb-configuration.model';
66

@@ -9,7 +9,7 @@ import type { TmdbImage } from '~/models/tmdb/tmdb-image.model';
99
import { TraktService } from '~/services/trakt.service';
1010
import { storage } from '~/utils/browser/browser-storage.utils';
1111
import { debounce } from '~/utils/debounce.utils';
12-
import { arrayMax } from '~/utils/math.utils';
12+
import { arrayMax, findClosestMatch } from '~/utils/math.utils';
1313

1414
type ImageStore = {
1515
movie: Record<string, string>;
@@ -26,7 +26,11 @@ export type ImageQuery = {
2626
type: keyof ImageStore;
2727
};
2828

29-
type ImagePayload = { posters?: TmdbImage[]; stills?: TmdbImage[]; profiles?: TmdbImage[] };
29+
type ImagePayload = {
30+
posters?: TmdbImage[];
31+
stills?: TmdbImage[];
32+
profiles?: TmdbImage[];
33+
};
3034

3135
const EmptyImageStore: ImageStore = {
3236
movie: {},
@@ -88,42 +92,77 @@ export const useImageStore = defineStore('data.image', () => {
8892
return queue[key];
8993
};
9094

91-
const fetchImageUrl = async (key: string, { id, season, episode, type }: ImageQuery) => {
95+
const getKeyAndType = ({ id, season, episode, type }: ImageQuery): { key: string; type: ImageQuery['type'] } => {
96+
if (type === 'episode' && season && episode) return { key: `${type}-${id}-${season}-${episode}`, type };
97+
if (['episode', 'season'].includes(type) && season) return { key: `${type}-${id}-${season}`, type: 'season' };
98+
if (['episode', 'season', 'show'].includes(type)) return { key: `${type}-${id}`, type: 'show' };
99+
return { key: `${type}-${id}`, type };
100+
};
101+
102+
const fetchImageUrl = async (
103+
key: string,
104+
{ id, season, episode, type }: ImageQuery,
105+
): Promise<{ image: string; key: string; type: ImageQuery['type'] } | undefined> => {
92106
let payload: ImagePayload;
93107
if (type === 'movie') {
94-
payload = await queueRequest(`${type}-${id}`, () => TraktService.posters.movie(id));
108+
payload = await queueRequest(key, () => TraktService.posters.movie(id));
95109
} else if (type === 'person') {
96-
payload = await queueRequest(`${type}-${id}`, () => TraktService.posters.person(id));
110+
payload = await queueRequest(key, () => TraktService.posters.person(id));
97111
} else if (type === 'episode' && season && episode) {
98-
payload = await queueRequest(`${type}-${id}-${season}-${episode}`, () => TraktService.posters.episode(id, season, episode));
112+
payload = await queueRequest(key, () => TraktService.posters.episode(id, season, episode));
99113
} else if (type === 'season' && season) {
100-
payload = await queueRequest(`${type}-${id}-${season}`, () => TraktService.posters.season(id, season));
101-
} else if (['show', 'episode', 'season'].includes(type)) {
102-
payload = await queueRequest(`${type}-${id}`, () => TraktService.posters.show(id));
114+
payload = await queueRequest(key, () => TraktService.posters.season(id, season));
115+
} else if (type === 'show') {
116+
payload = await queueRequest(key, () => TraktService.posters.show(id));
103117
} else throw new Error('Unsupported type or missing parameters for fetchImageUrl');
104118

105119
const fetchedImages = payload.posters ?? payload.stills ?? payload.profiles;
106120
if (!fetchedImages?.length) {
107-
console.warn('No images found for', { id, season, episode, type });
121+
if (type === 'episode') {
122+
const eType = 'season';
123+
const eKey = `${eType}-${id}-${season}`;
124+
if (images[eType][eKey]) return { image: images[eType][eKey], key: eKey, type: eType };
125+
return fetchImageUrl(eKey, { id, season, type: eType });
126+
}
127+
if (type === 'season') {
128+
const sType = 'show';
129+
const sKey = `${sType}-${id}`;
130+
if (images[sType][sKey]) return { image: images[sType][sKey], key: sKey, type: sType };
131+
return fetchImageUrl(sKey, { id, type: sType });
132+
}
108133
return;
109134
}
110135
const image = arrayMax(fetchedImages, 'vote_average', i => !!i.file_path)?.file_path;
111136
if (!image) return;
112137
images[type][key] = image;
113-
await syncSaveImageStore();
138+
syncSaveImageStore().catch(err => console.error('Failed to save image store', err));
139+
return { image, key, type };
114140
};
115141

116-
const getImageUrl = ({ id, season, episode, type }: ImageQuery, size: string = 'original') => {
142+
const getImageSize = (type: ImageQuery['type'], size: number) => {
143+
if (type === 'person') return findClosestMatch(size, imageSizes.value.poster);
144+
if (type === 'episode') return findClosestMatch(size, imageSizes.value.still);
145+
return findClosestMatch(size, imageSizes.value.poster);
146+
};
147+
148+
const getImageUrl = async (query: ImageQuery, size: number, response: Ref<string | undefined> = ref()) => {
117149
if (!tmdbConfig.value) throw new Error('TmdbConfiguration not initialized');
118-
const key = [id, season, episode].filter(Boolean).join('-');
119-
const imageRef = computed(() => images[type][key]);
120-
if (!imageRef.value) fetchImageUrl(key, { id, season, episode, type }).catch(console.error);
121-
122-
return computed(() => {
123-
if (!imageRef.value) return;
124-
if (!tmdbConfig.value?.images?.secure_base_url) return;
125-
return `${tmdbConfig.value.images.secure_base_url}${size}${imageRef.value}`;
126-
});
150+
if (!tmdbConfig.value?.images?.secure_base_url) throw new Error('TmdbConfiguration missing secure_base_url');
151+
152+
const { key, type } = getKeyAndType(query);
153+
154+
const baseUrl = tmdbConfig.value.images.secure_base_url;
155+
156+
if (images[type][key]) {
157+
response.value = `${baseUrl}${getImageSize(type, size)}${images[type][key]}`;
158+
return response;
159+
}
160+
161+
const image = await fetchImageUrl(key, query);
162+
if (!image) return response;
163+
response.value = `${baseUrl}${getImageSize(image.type, size)}${image.image}`;
164+
165+
return response;
127166
};
128167

129168
return { initImageStore, getImageUrl, imageSizes };

src/utils/math.utils.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export const arrayMax = <T>(array: Array<T>, prop: keyof T, filter?: (item: T) =
1313
return prev;
1414
});
1515

16-
export const findClosestMatch = (value: number, array: string[]) => {
16+
export const findClosestMatch = (value: number, array?: string[]) => {
17+
if (!array?.length) return 'original';
1718
let closestMatch = array[0];
1819
let minDifference = Math.abs(value - parseInt(array[0].substring(1), 10));
1920

@@ -27,5 +28,5 @@ export const findClosestMatch = (value: number, array: string[]) => {
2728
}
2829
}
2930

30-
return closestMatch;
31+
return closestMatch ?? 'original';
3132
};

0 commit comments

Comments
 (0)