mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix: hold gif thumbnails until first frame is ready
Distinguish GIF frame loading from GIF frame failure in useFetchGifFirstFrame(), so thread and catalog thumbnails no longer render animated GIFs before the still frame arrives. Keep the existing animated fallback when the first-frame fetch actually fails.
This commit is contained in:
@@ -35,7 +35,7 @@ interface CatalogPostMediaProps {
|
|||||||
export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
||||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const handleLoad = () => setIsLoaded(true);
|
const handleLoad = () => setIsLoaded(true);
|
||||||
@@ -76,8 +76,10 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight
|
|||||||
|
|
||||||
let thumbnailComponent: React.ReactNode = null;
|
let thumbnailComponent: React.ReactNode = null;
|
||||||
|
|
||||||
if (type === 'gif' && gifFrameUrl && !hasError) {
|
if (type === 'gif' && gifFrameStatus === 'ready' && gifFrameUrl && !hasError) {
|
||||||
thumbnailComponent = <img src={gifFrameUrl} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
thumbnailComponent = <img src={gifFrameUrl} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||||
|
} else if (type === 'gif' && gifFrameStatus === 'failed' && !hasError) {
|
||||||
|
thumbnailComponent = <img src={url} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||||
} else if (type === 'image' && !hasError) {
|
} else if (type === 'image' && !hasError) {
|
||||||
thumbnailComponent = <img src={url} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
thumbnailComponent = <img src={url} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||||
} else if (type === 'video' && !hasError) {
|
} else if (type === 'video' && !hasError) {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floatingEmbed, .subplebbitAvatar {
|
.floatingEmbed,
|
||||||
|
.subplebbitAvatar {
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
max-height: 250px;
|
max-height: 250px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -53,7 +54,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnailSmall span, .thumbnailSmall a {
|
.thumbnailSmall span,
|
||||||
|
.thumbnailSmall a {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
@@ -81,7 +83,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content img, .content video, .content iframe, .content audio {
|
.content img,
|
||||||
|
.content video,
|
||||||
|
.content iframe,
|
||||||
|
.content audio {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -91,6 +96,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gifPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.content iframe {
|
.content iframe {
|
||||||
border: none;
|
border: none;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -99,7 +111,7 @@
|
|||||||
.fileInfo {
|
.fileInfo {
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--post-mobile-file-info-text-color);
|
color: var(--post-mobile-file-info-text-color);
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +137,17 @@
|
|||||||
margin: 8px 10px 5px 5px;
|
margin: 8px 10px 5px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaMobile img, .mediaMobile video, .mediaMobile iframe, .mediaMobile audio {
|
.mediaMobile img,
|
||||||
|
.mediaMobile video,
|
||||||
|
.mediaMobile iframe,
|
||||||
|
.mediaMobile audio {
|
||||||
padding: 3px 0 5px 0;
|
padding: 3px 0 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaDesktopReply img, .mediaDesktopReply video, .mediaDesktopReply iframe, .mediaDesktopReply audio {
|
.mediaDesktopReply img,
|
||||||
|
.mediaDesktopReply video,
|
||||||
|
.mediaDesktopReply iframe,
|
||||||
|
.mediaDesktopReply audio {
|
||||||
padding: 3px 20px 5px 20px;
|
padding: 3px 20px 5px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +156,10 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaDesktopOp img, .mediaDesktopOp video, .mediaDesktopOp iframe, .mediaDesktopOp audio {
|
.mediaDesktopOp img,
|
||||||
|
.mediaDesktopOp video,
|
||||||
|
.mediaDesktopOp iframe,
|
||||||
|
.mediaDesktopOp audio {
|
||||||
padding: 3px 20px 5px 20px;
|
padding: 3px 20px 5px 20px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -164,4 +185,4 @@
|
|||||||
.fitToScreen video {
|
.fitToScreen video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,25 +45,42 @@ const Thumbnail = ({
|
|||||||
|
|
||||||
let thumbnailComponent: React.ReactNode = null;
|
let thumbnailComponent: React.ReactNode = null;
|
||||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
||||||
|
const gifThumbnailButtonProps =
|
||||||
|
gifFrameStatus === 'loading'
|
||||||
|
? {
|
||||||
|
role: 'button' as const,
|
||||||
|
tabIndex: 0,
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowThumbnail(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick: () => setShowThumbnail(false),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
if (type === 'gif') {
|
if (type === 'gif') {
|
||||||
thumbnailComponent = (
|
thumbnailComponent =
|
||||||
<img
|
gifFrameStatus === 'loading' ? (
|
||||||
src={gifFrameUrl || url}
|
<span className={styles.gifPlaceholder} aria-label='Loading GIF thumbnail' {...gifThumbnailButtonProps} />
|
||||||
alt=''
|
) : (
|
||||||
role='button'
|
<img
|
||||||
tabIndex={0}
|
src={gifFrameUrl || url}
|
||||||
onKeyDown={(e) => {
|
alt=''
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
role='button'
|
||||||
e.preventDefault();
|
tabIndex={0}
|
||||||
setShowThumbnail(false);
|
onKeyDown={(e) => {
|
||||||
}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
}}
|
e.preventDefault();
|
||||||
onClick={() => setShowThumbnail(false)}
|
setShowThumbnail(false);
|
||||||
/>
|
}
|
||||||
);
|
}}
|
||||||
|
onClick={() => setShowThumbnail(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (type === 'video') {
|
} else if (type === 'video') {
|
||||||
thumbnailComponent = thumbnail ? (
|
thumbnailComponent = thumbnail ? (
|
||||||
<img src={thumbnail} alt='' />
|
<img src={thumbnail} alt='' />
|
||||||
@@ -405,11 +422,11 @@ const CommentMedia = ({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { thumbnailHeight, thumbnailWidth, url } = commentMediaInfo || {};
|
const { thumbnailHeight, thumbnailWidth, url } = commentMediaInfo || {};
|
||||||
let type = commentMediaInfo?.type;
|
let type = commentMediaInfo?.type;
|
||||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||||
|
|
||||||
if (type === 'gif' && gifFrameUrl) {
|
if (type === 'gif' && gifFrameStatus === 'ready') {
|
||||||
type = 'animated gif';
|
type = 'animated gif';
|
||||||
} else if (type === 'gif' && !gifFrameUrl) {
|
} else if (type === 'gif' && gifFrameStatus === 'failed') {
|
||||||
type = 'static gif';
|
type = 'static gif';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -590,12 +590,12 @@ const PostMedia = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { url } = commentMediaInfo || {};
|
const { url } = commentMediaInfo || {};
|
||||||
let type = commentMediaInfo?.type;
|
let type = commentMediaInfo?.type;
|
||||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
|
|
||||||
if (type === 'gif' && gifFrameUrl !== null) {
|
if (type === 'gif' && gifFrameStatus === 'ready') {
|
||||||
type = 'animated gif';
|
type = 'animated gif';
|
||||||
} else if (type === 'gif' && gifFrameUrl === null) {
|
} else if (type === 'gif' && gifFrameStatus === 'failed') {
|
||||||
type = 'static gif';
|
type = 'static gif';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mediaInfo = getLinkMediaInfo(link);
|
const mediaInfo = getLinkMediaInfo(link);
|
||||||
let type = mediaInfo?.type;
|
let type = mediaInfo?.type;
|
||||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined);
|
const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined);
|
||||||
|
|
||||||
if (type === 'gif' && gifFrameUrl !== null) {
|
if (type === 'gif' && gifFrameStatus === 'ready') {
|
||||||
type = t('animated_gif');
|
type = t('animated_gif');
|
||||||
} else if (type === 'gif' && gifFrameUrl === null) {
|
} else if (type === 'gif') {
|
||||||
type = t('gif');
|
type = t('gif');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import localForageLru from '@bitsocialhq/bitsocial-react-hooks/dist/lib/localfor
|
|||||||
const gifFrameDb = localForageLru.createInstance({ name: '5chanGifFrames', size: 500 });
|
const gifFrameDb = localForageLru.createInstance({ name: '5chanGifFrames', size: 500 });
|
||||||
const failedUrls = new Set<string>();
|
const failedUrls = new Set<string>();
|
||||||
|
|
||||||
|
type GifFirstFrameStatus = 'idle' | 'loading' | 'ready' | 'failed';
|
||||||
|
|
||||||
|
interface GifFirstFrameState {
|
||||||
|
frameUrl: string | null;
|
||||||
|
status: GifFirstFrameStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const getCachedGifFrame = async (url: string): Promise<string | null> => {
|
const getCachedGifFrame = async (url: string): Promise<string | null> => {
|
||||||
return await gifFrameDb.getItem(url);
|
return await gifFrameDb.getItem(url);
|
||||||
};
|
};
|
||||||
@@ -40,10 +47,20 @@ export const readImage = (file: File): Promise<ArrayBuffer> => {
|
|||||||
|
|
||||||
const parseGif = async (buf: ArrayBuffer): Promise<Blob> => {
|
const parseGif = async (buf: ArrayBuffer): Promise<Blob> => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
await new Promise((resolve) => {
|
const sourceUrl = URL.createObjectURL(new Blob([buf]));
|
||||||
image.src = URL.createObjectURL(new Blob([buf]));
|
|
||||||
image.onload = resolve;
|
await new Promise((resolve, reject) => {
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(sourceUrl);
|
||||||
|
resolve(undefined);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(sourceUrl);
|
||||||
|
reject(new Error('Failed to parse GIF'));
|
||||||
|
};
|
||||||
|
image.src = sourceUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = image.width;
|
canvas.width = image.width;
|
||||||
canvas.height = image.height;
|
canvas.height = image.height;
|
||||||
@@ -62,28 +79,30 @@ const parseGif = async (buf: ArrayBuffer): Promise<Blob> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useFetchGifFirstFrame = (url: string | undefined) => {
|
const useFetchGifFirstFrame = (url: string | undefined) => {
|
||||||
const [frameUrl, setFrameUrl] = useState<string | null>(null);
|
const [gifFirstFrame, setGifFirstFrame] = useState<GifFirstFrameState>({ frameUrl: null, status: 'idle' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setFrameUrl(null);
|
setGifFirstFrame({ frameUrl: null, status: 'idle' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
|
setGifFirstFrame({ frameUrl: null, status: 'loading' });
|
||||||
|
|
||||||
const fetchFrame = async () => {
|
const fetchFrame = async () => {
|
||||||
if (failedUrls.has(url)) {
|
if (failedUrls.has(url)) {
|
||||||
if (isActive) setFrameUrl(null);
|
if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cachedFrame = await getCachedGifFrame(url);
|
const cachedFrame = await getCachedGifFrame(url);
|
||||||
if (cachedFrame) {
|
if (cachedFrame) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(cachedFrame);
|
const response = await fetch(cachedFrame);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if (isActive) setFrameUrl(cachedFrame);
|
if (isActive) setGifFirstFrame({ frameUrl: cachedFrame, status: 'ready' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -92,13 +111,15 @@ const useFetchGifFirstFrame = (url: string | undefined) => {
|
|||||||
const blob = typeof url === 'string' ? await parseGif(await fetchImage(url)) : await parseGif(await readImage(url as File));
|
const blob = typeof url === 'string' ? await parseGif(await fetchImage(url)) : await parseGif(await readImage(url as File));
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
setFrameUrl(objectUrl);
|
setGifFirstFrame({ frameUrl: objectUrl, status: 'ready' });
|
||||||
await setCachedGifFrame(url, objectUrl);
|
await setCachedGifFrame(url, objectUrl);
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failedUrls.add(url);
|
failedUrls.add(url);
|
||||||
console.error('Failed to load GIF frame:', error);
|
console.error('Failed to load GIF frame:', error);
|
||||||
if (isActive) setFrameUrl(null);
|
if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,7 +130,7 @@ const useFetchGifFirstFrame = (url: string | undefined) => {
|
|||||||
};
|
};
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
return frameUrl;
|
return gifFirstFrame;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useFetchGifFirstFrame;
|
export default useFetchGifFirstFrame;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const getDisplayMediaInfoType = (type: string, t: any) => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'image':
|
case 'image':
|
||||||
return t('image');
|
return t('image');
|
||||||
|
case 'gif':
|
||||||
|
return t('gif');
|
||||||
case 'animated gif':
|
case 'animated gif':
|
||||||
return t('animated_gif');
|
return t('animated_gif');
|
||||||
case 'static gif':
|
case 'static gif':
|
||||||
|
|||||||
Reference in New Issue
Block a user