feat: 영상 종료 3초 전 공식 홈페이지 링크 오버레이 표시

- VideoDetailItem/VideoListItem에 official_site_url 필드 추가
- 플레이어 onTimeUpdate로 잔여 3초 감지, 링크 카드 오버레이 노출
- official_site_url이 null이면 오버레이 미렌더링

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKTAqpFj8pbWzgvq7MRDHk
This commit is contained in:
hbyang 2026-08-21 10:45:15 +09:00
parent 0d3167c1a2
commit cea60e70db
5 changed files with 110 additions and 17 deletions

View File

@ -33,6 +33,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const [copied, setCopied] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [showSiteOverlay, setShowSiteOverlay] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [comments, setComments] = useState<CommentItem[]>([]);
@ -68,6 +69,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const fetchVideo = async () => {
setLoading(true);
setError(null);
setShowSiteOverlay(false);
try {
const data = await getVideoById(videoId);
setVideo(data);
@ -221,18 +223,46 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<div className="ado2-contents-error"><p>{error}</p></div>
) : video ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={video.result_movie_url}
controls
autoPlay
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
/>
<div className="video-detail-player-wrap">
<video
src={video.result_movie_url}
controls
autoPlay
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
onTimeUpdate={(e) => {
if (!video.official_site_url) return;
const v = e.currentTarget;
// 영상 종료 3초 전부터 공식 홈페이지 오버레이 노출
setShowSiteOverlay(
Number.isFinite(v.duration) && v.duration - v.currentTime <= 3
);
}}
/>
{video.official_site_url && showSiteOverlay && (
<a
href={video.official_site_url}
target="_blank"
rel="noopener noreferrer"
className="video-site-overlay"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span className="video-site-overlay-text">
<strong>{video.store_name}</strong>
{t('videoDetail.visitOfficialSite')}
</span>
</a>
)}
</div>
<div className="video-detail-info">
<h2 className="video-detail-store">{video.store_name}</h2>

View File

@ -624,6 +624,7 @@
"closeAriaLabel": "Close",
"share": "Share",
"copied": "Copied!",
"visitOfficialSite": "Visit official website",
"shareKakao": "KakaoTalk",
"shareFacebook": "Facebook",
"shareTwitter": "X (Twitter)",

View File

@ -623,6 +623,7 @@
"closeAriaLabel": "닫기",
"share": "공유하기",
"copied": "복사됨!",
"visitOfficialSite": "공식 홈페이지 방문하기",
"shareKakao": "카카오톡",
"shareFacebook": "페이스북",
"shareTwitter": "X (트위터)",

View File

@ -720,18 +720,77 @@
flex-direction: column;
}
.video-detail-player-wrap {
position: relative;
width: 100%;
max-width: 360px;
flex-shrink: 0;
}
.video-detail-content.landscape .video-detail-player-wrap {
max-width: 100%;
width: 100%;
}
.video-detail-player {
display: block;
width: 100%;
height: auto;
max-width: 360px;
border-radius: 12px;
}
/* 영상 종료 직전 공식 홈페이지 링크 오버레이 (유튜브 엔드스크린 스타일) */
.video-site-overlay {
position: absolute;
left: 12px;
right: 12px;
bottom: 64px; /* 네이티브 컨트롤 바를 가리지 않도록 */
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.75);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 10px;
color: #fff;
text-decoration: none;
font-size: 13px;
line-height: 1.35;
backdrop-filter: blur(4px);
animation: video-site-overlay-in 0.3s ease-out;
transition: background 0.2s;
}
.video-site-overlay:hover {
background: rgba(0, 0, 0, 0.9);
}
.video-site-overlay svg {
flex-shrink: 0;
}
.video-detail-content.landscape .video-detail-player {
max-width: 100%;
width: 100%;
.video-site-overlay-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.video-site-overlay-text strong {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes video-site-overlay-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.video-detail-info {
@ -1158,7 +1217,7 @@
.video-detail-content {
flex-direction: column;
}
.video-detail-player {
.video-detail-player-wrap {
max-width: 100%;
width: 100%;
}

View File

@ -281,6 +281,7 @@ export interface VideoListItem {
title?: string | null;
description?: string | null;
hashtags?: string[] | null;
official_site_url?: string | null;
created_at: string;
like_count: number;
comment_count: number;
@ -296,6 +297,7 @@ export interface VideoDetailItem {
region: string;
title?: string | null;
description?: string | null;
official_site_url?: string | null;
created_at: string;
like_count: number;
is_liked_by_me: boolean;