feat(landing): 소개 영상 버튼과 재생 모달 추가

- 사용 가이드 옆 '▶ 소개 영상 1분' 버튼, 네이티브 dialog에서 재생
- 자체 호스팅 mp4(56초·1920×1080·H.264, 무음), 열 때만 로드(preload none), 무음 자동재생
- Esc·배경·닫기로 종료 시 일시정지·처음으로·포커스 복귀, 모션 감소 시 자동재생 안 함
- 자막 원본(.srt)은 보관만, 화면 미연결
- verify-intro-video.cjs: Chrome 17개 검사

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:12 +09:00
1 parent fc520ec03d
commit ef596650cc
6 files changed
+285 -1

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
import './landing-video.css';
/** Self-hosted intro video (56 s, 1920×1080 H.264, no audio track). Fetched only when opened. */
export const introVideoUrl = new URL('./assets/pierzainer-intro.mp4', import.meta.url).href;
export const introVideoTriggerMarkup = `<button class="landing-video-trigger" type="button" aria-haspopup="dialog" aria-controls="landing-video"><span aria-hidden="true">▶</span>소개 영상 <span class="landing-video-trigger-time">1분</span></button>`;
/** Native dialog: Esc/backdrop close, pause + rewind on close, focus returns to the opener. */
export function createIntroVideo(root: HTMLElement, poster: string): { open: () => void; close: (restoreFocus?: boolean) => void } {
const trigger = root.querySelector<HTMLButtonElement>('.landing-video-trigger')!;
const dialog = document.createElement('dialog');
dialog.className = 'landing-video';
dialog.id = 'landing-video';
dialog.setAttribute('aria-labelledby', 'landing-video-title');
dialog.innerHTML = `
<div class="landing-video-head">
<div><span class="landing-video-kicker">PRODUCT TOUR / PIER &amp; ABUT</span><h2 id="landing-video-title">Pierzainer 소개 영상</h2></div>
<button type="button" class="landing-video-close" aria-label="소개 영상 닫기">×</button>
</div>
<div class="landing-video-frame">
<video controls playsinline muted preload="none" poster="${poster}" aria-describedby="landing-video-desc"></video>
</div>
<p id="landing-video-desc" class="landing-video-desc">설계정보 입력부터 3D 모델링, 철근배근, 도면작성, 수량산출까지의 흐름을 1분 동안 소개합니다. (음성 없음)</p>`;
root.append(dialog);
const video = dialog.querySelector('video')!;
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
const open = (): void => {
if (dialog.open) return;
if (!video.currentSrc && !video.querySelector('source')) {
const source = document.createElement('source');
source.src = introVideoUrl;
source.type = 'video/mp4';
video.append(source);
video.load();
}
dialog.showModal();
dialog.querySelector<HTMLButtonElement>('.landing-video-close')!.focus();
if (!reducedMotion?.matches) void video.play().catch(() => undefined);
};
const stop = (): void => {
video.pause();
if (video.readyState > 0) video.currentTime = 0;
};
const close = (restoreFocus = true): void => {
if (!dialog.open) return;
dialog.close();
if (restoreFocus && trigger.isConnected && !root.hidden) trigger.focus();
};
trigger.addEventListener('click', open);
dialog.querySelector('.landing-video-close')!.addEventListener('click', () => close());
dialog.addEventListener('close', stop);
dialog.addEventListener('cancel', (event) => { event.preventDefault(); close(); });
// Clicking the backdrop (outside the panel box) closes the dialog.
dialog.addEventListener('click', (event) => {
if (event.target !== dialog) return;
const box = dialog.getBoundingClientRect();
const inside = event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom;
if (!inside) close();
});
return { open, close };
}