kv-netflix/backend/static/scripts/components/InfoModal.js
Khoa.vo 00ccf95cae v1.0.6: Image optimization, navigation fixes, PWA improvements
- Optimized mobile image loading (180px vs 200px desktop)
- Fixed Install App navigation not working on desktop
- Fixed replaceChild null error in hero rendering
- Added PWA icon (512x512)
- Fixed back button navigation issues
- Added mobile bottom padding for nav bar
- Moved Get App FAB higher to avoid nav overlap
- Removed unnecessary pushState from video navigation
- Made Search/MyList tabs not scroll to top on mobile
- Removed duplicate Android TV section from download page
2025-12-24 12:21:34 +07:00

205 lines
11 KiB
JavaScript

/**
* Netflix 2025 Info Modal Component
* Premium, cinematic modal with video preview and rich metadata
*/
import { hapticLight, hapticMedium } from '../haptics.js';
export function createInfoModal(video, onClose, onPlay, recommendations = []) {
const modal = document.createElement('div');
modal.className = 'modal modal--info active';
modal.id = `modal-${video.id}`;
const backdropUrl = video.backdrop || video.thumbnail;
const isSeries = video.type === 'series' || video.category?.toLowerCase() === 'series';
modal.innerHTML = `
<div class="modal__backdrop"></div>
<div class="modal__container">
<button class="modal__close" aria-label="Close">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
<div class="modal__header">
<div class="modal__header-video">
<img src="${backdropUrl}" alt="${video.title}" class="modal__header-img">
${video.preview_url ? `
<video class="modal__header-preview" muted playsinline loop>
<source src="${video.preview_url}" type="video/mp4">
</video>
` : ''}
</div>
<div class="modal__header-vignette"></div>
<div class="modal__header-content">
<h2 class="modal__title">${video.title}</h2>
<div class="modal__actions">
<button class="modal__btn modal__btn--primary" data-action="play">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M8 5v14l11-7z"/></svg>
<span>Play</span>
</button>
<button class="modal__btn modal__btn--round" data-action="add" title="Add to My List">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" width="24" height="24"><path d="M12 5v14m-7-7h14" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<button class="modal__btn modal__btn--round" data-action="like" title="I like this">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" width="24" height="24"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
</div>
<div class="modal__body">
<div class="modal__info-grid">
<div class="modal__info-main">
<div class="modal__metadata">
<span class="modal__match">${video.matchScore || 95}% Match</span>
<span class="modal__year">${video.releaseYear || video.year || 2024}</span>
<span class="modal__age">${video.maturityRating || '13+'}</span>
<span class="modal__duration">${video.duration ? Math.floor(video.duration / 3600) + 'h ' + Math.floor((video.duration % 3600) / 60) + 'm' : '2h 15m'}</span>
<span class="modal__quality">${video.quality || 'HD'}</span>
</div>
<p class="modal__description">${video.description || 'No description available for this title.'}</p>
</div>
<div class="modal__info-side">
${video.cast && video.cast.length && video.cast[0] !== 'Unknown' ? `
<div class="modal__tags">
<span class="modal__label">Cast:</span>
<span class="modal__value">${video.cast.join(', ')}</span>
</div>
` : ''}
<div class="modal__tags">
<span class="modal__label">Genres:</span>
<span class="modal__value">${video.genres ? video.genres.join(', ') : video.category || 'Movies'}</span>
</div>
${video.director && video.director !== 'Unknown' ? `
<div class="modal__tags">
<span class="modal__label">Director:</span>
<span class="modal__value">${video.director}</span>
</div>
` : ''}
${video.country && video.country !== 'International' ? `
<div class="modal__tags">
<span class="modal__label">Country:</span>
<span class="modal__value">${video.country}</span>
</div>
` : ''}
</div>
</div>
${isSeries && video.episodes && video.episodes.length > 0 ? `
<div class="modal__episodes">
<div class="modal__section-header">
<h3 class="modal__section-title">Episodes</h3>
<span class="modal__episode-count">${video.episodes.length} Episodes</span>
</div>
<div class="modal__episodes-list">
${video.episodes.map(ep => `
<div class="episode-row" data-episode-url="${ep.url}">
<div class="episode-row__number">${ep.number}</div>
<div class="episode-row__img">
<img src="${video.backdrop || video.thumbnail}" alt="Episode ${ep.number}">
<div class="episode-row__play-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="episode-row__info">
<div class="episode-row__header">
<span class="episode-row__title">${ep.title || `Episode ${ep.number}`}</span>
<span class="episode-row__duration">${Math.floor(Math.random() * 20 + 40)}m</span>
</div>
<p class="episode-row__desc">${ep.description || (video.description || '').substring(0, 60)}...</p>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
${recommendations.length > 0 ? `
<div class="modal__recommendations">
<h3 class="modal__section-title">More Like This</h3>
<div class="recommendations-grid">
${recommendations.map(rec => `
<div class="recommendation-card" data-video-id="${rec.id}">
<div class="recommendation-card__img-wrapper">
<img src="${rec.thumbnail}" alt="${rec.title}">
<div class="recommendation-card__play">
<svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="recommendation-card__content">
<h4 class="recommendation-card__title">${rec.title}</h4>
<div class="recommendation-card__meta">
<span class="modal__match">${rec.matchScore || 90}% Match</span>
<span class="modal__age">${rec.maturityRating || '13+'}</span>
<span class="modal__year">${rec.year || 2024}</span>
</div>
<p class="recommendation-card__desc">${(rec.description || 'No description').substring(0, 80)}${rec.description && rec.description.length > 80 ? '...' : ''}</p>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
</div>
`;
// Event Listeners
modal.querySelector('.modal__close').addEventListener('click', () => {
hapticLight();
onClose(modal);
});
modal.querySelector('.modal__backdrop').addEventListener('click', () => {
onClose(modal);
});
modal.querySelector('[data-action="play"]').addEventListener('click', () => {
hapticMedium();
onPlay(video);
});
// Autoplay header video
const headerVideo = modal.querySelector('.modal__header-preview');
const headerImg = modal.querySelector('.modal__header-img');
if (headerVideo) {
setTimeout(() => {
headerVideo.play().then(() => {
headerImg.style.opacity = '0';
headerVideo.style.opacity = '1';
}).catch(e => console.log('Autoplay failed', e));
}, 1000);
}
// Recommendation card clicks
modal.querySelectorAll('.recommendation-card').forEach(card => {
card.addEventListener('click', () => {
const vidId = card.dataset.videoId;
const targetVid = recommendations.find(r => r.id == vidId);
if (targetVid) {
// In a real app, we might navigate or open another modal
onPlay(targetVid);
}
});
});
// Episode row clicks
modal.querySelectorAll('.episode-row').forEach(row => {
row.addEventListener('click', () => {
const url = row.dataset.episodeUrl;
if (url) {
// Create a temporary video object for the episode
const episodeTitle = row.querySelector('.episode-row__title').textContent;
const episodeVideo = {
...video,
source_url: url,
title: `${video.title}: ${episodeTitle}`,
isEpisode: true
};
onPlay(episodeVideo);
}
});
});
return modal;
}