feat: Implement e-learning classroom with video playback, progress tracking, and quiz functionality, alongside new course and category composables and Thai localization.
This commit is contained in:
parent
9232b6a21d
commit
4c575dc734
5 changed files with 570 additions and 169 deletions
|
|
@ -137,34 +137,27 @@ const loadLesson = async (lessonId: number) => {
|
|||
if (res.success) {
|
||||
currentLesson.value = res.data
|
||||
|
||||
// 2. Fetch progress separately (New Flow)
|
||||
// Note: fetchLessonContent might return progress in some APIs, but here we use dedicated endpoint as requested
|
||||
const progressRes = await fetchVideoProgress(lessonId)
|
||||
|
||||
if (progressRes.success && progressRes.data) {
|
||||
const p = progressRes.data
|
||||
|
||||
// Restore video time
|
||||
if (p.video_progress_seconds > 0) {
|
||||
currentTime.value = p.video_progress_seconds
|
||||
// Force update video element if ready, otherwise onVideoMetadataLoaded will handle it
|
||||
if (videoRef.value) {
|
||||
videoRef.value.currentTime = currentTime.value
|
||||
}
|
||||
}
|
||||
|
||||
// Check if completed to update UI
|
||||
if (p.is_completed) {
|
||||
if (courseData.value) {
|
||||
for (const chapter of courseData.value.chapters) {
|
||||
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
|
||||
if (lesson) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update Lesson Completion UI status safely
|
||||
if (currentLesson.value?.progress?.is_completed && courseData.value) {
|
||||
for (const chapter of courseData.value.chapters) {
|
||||
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
|
||||
if (lesson) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Initial Progress (Resume Playback)
|
||||
if (currentLesson.value.type === 'VIDEO') {
|
||||
const progressRes = await fetchVideoProgress(lessonId)
|
||||
if (progressRes.success && progressRes.data?.video_progress_seconds) {
|
||||
console.log('Resuming at:', progressRes.data.video_progress_seconds)
|
||||
initialSeekTime.value = progressRes.data.video_progress_seconds
|
||||
currentTime.value = initialSeekTime.value // Update UI immediately
|
||||
} else {
|
||||
initialSeekTime.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,12 +168,23 @@ const loadLesson = async (lessonId: number) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Video & Progress State
|
||||
const initialSeekTime = ref(0)
|
||||
const lastSavedTime = ref(-1)
|
||||
const lastSavedTimestamp = ref(0)
|
||||
|
||||
const onVideoMetadataLoaded = () => {
|
||||
if (videoRef.value && currentLesson.value) {
|
||||
// Restore time if needed
|
||||
if (currentTime.value > 0) {
|
||||
videoRef.value.currentTime = currentTime.value
|
||||
if (videoRef.value) {
|
||||
// Resume playback if we have a saved position
|
||||
if (initialSeekTime.value > 0) {
|
||||
// Ensure we don't seek past duration
|
||||
const seekTo = Math.min(initialSeekTime.value, videoRef.value.duration || Infinity)
|
||||
videoRef.value.currentTime = seekTo
|
||||
}
|
||||
|
||||
// Sync volume settings
|
||||
videoRef.value.volume = volume.value
|
||||
videoRef.value.muted = isMuted.value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,14 +195,110 @@ const togglePlay = () => {
|
|||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// ROBUST PROGRESS SAVING SYSTEM
|
||||
// -----------------------------------------------------
|
||||
|
||||
// Main Save Function
|
||||
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
|
||||
if (!videoRef.value || !currentLesson.value || currentLesson.value.type !== 'VIDEO') return
|
||||
|
||||
const now = Date.now()
|
||||
const currentSec = Math.floor(videoRef.value.currentTime)
|
||||
const durationSec = Math.floor(videoRef.value.duration || 0)
|
||||
|
||||
// 1. Validation: Don't save if time hasn't changed (SPAM Protection)
|
||||
if (!force && currentSec === lastSavedTime.value) return
|
||||
|
||||
// 2. Validation: Throttle (Rate Limit) - e.g. every 10 seconds
|
||||
if (!force && (now - lastSavedTimestamp.value < 10000)) return
|
||||
|
||||
// Save
|
||||
lastSavedTime.value = currentSec
|
||||
lastSavedTimestamp.value = now
|
||||
|
||||
console.log(`Saving progress: ${currentSec}/${durationSec} (KeepAlive: ${keepalive})`)
|
||||
|
||||
const res = await saveVideoProgress(currentLesson.value.id, currentSec, durationSec, keepalive)
|
||||
|
||||
// Check completion from backend logic
|
||||
if (res.success && res.data?.is_completed) {
|
||||
markLessonAsCompletedLocally(currentLesson.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update Sidebar UI
|
||||
const markLessonAsCompletedLocally = (lessonId: number) => {
|
||||
if (courseData.value) {
|
||||
for (const chapter of courseData.value.chapters) {
|
||||
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
|
||||
if (lesson) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateProgress = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
// UI Update
|
||||
currentTime.value = videoRef.value.currentTime
|
||||
duration.value = videoRef.value.duration
|
||||
videoProgress.value = (currentTime.value / duration.value) * 100
|
||||
|
||||
// Throttle save progress logic is handled in watcher or separate interval usually,
|
||||
// but let's check if we should save periodically here for simplicity or use specific events
|
||||
// Logic: Periodic Save (Throttle applied inside)
|
||||
if (isPlaying.value) {
|
||||
performSaveProgress(false, false)
|
||||
}
|
||||
}
|
||||
|
||||
// Volume Controls
|
||||
const volume = ref(1)
|
||||
const isMuted = ref(false)
|
||||
const previousVolume = ref(1)
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (isMuted.value || volume.value === 0) return 'volume_off'
|
||||
if (volume.value < 0.5) return 'volume_down'
|
||||
return 'volume_up'
|
||||
})
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!videoRef.value) return
|
||||
if (isMuted.value) {
|
||||
// Unmute
|
||||
isMuted.value = false
|
||||
volume.value = previousVolume.value || 1
|
||||
videoRef.value.muted = false
|
||||
videoRef.value.volume = volume.value
|
||||
} else {
|
||||
// Mute
|
||||
previousVolume.value = volume.value
|
||||
isMuted.value = true
|
||||
volume.value = 0
|
||||
videoRef.value.muted = true
|
||||
videoRef.value.volume = 0
|
||||
}
|
||||
}
|
||||
|
||||
const onVolumeChange = (val: any) => {
|
||||
// Handle both input event (event.target.value) or direct value if using q-slider
|
||||
const newVol = typeof val === 'number' ? val : Number(val.target.value)
|
||||
|
||||
if (!videoRef.value) return
|
||||
volume.value = newVol
|
||||
videoRef.value.volume = newVol
|
||||
|
||||
if (newVol > 0 && isMuted.value) {
|
||||
isMuted.value = false
|
||||
videoRef.value.muted = false
|
||||
} else if (newVol === 0 && !isMuted.value) {
|
||||
isMuted.value = true
|
||||
videoRef.value.muted = true
|
||||
}
|
||||
}
|
||||
|
||||
const videoSrc = computed(() => {
|
||||
|
|
@ -219,40 +319,42 @@ const videoSrc = computed(() => {
|
|||
// ==========================================
|
||||
// บันทึกอัตโนมัติทุกๆ 10 วินาทีเมื่อเล่นวิดีโอ
|
||||
// บันทึกอัตโนมัติทุกๆ 10 วินาทีเมื่อเล่นวิดีโอ
|
||||
watch(() => isPlaying.value, (playing) => {
|
||||
if (playing) {
|
||||
saveProgressInterval.value = setInterval(async () => {
|
||||
if (videoRef.value && currentLesson.value) {
|
||||
// Send integers to avoid potential backend float issues
|
||||
const res = await saveVideoProgress(
|
||||
currentLesson.value.id,
|
||||
Math.floor(videoRef.value.currentTime),
|
||||
Math.floor(videoRef.value.duration || 0)
|
||||
)
|
||||
|
||||
// Update local completion state if backend reports completed
|
||||
if (res.success && res.data?.is_completed && courseData.value) {
|
||||
for (const chapter of courseData.value.chapters) {
|
||||
const lesson = chapter.lessons.find((l: any) => l.id === currentLesson.value.id)
|
||||
if (lesson) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 10000) // Every 10 seconds
|
||||
} else {
|
||||
clearInterval(saveProgressInterval.value)
|
||||
// Save one last time on pause
|
||||
if (videoRef.value && currentLesson.value) {
|
||||
saveVideoProgress(
|
||||
currentLesson.value.id,
|
||||
Math.floor(videoRef.value.currentTime),
|
||||
Math.floor(videoRef.value.duration || 0)
|
||||
)
|
||||
}
|
||||
// Event Listeners for Robustness
|
||||
onMounted(() => {
|
||||
// Page/Tab Visibility Logic
|
||||
if (import.meta.client) {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('pagehide', handlePageHide)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pagehide', handlePageHide)
|
||||
}
|
||||
|
||||
// Final save attempt when component destroys
|
||||
performSaveProgress(true, true)
|
||||
})
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
console.log('Tab hidden, saving progress...')
|
||||
performSaveProgress(true, true) // Force save with keepalive
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageHide = () => {
|
||||
// Triggered on page refresh, close tab, or navigation
|
||||
performSaveProgress(true, true)
|
||||
}
|
||||
|
||||
// Watch Video Events
|
||||
watch(isPlaying, (playing) => {
|
||||
if (!playing) {
|
||||
// Paused: Save immediately
|
||||
performSaveProgress(true, false)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -260,40 +362,19 @@ watch(() => isPlaying.value, (playing) => {
|
|||
// เมื่อวิดีโอจบ ให้บันทึกว่าเรียนจบ (Complete)
|
||||
const onVideoEnded = async () => {
|
||||
isPlaying.value = false
|
||||
console.log('Video Ended')
|
||||
// Force save progress at 100%
|
||||
await performSaveProgress(true, false)
|
||||
|
||||
// Call explicit complete endpoint if exists
|
||||
if (currentLesson.value) {
|
||||
const res = await markLessonComplete(courseId.value, currentLesson.value.id)
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 1. Update UI tick
|
||||
if (courseData.value) {
|
||||
for (const chapter of courseData.value.chapters) {
|
||||
const lesson = chapter.lessons.find((l: any) => l.id === currentLesson.value.id)
|
||||
if (lesson) {
|
||||
if (!lesson.progress) lesson.progress = {}
|
||||
lesson.progress.is_completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Play Next Lesson (if available)
|
||||
if (res.data.next_lesson_id) {
|
||||
// Optional: Add auto-play user preference check or delay
|
||||
// For now, let's just show a notification or auto-load after small delay
|
||||
// handleLessonSelect(res.data.next_lesson_id)
|
||||
|
||||
// Or just let user click next
|
||||
}
|
||||
|
||||
// 3. Handle Course Completion
|
||||
if (res.success) {
|
||||
markLessonAsCompletedLocally(currentLesson.value.id)
|
||||
if (res.data.is_course_completed) {
|
||||
// Maybe show a congratulation modal
|
||||
alert("ยินดีด้วย! คุณเรียนจบหลักสูตรแล้ว")
|
||||
}
|
||||
}
|
||||
|
||||
// Reload course data to ensure everything is synced (optional but safer)
|
||||
// await loadCourseData()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -423,10 +504,26 @@ onBeforeUnmount(() => {
|
|||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent transition-opacity opacity-0 group-hover:opacity-100">
|
||||
<div class="flex items-center gap-4 text-white">
|
||||
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" />
|
||||
<div class="relative flex-grow h-1 bg-white/30 rounded cursor-pointer" @click="seek">
|
||||
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded" :style="{ width: videoProgress + '%' }"></div>
|
||||
<div class="relative flex-grow h-1 bg-white/30 rounded cursor-pointer group/progress" @click="seek">
|
||||
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded group-hover/progress:h-1.5 transition-all" :style="{ width: videoProgress + '%' }"></div>
|
||||
</div>
|
||||
<span class="text-xs font-mono">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<div class="flex items-center gap-2 group/volume">
|
||||
<q-btn flat round dense :icon="volumeIcon" @click.stop="toggleMute" color="white" />
|
||||
<div class="w-0 group-hover/volume:w-20 overflow-hidden transition-all duration-300 flex items-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
:value="volume"
|
||||
@input="onVolumeChange"
|
||||
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -438,9 +535,27 @@ onBeforeUnmount(() => {
|
|||
|
||||
<p class="text-slate-700 dark:text-slate-300 text-base md:text-lg" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
|
||||
|
||||
<!-- Lesson Content Area -->
|
||||
<!-- Lesson Content Area (Text/HTML) -->
|
||||
<div v-if="currentLesson.content" class="mt-6 prose dark:prose-invert max-w-none p-6 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)]">
|
||||
<div v-if="currentLesson.type === 'QUIZ'" class="mt-6 p-8 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)] text-center">
|
||||
<q-icon name="quiz" size="4rem" color="primary" class="mb-4" />
|
||||
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบ') }}</h2>
|
||||
<p class="text-slate-500 mb-6">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
|
||||
|
||||
<div class="flex justify-center gap-4 text-sm text-slate-500 mb-8">
|
||||
<span v-if="currentLesson.quiz?.questions?.length"><q-icon name="format_list_numbered" /> {{ currentLesson.quiz.questions.length }} ข้อ</span>
|
||||
<span v-if="currentLesson.quiz?.time_limit"><q-icon name="schedule" /> {{ currentLesson.quiz.time_limit }} นาที</span>
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
color="primary"
|
||||
size="lg"
|
||||
rounded
|
||||
:label="$t('quiz.startBtn', 'เริ่มทำแบบทดสอบ')"
|
||||
icon="play_arrow"
|
||||
@click="$router.push(`/classroom/quiz?course_id=${courseId}&lesson_id=${currentLesson.id}`)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="currentLesson.content" class="mt-6 prose dark:prose-invert max-w-none p-6 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)]">
|
||||
<div v-html="getLocalizedText(currentLesson.content)" class="text-base md:text-lg leading-relaxed text-slate-900 dark:text-slate-200"></div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue