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:
File diff suppressed because one or more lines are too long
@@ -1,196 +0,0 @@
|
|||||||
# E-Course Curriculum: Cara Jual Jasa via Online
|
|
||||||
## How to Sell Services Online
|
|
||||||
|
|
||||||
**Source Video**: Live Zoom - Diskusi Cara Jual Jasa via Online
|
|
||||||
**Duration**: 2 hours 30 minutes
|
|
||||||
**Speaker**: Dwindi Ramadhana
|
|
||||||
**Language**: Indonesian
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Course Overview
|
|
||||||
|
|
||||||
This comprehensive course teaches you how to successfully sell services online, with a focus on web development and plugin installation services. Learn from real-world examples, case studies, and practical strategies for building a sustainable service business.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Course Structure
|
|
||||||
|
|
||||||
### Chapter 1: Pengenalan & Temukan Pasar [00:00:00 - 00:15:00]
|
|
||||||
|
|
||||||
- [00:04:40 - 00:08:20] Pembukaan & Setup Diskusi
|
|
||||||
- Pengenalan topik cara jual jasa via online
|
|
||||||
- Format diskusi kasual dan interaktif
|
|
||||||
- Ruang lingkup: jasa pembuatan website & instalasi plugin
|
|
||||||
|
|
||||||
- [00:08:20 - 00:15:00] Cara Menemukan Market Anda
|
|
||||||
- Pentingnya bergabung dengan komunitas (Telegram, grup Facebook, forum)
|
|
||||||
- Kisah Abdurrahman bin Auf mencari pasar terlebih dahulu
|
|
||||||
- Mengamati pola komunitas dan masalah umum
|
|
||||||
- Jangan takut untuk bergabung dan mengamati
|
|
||||||
|
|
||||||
### Chapter 2: Discovery Masalah & Eksplorasi [00:15:00 - 00:30:00]
|
|
||||||
|
|
||||||
- [00:15:00 - 00:21:30] Identifikasi Masalah yang Sering Muncul
|
|
||||||
- Menemukan masalah yang sering disebutkan di komunitas
|
|
||||||
- Berpindah dari komunitas ke chat pribadi (Japri)
|
|
||||||
- Membangun jejaring melalui manfaat timbal balik
|
|
||||||
- Fase eksplorasi: trial and error di situs demo
|
|
||||||
|
|
||||||
- [00:21:30 - 00:30:00] Dasar Personal Branding
|
|
||||||
- Melakukan hal-hal bermanfaat untuk orang lain
|
|
||||||
- Pamerkan pengetahuan Anda (jangan disembunyikan)
|
|
||||||
- Memahami funnel AIDA (Awareness, Interest, Desire, Action)
|
|
||||||
- Cold market (grup) vs Warm market (chat pribadi) vs Hot market (klien)
|
|
||||||
|
|
||||||
### Chapter 3: Akuisisi & Manajemen Klien [00:30:00 - 00:45:00]
|
|
||||||
|
|
||||||
- [00:30:00 - 00:35:00] Psikologi Klien
|
|
||||||
- Aturan Klien: Semua orang ingin mengeluh
|
|
||||||
- Semua orang berpikir masalah mereka adalah yang terpenting
|
|
||||||
- Semua orang ingin didengar
|
|
||||||
- Empat kunci: Simak, Rekap, Offer, Deal
|
|
||||||
|
|
||||||
- [00:35:00 - 00:45:00] Strategi Harga
|
|
||||||
- Fokus pada effort Anda, bukan budget klien
|
|
||||||
- Studi kasus: Penetapan harga Elementor Pro
|
|
||||||
- Memahami nilai vs harga komoditas
|
|
||||||
- Contoh: Menjual lisensi Elementor Pro dengan harga Rp1.000/tahun
|
|
||||||
|
|
||||||
### Chapter 4: Deliver Layanan & Relasi Klien [00:45:00 - 01:00:00]
|
|
||||||
|
|
||||||
- [00:45:00 - 00:50:00] Prioritas Pelayanan Klien
|
|
||||||
- Prioritas 1: Respon cepat (jadilah responsif)
|
|
||||||
- Prioritas 2: Selesaikan proyek
|
|
||||||
- Prioritas 3: Eksplorasi hal baru
|
|
||||||
- Pentingnya keberadaan dan ketersediaan
|
|
||||||
|
|
||||||
- [00:50:00 - 01:00:00] Keberlanjutan Bisnis
|
|
||||||
- Mengapa harga rendah bermasalah (undercutting)
|
|
||||||
- Dua risiko utama: persaingan dan komoditisasi skill
|
|
||||||
- Transisi dari layanan dasar ke solusi spesialis
|
|
||||||
|
|
||||||
### Chapter 5: Sesi Tanya Jawab - Bagian 1 [01:00:00 - 01:15:00]
|
|
||||||
|
|
||||||
- [01:00:00 - 01:07:00] Implementasi Funnel
|
|
||||||
- Cara menerapkan AIDA dalam praktik
|
|
||||||
- Personal branding melalui partisipasi komunitas
|
|
||||||
- Menghadapi keberadaan komersial vs membantu di komunitas
|
|
||||||
|
|
||||||
- [01:07:00 - 01:15:00] Strategi Pemasaran
|
|
||||||
- Pemasaran afiliasi (model komisi 30%)
|
|
||||||
- Perbandingan pemasaran organik vs berbayar
|
|
||||||
- Contoh nyata keberhasilan afiliasi
|
|
||||||
|
|
||||||
### Chapter 6: Harga Lanjutan & Negosiasi [01:15:00 - 01:30:00]
|
|
||||||
|
|
||||||
- [01:15:00 - 01:22:00] Pendekatan Berorientasi Solusi
|
|
||||||
- Fokus pada solusi, bukan hanya menjual
|
|
||||||
- Studi kasus: Kustomisasi JetEngine
|
|
||||||
- Kapan merekomendasikan alternatif vs jasa Anda
|
|
||||||
|
|
||||||
- [01:22:00 - 01:30:00] Psikologi Harga
|
|
||||||
- Hindari harga terperinci (contoh 8 poin vs 4 poin)
|
|
||||||
- Memahami keberatan klien
|
|
||||||
- Jelas tentang ruang lingkup sebelum memberi harga
|
|
||||||
|
|
||||||
### Chapter 7: Manajemen Proyek & Kontrak [01:30:00 - 01:45:00]
|
|
||||||
|
|
||||||
- [01:30:00 - 01:37:00] Kejujuran dalam Relasi Klien
|
|
||||||
- Bersikap langsung tentang apa yang mungkin
|
|
||||||
- Contoh: Masalah kompatibilitas tema
|
|
||||||
- Menghindari janji yang berlebihan
|
|
||||||
|
|
||||||
- [01:37:00 - 01:45:00] Esensial Kontrak
|
|
||||||
- Kapan menggunakan kontrak (proyek besar)
|
|
||||||
- Format kontrak sederhana (1-2 halaman)
|
|
||||||
- Syarat dan ketentuan yang jelas
|
|
||||||
- Kebijakan uang muka
|
|
||||||
|
|
||||||
### Chapter 8: Pengembangan Diri & Etos Kerja [01:45:00 - 02:00:00]
|
|
||||||
|
|
||||||
- [01:45:00 - 01:53:00] Membangun Skill Anda
|
|
||||||
- Mindset workaholic vs kerja berkelanjutan
|
|
||||||
- Belajar dari rasa ingin tahu (contoh PSP)
|
|
||||||
- Eksplorasi dan pembelajaran terus-menerus
|
|
||||||
|
|
||||||
- [01:53:00 - 02:00:00] Alur Kerja Manajemen Proyek
|
|
||||||
- Strategi alokasi waktu
|
|
||||||
- Mengganggu saat menangani proyek
|
|
||||||
- Menyeimbangkan banyak klien
|
|
||||||
|
|
||||||
### Chapter 9: Sesi Tanya Jawab - Bagian 2 [02:00:00 - 02:15:00]
|
|
||||||
|
|
||||||
- [02:00:00 - 02:07:00] Lisensi Plugin & Etika
|
|
||||||
- Plugin GPL dari vendor (Envato, dll)
|
|
||||||
- Risiko plugin nulled/bajakan
|
|
||||||
- Mengunduh dari sumber resmi
|
|
||||||
- Edukasi klien tentang lisensi
|
|
||||||
|
|
||||||
- [02:07:00 - 02:15:00] Funnel AIDA Lebih Dalam
|
|
||||||
- Bagaimana eksplorasi memenuhi funnel
|
|
||||||
- Menciptakan kesadaran melalui kebermanfaatan
|
|
||||||
- Memindahkan prospek melalui tahapan
|
|
||||||
|
|
||||||
### Chapter 10: Tanya Jawab Final & Alat [02:15:00 - 02:30:18]
|
|
||||||
|
|
||||||
- [02:15:00 - 02:22:00] Pertanyaan Umum Layanan
|
|
||||||
- Etika pemasaran (hindari spam)
|
|
||||||
- Follow-up tanpa mengganggu
|
|
||||||
- Perjalanan belajar teknis
|
|
||||||
|
|
||||||
- [02:22:00 - 02:30:00] Alat yang Direkomendasikan
|
|
||||||
- Lingkungan pengembangan lokal (LocalWP)
|
|
||||||
- Rekomendasi hosting
|
|
||||||
- Sumber belajar
|
|
||||||
- Penutup dan perpisahan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Takeaways
|
|
||||||
|
|
||||||
### Prinsip Utama
|
|
||||||
1. **Komunitas Dulu**: Bergabunglah dengan komunitas sebelum mencoba menjual apa pun
|
|
||||||
2. **Fokus pada Masalah**: Identifikasi masalah yang sering disebutkan
|
|
||||||
3. **Eksplorasi & Belajar**: Gunakan situs demo untuk trial and error
|
|
||||||
4. **Bangun Kepercayaan**: Bagikan pengetahuan dengan gratis, jangan sembunyikan
|
|
||||||
5. **Pahami Psikologi**: Klien ingin didengar dan dipahami
|
|
||||||
6. **Harga Berbasis Nilai**: Harga berdasarkan effort dan nilai Anda, bukan budget klien
|
|
||||||
7. **Jadilah Responsif**: Waktu respon cepat sangat krusial
|
|
||||||
8. **Berorientasi Solusi**: Fokus pada memecahkan masalah, bukan hanya menjual jasa
|
|
||||||
9. **Kejujuran Menang**: Bersikap langsung tentang keterbatasan dan kemungkinan
|
|
||||||
10. **Pembelajaran Terus-menerus**: Selalu eksplorasi alat dan teknik baru
|
|
||||||
|
|
||||||
### Studi Kasus Utama
|
|
||||||
- Penjualan Elementor Pro (100+ domain)
|
|
||||||
- Negosiasi harga dengan klien
|
|
||||||
- Membangun personal branding di komunitas
|
|
||||||
- Transisi dari freelancer ke pemilik produk
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sumber Tambahan
|
|
||||||
|
|
||||||
### Tools yang Disebutkan
|
|
||||||
- LocalWP (local development)
|
|
||||||
- Elementor Pro
|
|
||||||
- JetEngine
|
|
||||||
- Sejoli Shortcode
|
|
||||||
|
|
||||||
### Komunitas yang Direkomendasikan
|
|
||||||
- Grup WordPress Indonesia
|
|
||||||
- Grup Facebook seputar web development
|
|
||||||
- Forum Telegram untuk diskusi teknis
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Catatan untuk Kursus
|
|
||||||
|
|
||||||
Kurikulum ini dirancang untuk memecah video 2.5 jam menjadi pelajaran yang mudah dicerna. Setiap chapter berfokus pada topik tertentu dengan durasi 15 menit, ideal untuk pembelajaran online. Materi mencakup contoh nyata, studi kasus, dan strategi praktis yang dapat langsung diterapkan.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Dokumen ini dibuat berdasarkan transcript video asli**
|
|
||||||
**Total Durasi Video**: 2 jam 30 menit 18 detik
|
|
||||||
**Jumlah Chapter**: 10
|
|
||||||
**Jumlah Pelajaran**: 20
|
|
||||||
372
adilo-ai-agent-quick-ref.md
Normal file
372
adilo-ai-agent-quick-ref.md
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
# Adilo Video Player - Quick AI Agent Reference
|
||||||
|
|
||||||
|
## For Your Windsurf/IDE AI Agent
|
||||||
|
|
||||||
|
Copy this into your `.codebase` instructions or share with AI agent:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project: LearnHub - Adilo M3U8 Video Player with Custom Chapters
|
||||||
|
|
||||||
|
### Problem Statement
|
||||||
|
Build a React video player that:
|
||||||
|
- Streams video from Adilo using M3U8 (HLS) direct URL
|
||||||
|
- Displays custom chapter navigation
|
||||||
|
- Allows click-to-jump to chapters
|
||||||
|
- Tracks user progress
|
||||||
|
- Saves completion data to Supabase
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **React 18+** (Hooks, Context)
|
||||||
|
- **HLS.js** - for M3U8 streaming
|
||||||
|
- **Supabase** - for progress tracking
|
||||||
|
- **HTML5 Video API** - native controls
|
||||||
|
- **CSS Modules** - styling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Command Reference
|
||||||
|
|
||||||
|
### Install Dependencies
|
||||||
|
```bash
|
||||||
|
npm install hls.js @supabase/supabase-js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure to Create
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── AdiloVideoPlayer.jsx # Main component
|
||||||
|
│ ├── ChapterNavigation.jsx # Chapter sidebar
|
||||||
|
│ └── ProgressBar.jsx # Progress indicator
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAdiloPlayer.js # HLS streaming logic
|
||||||
|
│ └── useChapterTracking.js # Chapter tracking
|
||||||
|
├── services/
|
||||||
|
│ ├── adiloService.js # Adilo API calls
|
||||||
|
│ └── progressService.js # Supabase progress
|
||||||
|
├── styles/
|
||||||
|
│ └── AdiloVideoPlayer.module.css
|
||||||
|
└── types/
|
||||||
|
└── video.types.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases (In Order)
|
||||||
|
|
||||||
|
### ⭐ PHASE 1: useAdiloPlayer Hook
|
||||||
|
**Goal**: Get HLS.js working with M3U8 URL
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- React hook that initializes HLS.js instance
|
||||||
|
- Return: videoRef, isReady, isPlaying, currentTime, duration
|
||||||
|
- Handle browser compatibility (Safari vs HLS.js)
|
||||||
|
- Clean up HLS instance on unmount
|
||||||
|
- Emit callbacks: onTimeUpdate, onEnded, onError
|
||||||
|
|
||||||
|
**Test with:**
|
||||||
|
```javascript
|
||||||
|
const { videoRef, currentTime, isReady } = useAdiloPlayer({
|
||||||
|
m3u8Url: "https://adilo.bigcommand.com/m3u8/...",
|
||||||
|
autoplay: false,
|
||||||
|
onTimeUpdate: (time) => console.log(time)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 2: useChapterTracking Hook
|
||||||
|
**Goal**: Determine which chapter is currently active
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- React hook that tracks active chapter
|
||||||
|
- Input: chapters array, currentTime
|
||||||
|
- Return: activeChapter, activeChapterId, chapterProgress
|
||||||
|
- Detect chapter transitions
|
||||||
|
- Calculate progress percentage
|
||||||
|
|
||||||
|
**Chapter data structure:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: "ch1",
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 120,
|
||||||
|
title: "Introduction",
|
||||||
|
description: "Welcome to the course"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test with:**
|
||||||
|
```javascript
|
||||||
|
const { activeChapter, chapterProgress } = useChapterTracking({
|
||||||
|
chapters: [...],
|
||||||
|
currentTime: 45
|
||||||
|
});
|
||||||
|
// activeChapter should be chapter with startTime ≤ 45 < endTime
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 3: AdiloVideoPlayer Component
|
||||||
|
**Goal**: Main player combining both hooks
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- Component that uses both hooks
|
||||||
|
- Renders: <video> element + video controls
|
||||||
|
- Props: m3u8Url, videoId, chapters, autoplay, showChapters
|
||||||
|
- Methods: jumpToChapter(), play(), pause()
|
||||||
|
- Callbacks: onChapterChange, onVideoComplete, onProgressUpdate
|
||||||
|
|
||||||
|
**Usage example:**
|
||||||
|
```jsx
|
||||||
|
<AdiloVideoPlayer
|
||||||
|
m3u8Url="https://adilo.bigcommand.com/m3u8/..."
|
||||||
|
chapters={[{id: "1", startTime: 0, endTime: 120, title: "Intro"}]}
|
||||||
|
onVideoComplete={() => markComplete()}
|
||||||
|
onChapterChange={(ch) => console.log(ch.title)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 4: ChapterNavigation Component
|
||||||
|
**Goal**: Display chapters user can click to jump
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
- Sidebar/timeline showing all chapters
|
||||||
|
- Highlight current active chapter
|
||||||
|
- Show time for each chapter
|
||||||
|
- Click handler to jump to chapter
|
||||||
|
- Progress bar for each chapter
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- chapters: Chapter[]
|
||||||
|
- activeChapterId: string
|
||||||
|
- currentTime: number
|
||||||
|
- onChapterClick: (startTime: number) => void
|
||||||
|
- completedChapters: string[]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 5: Supabase Integration
|
||||||
|
**Goal**: Save video progress to database
|
||||||
|
|
||||||
|
**Database schema needed:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE video_progress (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
video_id uuid NOT NULL,
|
||||||
|
last_position int,
|
||||||
|
completed_chapters text[],
|
||||||
|
watched_percentage int,
|
||||||
|
is_completed boolean DEFAULT false,
|
||||||
|
completed_at timestamp,
|
||||||
|
created_at timestamp DEFAULT now(),
|
||||||
|
updated_at timestamp DEFAULT now(),
|
||||||
|
UNIQUE(user_id, video_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Functions to implement:**
|
||||||
|
- `saveProgress(userId, videoId, currentTime, completedChapters)`
|
||||||
|
- `getLastPosition(userId, videoId)` - resume from last position
|
||||||
|
- `markVideoComplete(userId, videoId)`
|
||||||
|
- `getVideoAnalytics(userId, videoId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⭐ PHASE 6: Styling
|
||||||
|
**Goal**: Make it look good and responsive
|
||||||
|
|
||||||
|
**Key CSS classes needed:**
|
||||||
|
- `.adilo-player` - main container
|
||||||
|
- `.video-container` - video wrapper
|
||||||
|
- `.chapters-sidebar` - chapter list
|
||||||
|
- `.chapter-item` - individual chapter
|
||||||
|
- `.chapter-item.active` - highlight active
|
||||||
|
- `.progress-bar` - progress visualization
|
||||||
|
|
||||||
|
**Responsive breakpoints:**
|
||||||
|
- Desktop: sidebar on right
|
||||||
|
- Tablet: sidebar below video
|
||||||
|
- Mobile: horizontal timeline under video
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### HLS.js Initialization Pattern
|
||||||
|
```javascript
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.loadSource(m3u8Url);
|
||||||
|
hls.attachMedia(videoElement);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
videoElement.play();
|
||||||
|
});
|
||||||
|
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari native HLS support
|
||||||
|
videoElement.src = m3u8Url;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chapter Jump Implementation
|
||||||
|
```javascript
|
||||||
|
const jumpToChapter = (startTime) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = startTime;
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track Current Chapter Pattern
|
||||||
|
```javascript
|
||||||
|
const current = chapters.find(
|
||||||
|
ch => currentTime >= ch.startTime && currentTime < ch.endTime
|
||||||
|
);
|
||||||
|
setActiveChapter(current?.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounce Progress Saves
|
||||||
|
```javascript
|
||||||
|
// Save progress every 5 seconds, not on every timeupdate
|
||||||
|
const saveProgressDebounced = debounce(
|
||||||
|
(userId, videoId, time) => saveProgress(userId, videoId, time),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Tasks for AI Agent
|
||||||
|
|
||||||
|
When asking your AI agent to implement:
|
||||||
|
|
||||||
|
### Task: "Create useAdiloPlayer hook"
|
||||||
|
**Should generate:**
|
||||||
|
- Import HLS from 'hls.js'
|
||||||
|
- useRef for video element
|
||||||
|
- useEffect to initialize HLS
|
||||||
|
- useCallback for event handlers
|
||||||
|
- Clean up logic in return
|
||||||
|
|
||||||
|
### Task: "Add chapter jump functionality"
|
||||||
|
**Should implement:**
|
||||||
|
- Button click handler
|
||||||
|
- Call jumpToChapter(startTime)
|
||||||
|
- Update videoRef.current.currentTime
|
||||||
|
- Play the video
|
||||||
|
|
||||||
|
### Task: "Save progress to Supabase"
|
||||||
|
**Should implement:**
|
||||||
|
- Create/update row in video_progress table
|
||||||
|
- Include: user_id, video_id, last_position, completed_chapters
|
||||||
|
- Handle conflicts (UPSERT)
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### Task: "Make chapters responsive"
|
||||||
|
**Should implement:**
|
||||||
|
- CSS Grid for desktop (sidebar)
|
||||||
|
- Flex column for mobile
|
||||||
|
- Media query at 768px breakpoint
|
||||||
|
- Adjust spacing and font sizes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- [ ] useAdiloPlayer hook returns correct refs/values
|
||||||
|
- [ ] useChapterTracking calculates active chapter correctly
|
||||||
|
- [ ] jumpToChapter updates video.currentTime
|
||||||
|
- [ ] Progress saves to Supabase
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- [ ] Video plays when component mounts
|
||||||
|
- [ ] Chapter changes highlight in UI
|
||||||
|
- [ ] Clicking chapter jumps player
|
||||||
|
- [ ] Progress saves on interval
|
||||||
|
- [ ] Completion triggers callback
|
||||||
|
|
||||||
|
### Browser Tests
|
||||||
|
- [ ] Works on Chrome/Edge (HLS.js)
|
||||||
|
- [ ] Works on Firefox (HLS.js)
|
||||||
|
- [ ] Works on Safari (native HLS)
|
||||||
|
- [ ] Works on mobile browsers
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] Bad M3U8 URL shows error
|
||||||
|
- [ ] Network interruption handled
|
||||||
|
- [ ] Video paused mid-chapter
|
||||||
|
- [ ] Page refresh preserves position
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Needed
|
||||||
|
```
|
||||||
|
VITE_SUPABASE_URL=your_supabase_url
|
||||||
|
VITE_SUPABASE_ANON_KEY=your_supabase_key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
### HLS.js not loading?
|
||||||
|
- Check M3U8 URL is correct from Adilo
|
||||||
|
- Verify CORS headers from Adilo
|
||||||
|
- Check browser console for HLS.js errors
|
||||||
|
- Try `hls.on(Hls.Events.ERROR, console.error)`
|
||||||
|
|
||||||
|
### Chapter not highlighting?
|
||||||
|
- Add console.log(currentTime, chapters) to track values
|
||||||
|
- Verify chapter startTime/endTime are correct
|
||||||
|
- Check activeChapter state is updating
|
||||||
|
|
||||||
|
### Progress not saving?
|
||||||
|
- Verify Supabase connection works
|
||||||
|
- Check user_id and video_id are defined
|
||||||
|
- Add error logs to saveProgress function
|
||||||
|
- Check database table schema matches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Optimization Tips
|
||||||
|
|
||||||
|
1. **Memoize chapters list** to prevent re-renders
|
||||||
|
```javascript
|
||||||
|
const chapters = useMemo(() => chaptersData, [chaptersData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Debounce timeupdate events** (fires 60x per second!)
|
||||||
|
```javascript
|
||||||
|
const updateChapter = debounce(() => {...}, 100);
|
||||||
|
video.addEventListener('timeupdate', updateChapter);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Lazy load chapter images/thumbnails**
|
||||||
|
```javascript
|
||||||
|
<img loading="lazy" src={chapter.thumbnail} />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Use React.memo for ChapterNavigation**
|
||||||
|
```javascript
|
||||||
|
export default React.memo(ChapterNavigation);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **HLS.js Docs**: https://github.com/video-dev/hls.js/wiki
|
||||||
|
- **HTML5 Video API**: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video
|
||||||
|
- **Supabase JS Client**: https://supabase.com/docs/reference/javascript/introduction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Ready to implement! 🚀
|
||||||
|
|
||||||
|
Start with PHASE 1 (useAdiloPlayer hook), then PHASE 2-6 in order.
|
||||||
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!**
|
||||||
557
adilo-player-impl-plan.md
Normal file
557
adilo-player-impl-plan.md
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
# Adilo Custom Video Player with Chapter System - Implementation Plan
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Build a React video player component that uses Adilo's M3U8 streaming URL with custom chapter navigation system for LearnHub LMS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components Structure
|
||||||
|
```
|
||||||
|
VideoLesson/
|
||||||
|
├── AdiloVideoPlayer.jsx (Main player component)
|
||||||
|
├── ChapterNavigation.jsx (Chapter sidebar/timeline)
|
||||||
|
├── VideoControls.jsx (Custom controls - optional)
|
||||||
|
└── hooks/
|
||||||
|
├── useAdiloPlayer.js (HLS player logic)
|
||||||
|
└── useChapterTracking.js (Chapter progress tracking)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
```
|
||||||
|
Adilo M3U8 URL
|
||||||
|
↓
|
||||||
|
HLS.js (streaming)
|
||||||
|
↓
|
||||||
|
HTML5 <video> element
|
||||||
|
↓
|
||||||
|
currentTime tracking
|
||||||
|
↓
|
||||||
|
Chapter UI sync
|
||||||
|
↓
|
||||||
|
Supabase (save progress)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-Step Implementation
|
||||||
|
|
||||||
|
### PHASE 1: Dependencies Setup
|
||||||
|
|
||||||
|
**Install required packages:**
|
||||||
|
```bash
|
||||||
|
npm install hls.js
|
||||||
|
npm install @supabase/supabase-js # For progress tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
**No additional UI library needed** - use native HTML5 video + your own CSS for chapters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 2: Core Hook - useAdiloPlayer
|
||||||
|
|
||||||
|
**File: `hooks/useAdiloPlayer.js`**
|
||||||
|
|
||||||
|
**Purpose:** Handle HLS streaming with HLS.js library
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Initialize HLS instance
|
||||||
|
- Load M3U8 URL
|
||||||
|
- Handle browser compatibility (Safari native HLS vs HLS.js)
|
||||||
|
- Expose video element ref for external control
|
||||||
|
- Emit events (play, pause, ended, timeupdate)
|
||||||
|
|
||||||
|
**Function signature:**
|
||||||
|
```javascript
|
||||||
|
const {
|
||||||
|
videoRef,
|
||||||
|
isReady,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
error
|
||||||
|
} = useAdiloPlayer({
|
||||||
|
m3u8Url: string,
|
||||||
|
autoplay: boolean,
|
||||||
|
onTimeUpdate: (time: number) => void,
|
||||||
|
onEnded: () => void,
|
||||||
|
onError: (error) => void
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
- Auto-dispose HLS instance on unmount
|
||||||
|
- Handle loading states
|
||||||
|
- Error boundary for failed streams
|
||||||
|
- Track play/pause states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 3: Core Hook - useChapterTracking
|
||||||
|
|
||||||
|
**File: `hooks/useChapterTracking.js`**
|
||||||
|
|
||||||
|
**Purpose:** Track which chapter user is currently viewing
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Determine active chapter from currentTime
|
||||||
|
- Calculate chapter progress percentage
|
||||||
|
- Detect chapter transitions
|
||||||
|
- Export chapter completion data
|
||||||
|
|
||||||
|
**Function signature:**
|
||||||
|
```javascript
|
||||||
|
const {
|
||||||
|
activeChapter,
|
||||||
|
activeChapterId,
|
||||||
|
chapterProgress, // 0-100%
|
||||||
|
completedChapters,
|
||||||
|
chapterTimeline // for progress bar
|
||||||
|
} = useChapterTracking({
|
||||||
|
chapters: Chapter[],
|
||||||
|
currentTime: number,
|
||||||
|
onChapterChange: (chapter) => void
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chapter object structure:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
startTime: number, // in seconds
|
||||||
|
endTime: number, // in seconds
|
||||||
|
title: string,
|
||||||
|
description?: string, // optional
|
||||||
|
thumbnail?: string // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 4: Main Component - AdiloVideoPlayer
|
||||||
|
|
||||||
|
**File: `components/AdiloVideoPlayer.jsx`**
|
||||||
|
|
||||||
|
**Purpose:** Main video player component that combines HLS streaming + chapter tracking
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
m3u8Url: string, // From Adilo dashboard
|
||||||
|
videoId: string, // For database tracking
|
||||||
|
chapters: Chapter[], // Your chapter data
|
||||||
|
autoplay: boolean, // Default: false
|
||||||
|
showChapters: boolean, // Default: true
|
||||||
|
onVideoComplete: (data) => void, // Callback when video ends
|
||||||
|
onChapterChange: (chapter) => void,
|
||||||
|
onProgressUpdate: (progress) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component structure:**
|
||||||
|
```jsx
|
||||||
|
<div className="adilo-player">
|
||||||
|
{/* Video container */}
|
||||||
|
<div className="video-container">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
controls
|
||||||
|
controlsList="nodownload"
|
||||||
|
/>
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{!isReady && <LoadingSpinner />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapter Navigation */}
|
||||||
|
{showChapters && (
|
||||||
|
<ChapterNavigation
|
||||||
|
chapters={chapters}
|
||||||
|
activeChapterId={activeChapterId}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onChapterClick={jumpToChapter}
|
||||||
|
completedChapters={completedChapters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar (optional) */}
|
||||||
|
<ProgressBar
|
||||||
|
chapters={chapters}
|
||||||
|
currentTime={currentTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key methods:**
|
||||||
|
- `jumpToChapter(startTime)` - Seek to chapter
|
||||||
|
- `play()` / `pause()` - Control playback
|
||||||
|
- `getCurrentProgress()` - Get session progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 5: Chapter Navigation Component
|
||||||
|
|
||||||
|
**File: `components/ChapterNavigation.jsx`**
|
||||||
|
|
||||||
|
**Purpose:** Display chapters as sidebar/timeline with click-to-jump
|
||||||
|
|
||||||
|
**Layout options:**
|
||||||
|
1. **Sidebar** - Vertical list on side (desktop)
|
||||||
|
2. **Horizontal** - Timeline below video (mobile)
|
||||||
|
3. **Collapsible** - Toggle on mobile
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Show current/upcoming chapters
|
||||||
|
- Highlight active chapter
|
||||||
|
- Show time remaining for current chapter
|
||||||
|
- Progress indicators
|
||||||
|
- Drag-to-seek on timeline (optional)
|
||||||
|
|
||||||
|
**Chapter item structure:**
|
||||||
|
```jsx
|
||||||
|
<div className="chapter-item">
|
||||||
|
<div className="chapter-time">{formatTime(startTime)}</div>
|
||||||
|
<div className="chapter-title">{title}</div>
|
||||||
|
<div className="chapter-progress">{progressBar}</div>
|
||||||
|
<button onClick={() => jumpToChapter(startTime)}>
|
||||||
|
Jump to Chapter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 6: Supabase Integration (Optional)
|
||||||
|
|
||||||
|
**File: `services/progressService.js`**
|
||||||
|
|
||||||
|
**Purpose:** Save video progress to database
|
||||||
|
|
||||||
|
**Database table structure:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE video_progress (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
video_id uuid NOT NULL,
|
||||||
|
last_position int, -- seconds
|
||||||
|
completed_chapters text[], -- array of chapter IDs
|
||||||
|
watched_percentage int, -- 0-100
|
||||||
|
is_completed boolean,
|
||||||
|
completed_at timestamp,
|
||||||
|
created_at timestamp,
|
||||||
|
updated_at timestamp,
|
||||||
|
UNIQUE(user_id, video_id)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Functions to implement:**
|
||||||
|
```javascript
|
||||||
|
// Save current progress
|
||||||
|
saveProgress(userId, videoId, currentTime, completedChapters)
|
||||||
|
|
||||||
|
// Resume from last position
|
||||||
|
getLastPosition(userId, videoId)
|
||||||
|
|
||||||
|
// Mark video as complete
|
||||||
|
markVideoComplete(userId, videoId)
|
||||||
|
|
||||||
|
// Get completion analytics
|
||||||
|
getVideoAnalytics(userId, videoId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 7: Styling
|
||||||
|
|
||||||
|
**File: `styles/AdiloVideoPlayer.module.css` or your preferred CSS approach**
|
||||||
|
|
||||||
|
**Key styles needed:**
|
||||||
|
```css
|
||||||
|
/* Video container */
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chapter sidebar */
|
||||||
|
.chapters-sidebar {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 16px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item.active {
|
||||||
|
background: #e0f2fe;
|
||||||
|
border-left: 4px solid #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-time {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.progress-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 4px;
|
||||||
|
background: #e5e5e5;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-segment {
|
||||||
|
background: #0ea5e9;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-segment.completed {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chapters-sidebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Setup Phase
|
||||||
|
- [ ] Install dependencies (hls.js, @supabase/supabase-js)
|
||||||
|
- [ ] Set up folder structure
|
||||||
|
- [ ] Configure Supabase client (if using progress tracking)
|
||||||
|
|
||||||
|
### Core Development
|
||||||
|
- [ ] Implement `useAdiloPlayer` hook
|
||||||
|
- [ ] Implement `useChapterTracking` hook
|
||||||
|
- [ ] Create `AdiloVideoPlayer` component
|
||||||
|
- [ ] Create `ChapterNavigation` component
|
||||||
|
- [ ] Add styling/CSS
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- [ ] Implement Supabase progress service
|
||||||
|
- [ ] Add error handling & loading states
|
||||||
|
- [ ] Add accessibility features (ARIA labels, keyboard navigation)
|
||||||
|
- [ ] Test HLS streaming on different browsers
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Test video playback (Chrome, Firefox, Safari, mobile)
|
||||||
|
- [ ] Test chapter navigation
|
||||||
|
- [ ] Test progress saving to Supabase
|
||||||
|
- [ ] Test responsive design
|
||||||
|
- [ ] Test error scenarios (bad M3U8 URL, network issues)
|
||||||
|
|
||||||
|
### Optional Enhancements
|
||||||
|
- [ ] Add playback speed control
|
||||||
|
- [ ] Add quality selector (if HLS variants available)
|
||||||
|
- [ ] Add full-screen mode
|
||||||
|
- [ ] Add picture-in-picture
|
||||||
|
- [ ] Add watch history
|
||||||
|
- [ ] Add completion badges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Example Template
|
||||||
|
|
||||||
|
### Main Usage in LearnHub
|
||||||
|
```jsx
|
||||||
|
import AdiloVideoPlayer from '@/components/AdiloVideoPlayer';
|
||||||
|
|
||||||
|
function LessonPage({ lessonId }) {
|
||||||
|
const [lesson, setLesson] = useState(null);
|
||||||
|
const [userProgress, setUserProgress] = useState(null);
|
||||||
|
const user = useAuth().user;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch lesson with chapters
|
||||||
|
fetchLessonData(lessonId).then(setLesson);
|
||||||
|
|
||||||
|
// Get user's last progress
|
||||||
|
getLastPosition(user.id, lessonId).then(setUserProgress);
|
||||||
|
}, [lessonId]);
|
||||||
|
|
||||||
|
const handleChapterChange = (chapter) => {
|
||||||
|
console.log(`Chapter changed: ${chapter.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoComplete = (data) => {
|
||||||
|
// Mark as complete in Supabase
|
||||||
|
markVideoComplete(user.id, lessonId);
|
||||||
|
|
||||||
|
// Show completion message
|
||||||
|
toast.success('Lesson completed! 🎉');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressUpdate = (progress) => {
|
||||||
|
// Save progress every 10 seconds
|
||||||
|
if (progress.currentTime % 10 === 0) {
|
||||||
|
saveProgress(user.id, lessonId, progress.currentTime, progress.completedChapters);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!lesson) return <LoadingPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lesson-container">
|
||||||
|
<h1>{lesson.title}</h1>
|
||||||
|
|
||||||
|
<AdiloVideoPlayer
|
||||||
|
m3u8Url={lesson.m3u8Url}
|
||||||
|
videoId={lesson.id}
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
autoplay={false}
|
||||||
|
showChapters={true}
|
||||||
|
onChapterChange={handleChapterChange}
|
||||||
|
onVideoComplete={handleVideoComplete}
|
||||||
|
onProgressUpdate={handleProgressUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="lesson-content">
|
||||||
|
<h2>Lesson Details</h2>
|
||||||
|
<p>{lesson.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LessonPage;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
### Video URL Storage
|
||||||
|
Store the M3U8 URL in your Supabase `videos` or `lessons` table:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE lessons (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
title text,
|
||||||
|
description text,
|
||||||
|
m3u8_url text, -- Store the Adilo M3U8 URL here
|
||||||
|
chapters jsonb, -- Store chapters as JSON array
|
||||||
|
created_at timestamp
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
- ✅ **Don't expose M3U8 URL in frontend code** - fetch from backend
|
||||||
|
- ✅ **Validate M3U8 URLs** - only allow Adilo domains
|
||||||
|
- ✅ **Use CORS headers** - ensure Adilo allows cross-origin requests
|
||||||
|
- ✅ **Log access** - track who watches which videos
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
- ✅ **Chrome/Edge**: HLS.js library
|
||||||
|
- ✅ **Firefox**: HLS.js library
|
||||||
|
- ✅ **Safari**: Native HLS support (no library needed)
|
||||||
|
- ✅ **Mobile browsers**: Auto-detects capability
|
||||||
|
|
||||||
|
### Performance Tips
|
||||||
|
- 🚀 Lazy load chapter data
|
||||||
|
- 🚀 Debounce progress updates
|
||||||
|
- 🚀 Memoize chapter calculations
|
||||||
|
- 🚀 Use video preload="metadata"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls to Avoid
|
||||||
|
|
||||||
|
1. **❌ Don't forget to dispose HLS instance** - Memory leak
|
||||||
|
- ✅ Do: Clean up in useEffect return
|
||||||
|
|
||||||
|
2. **❌ Don't update state on every timeupdate** - Performance issue
|
||||||
|
- ✅ Do: Debounce or throttle updates
|
||||||
|
|
||||||
|
3. **❌ Don't hardcode M3U8 URLs in component** - Security issue
|
||||||
|
- ✅ Do: Fetch from backend API
|
||||||
|
|
||||||
|
4. **❌ Don't assume HLS.js works everywhere** - Safari native support exists
|
||||||
|
- ✅ Do: Check `Hls.isSupported()` and fallback
|
||||||
|
|
||||||
|
5. **❌ Don't forget CORS headers** - Cross-origin requests fail
|
||||||
|
- ✅ Do: Verify Adilo allows your domain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dev dependencies
|
||||||
|
npm install --save-dev @testing-library/react @testing-library/jest-dom
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Check bundle size
|
||||||
|
npm run analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Get M3U8 URL** from Adilo dashboard ✅ (You found it!)
|
||||||
|
2. **Store URL** in your Supabase lessons table
|
||||||
|
3. **Create the hooks** (start with `useAdiloPlayer`)
|
||||||
|
4. **Build the component** (AdiloVideoPlayer)
|
||||||
|
5. **Integrate chapters** (ChapterNavigation)
|
||||||
|
6. **Add progress tracking** (Supabase integration)
|
||||||
|
7. **Style and polish** (CSS/responsive design)
|
||||||
|
8. **Test thoroughly** (all browsers, mobile, edge cases)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── AdiloVideoPlayer.jsx
|
||||||
|
│ ├── ChapterNavigation.jsx
|
||||||
|
│ └── ProgressBar.jsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAdiloPlayer.js
|
||||||
|
│ └── useChapterTracking.js
|
||||||
|
├── services/
|
||||||
|
│ ├── progressService.js
|
||||||
|
│ └── adiloService.js
|
||||||
|
├── styles/
|
||||||
|
│ └── AdiloVideoPlayer.module.css
|
||||||
|
└── types/
|
||||||
|
└── video.types.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to implement? Start with Phase 1 & 2 (setup + useAdiloPlayer hook). Let me know if you need help with any specific phase!**
|
||||||
179
analyze_final.py
179
analyze_final.py
@@ -1,179 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Analyze video transcript to identify topics and create chapter divisions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
def seconds_to_timestamp(seconds):
|
|
||||||
"""Convert seconds to readable timestamp."""
|
|
||||||
total_seconds = int(float(seconds))
|
|
||||||
hours, remainder = divmod(total_seconds, 3600)
|
|
||||||
minutes, seconds = divmod(remainder, 60)
|
|
||||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
||||||
|
|
||||||
def load_transcript(file_path):
|
|
||||||
"""Load JSON transcript file."""
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def extract_segments(data):
|
|
||||||
"""Extract transcript segments with timestamps."""
|
|
||||||
segments = []
|
|
||||||
|
|
||||||
for track in data[0]['tracks']:
|
|
||||||
if 'transcript' in track:
|
|
||||||
for item in track['transcript']:
|
|
||||||
start = float(item.get('start', 0))
|
|
||||||
dur = float(item.get('dur', 0))
|
|
||||||
text = item.get('text', '').strip()
|
|
||||||
|
|
||||||
if text and text != '\n':
|
|
||||||
segments.append({
|
|
||||||
'start': start,
|
|
||||||
'end': start + dur,
|
|
||||||
'text': text
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by start time
|
|
||||||
segments.sort(key=lambda x: x['start'])
|
|
||||||
return segments
|
|
||||||
|
|
||||||
def extract_keywords(text):
|
|
||||||
"""Extract key topics from text."""
|
|
||||||
keywords = {
|
|
||||||
'Market & Community': ['market', 'pasar', 'grup', 'komunitas', 'telegram', 'facebook', 'forum'],
|
|
||||||
'Problem Finding': ['masalah', 'problem', 'kesulitan', 'permasalahan', 'error', 'bermasalah'],
|
|
||||||
'Exploration': ['explor', 'coba', 'trial', 'nyoba', 'eksplor', 'explore'],
|
|
||||||
'Personal Branding': ['branding', 'personal branding', 'show off', 'image', 'eksistensi'],
|
|
||||||
'AIDA/Funnel': ['aida', 'awareness', 'interest', 'desire', 'action', 'funel', 'funnel'],
|
|
||||||
'Trust': ['trust', 'percaya', 'kepercayaan'],
|
|
||||||
'Clients': ['klien', 'client', 'pelanggan', 'customer'],
|
|
||||||
'Pricing': ['harga', 'price', 'bayar', 'budget', 'rp', 'juta', 'ribu', 'dibayar'],
|
|
||||||
'Negotiation': ['tawar', 'negosiasi', 'deal'],
|
|
||||||
'Services': ['jasa', 'service', 'website', 'plugin', 'elementor', 'instal'],
|
|
||||||
'Cold/Warm/Hot Market': ['cold market', 'warm market', 'hot market', 'dingin', 'hangat'],
|
|
||||||
'Network': ['network', 'jaringan', 'koneksi', 'hubungan'],
|
|
||||||
'Sharing': ['sharing', 'share', 'bagi'],
|
|
||||||
'Products': ['produk', 'product', 'template'],
|
|
||||||
'Japri': ['japri', 'private', 'chat pribadi'],
|
|
||||||
}
|
|
||||||
|
|
||||||
found = []
|
|
||||||
text_lower = text.lower()
|
|
||||||
|
|
||||||
for topic, kw_list in keywords.items():
|
|
||||||
count = sum(1 for kw in kw_list if kw.lower() in text_lower)
|
|
||||||
if count > 0:
|
|
||||||
found.append((topic, count))
|
|
||||||
|
|
||||||
return sorted(found, key=lambda x: x[1], reverse=True)
|
|
||||||
|
|
||||||
def analyze_video():
|
|
||||||
"""Analyze the video transcript."""
|
|
||||||
file_path = "/Users/dwindown/CascadeProjects/MeetDwindiCom/access-hub/Live Zoom - Diskusi Cara Jual Jasa via Online.json"
|
|
||||||
|
|
||||||
print("="*80)
|
|
||||||
print("VIDEO TRANSCRIPT ANALYSIS")
|
|
||||||
print("Cara Jual Jasa via Online (How to Sell Services Online)")
|
|
||||||
print("="*80)
|
|
||||||
print()
|
|
||||||
|
|
||||||
data = load_transcript(file_path)
|
|
||||||
segments = extract_segments(data)
|
|
||||||
|
|
||||||
print(f"Total segments: {len(segments)}")
|
|
||||||
|
|
||||||
if not segments:
|
|
||||||
print("No segments found!")
|
|
||||||
return
|
|
||||||
|
|
||||||
total_duration = segments[-1]['end']
|
|
||||||
print(f"Total duration: {seconds_to_timestamp(total_duration)} ({total_duration/60:.1f} minutes)\n")
|
|
||||||
|
|
||||||
# Create time-based groups every 5 minutes
|
|
||||||
print("="*80)
|
|
||||||
print("CONTENT BREAKDOWN BY 5-MINUTE INTERVALS")
|
|
||||||
print("="*80)
|
|
||||||
print()
|
|
||||||
|
|
||||||
window = 300 # 5 minutes
|
|
||||||
current_time = 0
|
|
||||||
section_num = 1
|
|
||||||
|
|
||||||
while current_time < total_duration:
|
|
||||||
window_end = min(current_time + window, total_duration)
|
|
||||||
window_segments = [s for s in segments
|
|
||||||
if current_time <= s['start'] < window_end]
|
|
||||||
|
|
||||||
if window_segments:
|
|
||||||
# Combine text
|
|
||||||
combined_text = ' '.join([s['text'] for s in window_segments])
|
|
||||||
|
|
||||||
# Extract keywords
|
|
||||||
keywords = extract_keywords(combined_text)
|
|
||||||
|
|
||||||
print(f"Section {section_num}: {seconds_to_timestamp(current_time)} - {seconds_to_timestamp(window_end)}")
|
|
||||||
print("-" * 80)
|
|
||||||
|
|
||||||
# Show first 400 characters as preview
|
|
||||||
preview = combined_text[:400]
|
|
||||||
print(f"Content: {preview}...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if keywords:
|
|
||||||
print("Key topics detected:")
|
|
||||||
for topic, count in keywords[:7]:
|
|
||||||
print(f" • {topic}: {count} mentions")
|
|
||||||
else:
|
|
||||||
print("Key topics: (transition/break section)")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print()
|
|
||||||
|
|
||||||
section_num += 1
|
|
||||||
|
|
||||||
current_time = window_end
|
|
||||||
|
|
||||||
# Now create suggested chapters based on content analysis
|
|
||||||
print("\n")
|
|
||||||
print("="*80)
|
|
||||||
print("SUGGESTED CHAPTER STRUCTURE")
|
|
||||||
print("="*80)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Create larger 15-minute groups for chapter suggestions
|
|
||||||
chapter_window = 900 # 15 minutes
|
|
||||||
current_time = 0
|
|
||||||
chapter_num = 1
|
|
||||||
|
|
||||||
while current_time < total_duration:
|
|
||||||
chapter_end = min(current_time + chapter_window, total_duration)
|
|
||||||
chapter_segments = [s for s in segments
|
|
||||||
if current_time <= s['start'] < chapter_end]
|
|
||||||
|
|
||||||
if chapter_segments:
|
|
||||||
combined_text = ' '.join([s['text'] for s in chapter_segments])
|
|
||||||
keywords = extract_keywords(combined_text)
|
|
||||||
|
|
||||||
# Get top 3 keywords for chapter title
|
|
||||||
main_topics = [kw[0] for kw in keywords[:3]]
|
|
||||||
|
|
||||||
print(f"Chapter {chapter_num}: {seconds_to_timestamp(current_time)} - {seconds_to_timestamp(chapter_end)}")
|
|
||||||
print(f"Main topics: {', '.join(main_topics)}")
|
|
||||||
|
|
||||||
# Show first 300 chars
|
|
||||||
preview = combined_text[:300].replace('\n', ' ')
|
|
||||||
print(f"Preview: {preview}...")
|
|
||||||
print()
|
|
||||||
print()
|
|
||||||
|
|
||||||
chapter_num += 1
|
|
||||||
|
|
||||||
current_time = chapter_end
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
analyze_video()
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Analyze video transcript to identify topics and create chapter divisions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
def seconds_to_timestamp(seconds):
|
|
||||||
"""Convert seconds to readable timestamp."""
|
|
||||||
td = timedelta(seconds=float(seconds))
|
|
||||||
hours, remainder = divmod(td.seconds, 3600)
|
|
||||||
minutes, seconds = divmod(remainder, 60)
|
|
||||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
||||||
|
|
||||||
def load_transcript(file_path):
|
|
||||||
"""Load JSON transcript file."""
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def extract_text_with_timestamps(data):
|
|
||||||
"""Extract text segments with timestamps."""
|
|
||||||
segments = []
|
|
||||||
for entry in data:
|
|
||||||
if 'events' in entry:
|
|
||||||
for event in entry['events']:
|
|
||||||
if 'segs' in event:
|
|
||||||
for seg in event['segs']:
|
|
||||||
if 'utf8' in seg:
|
|
||||||
segments.append({
|
|
||||||
'start': float(event.get('tStartMs', 0)) / 1000,
|
|
||||||
'text': seg['utf8']
|
|
||||||
})
|
|
||||||
return segments
|
|
||||||
|
|
||||||
def clean_text(text):
|
|
||||||
"""Clean transcript text."""
|
|
||||||
# Remove extra whitespace
|
|
||||||
text = ' '.join(text.split())
|
|
||||||
return text
|
|
||||||
|
|
||||||
def identify_keywords(text):
|
|
||||||
"""Identify important keywords in Indonesian business context."""
|
|
||||||
keywords = {
|
|
||||||
'market': ['market', 'pasar', 'grup', 'komunitas', 'community'],
|
|
||||||
'problem': ['masalah', 'problem', 'kesulitan', 'error', 'gagal'],
|
|
||||||
'branding': ['branding', 'personal branding', 'image', 'citra'],
|
|
||||||
'funnel': ['funel', 'funnel', 'awareness', 'desire', 'action'],
|
|
||||||
'client': ['klien', 'client', 'pelanggan', 'customer'],
|
|
||||||
'price': ['harga', 'price', 'bayar', 'paid', 'invoice'],
|
|
||||||
'negotiation': ['tawar', 'negosiasi', 'deal'],
|
|
||||||
'service': ['jasa', 'service', 'website', 'plugin'],
|
|
||||||
'exploration': ['explore', 'eksplor', 'coba', 'trial'],
|
|
||||||
'network': ['network', 'jaringan', 'koneksi'],
|
|
||||||
'sharing': ['sharing', 'share', 'bagi'],
|
|
||||||
'product': ['produk', 'product', 'template', 'plugin'],
|
|
||||||
'trust': ['trust', 'percaya', 'kepercayaan'],
|
|
||||||
'sales': ['jual', 'sales', 'closing'],
|
|
||||||
}
|
|
||||||
return keywords
|
|
||||||
|
|
||||||
def analyze_structure():
|
|
||||||
"""Analyze the transcript structure."""
|
|
||||||
file_path = "/Users/dwindown/CascadeProjects/MeetDwindiCom/access-hub/Live Zoom - Diskusi Cara Jual Jasa via Online.json"
|
|
||||||
|
|
||||||
print("Loading transcript...")
|
|
||||||
data = load_transcript(file_path)
|
|
||||||
|
|
||||||
print("Extracting segments...")
|
|
||||||
segments = extract_text_with_timestamps(data)
|
|
||||||
|
|
||||||
print(f"\nTotal segments: {len(segments)}")
|
|
||||||
|
|
||||||
# Get total duration
|
|
||||||
if segments:
|
|
||||||
total_duration = segments[-1]['start']
|
|
||||||
print(f"Total duration: {seconds_to_timestamp(total_duration)} ({total_duration/60:.1f} minutes)")
|
|
||||||
|
|
||||||
# Sample segments at different intervals
|
|
||||||
print("\n=== SAMPLING SEGMENTS AT KEY INTERVALS ===\n")
|
|
||||||
|
|
||||||
sample_points = [0, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000,
|
|
||||||
3300, 3600, 3900, 4200, 4500, 4800, 5100, 5400, 5700, 6000,
|
|
||||||
6300, 6600, 6900, 7200, 7500, 7800, 8100, 8400, 8700, 9000]
|
|
||||||
|
|
||||||
for i, target_time in enumerate(sample_points):
|
|
||||||
# Find closest segment
|
|
||||||
closest = None
|
|
||||||
min_diff = float('inf')
|
|
||||||
|
|
||||||
for seg in segments:
|
|
||||||
diff = abs(seg['start'] - target_time)
|
|
||||||
if diff < min_diff:
|
|
||||||
min_diff = diff
|
|
||||||
closest = seg
|
|
||||||
|
|
||||||
if closest and min_diff < 60: # Within 1 minute
|
|
||||||
text = clean_text(closest['text'])
|
|
||||||
if len(text) > 50: # Only meaningful segments
|
|
||||||
print(f"[{seconds_to_timestamp(closest['start'])}]")
|
|
||||||
print(f"{text[:200]}...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Look for transition phrases
|
|
||||||
print("\n=== LOOKING FOR TRANSITION PHRASES ===\n")
|
|
||||||
|
|
||||||
transition_phrases = [
|
|
||||||
'oke',
|
|
||||||
'jadi',
|
|
||||||
'nah',
|
|
||||||
'kemudian',
|
|
||||||
'selanjutnya',
|
|
||||||
'setelah itu',
|
|
||||||
'sekarang',
|
|
||||||
'selanjutnya',
|
|
||||||
'lanjut',
|
|
||||||
'terus',
|
|
||||||
'setelah',
|
|
||||||
'nah sekarang',
|
|
||||||
'oke jadi',
|
|
||||||
'jadi sekarang',
|
|
||||||
'nah kalau',
|
|
||||||
]
|
|
||||||
|
|
||||||
# Look for sections mentioning main topics
|
|
||||||
print("\n=== SEARCHING FOR TOPIC MENTIONS ===\n")
|
|
||||||
|
|
||||||
topic_keywords = {
|
|
||||||
'Market/Community': ['market', 'pasar', 'grup', 'komunitas', 'community', 'telegram', 'facebook'],
|
|
||||||
'Problem Finding': ['masalah', 'problem', 'kesulitan', 'permasalahan'],
|
|
||||||
'Personal Branding': ['branding', 'personal branding', 'show off'],
|
|
||||||
'AIDA Funnel': ['aida', 'awareness', 'interest', 'desire', 'action', 'funel', 'funnel'],
|
|
||||||
'Getting Clients': ['klien', 'client', 'calon klien'],
|
|
||||||
'Pricing/Payment': ['harga', 'bayar', 'budget', 'invoice', 'price'],
|
|
||||||
'Negotiation': ['tawar', 'negosiasi', 'deal'],
|
|
||||||
'Trust Building': ['trust', 'percaya', 'kepercayaan'],
|
|
||||||
'Services/Products': ['jasa', 'service', 'produk', 'elementor', 'plugin', 'website'],
|
|
||||||
'Cold/Warm/Hot Market': ['cold market', 'warm market', 'hot market'],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Find segments grouped by time periods
|
|
||||||
print("\n=== CONTENT BY TIME PERIODS (every 10 minutes) ===\n")
|
|
||||||
|
|
||||||
period = 600 # 10 minutes in seconds
|
|
||||||
current_period = 0
|
|
||||||
|
|
||||||
while current_period < total_duration:
|
|
||||||
period_end = current_period + period
|
|
||||||
period_segments = [s for s in segments if current_period <= s['start'] < period_end]
|
|
||||||
|
|
||||||
if period_segments:
|
|
||||||
# Combine text from this period
|
|
||||||
period_text = ' '.join([clean_text(s['text']) for s in period_segments[:20]]) # First 20 segments
|
|
||||||
period_text = period_text[:500] # First 500 chars
|
|
||||||
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
print(f"PERIOD: {seconds_to_timestamp(current_period)} - {seconds_to_timestamp(period_end)}")
|
|
||||||
print(f"{'='*70}")
|
|
||||||
print(period_text)
|
|
||||||
print()
|
|
||||||
|
|
||||||
current_period = period_end
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
analyze_structure()
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Analyze video transcript to identify topics and create chapter divisions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
def seconds_to_timestamp(seconds):
|
|
||||||
"""Convert seconds to readable timestamp."""
|
|
||||||
td = timedelta(seconds=float(seconds))
|
|
||||||
total_seconds = int(td.total_seconds())
|
|
||||||
hours, remainder = divmod(total_seconds, 3600)
|
|
||||||
minutes, seconds = divmod(remainder, 60)
|
|
||||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
||||||
|
|
||||||
def load_transcript(file_path):
|
|
||||||
"""Load JSON transcript file."""
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def extract_transcript_segments(data):
|
|
||||||
"""Extract all transcript segments with timestamps."""
|
|
||||||
segments = []
|
|
||||||
|
|
||||||
# The structure has a 'tracks' key
|
|
||||||
if 'tracks' in data[0]:
|
|
||||||
for track in data[0]['tracks']:
|
|
||||||
if track['kind'] == 'asr': # Automatic Speech Recognition
|
|
||||||
for event in track['events']:
|
|
||||||
start_time = event.get('tStartMs', 0) / 1000
|
|
||||||
duration = event.get('dDurationMs', 0) / 1000
|
|
||||||
|
|
||||||
# Extract text from segments
|
|
||||||
text_parts = []
|
|
||||||
if 'segs' in event:
|
|
||||||
for seg in event['segs']:
|
|
||||||
if 'utf8' in seg:
|
|
||||||
text_parts.append(seg['utf8'])
|
|
||||||
|
|
||||||
text = ' '.join(text_parts)
|
|
||||||
if text.strip():
|
|
||||||
segments.append({
|
|
||||||
'start': start_time,
|
|
||||||
'end': start_time + duration,
|
|
||||||
'text': text
|
|
||||||
})
|
|
||||||
|
|
||||||
return segments
|
|
||||||
|
|
||||||
def group_by_time_window(segments, window_seconds=600):
|
|
||||||
"""Group segments into time windows for analysis."""
|
|
||||||
groups = []
|
|
||||||
current_time = 0
|
|
||||||
|
|
||||||
while current_time < segments[-1]['end']:
|
|
||||||
window_end = current_time + window_seconds
|
|
||||||
window_segments = [s for s in segments
|
|
||||||
if current_time <= s['start'] < window_end]
|
|
||||||
|
|
||||||
if window_segments:
|
|
||||||
combined_text = ' '.join([s['text'] for s in window_segments])
|
|
||||||
groups.append({
|
|
||||||
'start': current_time,
|
|
||||||
'end': window_end,
|
|
||||||
'segments': window_segments,
|
|
||||||
'text': combined_text
|
|
||||||
})
|
|
||||||
|
|
||||||
current_time = window_end
|
|
||||||
|
|
||||||
return groups
|
|
||||||
|
|
||||||
def extract_keywords(text):
|
|
||||||
"""Extract key topics from text."""
|
|
||||||
keywords = {
|
|
||||||
'Market & Community': ['market', 'pasar', 'grup', 'komunitas', 'telegram', 'facebook', 'forum'],
|
|
||||||
'Problem Finding': ['masalah', 'problem', 'kesulitan', 'permasalahan', 'error'],
|
|
||||||
'Exploration': ['explor', 'coba', 'trial', 'nyoba', 'eksplor'],
|
|
||||||
'Personal Branding': ['branding', 'personal branding', 'show off', 'image'],
|
|
||||||
'AIDA/Funnel': ['aida', 'awareness', 'interest', 'desire', 'action', 'funel', 'funnel'],
|
|
||||||
'Trust': ['trust', 'percaya', 'kepercayaan'],
|
|
||||||
'Clients': ['klien', 'client', 'pelanggan', 'customer'],
|
|
||||||
'Pricing': ['harga', 'price', 'bayar', 'budget', 'rp', 'juta', 'ribu'],
|
|
||||||
'Negotiation': ['tawar', 'negosiasi', 'deal'],
|
|
||||||
'Services': ['jasa', 'service', 'website', 'plugin', 'elementor', 'instal'],
|
|
||||||
'Cold/Warm/Hot Market': ['cold', 'warm', 'hot', 'dingin', 'hangat'],
|
|
||||||
'Network': ['network', 'jaringan', 'koneksi', 'hubungan'],
|
|
||||||
'Sharing': ['sharing', 'share', 'bagi'],
|
|
||||||
'Products': ['produk', 'product', 'template'],
|
|
||||||
}
|
|
||||||
|
|
||||||
found = []
|
|
||||||
text_lower = text.lower()
|
|
||||||
|
|
||||||
for topic, kw_list in keywords.items():
|
|
||||||
count = sum(1 for kw in kw_list if kw.lower() in text_lower)
|
|
||||||
if count > 0:
|
|
||||||
found.append((topic, count))
|
|
||||||
|
|
||||||
return sorted(found, key=lambda x: x[1], reverse=True)
|
|
||||||
|
|
||||||
def identify_main_topics():
|
|
||||||
"""Identify main topics throughout the video."""
|
|
||||||
file_path = "/Users/dwindown/CascadeProjects/MeetDwindiCom/access-hub/Live Zoom - Diskusi Cara Jual Jasa via Online.json"
|
|
||||||
|
|
||||||
print("Loading transcript...")
|
|
||||||
data = load_transcript(file_path)
|
|
||||||
|
|
||||||
print("Extracting segments...")
|
|
||||||
segments = extract_transcript_segments(data)
|
|
||||||
|
|
||||||
print(f"Total segments: {len(segments)}")
|
|
||||||
|
|
||||||
if not segments:
|
|
||||||
print("No segments found!")
|
|
||||||
return
|
|
||||||
|
|
||||||
total_duration = segments[-1]['end']
|
|
||||||
print(f"Total duration: {seconds_to_timestamp(total_duration)} ({total_duration/60:.1f} minutes)")
|
|
||||||
|
|
||||||
print("\n" + "="*80)
|
|
||||||
print("ANALYZING CONTENT IN 10-MINUTE INTERVALS")
|
|
||||||
print("="*80 + "\n")
|
|
||||||
|
|
||||||
# Group by 10-minute windows
|
|
||||||
groups = group_by_time_window(segments, window_seconds=600)
|
|
||||||
|
|
||||||
for i, group in enumerate(groups, 1):
|
|
||||||
print(f"\n{'='*80}")
|
|
||||||
print(f"SECTION {i}: {seconds_to_timestamp(group['start'])} - {seconds_to_timestamp(group['end'])}")
|
|
||||||
print(f"{'='*80}")
|
|
||||||
|
|
||||||
# Get first 500 chars for preview
|
|
||||||
preview = group['text'][:500]
|
|
||||||
print(f"\nContent Preview:\n{preview}...")
|
|
||||||
|
|
||||||
# Extract keywords
|
|
||||||
keywords = extract_keywords(group['text'])
|
|
||||||
if keywords:
|
|
||||||
print(f"\nMain Topics:")
|
|
||||||
for topic, count in keywords[:5]:
|
|
||||||
print(f" - {topic}: {count} mentions")
|
|
||||||
|
|
||||||
print("\n" + "="*80)
|
|
||||||
print("DETAILED BREAKDOWN (5-minute intervals for first hour)")
|
|
||||||
print("="*80 + "\n")
|
|
||||||
|
|
||||||
# More detailed for first hour
|
|
||||||
detailed_groups = group_by_time_window(segments[:int(len(segments)*0.4)], window_seconds=300)
|
|
||||||
|
|
||||||
for i, group in enumerate(detailed_groups, 1):
|
|
||||||
print(f"\n--- {seconds_to_timestamp(group['start'])} - {seconds_to_timestamp(group['end'])} ---")
|
|
||||||
|
|
||||||
# Get text summary
|
|
||||||
text_summary = group['text'][:300]
|
|
||||||
print(f"{text_summary}...")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
identify_main_topics()
|
|
||||||
236
package-lock.json
generated
236
package-lock.json
generated
@@ -49,12 +49,15 @@
|
|||||||
"@tiptap/extension-text-align": "^3.14.0",
|
"@tiptap/extension-text-align": "^3.14.0",
|
||||||
"@tiptap/react": "^3.13.0",
|
"@tiptap/react": "^3.13.0",
|
||||||
"@tiptap/starter-kit": "^3.13.0",
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
|
"@types/hls.js": "^0.13.3",
|
||||||
|
"@types/video.js": "^7.3.58",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
@@ -74,6 +77,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tiptap-extension-resize-image": "^1.3.2",
|
"tiptap-extension-resize-image": "^1.3.2",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
|
"video.js": "^8.23.4",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3502,6 +3506,12 @@
|
|||||||
"@types/unist": "*"
|
"@types/unist": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/hls.js": {
|
||||||
|
"version": "0.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hls.js/-/hls.js-0.13.3.tgz",
|
||||||
|
"integrity": "sha512-Po8ZPCsAcPPuf5OODPEkb6cdWJ/w4BdX1veP7IIOc2WG0x1SW4GEQ1+FHKN1AMG2AePJfNUceJbh5PKtP92yRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3590,6 +3600,12 @@
|
|||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/video.js": {
|
||||||
|
"version": "7.3.58",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz",
|
||||||
|
"integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -3857,6 +3873,54 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@videojs/http-streaming": {
|
||||||
|
"version": "3.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz",
|
||||||
|
"integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^4.1.1",
|
||||||
|
"aes-decrypter": "^4.0.2",
|
||||||
|
"global": "^4.4.0",
|
||||||
|
"m3u8-parser": "^7.2.0",
|
||||||
|
"mpd-parser": "^1.3.1",
|
||||||
|
"mux.js": "7.1.0",
|
||||||
|
"video.js": "^7 || ^8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8",
|
||||||
|
"npm": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"video.js": "^8.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@videojs/vhs-utils": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"global": "^4.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8",
|
||||||
|
"npm": ">=5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@videojs/xhr": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"global": "~4.4.0",
|
||||||
|
"is-function": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react-swc": {
|
"node_modules/@vitejs/plugin-react-swc": {
|
||||||
"version": "3.11.0",
|
"version": "3.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
|
||||||
@@ -3871,6 +3935,15 @@
|
|||||||
"vite": "^4 || ^5 || ^6 || ^7"
|
"vite": "^4 || ^5 || ^6 || ^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||||
|
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -3894,6 +3967,18 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/aes-decrypter": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^4.1.1",
|
||||||
|
"global": "^4.4.0",
|
||||||
|
"pkcs7": "^1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -4522,6 +4607,11 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-walk": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
@@ -5083,6 +5173,16 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "15.15.0",
|
"version": "15.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||||
@@ -5134,6 +5234,12 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||||
|
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/iceberg-js": {
|
"node_modules/iceberg-js": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
@@ -5244,6 +5350,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-function": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -5926,6 +6038,17 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/m3u8-parser": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^4.1.1",
|
||||||
|
"global": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/markdown-it": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.0",
|
"version": "14.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
@@ -5971,6 +6094,15 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/min-document": {
|
||||||
|
"version": "2.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
|
||||||
|
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-walk": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -5993,6 +6125,21 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mpd-parser": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^4.0.0",
|
||||||
|
"@xmldom/xmldom": "^0.8.3",
|
||||||
|
"global": "^4.4.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mpd-to-m3u8-json": "bin/parse.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -6000,6 +6147,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mux.js": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.11.2",
|
||||||
|
"global": "^4.4.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"muxjs-transmux": "bin/transmux.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8",
|
||||||
|
"npm": ">=5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -6242,6 +6406,18 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkcs7": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pkcs7": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/plyr": {
|
"node_modules/plyr": {
|
||||||
"version": "3.8.3",
|
"version": "3.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz",
|
||||||
@@ -6424,6 +6600,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -7629,6 +7814,57 @@
|
|||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/video.js": {
|
||||||
|
"version": "8.23.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz",
|
||||||
|
"integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/http-streaming": "^3.17.2",
|
||||||
|
"@videojs/vhs-utils": "^4.1.1",
|
||||||
|
"@videojs/xhr": "2.7.0",
|
||||||
|
"aes-decrypter": "^4.0.2",
|
||||||
|
"global": "4.4.0",
|
||||||
|
"m3u8-parser": "^7.2.0",
|
||||||
|
"mpd-parser": "^1.3.1",
|
||||||
|
"mux.js": "^7.0.1",
|
||||||
|
"videojs-contrib-quality-levels": "4.1.0",
|
||||||
|
"videojs-font": "4.2.0",
|
||||||
|
"videojs-vtt.js": "0.15.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/videojs-contrib-quality-levels": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"global": "^4.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16",
|
||||||
|
"npm": ">=8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"video.js": "^8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/videojs-font": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/videojs-vtt.js": {
|
||||||
|
"version": "0.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
|
||||||
|
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"global": "^4.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.19",
|
"version": "5.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||||
|
|||||||
@@ -52,12 +52,15 @@
|
|||||||
"@tiptap/extension-text-align": "^3.14.0",
|
"@tiptap/extension-text-align": "^3.14.0",
|
||||||
"@tiptap/react": "^3.13.0",
|
"@tiptap/react": "^3.13.0",
|
||||||
"@tiptap/starter-kit": "^3.13.0",
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
|
"@types/hls.js": "^0.13.3",
|
||||||
|
"@types/video.js": "^7.3.58",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
@@ -77,6 +80,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tiptap-extension-resize-image": "^1.3.2",
|
"tiptap-extension-resize-image": "^1.3.2",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
|
"video.js": "^8.23.4",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ interface TimelineChaptersProps {
|
|||||||
|
|
||||||
export function TimelineChapters({
|
export function TimelineChapters({
|
||||||
chapters,
|
chapters,
|
||||||
isYouTube = true,
|
|
||||||
onChapterClick,
|
onChapterClick,
|
||||||
currentTime = 0,
|
currentTime = 0,
|
||||||
accentColor = '#f97316',
|
accentColor = '#f97316',
|
||||||
@@ -64,14 +63,10 @@ export function TimelineChapters({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => isYouTube && onChapterClick && onChapterClick(chapter.time)}
|
onClick={() => onChapterClick && onChapterClick(chapter.time)}
|
||||||
disabled={!isYouTube}
|
|
||||||
className={`
|
className={`
|
||||||
w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left
|
w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left
|
||||||
${isYouTube
|
hover:bg-muted cursor-pointer
|
||||||
? 'hover:bg-muted cursor-pointer'
|
|
||||||
: 'cursor-default'
|
|
||||||
}
|
|
||||||
${active
|
${active
|
||||||
? `bg-primary/10 border-l-4`
|
? `bg-primary/10 border-l-4`
|
||||||
: 'border-l-4 border-transparent'
|
: 'border-l-4 border-transparent'
|
||||||
@@ -82,7 +77,7 @@ export function TimelineChapters({
|
|||||||
? { borderColor: accentColor, backgroundColor: `${accentColor}10` }
|
? { borderColor: accentColor, backgroundColor: `${accentColor}10` }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
title={isYouTube ? `Klik untuk lompat ke ${formatTime(chapter.time)}` : undefined}
|
title={`Klik untuk lompat ke ${formatTime(chapter.time)}`}
|
||||||
>
|
>
|
||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
<div className={`
|
<div className={`
|
||||||
@@ -111,12 +106,6 @@ export function TimelineChapters({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isYouTube && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-3 italic">
|
|
||||||
Timeline hanya tersedia untuk video YouTube
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback } from 'react';
|
||||||
import { Plyr } from 'plyr-react';
|
import { Plyr } from 'plyr-react';
|
||||||
import 'plyr/dist/plyr.css';
|
import 'plyr/dist/plyr.css';
|
||||||
|
import { useAdiloPlayer } from '@/hooks/useAdiloPlayer';
|
||||||
|
import { useVideoProgress } from '@/hooks/useVideoProgress';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
interface VideoChapter {
|
interface VideoChapter {
|
||||||
time: number; // Time in seconds
|
time: number; // Time in seconds
|
||||||
@@ -8,13 +12,18 @@ interface VideoChapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface VideoPlayerWithChaptersProps {
|
interface VideoPlayerWithChaptersProps {
|
||||||
videoUrl: string;
|
videoUrl?: string;
|
||||||
embedCode?: string | null;
|
embedCode?: string | null;
|
||||||
|
m3u8Url?: string;
|
||||||
|
mp4Url?: string;
|
||||||
|
videoHost?: 'youtube' | 'adilo' | 'unknown';
|
||||||
chapters?: VideoChapter[];
|
chapters?: VideoChapter[];
|
||||||
accentColor?: string;
|
accentColor?: string;
|
||||||
onChapterChange?: (chapter: VideoChapter) => void;
|
onChapterChange?: (chapter: VideoChapter) => void;
|
||||||
onTimeUpdate?: (time: number) => void;
|
onTimeUpdate?: (time: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
videoId?: string; // For progress tracking
|
||||||
|
videoType?: 'lesson' | 'webinar'; // For progress tracking
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPlayerRef {
|
export interface VideoPlayerRef {
|
||||||
@@ -25,22 +34,87 @@ export interface VideoPlayerRef {
|
|||||||
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
|
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
|
||||||
videoUrl,
|
videoUrl,
|
||||||
embedCode,
|
embedCode,
|
||||||
|
m3u8Url,
|
||||||
|
mp4Url,
|
||||||
|
videoHost = 'unknown',
|
||||||
chapters = [],
|
chapters = [],
|
||||||
accentColor,
|
accentColor,
|
||||||
onChapterChange,
|
onChapterChange,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
className = '',
|
className = '',
|
||||||
|
videoId,
|
||||||
|
videoType,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const plyrRef = useRef<any>(null);
|
const plyrRef = useRef<any>(null);
|
||||||
|
const currentChapterIndexRef = useRef<number>(-1);
|
||||||
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [playerInstance, setPlayerInstance] = useState<any>(null);
|
const [playerInstance, setPlayerInstance] = useState<any>(null);
|
||||||
|
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||||
|
const [resumeTime, setResumeTime] = useState(0);
|
||||||
|
const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Detect if this is a YouTube URL
|
// Determine if using Adilo (M3U8) or YouTube
|
||||||
const isYouTube = videoUrl && (
|
const isAdilo = videoHost === 'adilo' || m3u8Url;
|
||||||
videoUrl.includes('youtube.com') ||
|
const isYouTube = videoHost === 'youtube' || (videoUrl && (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')));
|
||||||
videoUrl.includes('youtu.be')
|
|
||||||
);
|
// Video progress tracking
|
||||||
|
const { progress, loading: progressLoading, saveProgress: saveProgressDirect, hasProgress } = useVideoProgress({
|
||||||
|
videoId: videoId || '',
|
||||||
|
videoType: videoType || 'lesson',
|
||||||
|
duration: playerInstance?.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounced save function (saves every 5 seconds)
|
||||||
|
const saveProgressDebounced = useCallback((time: number) => {
|
||||||
|
if (saveProgressTimeoutRef.current) {
|
||||||
|
clearTimeout(saveProgressTimeoutRef.current);
|
||||||
|
}
|
||||||
|
saveProgressTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveProgressDirect(time);
|
||||||
|
}, 5000);
|
||||||
|
}, [saveProgressDirect]);
|
||||||
|
|
||||||
|
// Stable callback for finding current chapter
|
||||||
|
const findCurrentChapter = useCallback((time: number) => {
|
||||||
|
if (chapters.length === 0) return -1;
|
||||||
|
|
||||||
|
let index = chapters.findIndex((chapter, i) => {
|
||||||
|
const nextChapter = chapters[i + 1];
|
||||||
|
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (index === -1 && time < chapters[0].time) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}, [chapters]);
|
||||||
|
|
||||||
|
// Stable onTimeUpdate callback for Adilo player
|
||||||
|
const handleAdiloTimeUpdate = useCallback((time: number) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
onTimeUpdate?.(time);
|
||||||
|
saveProgressDebounced(time);
|
||||||
|
|
||||||
|
// Find and update current chapter for Adilo
|
||||||
|
const index = findCurrentChapter(time);
|
||||||
|
if (index !== currentChapterIndexRef.current) {
|
||||||
|
currentChapterIndexRef.current = index;
|
||||||
|
setCurrentChapterIndex(index);
|
||||||
|
if (index >= 0 && onChapterChange) {
|
||||||
|
onChapterChange(chapters[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onTimeUpdate, onChapterChange, findCurrentChapter, chapters, saveProgressDebounced]);
|
||||||
|
|
||||||
|
// Adilo player hook
|
||||||
|
const adiloPlayer = useAdiloPlayer({
|
||||||
|
m3u8Url,
|
||||||
|
mp4Url,
|
||||||
|
onTimeUpdate: handleAdiloTimeUpdate,
|
||||||
|
accentColor,
|
||||||
|
});
|
||||||
|
|
||||||
// Get YouTube video ID
|
// Get YouTube video ID
|
||||||
const getYouTubeId = (url: string): string | null => {
|
const getYouTubeId = (url: string): string | null => {
|
||||||
@@ -109,23 +183,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
onTimeUpdate(time);
|
onTimeUpdate(time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveProgressDebounced(time);
|
||||||
|
|
||||||
// Find current chapter
|
// Find current chapter
|
||||||
if (chapters.length > 0) {
|
const index = findCurrentChapter(time);
|
||||||
let index = chapters.findIndex((chapter, i) => {
|
if (index !== currentChapterIndexRef.current) {
|
||||||
const nextChapter = chapters[i + 1];
|
currentChapterIndexRef.current = index;
|
||||||
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
setCurrentChapterIndex(index);
|
||||||
});
|
if (index >= 0 && onChapterChange) {
|
||||||
|
onChapterChange(chapters[index]);
|
||||||
// If before first chapter, no active chapter
|
|
||||||
if (index === -1 && time < chapters[0].time) {
|
|
||||||
index = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index !== currentChapterIndex) {
|
|
||||||
setCurrentChapterIndex(index);
|
|
||||||
if (index >= 0 && onChapterChange) {
|
|
||||||
onChapterChange(chapters[index]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -139,22 +205,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
onTimeUpdate(time);
|
onTimeUpdate(time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveProgressDebounced(time);
|
||||||
|
|
||||||
// Find current chapter
|
// Find current chapter
|
||||||
if (chapters.length > 0) {
|
const index = findCurrentChapter(time);
|
||||||
let index = chapters.findIndex((chapter, i) => {
|
if (index !== currentChapterIndexRef.current) {
|
||||||
const nextChapter = chapters[i + 1];
|
currentChapterIndexRef.current = index;
|
||||||
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
setCurrentChapterIndex(index);
|
||||||
});
|
if (index >= 0 && onChapterChange) {
|
||||||
|
onChapterChange(chapters[index]);
|
||||||
if (index === -1 && time < chapters[0].time) {
|
|
||||||
index = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index !== currentChapterIndex) {
|
|
||||||
setCurrentChapterIndex(index);
|
|
||||||
if (index >= 0 && onChapterChange) {
|
|
||||||
onChapterChange(chapters[index]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -166,11 +225,24 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearInterval(checkPlayer);
|
return () => clearInterval(checkPlayer);
|
||||||
}, [isYouTube, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]);
|
}, [isYouTube, findCurrentChapter, onChapterChange, onTimeUpdate, chapters, saveProgressDebounced]);
|
||||||
|
|
||||||
// Jump to specific time using Plyr API
|
// Jump to specific time using Plyr API or Adilo player
|
||||||
const jumpToTime = (time: number) => {
|
const jumpToTime = (time: number) => {
|
||||||
if (playerInstance) {
|
if (isAdilo) {
|
||||||
|
const video = adiloPlayer.videoRef.current;
|
||||||
|
if (video && adiloPlayer.isReady) {
|
||||||
|
video.currentTime = time;
|
||||||
|
const wasPlaying = !video.paused;
|
||||||
|
if (wasPlaying) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Jump failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (playerInstance) {
|
||||||
playerInstance.currentTime = time;
|
playerInstance.currentTime = time;
|
||||||
playerInstance.play();
|
playerInstance.play();
|
||||||
}
|
}
|
||||||
@@ -180,14 +252,204 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
return currentTime;
|
return currentTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check for saved progress and show resume prompt
|
||||||
|
useEffect(() => {
|
||||||
|
if (!progressLoading && hasProgress && progress && progress.last_position > 5) {
|
||||||
|
setShowResumePrompt(true);
|
||||||
|
setResumeTime(progress.last_position);
|
||||||
|
}
|
||||||
|
}, [progressLoading, hasProgress, progress]);
|
||||||
|
|
||||||
|
const handleResume = () => {
|
||||||
|
jumpToTime(resumeTime);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartFromBeginning = () => {
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save progress immediately on pause/ended
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adiloPlayer.videoRef.current) return;
|
||||||
|
|
||||||
|
const video = adiloPlayer.videoRef.current;
|
||||||
|
const handlePause = () => {
|
||||||
|
// Save immediately on pause
|
||||||
|
saveProgressDirect(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
// Save immediately on end
|
||||||
|
saveProgressDirect(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('pause', handlePause);
|
||||||
|
video.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('pause', handlePause);
|
||||||
|
video.removeEventListener('ended', handleEnded);
|
||||||
|
};
|
||||||
|
}, [adiloPlayer.videoRef, saveProgressDirect]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
|
// Ignore if user is typing in an input
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement ||
|
||||||
|
e.target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = isAdilo ? adiloPlayer.videoRef.current : playerInstance;
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
// Space: Play/Pause
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isAdilo) {
|
||||||
|
const video = player as HTMLVideoElement;
|
||||||
|
video.paused ? video.play() : video.pause();
|
||||||
|
} else {
|
||||||
|
player.playing ? player.pause() : player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Left: Back 5 seconds
|
||||||
|
if (e.code === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
jumpToTime(Math.max(0, currentTime - 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Right: Forward 5 seconds
|
||||||
|
if (e.code === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration;
|
||||||
|
jumpToTime(Math.min(duration, currentTime + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Up: Volume up 10%
|
||||||
|
if (e.code === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isAdilo) {
|
||||||
|
const newVolume = Math.min(1, player.volume + 0.1);
|
||||||
|
player.volume = newVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow Down: Volume down 10%
|
||||||
|
if (e.code === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isAdilo) {
|
||||||
|
const newVolume = Math.max(0, player.volume - 0.1);
|
||||||
|
player.volume = newVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// F: Fullscreen
|
||||||
|
if (e.code === 'KeyF') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isAdilo) {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
(player as HTMLVideoElement).parentElement?.requestFullscreen();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.fullscreen.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M: Mute
|
||||||
|
if (e.code === 'KeyM') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isAdilo) {
|
||||||
|
(player as HTMLVideoElement).muted = !(player as HTMLVideoElement).muted;
|
||||||
|
} else {
|
||||||
|
player.muted = !player.muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// J: Back 10 seconds
|
||||||
|
if (e.code === 'KeyJ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
jumpToTime(Math.max(0, currentTime - 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// L: Forward 10 seconds
|
||||||
|
if (e.code === 'KeyL') {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||||
|
const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration;
|
||||||
|
jumpToTime(Math.min(duration, currentTime + 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyPress);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyPress);
|
||||||
|
}, [isAdilo, adiloPlayer.isReady, playerInstance]);
|
||||||
|
|
||||||
// Expose methods via ref
|
// Expose methods via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
jumpToTime,
|
jumpToTime,
|
||||||
getCurrentTime,
|
getCurrentTime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Adilo M3U8 Player with Video.js
|
||||||
|
if (isAdilo) {
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<div className="aspect-video rounded-lg overflow-hidden bg-black vjs-big-play-centered">
|
||||||
|
<video
|
||||||
|
ref={adiloPlayer.videoRef}
|
||||||
|
className="video-js vjs-default-skin vjs-big-play-centered vjs-fill"
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resume prompt */}
|
||||||
|
{showResumePrompt && (
|
||||||
|
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-10 rounded-lg">
|
||||||
|
<div className="text-center space-y-4 p-6">
|
||||||
|
<div className="text-white text-lg font-semibold">
|
||||||
|
Lanjutkan dari posisi terakhir?
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 text-sm">
|
||||||
|
{Math.floor(resumeTime / 60)}:{String(Math.floor(resumeTime % 60)).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={handleResume}
|
||||||
|
className="bg-primary hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Lanjutkan
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleStartFromBeginning}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
||||||
|
>
|
||||||
|
Mulai dari awal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (useEmbed) {
|
if (useEmbed) {
|
||||||
// Custom embed (Adilo, Vimeo, etc.)
|
// Custom embed (Vimeo, etc. - not Adilo anymore)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
||||||
@@ -248,8 +510,16 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
'current-time',
|
'current-time',
|
||||||
'mute',
|
'mute',
|
||||||
'volume',
|
'volume',
|
||||||
|
'captions',
|
||||||
|
'settings',
|
||||||
|
'pip',
|
||||||
|
'airplay',
|
||||||
'fullscreen',
|
'fullscreen',
|
||||||
],
|
],
|
||||||
|
speed: {
|
||||||
|
selected: 1,
|
||||||
|
options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||||
|
},
|
||||||
youtube: {
|
youtube: {
|
||||||
noCookie: true,
|
noCookie: true,
|
||||||
rel: 0,
|
rel: 0,
|
||||||
@@ -261,6 +531,10 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
fs: 0,
|
fs: 0,
|
||||||
},
|
},
|
||||||
hideControls: false,
|
hideControls: false,
|
||||||
|
keyboardShortcuts: {
|
||||||
|
focused: true,
|
||||||
|
global: true,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function ChaptersEditor({ chapters, onChange, className = '' }: ChaptersE
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
|
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
|
||||||
<p>💡 <strong>Format:</strong> Enter time as MM:SS or HH:MM:SS (e.g., 5:30 or 1:23:34)</p>
|
<p>💡 <strong>Format:</strong> Enter time as MM:SS or HH:MM:SS (e.g., 5:30 or 1:23:34)</p>
|
||||||
<p>📌 <strong>Note:</strong> Chapters only work with YouTube videos. Embed codes show static timeline.</p>
|
<p>📌 <strong>Note:</strong> Chapters work with both YouTube and Adilo videos.</p>
|
||||||
<p>✨ <strong>Tip:</strong> Chapters are automatically sorted by time when displayed.</p>
|
<p>✨ <strong>Tip:</strong> Chapters are automatically sorted by time when displayed.</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -29,6 +30,11 @@ interface Lesson {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
|
youtube_url: string | null;
|
||||||
|
embed_code: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
chapters?: VideoChapter[];
|
chapters?: VideoChapter[];
|
||||||
@@ -54,6 +60,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
video_url: '',
|
video_url: '',
|
||||||
|
youtube_url: '',
|
||||||
|
embed_code: '',
|
||||||
|
m3u8_url: '',
|
||||||
|
mp4_url: '',
|
||||||
|
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||||
release_at: '',
|
release_at: '',
|
||||||
chapters: [] as VideoChapter[],
|
chapters: [] as VideoChapter[],
|
||||||
});
|
});
|
||||||
@@ -73,7 +84,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
.order('position'),
|
.order('position'),
|
||||||
supabase
|
supabase
|
||||||
.from('bootcamp_lessons')
|
.from('bootcamp_lessons')
|
||||||
.select('*')
|
.select('id, module_id, title, content, video_url, youtube_url, embed_code, m3u8_url, mp4_url, video_host, position, release_at, chapters')
|
||||||
.order('position'),
|
.order('position'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -177,6 +188,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
video_url: '',
|
video_url: '',
|
||||||
|
youtube_url: '',
|
||||||
|
embed_code: '',
|
||||||
|
m3u8_url: '',
|
||||||
|
mp4_url: '',
|
||||||
|
video_host: 'youtube',
|
||||||
release_at: '',
|
release_at: '',
|
||||||
});
|
});
|
||||||
setLessonDialogOpen(true);
|
setLessonDialogOpen(true);
|
||||||
@@ -189,6 +205,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
content: lesson.content || '',
|
content: lesson.content || '',
|
||||||
video_url: lesson.video_url || '',
|
video_url: lesson.video_url || '',
|
||||||
|
youtube_url: lesson.youtube_url || '',
|
||||||
|
embed_code: lesson.embed_code || '',
|
||||||
|
m3u8_url: lesson.m3u8_url || '',
|
||||||
|
mp4_url: lesson.mp4_url || '',
|
||||||
|
video_host: lesson.video_host || 'youtube',
|
||||||
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
||||||
chapters: lesson.chapters || [],
|
chapters: lesson.chapters || [],
|
||||||
});
|
});
|
||||||
@@ -206,6 +227,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
title: lessonForm.title,
|
title: lessonForm.title,
|
||||||
content: lessonForm.content || null,
|
content: lessonForm.content || null,
|
||||||
video_url: lessonForm.video_url || null,
|
video_url: lessonForm.video_url || null,
|
||||||
|
youtube_url: lessonForm.youtube_url || null,
|
||||||
|
embed_code: lessonForm.embed_code || null,
|
||||||
|
m3u8_url: lessonForm.m3u8_url || null,
|
||||||
|
mp4_url: lessonForm.mp4_url || null,
|
||||||
|
video_host: lessonForm.video_host || 'youtube',
|
||||||
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
||||||
chapters: lessonForm.chapters || [],
|
chapters: lessonForm.chapters || [],
|
||||||
};
|
};
|
||||||
@@ -443,15 +469,70 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
className="border-2"
|
className="border-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Video URL</Label>
|
<Label>Video Host</Label>
|
||||||
<Input
|
<Select
|
||||||
value={lessonForm.video_url}
|
value={lessonForm.video_host}
|
||||||
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
|
||||||
placeholder="https://youtube.com/... or https://vimeo.com/..."
|
>
|
||||||
className="border-2"
|
<SelectTrigger className="border-2">
|
||||||
/>
|
<SelectValue placeholder="Select video host" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="youtube">YouTube</SelectItem>
|
||||||
|
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* YouTube URL */}
|
||||||
|
{lessonForm.video_host === 'youtube' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>YouTube URL</Label>
|
||||||
|
<Input
|
||||||
|
value={lessonForm.video_url}
|
||||||
|
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Paste YouTube URL here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Adilo URLs */}
|
||||||
|
{lessonForm.video_host === 'adilo' && (
|
||||||
|
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>M3U8 URL (Primary)</Label>
|
||||||
|
<Input
|
||||||
|
value={lessonForm.m3u8_url}
|
||||||
|
onChange={(e) => setLessonForm({ ...lessonForm, m3u8_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/m3u8/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
HLS streaming URL from Adilo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>MP4 URL (Optional Fallback)</Label>
|
||||||
|
<Input
|
||||||
|
value={lessonForm.mp4_url}
|
||||||
|
onChange={(e) => setLessonForm({ ...lessonForm, mp4_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/videos/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Direct MP4 file for legacy browsers (optional)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ChaptersEditor
|
<ChaptersEditor
|
||||||
chapters={lessonForm.chapters || []}
|
chapters={lessonForm.chapters || []}
|
||||||
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ interface ReviewModalProps {
|
|||||||
orderId?: string | null;
|
orderId?: string | null;
|
||||||
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
|
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
existingReview?: {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ export function ReviewModal({
|
|||||||
orderId,
|
orderId,
|
||||||
type,
|
type,
|
||||||
contextLabel,
|
contextLabel,
|
||||||
|
existingReview,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ReviewModalProps) {
|
}: ReviewModalProps) {
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
@@ -34,6 +41,20 @@ export function ReviewModal({
|
|||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Pre-populate form when existingReview is provided or modal opens with existing data
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingReview) {
|
||||||
|
setRating(existingReview.rating);
|
||||||
|
setTitle(existingReview.title || '');
|
||||||
|
setBody(existingReview.body || '');
|
||||||
|
} else {
|
||||||
|
// Reset form for new review
|
||||||
|
setRating(0);
|
||||||
|
setTitle('');
|
||||||
|
setBody('');
|
||||||
|
}
|
||||||
|
}, [existingReview, open]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (rating === 0) {
|
if (rating === 0) {
|
||||||
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
|
||||||
@@ -45,22 +66,46 @@ export function ReviewModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const { error } = await supabase.from('reviews').insert({
|
|
||||||
user_id: userId,
|
let error;
|
||||||
product_id: productId || null,
|
|
||||||
order_id: orderId || null,
|
if (existingReview) {
|
||||||
type,
|
// Update existing review
|
||||||
rating,
|
const result = await supabase
|
||||||
title: title.trim(),
|
.from('reviews')
|
||||||
body: body.trim() || null,
|
.update({
|
||||||
is_approved: false,
|
rating,
|
||||||
});
|
title: title.trim(),
|
||||||
|
body: body.trim() || null,
|
||||||
|
is_approved: false, // Reset approval status on edit
|
||||||
|
})
|
||||||
|
.eq('id', existingReview.id);
|
||||||
|
error = result.error;
|
||||||
|
} else {
|
||||||
|
// Insert new review
|
||||||
|
const result = await supabase.from('reviews').insert({
|
||||||
|
user_id: userId,
|
||||||
|
product_id: productId || null,
|
||||||
|
order_id: orderId || null,
|
||||||
|
type,
|
||||||
|
rating,
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim() || null,
|
||||||
|
is_approved: false,
|
||||||
|
});
|
||||||
|
error = result.error;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Review submit error:', error);
|
console.error('Review submit error:', error);
|
||||||
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
|
||||||
} else {
|
} else {
|
||||||
toast({ title: 'Berhasil', description: 'Terima kasih! Ulasan Anda akan ditinjau oleh admin.' });
|
toast({
|
||||||
|
title: 'Berhasil',
|
||||||
|
description: existingReview
|
||||||
|
? 'Ulasan Anda diperbarui dan akan ditinjau ulang oleh admin.'
|
||||||
|
: 'Terima kasih! Ulasan Anda akan ditinjau oleh admin.'
|
||||||
|
});
|
||||||
// Reset form
|
// Reset form
|
||||||
setRating(0);
|
setRating(0);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
@@ -81,7 +126,7 @@ export function ReviewModal({
|
|||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Beri Ulasan</DialogTitle>
|
<DialogTitle>{existingReview ? 'Edit Ulasan' : 'Beri Ulasan'}</DialogTitle>
|
||||||
{contextLabel && (
|
{contextLabel && (
|
||||||
<DialogDescription>{contextLabel}</DialogDescription>
|
<DialogDescription>{contextLabel}</DialogDescription>
|
||||||
)}
|
)}
|
||||||
@@ -140,7 +185,7 @@ export function ReviewModal({
|
|||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={submitting}>
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
{submitting ? 'Mengirim...' : 'Kirim Ulasan'}
|
{submitting ? 'Menyimpan...' : (existingReview ? 'Simpan Perubahan' : 'Kirim Ulasan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
352
src/hooks/useAdiloPlayer.ts
Normal file
352
src/hooks/useAdiloPlayer.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import videojs from 'video.js';
|
||||||
|
import 'video.js/dist/video-js.css';
|
||||||
|
|
||||||
|
interface UseAdiloPlayerProps {
|
||||||
|
m3u8Url?: string;
|
||||||
|
mp4Url?: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
onTimeUpdate?: (time: number) => void;
|
||||||
|
onDuration?: (duration: number) => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
onError?: (error: any) => void;
|
||||||
|
accentColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdiloPlayer = ({
|
||||||
|
m3u8Url,
|
||||||
|
mp4Url,
|
||||||
|
autoplay = false,
|
||||||
|
onTimeUpdate,
|
||||||
|
onDuration,
|
||||||
|
onEnded,
|
||||||
|
onError,
|
||||||
|
accentColor,
|
||||||
|
}: UseAdiloPlayerProps) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const videoJsRef = useRef<any>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(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<any>(null);
|
||||||
|
|
||||||
|
// Use refs to store stable callback references
|
||||||
|
const callbacksRef = useRef({
|
||||||
|
onTimeUpdate,
|
||||||
|
onDuration,
|
||||||
|
onEnded,
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update callbacks ref when props change
|
||||||
|
useEffect(() => {
|
||||||
|
callbacksRef.current = {
|
||||||
|
onTimeUpdate,
|
||||||
|
onDuration,
|
||||||
|
onEnded,
|
||||||
|
onError,
|
||||||
|
};
|
||||||
|
}, [onTimeUpdate, onDuration, onEnded, onError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || (!m3u8Url && !mp4Url)) return;
|
||||||
|
|
||||||
|
// Clean up previous HLS instance
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Try M3U8 with HLS.js first
|
||||||
|
if (m3u8Url) {
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
xhrSetup: (xhr, url) => {
|
||||||
|
// Allow CORS for HLS requests
|
||||||
|
xhr.withCredentials = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hlsRef.current = hls;
|
||||||
|
hls.loadSource(m3u8Url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
|
||||||
|
console.log('✅ HLS manifest parsed:', data.levels.length, 'quality levels');
|
||||||
|
// Don't set ready yet - wait for first fragment to load
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.FRAG_PARSED, () => {
|
||||||
|
console.log('✅ First segment loaded, video ready');
|
||||||
|
setIsReady(true);
|
||||||
|
|
||||||
|
// Log video element state
|
||||||
|
console.log('📹 Video element state:', {
|
||||||
|
readyState: video.readyState,
|
||||||
|
videoWidth: video.videoWidth,
|
||||||
|
videoHeight: video.videoHeight,
|
||||||
|
duration: video.duration,
|
||||||
|
paused: video.paused,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Autoplay failed:', err);
|
||||||
|
callbacksRef.current.onError?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
console.error('❌ HLS error:', data.type, data.details);
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
console.log('🔄 Recovering from network error...');
|
||||||
|
hls.startLoad();
|
||||||
|
break;
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.log('🔄 Recovering from media error...');
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('💥 Fatal error, destroying HLS instance');
|
||||||
|
hls.destroy();
|
||||||
|
// Fallback to MP4
|
||||||
|
if (mp4Url) {
|
||||||
|
console.log('📹 Falling back to MP4');
|
||||||
|
video.src = mp4Url;
|
||||||
|
} else {
|
||||||
|
setError(data);
|
||||||
|
callbacksRef.current.onError?.(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari native HLS support
|
||||||
|
video.src = m3u8Url;
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Autoplay failed:', err);
|
||||||
|
callbacksRef.current.onError?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No HLS support, fallback to MP4
|
||||||
|
if (mp4Url) {
|
||||||
|
video.src = mp4Url;
|
||||||
|
} else {
|
||||||
|
setError(new Error('No supported video format'));
|
||||||
|
callbacksRef.current.onError?.(new Error('No supported video format'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (mp4Url) {
|
||||||
|
// Direct MP4 playback
|
||||||
|
video.src = mp4Url;
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Autoplay failed:', err);
|
||||||
|
callbacksRef.current.onError?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time update handler
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const time = video.currentTime;
|
||||||
|
setCurrentTime(time);
|
||||||
|
callbacksRef.current.onTimeUpdate?.(time);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Duration handler
|
||||||
|
const handleDurationChange = () => {
|
||||||
|
const dur = video.duration;
|
||||||
|
if (dur && !isNaN(dur)) {
|
||||||
|
setDuration(dur);
|
||||||
|
callbacksRef.current.onDuration?.(dur);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play/pause handlers
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
callbacksRef.current.onEnded?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.addEventListener('durationchange', handleDurationChange);
|
||||||
|
video.addEventListener('play', handlePlay);
|
||||||
|
video.addEventListener('pause', handlePause);
|
||||||
|
video.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
// Initialize Video.js after HLS.js has set up the video
|
||||||
|
// Wait for video to be ready before initializing Video.js
|
||||||
|
const initializeVideoJs = () => {
|
||||||
|
if (!videoRef.current || videoJsRef.current) return;
|
||||||
|
|
||||||
|
// Initialize Video.js with the video element
|
||||||
|
const player = videojs(videoRef.current, {
|
||||||
|
controls: true,
|
||||||
|
autoplay: false,
|
||||||
|
preload: 'auto',
|
||||||
|
fluid: false,
|
||||||
|
fill: true,
|
||||||
|
responsive: false,
|
||||||
|
html5: {
|
||||||
|
vhs: {
|
||||||
|
overrideNative: true,
|
||||||
|
},
|
||||||
|
nativeVideoTracks: false,
|
||||||
|
nativeAudioTracks: false,
|
||||||
|
nativeTextTracks: false,
|
||||||
|
},
|
||||||
|
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||||
|
controlBar: {
|
||||||
|
volumePanel: {
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
videoJsRef.current = player;
|
||||||
|
|
||||||
|
// Apply custom accent color if provided
|
||||||
|
if (accentColor) {
|
||||||
|
const styleId = 'videojs-custom-theme';
|
||||||
|
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
|
||||||
|
|
||||||
|
if (!styleElement) {
|
||||||
|
styleElement = document.createElement('style');
|
||||||
|
styleElement.id = styleId;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
styleElement.textContent = `
|
||||||
|
.video-js .vjs-play-progress,
|
||||||
|
.video-js .vjs-volume-level {
|
||||||
|
background-color: ${accentColor} !important;
|
||||||
|
}
|
||||||
|
.video-js .vjs-control-bar,
|
||||||
|
.video-js .vjs-big-play-button {
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.video-js .vjs-slider {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Video.js initialized successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Video.js after a short delay to ensure HLS.js is ready
|
||||||
|
const initTimeout = setTimeout(() => {
|
||||||
|
initializeVideoJs();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(initTimeout);
|
||||||
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.removeEventListener('durationchange', handleDurationChange);
|
||||||
|
video.removeEventListener('play', handlePlay);
|
||||||
|
video.removeEventListener('pause', handlePause);
|
||||||
|
video.removeEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
if (videoJsRef.current) {
|
||||||
|
videoJsRef.current.dispose();
|
||||||
|
videoJsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [m3u8Url, mp4Url, autoplay, accentColor]);
|
||||||
|
|
||||||
|
// Jump to specific time
|
||||||
|
const jumpToTime = useCallback((time: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && isReady) {
|
||||||
|
const wasPlaying = !video.paused;
|
||||||
|
|
||||||
|
// Wait for video to be seekable if needed
|
||||||
|
if (video.seekable.length > 0) {
|
||||||
|
video.currentTime = time;
|
||||||
|
|
||||||
|
// Only attempt to play if video was already playing
|
||||||
|
if (wasPlaying) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
// Ignore AbortError from rapid play() calls
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Jump failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Video not seekable yet, wait for it to be ready
|
||||||
|
console.log('⏳ Video not seekable yet, waiting...');
|
||||||
|
const onSeekable = () => {
|
||||||
|
video.currentTime = time;
|
||||||
|
if (wasPlaying) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Jump failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
video.removeEventListener('canplay', onSeekable);
|
||||||
|
};
|
||||||
|
video.addEventListener('canplay', onSeekable, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
// Play control
|
||||||
|
const play = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && isReady) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error('Play failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
// Pause control
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoRef,
|
||||||
|
isReady,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
error,
|
||||||
|
jumpToTime,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
};
|
||||||
|
};
|
||||||
128
src/hooks/useVideoProgress.ts
Normal file
128
src/hooks/useVideoProgress.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
interface UseVideoProgressOptions {
|
||||||
|
videoId: string;
|
||||||
|
videoType: 'lesson' | 'webinar';
|
||||||
|
duration?: number;
|
||||||
|
onSaveInterval?: number; // seconds, default 5
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoProgress {
|
||||||
|
last_position: number;
|
||||||
|
total_duration?: number;
|
||||||
|
completed: boolean;
|
||||||
|
last_watched_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVideoProgress = ({
|
||||||
|
videoId,
|
||||||
|
videoType,
|
||||||
|
duration,
|
||||||
|
onSaveInterval = 5,
|
||||||
|
}: UseVideoProgressOptions) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [progress, setProgress] = useState<VideoProgress | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const lastSavedPosition = useRef<number>(0);
|
||||||
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const userRef = useRef(user);
|
||||||
|
const videoIdRef = useRef(videoId);
|
||||||
|
const videoTypeRef = useRef(videoType);
|
||||||
|
const durationRef = useRef(duration);
|
||||||
|
|
||||||
|
// Update refs when props change
|
||||||
|
useEffect(() => {
|
||||||
|
userRef.current = user;
|
||||||
|
videoIdRef.current = videoId;
|
||||||
|
videoTypeRef.current = videoType;
|
||||||
|
durationRef.current = duration;
|
||||||
|
}, [user, videoId, videoType, duration]);
|
||||||
|
|
||||||
|
// Load existing progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !videoId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProgress = async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('video_id', videoId)
|
||||||
|
.eq('video_type', videoType)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error loading video progress:', error);
|
||||||
|
} else if (data) {
|
||||||
|
setProgress(data);
|
||||||
|
lastSavedPosition.current = data.last_position;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProgress();
|
||||||
|
}, [user, videoId, videoType]);
|
||||||
|
|
||||||
|
// Save progress directly (not debounced for reliability)
|
||||||
|
const saveProgress = useCallback(async (position: number) => {
|
||||||
|
const currentUser = userRef.current;
|
||||||
|
const currentVideoId = videoIdRef.current;
|
||||||
|
const currentVideoType = videoTypeRef.current;
|
||||||
|
const currentDuration = durationRef.current;
|
||||||
|
|
||||||
|
if (!currentUser || !currentVideoId) return;
|
||||||
|
|
||||||
|
// Don't save if position hasn't changed significantly (less than 1 second)
|
||||||
|
if (Math.abs(position - lastSavedPosition.current) < 1) return;
|
||||||
|
|
||||||
|
const completed = currentDuration ? position / currentDuration >= 0.95 : false;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('video_progress')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
user_id: currentUser.id,
|
||||||
|
video_id: currentVideoId,
|
||||||
|
video_type: currentVideoType,
|
||||||
|
last_position: position,
|
||||||
|
total_duration: currentDuration,
|
||||||
|
completed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onConflict: 'user_id,video_id,video_type',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error saving video progress:', error);
|
||||||
|
} else {
|
||||||
|
lastSavedPosition.current = position;
|
||||||
|
}
|
||||||
|
}, []); // Empty deps - uses refs internally
|
||||||
|
|
||||||
|
// Save on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
// Save final position
|
||||||
|
if (lastSavedPosition.current > 0) {
|
||||||
|
saveProgress(lastSavedPosition.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [saveProgress]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
progress,
|
||||||
|
loading,
|
||||||
|
saveProgress, // Return the direct save function
|
||||||
|
hasProgress: progress !== null && progress.last_position > 5, // Only show if more than 5 seconds watched
|
||||||
|
};
|
||||||
|
};
|
||||||
43
src/lib/adiloHelper.ts
Normal file
43
src/lib/adiloHelper.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Extract M3U8 and MP4 URLs from Adilo embed code
|
||||||
|
*/
|
||||||
|
export const extractAdiloUrls = (embedCode: string): { m3u8Url?: string; mp4Url?: string } => {
|
||||||
|
const m3u8Match = embedCode.match(/(https:\/\/[^"'\s]+\.m3u8[^"'\s]*)/);
|
||||||
|
const mp4Match = embedCode.match(/(https:\/\/[^"'\s]+\.mp4[^"'\s]*)/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
m3u8Url: m3u8Match?.[1],
|
||||||
|
mp4Url: mp4Match?.[1],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Adilo embed code from URLs
|
||||||
|
*/
|
||||||
|
export const generateAdiloEmbed = (m3u8Url: string, videoId: string): string => {
|
||||||
|
return `<iframe src="https://adilo.bigcommand.com/embed/${videoId}" allowfullscreen></iframe>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is an Adilo URL
|
||||||
|
*/
|
||||||
|
export const isAdiloUrl = (url: string): boolean => {
|
||||||
|
return url.includes('adilo.bigcommand.com') || url.includes('.m3u8');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a YouTube URL
|
||||||
|
*/
|
||||||
|
export const isYouTubeUrl = (url: string): boolean => {
|
||||||
|
return url.includes('youtube.com') || url.includes('youtu.be');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video host type from URL
|
||||||
|
*/
|
||||||
|
export const getVideoHostType = (url?: string | null): 'youtube' | 'adilo' | 'unknown' => {
|
||||||
|
if (!url) return 'unknown';
|
||||||
|
if (isYouTubeUrl(url)) return 'youtube';
|
||||||
|
if (isAdiloUrl(url)) return 'adilo';
|
||||||
|
return 'unknown';
|
||||||
|
};
|
||||||
@@ -25,7 +25,6 @@ interface Product {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
video_source?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
@@ -42,6 +41,9 @@ interface Lesson {
|
|||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
youtube_url: string | null;
|
youtube_url: string | null;
|
||||||
embed_code: string | null;
|
embed_code: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
@@ -91,7 +93,7 @@ export default function Bootcamp() {
|
|||||||
const checkAccessAndFetch = async () => {
|
const checkAccessAndFetch = async () => {
|
||||||
const { data: productData, error: productError } = await supabase
|
const { data: productData, error: productError } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
.select('id, title, slug, video_source')
|
.select('id, title, slug')
|
||||||
.eq('slug', slug)
|
.eq('slug', slug)
|
||||||
.eq('type', 'bootcamp')
|
.eq('type', 'bootcamp')
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@@ -140,6 +142,9 @@ export default function Bootcamp() {
|
|||||||
video_url,
|
video_url,
|
||||||
youtube_url,
|
youtube_url,
|
||||||
embed_code,
|
embed_code,
|
||||||
|
m3u8_url,
|
||||||
|
mp4_url,
|
||||||
|
video_host,
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
position,
|
position,
|
||||||
release_at,
|
release_at,
|
||||||
@@ -283,12 +288,39 @@ export default function Bootcamp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
||||||
const activeSource = product?.video_source || 'youtube';
|
|
||||||
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||||
|
|
||||||
// Get video based on product's active source
|
// Get video based on lesson's video_host (prioritize Adilo)
|
||||||
const getVideoSource = () => {
|
const getVideoSource = () => {
|
||||||
if (activeSource === 'youtube') {
|
// If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
|
||||||
|
const lessonVideoHost = lesson.video_host || (
|
||||||
|
lesson.m3u8_url ? 'adilo' :
|
||||||
|
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
||||||
|
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
|
||||||
|
'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lessonVideoHost === 'adilo') {
|
||||||
|
// Adilo M3U8 streaming
|
||||||
|
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'adilo',
|
||||||
|
m3u8Url: lesson.m3u8_url,
|
||||||
|
mp4Url: lesson.mp4_url || undefined,
|
||||||
|
videoHost: 'adilo'
|
||||||
|
};
|
||||||
|
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
|
||||||
|
// Fallback to MP4 only
|
||||||
|
return {
|
||||||
|
type: 'adilo',
|
||||||
|
mp4Url: lesson.mp4_url,
|
||||||
|
videoHost: 'adilo'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube or fallback
|
||||||
|
if (lessonVideoHost === 'youtube') {
|
||||||
if (lesson.youtube_url && lesson.youtube_url.trim()) {
|
if (lesson.youtube_url && lesson.youtube_url.trim()) {
|
||||||
return {
|
return {
|
||||||
type: 'youtube',
|
type: 'youtube',
|
||||||
@@ -302,32 +334,14 @@ export default function Bootcamp() {
|
|||||||
url: lesson.video_url,
|
url: lesson.video_url,
|
||||||
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
// Fallback to embed if YouTube not available
|
|
||||||
return lesson.embed_code && lesson.embed_code.trim() ? {
|
|
||||||
type: 'embed',
|
|
||||||
html: lesson.embed_code
|
|
||||||
} : null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (lesson.embed_code && lesson.embed_code.trim()) {
|
|
||||||
return {
|
|
||||||
type: 'embed',
|
|
||||||
html: lesson.embed_code
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Fallback to YouTube if embed not available
|
|
||||||
return lesson.youtube_url && lesson.youtube_url.trim() ? {
|
|
||||||
type: 'youtube',
|
|
||||||
url: lesson.youtube_url,
|
|
||||||
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
|
||||||
} : lesson.video_url && lesson.video_url.trim() ? {
|
|
||||||
type: 'youtube',
|
|
||||||
url: lesson.video_url,
|
|
||||||
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
|
||||||
} : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final fallback: try embed code
|
||||||
|
return lesson.embed_code && lesson.embed_code.trim() ? {
|
||||||
|
type: 'embed',
|
||||||
|
html: lesson.embed_code
|
||||||
|
} : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const video = getVideoSource();
|
const video = getVideoSource();
|
||||||
@@ -355,7 +369,6 @@ export default function Bootcamp() {
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TimelineChapters
|
<TimelineChapters
|
||||||
chapters={lesson.chapters}
|
chapters={lesson.chapters}
|
||||||
isYouTube={false}
|
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
accentColor={accentColor}
|
accentColor={accentColor}
|
||||||
/>
|
/>
|
||||||
@@ -365,19 +378,24 @@ export default function Bootcamp() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// YouTube with chapters support
|
// Adilo or YouTube with chapters support
|
||||||
const isYouTube = video.type === 'youtube';
|
const isYouTube = video.type === 'youtube';
|
||||||
|
const isAdilo = video.type === 'adilo';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
|
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
|
||||||
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||||
<VideoPlayerWithChapters
|
<VideoPlayerWithChapters
|
||||||
ref={playerRef}
|
ref={playerRef}
|
||||||
videoUrl={video.url}
|
videoUrl={isYouTube ? video.url : undefined}
|
||||||
embedCode={lesson.embed_code}
|
m3u8Url={isAdilo ? video.m3u8Url : undefined}
|
||||||
|
mp4Url={isAdilo ? video.mp4Url : undefined}
|
||||||
|
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
|
||||||
chapters={lesson.chapters}
|
chapters={lesson.chapters}
|
||||||
accentColor={accentColor}
|
accentColor={accentColor}
|
||||||
onTimeUpdate={setCurrentTime}
|
onTimeUpdate={setCurrentTime}
|
||||||
|
videoId={lesson.id}
|
||||||
|
videoType="lesson"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -385,7 +403,6 @@ export default function Bootcamp() {
|
|||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<TimelineChapters
|
<TimelineChapters
|
||||||
chapters={lesson.chapters}
|
chapters={lesson.chapters}
|
||||||
isYouTube={isYouTube}
|
|
||||||
onChapterClick={(time) => {
|
onChapterClick={(time) => {
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
playerRef.current.jumpToTime(time);
|
playerRef.current.jumpToTime(time);
|
||||||
@@ -704,6 +721,12 @@ export default function Bootcamp() {
|
|||||||
productId={product.id}
|
productId={product.id}
|
||||||
type="bootcamp"
|
type="bootcamp"
|
||||||
contextLabel={product.title}
|
contextLabel={product.title}
|
||||||
|
existingReview={userReview ? {
|
||||||
|
id: userReview.id,
|
||||||
|
rating: userReview.rating,
|
||||||
|
title: userReview.title,
|
||||||
|
body: userReview.body,
|
||||||
|
} : undefined}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
// Refresh review data
|
// Refresh review data
|
||||||
const refreshReview = async () => {
|
const refreshReview = async () => {
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ interface Product {
|
|||||||
sale_price: number | null;
|
sale_price: number | null;
|
||||||
meeting_link: string | null;
|
meeting_link: string | null;
|
||||||
recording_url: string | null;
|
recording_url: string | null;
|
||||||
|
m3u8_url: string | null;
|
||||||
|
mp4_url: string | null;
|
||||||
|
video_host: 'youtube' | 'adilo' | 'unknown' | null;
|
||||||
event_start: string | null;
|
event_start: string | null;
|
||||||
duration_minutes: number | null;
|
duration_minutes: number | null;
|
||||||
|
chapters?: { time: number; title: string; }[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ interface Lesson {
|
|||||||
title: string;
|
title: string;
|
||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
position: number;
|
position: number;
|
||||||
|
chapters?: { time: number; title: string; }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserReview {
|
interface UserReview {
|
||||||
@@ -105,7 +110,7 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
const fetchCurriculum = async () => {
|
const fetchCurriculum = async () => {
|
||||||
if (!product) return;
|
if (!product) return;
|
||||||
|
|
||||||
const { data: modulesData } = await supabase
|
const { data: modulesData } = await supabase
|
||||||
.from('bootcamp_modules')
|
.from('bootcamp_modules')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -116,7 +121,8 @@ export default function ProductDetail() {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
position
|
position,
|
||||||
|
chapters
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('product_id', product.id)
|
.eq('product_id', product.id)
|
||||||
@@ -215,6 +221,43 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
||||||
|
|
||||||
|
const formatChapterTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWebinarChapters = () => {
|
||||||
|
if (product?.type !== 'webinar' || !product.chapters || product.chapters.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Daftar isi Webinar</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{product.chapters.map((chapter, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent transition-colors cursor-pointer group"
|
||||||
|
onClick={() => product && navigate(`/webinar/${product.slug}`)}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-12 text-center">
|
||||||
|
<span className="text-sm font-mono text-muted-foreground group-hover:text-primary">
|
||||||
|
{formatChapterTime(chapter.time)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{chapter.title}</p>
|
||||||
|
</div>
|
||||||
|
<Play className="w-4 h-4 text-muted-foreground group-hover:text-primary flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getVideoEmbed = (url: string) => {
|
const getVideoEmbed = (url: string) => {
|
||||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||||
@@ -268,20 +311,25 @@ export default function ProductDetail() {
|
|||||||
if (product.recording_url) {
|
if (product.recording_url) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
<Card className="border-2 border-primary/20 bg-primary/5">
|
||||||
<iframe
|
<CardContent className="pt-6">
|
||||||
src={getVideoEmbed(product.recording_url)}
|
<div className="flex items-start gap-4">
|
||||||
className="w-full h-full"
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
<Play className="w-6 h-6 text-primary" />
|
||||||
allowFullScreen
|
</div>
|
||||||
/>
|
<div className="flex-1">
|
||||||
</div>
|
<h3 className="font-semibold text-lg mb-1">Rekaman webinar tersedia</h3>
|
||||||
<Button asChild variant="outline" className="border-2">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
<a href={product.recording_url} target="_blank" rel="noopener noreferrer">
|
Akses rekaman webinar kapan saja. Pelajari materi sesuai kecepatan Anda.
|
||||||
<Video className="w-4 h-4 mr-2" />
|
</p>
|
||||||
Tonton Rekaman
|
<Button onClick={() => navigate(`/webinar/${product.slug}`)} size="lg">
|
||||||
</a>
|
<Video className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Tonton Sekarang
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -352,15 +400,36 @@ export default function ProductDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-2">
|
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-3">
|
||||||
{module.lessons.map((lesson) => (
|
{module.lessons.map((lesson) => (
|
||||||
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm">
|
<div key={lesson.id} className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
{/* Lesson header */}
|
||||||
<Play className="w-3 h-3 text-muted-foreground" />
|
<div className="flex items-center justify-between py-1 text-sm">
|
||||||
<span>{lesson.title}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
{lesson.duration_seconds && (
|
||||||
|
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lesson.duration_seconds && (
|
|
||||||
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
{/* Lesson chapters (if any) */}
|
||||||
|
{lesson.chapters && lesson.chapters.length > 0 && (
|
||||||
|
<div className="ml-5 space-y-1">
|
||||||
|
{lesson.chapters.map((chapter, chapterIndex) => (
|
||||||
|
<div
|
||||||
|
key={chapterIndex}
|
||||||
|
className="flex items-center gap-2 py-1 px-2 text-xs text-muted-foreground hover:bg-accent/50 rounded transition-colors cursor-pointer group"
|
||||||
|
onClick={() => product && navigate(`/bootcamp/${product.slug}`)}
|
||||||
|
>
|
||||||
|
<span className="font-mono w-10 text-center group-hover:text-primary">
|
||||||
|
{formatChapterTime(chapter.time)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 group-hover:text-foreground">{chapter.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -378,6 +447,39 @@ export default function ProductDetail() {
|
|||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Ownership Banner - shown at top for purchased users */}
|
||||||
|
{hasAccess && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-950 border-2 border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-900 dark:text-green-100">
|
||||||
|
Anda memiliki akses ke produk ini
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-300">
|
||||||
|
{product.type === 'webinar' && 'Selamat menonton rekaman webinar!'}
|
||||||
|
{product.type === 'bootcamp' && 'Mulai belajar sekarang!'}
|
||||||
|
{product.type === 'consulting' && 'Jadwalkan sesi konsultasi Anda.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (product.type === 'webinar') {
|
||||||
|
navigate(`/webinar/${product.slug}`);
|
||||||
|
} else if (product.type === 'bootcamp') {
|
||||||
|
navigate(`/bootcamp/${product.slug}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white shadow-sm"
|
||||||
|
>
|
||||||
|
Tonton Sekarang →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||||
@@ -392,7 +494,6 @@ export default function ProductDetail() {
|
|||||||
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
|
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||||
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
||||||
)}
|
)}
|
||||||
{hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -424,7 +525,7 @@ export default function ProductDetail() {
|
|||||||
{product.content && (
|
{product.content && (
|
||||||
<Card className="border-2 border-border mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div
|
<div
|
||||||
className="prose max-w-none"
|
className="prose max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: product.content }}
|
dangerouslySetInnerHTML={{ __html: product.content }}
|
||||||
/>
|
/>
|
||||||
@@ -432,6 +533,8 @@ export default function ProductDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderWebinarChapters()}
|
||||||
|
|
||||||
<div className="flex gap-4 flex-wrap">
|
<div className="flex gap-4 flex-wrap">
|
||||||
{renderActionButtons()}
|
{renderActionButtons()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useVideoProgress } from '@/hooks/useVideoProgress';
|
||||||
import { AppLayout } from '@/components/AppLayout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { ChevronLeft, Play } from 'lucide-react';
|
import { ChevronLeft, Play, Star, Clock, CheckCircle } from 'lucide-react';
|
||||||
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
|
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
|
||||||
import { TimelineChapters } from '@/components/TimelineChapters';
|
import { TimelineChapters } from '@/components/TimelineChapters';
|
||||||
|
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
interface VideoChapter {
|
interface VideoChapter {
|
||||||
time: number;
|
time: number;
|
||||||
@@ -21,10 +24,22 @@ interface Product {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
recording_url: string | null;
|
recording_url: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
description: string | null;
|
description: string | null;
|
||||||
chapters?: VideoChapter[];
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function WebinarRecording() {
|
export default function WebinarRecording() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -34,6 +49,8 @@ export default function WebinarRecording() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [accentColor, setAccentColor] = useState<string>('');
|
const [accentColor, setAccentColor] = useState<string>('');
|
||||||
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
const playerRef = useRef<VideoPlayerRef>(null);
|
const playerRef = useRef<VideoPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,7 +64,7 @@ export default function WebinarRecording() {
|
|||||||
const checkAccessAndFetch = async () => {
|
const checkAccessAndFetch = async () => {
|
||||||
const { data: productData, error: productError } = await supabase
|
const { data: productData, error: productError } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
.select('id, title, slug, recording_url, description, chapters')
|
.select('id, title, slug, recording_url, m3u8_url, mp4_url, video_host, description, chapters')
|
||||||
.eq('slug', slug)
|
.eq('slug', slug)
|
||||||
.eq('type', 'webinar')
|
.eq('type', 'webinar')
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@@ -103,23 +120,60 @@ export default function WebinarRecording() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
|
// Check if user has already reviewed this webinar
|
||||||
|
checkUserReview();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isYouTube = product?.recording_url && (
|
const checkUserReview = async () => {
|
||||||
product.recording_url.includes('youtube.com') ||
|
if (!product || !user) return;
|
||||||
product.recording_url.includes('youtu.be')
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('product_id', product.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
setUserReview(data[0] as UserReview);
|
||||||
|
} else {
|
||||||
|
setUserReview(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user has submitted a review (regardless of approval status)
|
||||||
|
const hasSubmittedReview = userReview !== null;
|
||||||
|
|
||||||
|
// Determine video host (prioritize Adilo over YouTube)
|
||||||
|
const detectedVideoHost = product?.video_host || (
|
||||||
|
product?.m3u8_url ? 'adilo' :
|
||||||
|
product?.recording_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
||||||
|
product?.recording_url?.includes('youtube.com') || product?.recording_url?.includes('youtu.be')
|
||||||
|
? 'youtube'
|
||||||
|
: 'unknown'
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChapterClick = (time: number) => {
|
const handleChapterClick = useCallback((time: number) => {
|
||||||
// VideoPlayerWithChapters will handle the jump
|
// VideoPlayerWithChapters will handle the jump
|
||||||
if (playerRef.current && playerRef.current.jumpToTime) {
|
if (playerRef.current && playerRef.current.jumpToTime) {
|
||||||
playerRef.current.jumpToTime(time);
|
playerRef.current.jumpToTime(time);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleTimeUpdate = (time: number) => {
|
const handleTimeUpdate = useCallback((time: number) => {
|
||||||
setCurrentTime(time);
|
setCurrentTime(time);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Fetch progress data for review trigger
|
||||||
|
const { progress, hasProgress: hasWatchProgress } = useVideoProgress({
|
||||||
|
videoId: product?.id || '',
|
||||||
|
videoType: 'webinar',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show review prompt if user has watched more than 5 seconds (any engagement)
|
||||||
|
const shouldShowReviewPrompt = hasWatchProgress;
|
||||||
|
|
||||||
if (authLoading || loading) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
@@ -138,69 +192,177 @@ export default function WebinarRecording() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8 max-w-5xl">
|
||||||
<Button variant="ghost" onClick={() => navigate('/dashboard')} className="mb-6">
|
<Button variant="ghost" onClick={() => navigate('/dashboard')} className="mb-6">
|
||||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
Kembali ke Dashboard
|
Kembali ke Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Card className="border-2 border-border">
|
<h1 className="text-3xl md:text-4xl font-bold mb-6">{product.title}</h1>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl md:text-3xl">{product.title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Video Player with Chapters */}
|
|
||||||
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6" : ""}>
|
|
||||||
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
|
||||||
{product.recording_url && (
|
|
||||||
<VideoPlayerWithChapters
|
|
||||||
ref={playerRef}
|
|
||||||
videoUrl={product.recording_url}
|
|
||||||
chapters={product.chapters}
|
|
||||||
accentColor={accentColor}
|
|
||||||
onTimeUpdate={handleTimeUpdate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline Chapters */}
|
{/* Video Player */}
|
||||||
{hasChapters && (
|
<div className="mb-6">
|
||||||
<div className="lg:col-span-1">
|
{(product.recording_url || product.m3u8_url) && (
|
||||||
<TimelineChapters
|
<VideoPlayerWithChapters
|
||||||
chapters={product.chapters}
|
ref={playerRef}
|
||||||
isYouTube={isYouTube}
|
videoUrl={product.recording_url || undefined}
|
||||||
onChapterClick={handleChapterClick}
|
m3u8Url={product.m3u8_url || undefined}
|
||||||
currentTime={currentTime}
|
mp4Url={product.mp4_url || undefined}
|
||||||
accentColor={accentColor}
|
videoHost={detectedVideoHost}
|
||||||
/>
|
chapters={product.chapters}
|
||||||
</div>
|
accentColor={accentColor}
|
||||||
)}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
</div>
|
videoId={product.id}
|
||||||
|
videoType="webinar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Timeline Chapters - video track for navigation */}
|
||||||
{product.description && (
|
{hasChapters && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<TimelineChapters
|
||||||
|
chapters={product.chapters}
|
||||||
|
onChapterClick={handleChapterClick}
|
||||||
|
currentTime={currentTime}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{product.description && (
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
<div className="prose max-w-none">
|
<div className="prose max-w-none">
|
||||||
<div dangerouslySetInnerHTML={{ __html: product.description }} />
|
<div dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<Card className="bg-muted border-2 border-border">
|
<Card className="bg-muted border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||||
<Play className="w-5 h-5" />
|
<Play className="w-5 h-5" />
|
||||||
Panduan Menonton
|
Panduan Menonton
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||||
<li>Gunakan tombol fullscreen di pojok kanan bawah video untuk tampilan terbaik</li>
|
<li>Gunakan tombol fullscreen di pojok kanan bawah video untuk tampilan terbaik</li>
|
||||||
<li>Anda dapat memutar ulang video kapan saja</li>
|
<li>Anda dapat memutar ulang video kapan saja</li>
|
||||||
<li>Pastikan koneksi internet stabil untuk pengalaman menonton yang lancar</li>
|
<li>Pastikan koneksi internet stabil untuk pengalaman menonton yang lancar</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Review Section - Show after any engagement, but only if user hasn't submitted a review yet */}
|
||||||
|
{shouldShowReviewPrompt && !hasSubmittedReview && (
|
||||||
|
<Card className="border-2 border-primary/20 bg-primary/5 mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
|
<Star className="w-6 h-6 text-primary fill-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">Bagaimana webinar ini?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Berikan ulasan Anda untuk membantu peserta lain memilih webinar yang tepat.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setReviewModalOpen(true)}>
|
||||||
|
<Star className="w-4 h-4 mr-2" />
|
||||||
|
Beri ulasan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User's Existing Review */}
|
||||||
|
{userReview && (
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCircle className={`w-5 h-5 ${userReview.is_approved ? 'text-green-600' : 'text-yellow-600'}`} />
|
||||||
|
Ulasan Anda{!userReview.is_approved && ' (Menunggu Persetujuan)'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
star <= userReview.rating
|
||||||
|
? 'text-yellow-500 fill-yellow-500'
|
||||||
|
: 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{new Date(userReview.created_at).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
{!userReview.is_approved && (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300">
|
||||||
|
Menunggu persetujuan admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{userReview.title && (
|
||||||
|
<h4 className="font-semibold text-lg mb-2">{userReview.title}</h4>
|
||||||
|
)}
|
||||||
|
{userReview.body && (
|
||||||
|
<p className="text-muted-foreground">{userReview.body}</p>
|
||||||
|
)}
|
||||||
|
{!userReview.is_approved && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 italic">
|
||||||
|
Ulasan Anda sedang ditinjau oleh admin dan akan segera ditampilkan setelah disetujui.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setReviewModalOpen(true)}
|
||||||
|
>
|
||||||
|
Edit ulasan
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Review Modal */}
|
||||||
|
{product && user && (
|
||||||
|
<ReviewModal
|
||||||
|
open={reviewModalOpen}
|
||||||
|
onOpenChange={setReviewModalOpen}
|
||||||
|
userId={user.id}
|
||||||
|
productId={product.id}
|
||||||
|
type="webinar"
|
||||||
|
contextLabel={product.title}
|
||||||
|
existingReview={userReview ? {
|
||||||
|
id: userReview.id,
|
||||||
|
rating: userReview.rating,
|
||||||
|
title: userReview.title,
|
||||||
|
body: userReview.body,
|
||||||
|
} : undefined}
|
||||||
|
onSuccess={() => {
|
||||||
|
checkUserReview();
|
||||||
|
toast({
|
||||||
|
title: 'Terima kasih!',
|
||||||
|
description: userReview
|
||||||
|
? 'Ulasan Anda berhasil diperbarui.'
|
||||||
|
: 'Ulasan Anda berhasil disimpan.',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,14 @@ interface Product {
|
|||||||
content: string;
|
content: string;
|
||||||
meeting_link: string | null;
|
meeting_link: string | null;
|
||||||
recording_url: string | null;
|
recording_url: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
event_start: string | null;
|
event_start: string | null;
|
||||||
duration_minutes: number | null;
|
duration_minutes: number | null;
|
||||||
price: number;
|
price: number;
|
||||||
sale_price: number | null;
|
sale_price: number | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
video_source?: string;
|
|
||||||
chapters?: VideoChapter[];
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,12 +55,14 @@ const emptyProduct = {
|
|||||||
content: '',
|
content: '',
|
||||||
meeting_link: '',
|
meeting_link: '',
|
||||||
recording_url: '',
|
recording_url: '',
|
||||||
|
m3u8_url: '',
|
||||||
|
mp4_url: '',
|
||||||
|
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||||
event_start: null as string | null,
|
event_start: null as string | null,
|
||||||
duration_minutes: null as number | null,
|
duration_minutes: null as number | null,
|
||||||
price: 0,
|
price: 0,
|
||||||
sale_price: null as number | null,
|
sale_price: null as number | null,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
video_source: 'youtube' as string,
|
|
||||||
chapters: [] as VideoChapter[],
|
chapters: [] as VideoChapter[],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +88,10 @@ export default function AdminProducts() {
|
|||||||
}, [user, isAdmin, authLoading]);
|
}, [user, isAdmin, authLoading]);
|
||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
|
const { data, error } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('id, title, slug, type, description, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, duration_minutes, price, sale_price, is_active, chapters')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
if (!error && data) setProducts(data);
|
if (!error && data) setProducts(data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -121,12 +128,14 @@ export default function AdminProducts() {
|
|||||||
content: product.content || '',
|
content: product.content || '',
|
||||||
meeting_link: product.meeting_link || '',
|
meeting_link: product.meeting_link || '',
|
||||||
recording_url: product.recording_url || '',
|
recording_url: product.recording_url || '',
|
||||||
|
m3u8_url: product.m3u8_url || '',
|
||||||
|
mp4_url: product.mp4_url || '',
|
||||||
|
video_host: product.video_host || 'youtube',
|
||||||
event_start: product.event_start ? product.event_start.slice(0, 16) : null,
|
event_start: product.event_start ? product.event_start.slice(0, 16) : null,
|
||||||
duration_minutes: product.duration_minutes,
|
duration_minutes: product.duration_minutes,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
sale_price: product.sale_price,
|
sale_price: product.sale_price,
|
||||||
is_active: product.is_active,
|
is_active: product.is_active,
|
||||||
video_source: product.video_source || 'youtube',
|
|
||||||
chapters: product.chapters || [],
|
chapters: product.chapters || [],
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -152,12 +161,14 @@ export default function AdminProducts() {
|
|||||||
content: form.content,
|
content: form.content,
|
||||||
meeting_link: form.meeting_link || null,
|
meeting_link: form.meeting_link || null,
|
||||||
recording_url: form.recording_url || null,
|
recording_url: form.recording_url || null,
|
||||||
|
m3u8_url: form.m3u8_url || null,
|
||||||
|
mp4_url: form.mp4_url || null,
|
||||||
|
video_host: form.video_host || 'youtube',
|
||||||
event_start: form.event_start || null,
|
event_start: form.event_start || null,
|
||||||
duration_minutes: form.duration_minutes || null,
|
duration_minutes: form.duration_minutes || null,
|
||||||
price: form.price,
|
price: form.price,
|
||||||
sale_price: form.sale_price || null,
|
sale_price: form.sale_price || null,
|
||||||
is_active: form.is_active,
|
is_active: form.is_active,
|
||||||
video_source: form.video_source || 'youtube',
|
|
||||||
chapters: form.chapters || [],
|
chapters: form.chapters || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -461,18 +472,73 @@ export default function AdminProducts() {
|
|||||||
<Label>Konten</Label>
|
<Label>Konten</Label>
|
||||||
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
|
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Meeting Link</Label>
|
|
||||||
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Recording URL</Label>
|
|
||||||
<Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{form.type === 'webinar' && (
|
{form.type === 'webinar' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Meeting Link</Label>
|
||||||
|
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Video Host</Label>
|
||||||
|
<Select value={form.video_host} onValueChange={(value: 'youtube' | 'adilo') => setForm({ ...form, video_host: value })}>
|
||||||
|
<SelectTrigger className="border-2">
|
||||||
|
<SelectValue placeholder="Select video host" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="youtube">YouTube</SelectItem>
|
||||||
|
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* YouTube URL */}
|
||||||
|
{form.video_host === 'youtube' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>YouTube Recording URL</Label>
|
||||||
|
<Input
|
||||||
|
value={form.recording_url}
|
||||||
|
onChange={(e) => setForm({ ...form, recording_url: e.target.value })}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Paste YouTube URL here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Adilo URLs */}
|
||||||
|
{form.video_host === 'adilo' && (
|
||||||
|
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>M3U8 URL (Primary)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.m3u8_url}
|
||||||
|
onChange={(e) => setForm({ ...form, m3u8_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/m3u8/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
HLS streaming URL from Adilo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>MP4 URL (Optional Fallback)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mp4_url}
|
||||||
|
onChange={(e) => setForm({ ...form, mp4_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/videos/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Direct MP4 file for legacy browsers (optional)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tanggal & Waktu Webinar</Label>
|
<Label>Tanggal & Waktu Webinar</Label>
|
||||||
@@ -523,10 +589,10 @@ export default function AdminProducts() {
|
|||||||
<RadioGroupItem value="embed" id="embed" />
|
<RadioGroupItem value="embed" id="embed" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label htmlFor="embed" className="font-medium cursor-pointer">
|
<Label htmlFor="embed" className="font-medium cursor-pointer">
|
||||||
Custom Embed (Backup)
|
Adilo (Backup)
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Use custom embed codes (Adilo, Vimeo, etc.) for all lessons
|
Use Adilo M3U8/MP4 URLs for all lessons
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,7 +601,7 @@ export default function AdminProducts() {
|
|||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
This setting affects ALL lessons in this bootcamp. Configure both YouTube URLs and embed codes for each lesson in the curriculum editor. Use this toggle to switch between sources instantly.
|
This setting affects ALL lessons in this bootcamp. Configure video URLs for each lesson in the curriculum editor. Use this toggle to switch between YouTube and Adilo sources instantly.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical, ArrowLeft,
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
import { AppLayout } from '@/components/AppLayout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +19,11 @@ interface Module {
|
|||||||
position: number;
|
position: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
module_id: string;
|
module_id: string;
|
||||||
@@ -25,8 +32,12 @@ interface Lesson {
|
|||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
youtube_url: string | null;
|
youtube_url: string | null;
|
||||||
embed_code: string | null;
|
embed_code: string | null;
|
||||||
|
m3u8_url?: string | null;
|
||||||
|
mp4_url?: string | null;
|
||||||
|
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCurriculum() {
|
export default function ProductCurriculum() {
|
||||||
@@ -52,7 +63,11 @@ export default function ProductCurriculum() {
|
|||||||
video_url: '',
|
video_url: '',
|
||||||
youtube_url: '',
|
youtube_url: '',
|
||||||
embed_code: '',
|
embed_code: '',
|
||||||
|
m3u8_url: '',
|
||||||
|
mp4_url: '',
|
||||||
|
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||||
release_at: '',
|
release_at: '',
|
||||||
|
chapters: [] as VideoChapter[],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,7 +82,7 @@ export default function ProductCurriculum() {
|
|||||||
const [productRes, modulesRes, lessonsRes] = await Promise.all([
|
const [productRes, modulesRes, lessonsRes] = await Promise.all([
|
||||||
supabase.from('products').select('id, title, slug').eq('id', id).single(),
|
supabase.from('products').select('id, title, slug').eq('id', id).single(),
|
||||||
supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'),
|
supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'),
|
||||||
supabase.from('bootcamp_lessons').select('*').order('position'),
|
supabase.from('bootcamp_lessons').select('id, module_id, title, content, video_url, youtube_url, embed_code, m3u8_url, mp4_url, video_host, position, release_at, chapters').order('position'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (productRes.data) {
|
if (productRes.data) {
|
||||||
@@ -176,7 +191,11 @@ export default function ProductCurriculum() {
|
|||||||
video_url: '',
|
video_url: '',
|
||||||
youtube_url: '',
|
youtube_url: '',
|
||||||
embed_code: '',
|
embed_code: '',
|
||||||
|
m3u8_url: '',
|
||||||
|
mp4_url: '',
|
||||||
|
video_host: 'youtube',
|
||||||
release_at: '',
|
release_at: '',
|
||||||
|
chapters: [],
|
||||||
});
|
});
|
||||||
setSelectedModuleId(moduleId);
|
setSelectedModuleId(moduleId);
|
||||||
setSelectedLessonId('new');
|
setSelectedLessonId('new');
|
||||||
@@ -191,7 +210,11 @@ export default function ProductCurriculum() {
|
|||||||
video_url: lesson.video_url || '',
|
video_url: lesson.video_url || '',
|
||||||
youtube_url: lesson.youtube_url || '',
|
youtube_url: lesson.youtube_url || '',
|
||||||
embed_code: lesson.embed_code || '',
|
embed_code: lesson.embed_code || '',
|
||||||
|
m3u8_url: lesson.m3u8_url || '',
|
||||||
|
mp4_url: lesson.mp4_url || '',
|
||||||
|
video_host: lesson.video_host || 'youtube',
|
||||||
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
||||||
|
chapters: lesson.chapters || [],
|
||||||
});
|
});
|
||||||
setSelectedModuleId(lesson.module_id);
|
setSelectedModuleId(lesson.module_id);
|
||||||
setSelectedLessonId(lesson.id);
|
setSelectedLessonId(lesson.id);
|
||||||
@@ -212,7 +235,11 @@ export default function ProductCurriculum() {
|
|||||||
video_url: lessonForm.video_url || null,
|
video_url: lessonForm.video_url || null,
|
||||||
youtube_url: lessonForm.youtube_url || null,
|
youtube_url: lessonForm.youtube_url || null,
|
||||||
embed_code: lessonForm.embed_code || null,
|
embed_code: lessonForm.embed_code || null,
|
||||||
|
m3u8_url: lessonForm.m3u8_url || null,
|
||||||
|
mp4_url: lessonForm.mp4_url || null,
|
||||||
|
video_host: lessonForm.video_host || 'youtube',
|
||||||
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
||||||
|
chapters: lessonForm.chapters || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingLesson) {
|
if (editingLesson) {
|
||||||
@@ -543,50 +570,83 @@ export default function ProductCurriculum() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-2">
|
||||||
|
<Label>Video Host</Label>
|
||||||
|
<Select
|
||||||
|
value={lessonForm.video_host}
|
||||||
|
onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="border-2">
|
||||||
|
<SelectValue placeholder="Select video host" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="youtube">YouTube</SelectItem>
|
||||||
|
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* YouTube URL */}
|
||||||
|
{lessonForm.video_host === 'youtube' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>YouTube URL (Primary)</Label>
|
<Label>YouTube URL</Label>
|
||||||
<Input
|
<Input
|
||||||
value={lessonForm.youtube_url}
|
value={lessonForm.video_url}
|
||||||
onChange={(e) => setLessonForm({ ...lessonForm, youtube_url: e.target.value })}
|
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
||||||
placeholder="https://www.youtube.com/watch?v=..."
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
className="border-2"
|
className="border-2"
|
||||||
/>
|
/>
|
||||||
{lessonForm.youtube_url && (
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-xs text-green-600">✓ YouTube configured</p>
|
Paste YouTube URL here
|
||||||
)}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Adilo URLs */}
|
||||||
<Label>Release Date (optional)</Label>
|
{lessonForm.video_host === 'adilo' && (
|
||||||
<Input
|
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
|
||||||
type="date"
|
<div className="space-y-2">
|
||||||
value={lessonForm.release_at}
|
<Label>M3U8 URL (Primary)</Label>
|
||||||
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
|
<Input
|
||||||
className="border-2"
|
value={lessonForm.m3u8_url}
|
||||||
/>
|
onChange={(e) => setLessonForm({ ...lessonForm, m3u8_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/m3u8/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
HLS streaming URL from Adilo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>MP4 URL (Optional Fallback)</Label>
|
||||||
|
<Input
|
||||||
|
value={lessonForm.mp4_url}
|
||||||
|
onChange={(e) => setLessonForm({ ...lessonForm, mp4_url: e.target.value })}
|
||||||
|
placeholder="https://adilo.bigcommand.com/videos/..."
|
||||||
|
className="border-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Direct MP4 file for legacy browsers (optional)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Embed Code (Backup)</Label>
|
<Label>Release Date (optional)</Label>
|
||||||
<textarea
|
<Input
|
||||||
value={lessonForm.embed_code}
|
type="date"
|
||||||
onChange={(e) => setLessonForm({ ...lessonForm, embed_code: e.target.value })}
|
value={lessonForm.release_at}
|
||||||
placeholder="<iframe>...</iframe>"
|
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
|
||||||
rows={4}
|
className="border-2"
|
||||||
className="w-full px-3 py-2 border-2 border-border rounded-md font-mono text-sm"
|
|
||||||
/>
|
/>
|
||||||
{lessonForm.embed_code && (
|
|
||||||
<p className="text-xs text-green-600">✓ Embed code configured</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-muted border-2 border-border rounded-lg">
|
<ChaptersEditor
|
||||||
<p className="text-sm text-muted-foreground">
|
chapters={lessonForm.chapters || []}
|
||||||
💡 <strong>Tip:</strong> Configure both YouTube URL and embed code for redundancy. Use product settings to toggle between sources. This setting affects ALL lessons in the bootcamp.
|
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Content</Label>
|
<Label>Content</Label>
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ interface ConsultingSession {
|
|||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
status: string;
|
status: string;
|
||||||
recording_url: string | null;
|
|
||||||
topic_category: string | null;
|
topic_category: string | null;
|
||||||
|
meet_link: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberAccess() {
|
export default function MemberAccess() {
|
||||||
@@ -73,7 +73,7 @@ export default function MemberAccess() {
|
|||||||
// Get completed consulting sessions with recordings
|
// Get completed consulting sessions with recordings
|
||||||
supabase
|
supabase
|
||||||
.from('consulting_slots')
|
.from('consulting_slots')
|
||||||
.select('id, date, start_time, end_time, status, recording_url, topic_category')
|
.select('id, date, start_time, end_time, status, topic_category, meet_link')
|
||||||
.eq('user_id', user!.id)
|
.eq('user_id', user!.id)
|
||||||
.eq('status', 'done')
|
.eq('status', 'done')
|
||||||
.order('date', { ascending: false }),
|
.order('date', { ascending: false }),
|
||||||
@@ -298,16 +298,16 @@ export default function MemberAccess() {
|
|||||||
<Clock className="w-4 h-4 ml-2" />
|
<Clock className="w-4 h-4 ml-2" />
|
||||||
<span>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</span>
|
<span>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</span>
|
||||||
</div>
|
</div>
|
||||||
{session.recording_url ? (
|
{session.meet_link ? (
|
||||||
<Button asChild className="shadow-sm">
|
<Button asChild className="shadow-sm" size="sm">
|
||||||
<a href={session.recording_url} target="_blank" rel="noopener noreferrer">
|
<a href={session.meet_link} target="_blank" rel="noopener noreferrer">
|
||||||
<Video className="w-4 h-4 mr-2" />
|
<Video className="w-4 h-4 mr-2" />
|
||||||
Tonton Rekaman
|
Rekam Sesi
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Badge className="bg-muted text-primary">Rekaman segera tersedia</Badge>
|
<Badge className="bg-muted text-primary">Selesai</Badge>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ interface ConsultingSlot {
|
|||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
status: string;
|
status: string;
|
||||||
product_id: string | null;
|
|
||||||
meet_link: string | null;
|
meet_link: string | null;
|
||||||
|
topic_category?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberDashboard() {
|
export default function MemberDashboard() {
|
||||||
@@ -144,7 +144,7 @@ export default function MemberDashboard() {
|
|||||||
// Fetch confirmed consulting slots for quick access
|
// Fetch confirmed consulting slots for quick access
|
||||||
supabase
|
supabase
|
||||||
.from("consulting_slots")
|
.from("consulting_slots")
|
||||||
.select("id, date, start_time, end_time, status, product_id, meet_link")
|
.select("id, date, start_time, end_time, status, meet_link, topic_category")
|
||||||
.eq("user_id", user!.id)
|
.eq("user_id", user!.id)
|
||||||
.eq("status", "confirmed")
|
.eq("status", "confirmed")
|
||||||
.order("date", { ascending: false }),
|
.order("date", { ascending: false }),
|
||||||
@@ -178,10 +178,9 @@ export default function MemberDashboard() {
|
|||||||
|
|
||||||
switch (item.product.type) {
|
switch (item.product.type) {
|
||||||
case "consulting": {
|
case "consulting": {
|
||||||
// Only show if user has a confirmed upcoming consulting slot for this product
|
// Only show if user has a confirmed upcoming consulting slot
|
||||||
const upcomingSlot = consultingSlots.find(
|
const upcomingSlot = consultingSlots.find(
|
||||||
(slot) =>
|
(slot) =>
|
||||||
slot.product_id === item.product.id &&
|
|
||||||
slot.status === "confirmed" &&
|
slot.status === "confirmed" &&
|
||||||
new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0))
|
new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0))
|
||||||
);
|
);
|
||||||
@@ -350,7 +349,7 @@ export default function MemberDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white" : "bg-amber-500 text-white"} rounded-full>
|
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white rounded-full" : "bg-amber-500 text-white rounded-full"}>
|
||||||
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||||
|
|||||||
37
supabase/migrations/20250101000001_adilo_video_support.sql
Normal file
37
supabase/migrations/20250101000001_adilo_video_support.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- Add Adilo video columns to products table (webinars)
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN IF NOT EXISTS m3u8_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS mp4_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS video_host TEXT DEFAULT 'youtube',
|
||||||
|
ADD COLUMN IF NOT EXISTS adilo_video_id TEXT;
|
||||||
|
|
||||||
|
-- Add Adilo video columns to bootcamp_lessons table
|
||||||
|
ALTER TABLE bootcamp_lessons
|
||||||
|
ADD COLUMN IF NOT EXISTS m3u8_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS mp4_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS video_host TEXT DEFAULT 'youtube',
|
||||||
|
ADD COLUMN IF NOT EXISTS adilo_video_id TEXT;
|
||||||
|
|
||||||
|
-- Add constraint to ensure valid video hosts
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD CONSTRAINT products_video_host_check
|
||||||
|
CHECK (video_host IN ('youtube', 'adilo'));
|
||||||
|
|
||||||
|
ALTER TABLE bootcamp_lessons
|
||||||
|
ADD CONSTRAINT bootcamp_lessons_video_host_check
|
||||||
|
CHECK (video_host IN ('youtube', 'adilo'));
|
||||||
|
|
||||||
|
-- Create indexes for faster queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_video_host ON products(video_host);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bootcamp_lessons_video_host ON bootcamp_lessons(video_host);
|
||||||
|
|
||||||
|
-- Comments for documentation
|
||||||
|
COMMENT ON COLUMN products.m3u8_url IS 'M3U8 streaming URL from Adilo for HLS playback';
|
||||||
|
COMMENT ON COLUMN products.mp4_url IS 'MP4 fallback URL from Adilo for direct download/legacy browsers';
|
||||||
|
COMMENT ON COLUMN products.video_host IS 'Video hosting platform: youtube or adilo';
|
||||||
|
COMMENT ON COLUMN products.adilo_video_id IS 'Adilo video identifier for API reference';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN bootcamp_lessons.m3u8_url IS 'M3U8 streaming URL from Adilo for HLS playback';
|
||||||
|
COMMENT ON COLUMN bootcamp_lessons.mp4_url IS 'MP4 fallback URL from Adilo for direct download/legacy browsers';
|
||||||
|
COMMENT ON COLUMN bootcamp_lessons.video_host IS 'Video hosting platform: youtube or adilo';
|
||||||
|
COMMENT ON COLUMN bootcamp_lessons.adilo_video_id IS 'Adilo video identifier for API reference';
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
-- Fix consulting_slots table: ensure user_id column exists, backfill from orders, and add RLS policies
|
||||||
|
-- This fixes the 400 error when members try to fetch their consulting slots
|
||||||
|
|
||||||
|
-- Add user_id column if it doesn't exist
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'consulting_slots'
|
||||||
|
AND column_name = 'user_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE consulting_slots
|
||||||
|
ADD COLUMN user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
RAISE NOTICE 'user_id column added to consulting_slots';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'user_id column already exists in consulting_slots';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Backfill user_id from orders for existing records
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
backfill_count INTEGER;
|
||||||
|
null_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Count NULL user_ids before backfill
|
||||||
|
SELECT COUNT(*) INTO null_count FROM consulting_slots WHERE user_id IS NULL;
|
||||||
|
RAISE NOTICE 'Found % consulting_slots with NULL user_id', null_count;
|
||||||
|
|
||||||
|
-- Backfill from orders
|
||||||
|
UPDATE consulting_slots cs
|
||||||
|
SET user_id = o.user_id
|
||||||
|
FROM orders o
|
||||||
|
WHERE cs.order_id = o.id
|
||||||
|
AND cs.user_id IS NULL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS backfill_count = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'Backfilled user_id for % consulting_slots from orders', backfill_count;
|
||||||
|
|
||||||
|
-- Check remaining NULLs
|
||||||
|
SELECT COUNT(*) INTO null_count FROM consulting_slots WHERE user_id IS NULL;
|
||||||
|
RAISE NOTICE 'Remaining consulting_slots with NULL user_id: %', null_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Create index for faster queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consulting_slots_user_id ON consulting_slots(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consulting_slots_user_status ON consulting_slots(user_id, status);
|
||||||
|
|
||||||
|
-- Enable RLS on consulting_slots (if not already enabled)
|
||||||
|
ALTER TABLE consulting_slots ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Drop ALL existing policies first to avoid conflicts
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_select_own" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_insert_own" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_update_own" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_select_all" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_service_role" ON consulting_slots;
|
||||||
|
|
||||||
|
-- Create RLS policies for consulting_slots
|
||||||
|
-- Policy for users to see their own slots
|
||||||
|
CREATE POLICY "consulting_slots_select_own"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Policy for users to insert their own slots
|
||||||
|
CREATE POLICY "consulting_slots_insert_own"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Policy for users to update their own slots
|
||||||
|
CREATE POLICY "consulting_slots_update_own"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Policy for service role (admins) to access all slots
|
||||||
|
CREATE POLICY "consulting_slots_service_role"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Grant permissions
|
||||||
|
GRANT USAGE ON SCHEMA public TO service_role;
|
||||||
|
GRANT ALL ON consulting_slots TO service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON consulting_slots TO authenticated;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- Clean up ALL consulting_slots RLS policies and recreate with simple working policies
|
||||||
|
-- This fixes the 400 error caused by conflicting policies using has_role() function
|
||||||
|
|
||||||
|
-- Drop ALL existing policies (including the problematic ones with has_role)
|
||||||
|
DROP POLICY IF EXISTS "Users see own slots" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "Admin manage slots" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "Users create own slots" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_select_own" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_insert_own" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_update_own" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_select_all" ON consulting_slots;
|
||||||
|
DROP POLICY IF EXISTS "consulting_slots_service_role" ON consulting_slots;
|
||||||
|
|
||||||
|
-- Create simple, working policies
|
||||||
|
-- Users can see their own consulting slots
|
||||||
|
CREATE POLICY "Users can view own consulting slots"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Users can insert their own consulting slots
|
||||||
|
CREATE POLICY "Users can insert own consulting slots"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Users can update their own consulting slots
|
||||||
|
CREATE POLICY "Users can update own consulting slots"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Users can delete their own consulting slots
|
||||||
|
CREATE POLICY "Users can delete own consulting slots"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Service role (for edge functions and admin operations) can do everything
|
||||||
|
CREATE POLICY "Service role full access"
|
||||||
|
ON consulting_slots
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
59
supabase/migrations/20250101000004_video_progress.sql
Normal file
59
supabase/migrations/20250101000004_video_progress.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- Video progress tracking table
|
||||||
|
-- Stores user's playback position for lessons and webinars
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS video_progress (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
video_id TEXT NOT NULL, -- lesson_id or webinar/product_id
|
||||||
|
video_type TEXT NOT NULL CHECK (video_type IN ('lesson', 'webinar')),
|
||||||
|
last_position DECIMAL(10,2) NOT NULL DEFAULT 0, -- seconds
|
||||||
|
total_duration DECIMAL(10,2), -- total video duration in seconds
|
||||||
|
completed BOOLEAN DEFAULT FALSE,
|
||||||
|
last_watched_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, video_id, video_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for fast queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_progress_user ON video_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_progress_user_type ON video_progress(user_id, video_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_progress_completed ON video_progress(user_id, completed) WHERE completed = TRUE;
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE video_progress ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Drop existing policies if any
|
||||||
|
DROP POLICY IF EXISTS "Users manage own progress" ON video_progress;
|
||||||
|
DROP POLICY IF EXISTS "Service role full access" ON video_progress;
|
||||||
|
|
||||||
|
-- Users can manage their own progress
|
||||||
|
CREATE POLICY "Users manage own progress"
|
||||||
|
ON video_progress
|
||||||
|
FOR ALL
|
||||||
|
TO authenticated
|
||||||
|
USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Service role has full access
|
||||||
|
CREATE POLICY "Service role full access"
|
||||||
|
ON video_progress
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Function to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_video_progress_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to auto-update updated_at
|
||||||
|
DROP TRIGGER IF EXISTS update_video_progress_updated_at ON video_progress;
|
||||||
|
CREATE TRIGGER update_video_progress_updated_at
|
||||||
|
BEFORE UPDATE ON video_progress
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_video_progress_updated_at();
|
||||||
Reference in New Issue
Block a user