Fix missing thumbnails: Add error handling to image/video elements with fallback to YouTube defaults
This commit is contained in:
parent
16e146ce11
commit
86913861f2
9 changed files with 94 additions and 50 deletions
|
|
@ -4,9 +4,9 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kv-tube-app:
|
kv-tube:
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v4.0.8
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v4.0.8
|
||||||
container_name: kv-tube-app
|
container_name: kv-tube
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
|
||||||
style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
|
style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
|
||||||
className="videocard-thumb"
|
className="videocard-thumb"
|
||||||
priority={false}
|
priority={false}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; // Fallback to YouTube's default thumbnail
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{video.duration && !video.is_mix && (
|
{video.duration && !video.is_mix && (
|
||||||
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
||||||
|
|
|
||||||
|
|
@ -144,12 +144,16 @@ export default async function LibraryPage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
className="videocard-thumb"
|
className="videocard-thumb"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; // Fallback to YouTube's default thumbnail
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{video.duration && (
|
{video.duration && (
|
||||||
<div className="duration-badge">{video.duration}</div>
|
<div className="duration-badge">{video.duration}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -119,11 +119,15 @@ export default async function SubscriptionsPage() {
|
||||||
className="card-hover-lift"
|
className="card-hover-lift"
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; // Fallback to YouTube's default thumbnail
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{video.duration && (
|
{video.duration && (
|
||||||
<div className="duration-badge">{video.duration}</div>
|
<div className="duration-badge">{video.duration}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,16 @@ async function SearchResults({ query }: { query: string }) {
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div style={{ position: 'relative', width: '360px', minWidth: '360px', aspectRatio: '16/9', flexShrink: 0, overflow: 'hidden', borderRadius: '8px' }} className="search-result-thumb-container">
|
<div style={{ position: 'relative', width: '360px', minWidth: '360px', aspectRatio: '16/9', flexShrink: 0, overflow: 'hidden', borderRadius: '8px' }} className="search-result-thumb-container">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={v.thumbnail}
|
src={v.thumbnail}
|
||||||
alt={v.title}
|
alt={v.title}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', backgroundColor: '#272727' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover', backgroundColor: '#272727' }}
|
||||||
className="search-result-thumb"
|
className="search-result-thumb"
|
||||||
/>
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; // Fallback to YouTube's default thumbnail
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{v.duration && (
|
{v.duration && (
|
||||||
<span className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
<span className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
||||||
{v.duration}
|
{v.duration}
|
||||||
|
|
@ -108,10 +112,18 @@ async function SearchResults({ query }: { query: string }) {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<div style={{ width: '24px', height: '24px', borderRadius: '50%', background: 'var(--yt-avatar-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '11px', color: '#fff', overflow: 'hidden', fontWeight: 600 }}>
|
<div style={{ width: '24px', height: '24px', borderRadius: '50%', background: 'var(--yt-avatar-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '11px', color: '#fff', overflow: 'hidden', fontWeight: 600 }}>
|
||||||
{v.avatar_url ? (
|
{v.avatar_url ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={v.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<img
|
||||||
) : firstLetter}
|
src={v.avatar_url}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'; // Fallback to YouTube's default channel avatar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : firstLetter}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>{v.uploader}</span>
|
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>{v.uploader}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -115,13 +115,17 @@ export default function Comments({ videoId }: CommentsProps) {
|
||||||
{comments.map((c) => (
|
{comments.map((c) => (
|
||||||
<div key={c.id} style={{ display: 'flex', gap: '16px', marginBottom: '20px' }}>
|
<div key={c.id} style={{ display: 'flex', gap: '16px', marginBottom: '20px' }}>
|
||||||
<div style={{ position: 'relative', width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', flexShrink: 0, backgroundColor: 'var(--yt-hover)' }}>
|
<div style={{ position: 'relative', width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', flexShrink: 0, backgroundColor: 'var(--yt-hover)' }}>
|
||||||
<Image
|
<Image
|
||||||
src={c.author_thumbnail || 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'}
|
src={c.author_thumbnail || 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'}
|
||||||
alt={c.author}
|
alt={c.author}
|
||||||
fill
|
fill
|
||||||
sizes="40px"
|
sizes="40px"
|
||||||
style={{ objectFit: 'cover' }}
|
style={{ objectFit: 'cover' }}
|
||||||
/>
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/img/channels/c_ip_m_default.jpg'; // Fallback to YouTube's default channel avatar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, gap: '4px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, gap: '4px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
|
||||||
|
|
|
||||||
|
|
@ -93,13 +93,17 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
<Image
|
<Image
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
fill
|
fill
|
||||||
sizes="100px"
|
sizes="100px"
|
||||||
style={{ objectFit: 'cover' }}
|
style={{ objectFit: 'cover' }}
|
||||||
/>
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; // Fallback to YouTube's default thumbnail
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{video.duration && (
|
{video.duration && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,15 @@ export default async function RelatedVideos({ videoId, title, uploader }: { vide
|
||||||
return (
|
return (
|
||||||
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
|
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
|
||||||
<div className="related-thumb-container">
|
<div className="related-thumb-container">
|
||||||
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
|
<img
|
||||||
|
src={video.thumbnail}
|
||||||
|
alt={video.title}
|
||||||
|
className="related-thumb-img"
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; // Fallback to YouTube's default thumbnail
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{video.duration && (
|
{video.duration && (
|
||||||
<div className="duration-badge">
|
<div className="duration-badge">
|
||||||
{video.duration}
|
{video.duration}
|
||||||
|
|
|
||||||
|
|
@ -557,18 +557,22 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
webkit-playsinline="true"
|
webkit-playsinline="true"
|
||||||
x5-playsinline="true"
|
x5-playsinline="true"
|
||||||
x5-video-player-type="h5"
|
x5-video-player-type="h5"
|
||||||
x5-video-player-fullscreen="true"
|
x5-video-player-fullscreen="true"
|
||||||
preload="auto"
|
preload="auto"
|
||||||
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
||||||
/>
|
onError={(e) => {
|
||||||
|
e.target.onError = null; // Prevent infinite loop
|
||||||
|
e.target.poster = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; // Fallback to YouTube's default thumbnail
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<audio ref={audioRef} style={{ display: 'none' }} />
|
<audio ref={audioRef} style={{ display: 'none' }} />
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue