Display bootcamp lesson chapters on Product Detail page as marketing content
This commit implements displaying lesson chapters/timeline as marketing content on the Product Detail page for bootcamp products, helping potential buyers understand the detailed breakdown of what they'll learn. ## Changes ### Product Detail Page (src/pages/ProductDetail.tsx) - Updated Lesson interface to include optional chapters property - Modified fetchCurriculum to fetch chapters along with lessons - Enhanced renderCurriculumPreview to display chapters as nested content under lessons - Chapters shown with timestamps and titles, clickable to navigate to bootcamp access page - Visual hierarchy: Module → Lesson → Chapters with proper indentation and styling ### Review System Fixes - Fixed review prompt re-appearing after submission (before admin approval) - Added hasSubmittedReview check to prevent showing prompt when review exists - Fixed edit review functionality to pre-populate form with existing data - ReviewModal now handles both INSERT (new) and UPDATE (edit) operations - Edit resets is_approved to false requiring re-approval ### Video Player Enhancements - Implemented Adilo/Video.js integration for M3U8/HLS playback - Added video progress tracking with refs pattern for reliability - Implemented chapter navigation for both Adilo and YouTube players - Added keyboard shortcuts (Space, Arrows, F, M, J, L) - Resume prompt for returning users with saved progress ### Database Migrations - Added Adilo video support fields (m3u8_url, mp4_url, video_host) - Created video_progress table for tracking user watch progress - Fixed consulting slots user_id foreign key - Added chapters support to products and bootcamp_lessons tables ### Documentation - Added Adilo implementation plan and quick reference docs - Cleaned up transcript analysis files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
702
adilo-code-templates-starter.md
Normal file
702
adilo-code-templates-starter.md
Normal file
@@ -0,0 +1,702 @@
|
||||
# Code Templates - Copy & Paste Starting Points
|
||||
|
||||
## File 1: hooks/useAdiloPlayer.js
|
||||
|
||||
```javascript
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
/**
|
||||
* Hook for managing Adilo video playback via HLS.js
|
||||
* Handles M3U8 URL streaming with browser compatibility
|
||||
*/
|
||||
export function useAdiloPlayer({
|
||||
m3u8Url,
|
||||
autoplay = false,
|
||||
onTimeUpdate = () => {},
|
||||
onEnded = () => {},
|
||||
onError = () => {},
|
||||
} = {}) {
|
||||
const videoRef = useRef(null);
|
||||
const hlsRef = useRef(null);
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Initialize HLS streaming
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !m3u8Url) return;
|
||||
|
||||
try {
|
||||
// Safari has native HLS support
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = m3u8Url;
|
||||
setIsReady(true);
|
||||
}
|
||||
// Other browsers use HLS.js
|
||||
else if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
autoStartLoad: true,
|
||||
startPosition: -1,
|
||||
});
|
||||
|
||||
hls.loadSource(m3u8Url);
|
||||
hls.attachMedia(video);
|
||||
hlsRef.current = hls;
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsReady(true);
|
||||
if (autoplay) {
|
||||
video.play().catch(err => console.error('Autoplay failed:', err));
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS Error:', data);
|
||||
setError(data.message || 'HLS streaming error');
|
||||
onError(data);
|
||||
});
|
||||
} else {
|
||||
setError('HLS streaming not supported in this browser');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Video initialization error:', err);
|
||||
setError(err.message);
|
||||
onError(err);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [m3u8Url, autoplay, onError]);
|
||||
|
||||
// Track video events
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
onTimeUpdate(video.currentTime);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
};
|
||||
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
onEnded();
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, [onTimeUpdate, onEnded]);
|
||||
|
||||
// Control methods
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play().catch(err => console.error('Play error:', err));
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((time) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
isReady,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
error,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 2: hooks/useChapterTracking.js
|
||||
|
||||
```javascript
|
||||
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for tracking which chapter is currently active
|
||||
* based on video's currentTime
|
||||
*/
|
||||
export function useChapterTracking({
|
||||
chapters = [],
|
||||
currentTime = 0,
|
||||
onChapterChange = () => {},
|
||||
} = {}) {
|
||||
const [activeChapterId, setActiveChapterId] = useState(null);
|
||||
const [completedChapters, setCompletedChapters] = useState([]);
|
||||
|
||||
// Find active chapter from currentTime
|
||||
const activeChapter = useMemo(() => {
|
||||
return chapters.find(
|
||||
ch => currentTime >= ch.startTime && currentTime < ch.endTime
|
||||
) || null;
|
||||
}, [chapters, currentTime]);
|
||||
|
||||
// Detect chapter changes
|
||||
useEffect(() => {
|
||||
if (activeChapter?.id !== activeChapterId) {
|
||||
setActiveChapterId(activeChapter?.id || null);
|
||||
if (activeChapter) {
|
||||
onChapterChange(activeChapter);
|
||||
}
|
||||
}
|
||||
}, [activeChapter, activeChapterId, onChapterChange]);
|
||||
|
||||
// Track completed chapters
|
||||
useEffect(() => {
|
||||
if (activeChapter?.id && !completedChapters.includes(activeChapter.id)) {
|
||||
// Mark chapter as visited (not necessarily completed)
|
||||
setCompletedChapters(prev => [...prev, activeChapter.id]);
|
||||
}
|
||||
}, [activeChapter?.id, completedChapters]);
|
||||
|
||||
// Calculate current chapter progress
|
||||
const chapterProgress = useMemo(() => {
|
||||
if (!activeChapter) return 0;
|
||||
|
||||
const chapterDuration = activeChapter.endTime - activeChapter.startTime;
|
||||
const timeInChapter = currentTime - activeChapter.startTime;
|
||||
return Math.round((timeInChapter / chapterDuration) * 100);
|
||||
}, [activeChapter, currentTime]);
|
||||
|
||||
// Get overall video progress
|
||||
const overallProgress = useMemo(() => {
|
||||
if (!chapters.length) return 0;
|
||||
const lastChapter = chapters[chapters.length - 1];
|
||||
return Math.round((currentTime / lastChapter.endTime) * 100);
|
||||
}, [chapters, currentTime]);
|
||||
|
||||
return {
|
||||
activeChapter,
|
||||
activeChapterId,
|
||||
chapterProgress, // 0-100 within current chapter
|
||||
overallProgress, // 0-100 for entire video
|
||||
completedChapters, // Array of visited chapter IDs
|
||||
isVideoComplete: overallProgress >= 100,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 3: components/AdiloVideoPlayer.jsx
|
||||
|
||||
```javascript
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useAdiloPlayer } from '@/hooks/useAdiloPlayer';
|
||||
import { useChapterTracking } from '@/hooks/useChapterTracking';
|
||||
import ChapterNavigation from './ChapterNavigation';
|
||||
import styles from './AdiloVideoPlayer.module.css';
|
||||
|
||||
/**
|
||||
* Main Adilo video player component with chapter support
|
||||
*/
|
||||
export default function AdiloVideoPlayer({
|
||||
m3u8Url,
|
||||
videoId,
|
||||
chapters = [],
|
||||
autoplay = false,
|
||||
showChapters = true,
|
||||
onVideoComplete = () => {},
|
||||
onChapterChange = () => {},
|
||||
onProgressUpdate = () => {},
|
||||
}) {
|
||||
const [lastSaveTime, setLastSaveTime] = useState(0);
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
isReady,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
error,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
} = useAdiloPlayer({
|
||||
m3u8Url,
|
||||
autoplay,
|
||||
onTimeUpdate: handleTimeUpdate,
|
||||
onEnded: handleVideoEnded,
|
||||
onError: (err) => console.error('Player error:', err),
|
||||
});
|
||||
|
||||
const {
|
||||
activeChapter,
|
||||
activeChapterId,
|
||||
chapterProgress,
|
||||
overallProgress,
|
||||
completedChapters,
|
||||
isVideoComplete,
|
||||
} = useChapterTracking({
|
||||
chapters,
|
||||
currentTime,
|
||||
onChapterChange,
|
||||
});
|
||||
|
||||
// Save progress periodically (every 5 seconds)
|
||||
function handleTimeUpdate(time) {
|
||||
const now = Date.now();
|
||||
if (now - lastSaveTime > 5000) {
|
||||
onProgressUpdate({
|
||||
videoId,
|
||||
currentTime: time,
|
||||
duration,
|
||||
progress: overallProgress,
|
||||
activeChapterId,
|
||||
completedChapters,
|
||||
});
|
||||
setLastSaveTime(now);
|
||||
}
|
||||
}
|
||||
|
||||
function handleVideoEnded() {
|
||||
onVideoComplete({
|
||||
videoId,
|
||||
completedChapters,
|
||||
totalWatched: duration,
|
||||
});
|
||||
}
|
||||
|
||||
const handleChapterClick = useCallback((startTime) => {
|
||||
seek(startTime);
|
||||
play();
|
||||
}, [seek, play]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Main Video Player */}
|
||||
<div className={styles.playerWrapper}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={styles.video}
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
/>
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{!isReady && (
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner} />
|
||||
<p>Loading video...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
<p>⚠️ Error: {error}</p>
|
||||
<p className={styles.errorSmall}>
|
||||
Make sure the M3U8 URL is valid and accessible
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className={styles.progressContainer}>
|
||||
<div className={styles.progressBar}>
|
||||
{chapters.map((chapter, idx) => (
|
||||
<div
|
||||
key={chapter.id}
|
||||
className={`${styles.progressSegment} ${
|
||||
completedChapters.includes(chapter.id) ? styles.completed : ''
|
||||
}`}
|
||||
style={{
|
||||
flex: chapter.endTime - chapter.startTime,
|
||||
opacity: activeChapterId === chapter.id ? 1 : 0.7,
|
||||
}}
|
||||
onClick={() => handleChapterClick(chapter.startTime)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.timeInfo}>
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter Navigation */}
|
||||
{showChapters && (
|
||||
<ChapterNavigation
|
||||
chapters={chapters}
|
||||
activeChapterId={activeChapterId}
|
||||
currentTime={currentTime}
|
||||
completedChapters={completedChapters}
|
||||
onChapterClick={handleChapterClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status Info */}
|
||||
<div className={styles.statusBar}>
|
||||
<span>Playing: {activeChapter?.title || 'Video'}</span>
|
||||
<span className={styles.progress}>{overallProgress}% watched</span>
|
||||
{isVideoComplete && <span className={styles.complete}>✓ Completed</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility function
|
||||
function formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 4: components/ChapterNavigation.jsx
|
||||
|
||||
```javascript
|
||||
import React from 'react';
|
||||
import styles from './ChapterNavigation.module.css';
|
||||
|
||||
/**
|
||||
* Chapter navigation sidebar component
|
||||
*/
|
||||
export default function ChapterNavigation({
|
||||
chapters = [],
|
||||
activeChapterId,
|
||||
currentTime = 0,
|
||||
completedChapters = [],
|
||||
onChapterClick = () => {},
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<h3 className={styles.title}>Chapters</h3>
|
||||
|
||||
<div className={styles.chaptersList}>
|
||||
{chapters.map((chapter) => {
|
||||
const isActive = chapter.id === activeChapterId;
|
||||
const isCompleted = completedChapters.includes(chapter.id);
|
||||
const timeRemaining = chapter.endTime - currentTime;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={chapter.id}
|
||||
className={`${styles.chapterItem} ${
|
||||
isActive ? styles.active : ''
|
||||
} ${isCompleted ? styles.completed : ''}`}
|
||||
onClick={() => onChapterClick(chapter.startTime)}
|
||||
title={chapter.description || chapter.title}
|
||||
>
|
||||
<div className={styles.time}>
|
||||
{formatTime(chapter.startTime)}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.title}>{chapter.title}</div>
|
||||
{chapter.description && (
|
||||
<p className={styles.description}>{chapter.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCompleted && (
|
||||
<span className={styles.badge}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 5: services/progressService.js
|
||||
|
||||
```javascript
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.VITE_SUPABASE_URL,
|
||||
process.env.VITE_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
/**
|
||||
* Save video progress to Supabase
|
||||
*/
|
||||
export async function saveProgress(userId, videoId, currentTime, completedChapters) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('video_progress')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
video_id: videoId,
|
||||
last_position: Math.round(currentTime),
|
||||
completed_chapters: completedChapters,
|
||||
updated_at: new Date(),
|
||||
}, {
|
||||
onConflict: 'user_id,video_id'
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error saving progress:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's last position for a video
|
||||
*/
|
||||
export async function getLastPosition(userId, videoId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('video_progress')
|
||||
.select('last_position, completed_chapters')
|
||||
.eq('user_id', userId)
|
||||
.eq('video_id', videoId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') throw error; // 116 = no rows
|
||||
return data || { last_position: 0, completed_chapters: [] };
|
||||
} catch (error) {
|
||||
console.error('Error fetching progress:', error);
|
||||
return { last_position: 0, completed_chapters: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark video as completed
|
||||
*/
|
||||
export async function markVideoComplete(userId, videoId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('video_progress')
|
||||
.update({
|
||||
is_completed: true,
|
||||
completed_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.eq('user_id', userId)
|
||||
.eq('video_id', videoId);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error marking complete:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video analytics
|
||||
*/
|
||||
export async function getVideoAnalytics(userId, videoId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('video_progress')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('video_id', videoId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') throw error;
|
||||
return data || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching analytics:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 6: styles/AdiloVideoPlayer.module.css
|
||||
|
||||
```css
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
}
|
||||
|
||||
.errorSmall {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progressContainer {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressSegment {
|
||||
flex: 1;
|
||||
background: #0ea5e9;
|
||||
border-radius: 1px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.progressSegment.completed {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.timeInfo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.progress {
|
||||
font-weight: 600;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
.complete {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.progressContainer {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ready to start? Copy these files into your project and follow the implementation plan!**
|
||||
Reference in New Issue
Block a user