feat: Add useMediaPrefs composable for persistent media playback controls and introduce the classroom learning page.

This commit is contained in:
supalerk-ar66 2026-01-29 14:02:32 +07:00
parent 4c575dc734
commit b59eac1388
2 changed files with 247 additions and 58 deletions

View file

@ -18,7 +18,10 @@ useHead({
const route = useRoute()
const { t } = useI18n()
const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, markLessonComplete, checkLessonAccess, fetchVideoProgress } = useCourse()
// Media Prefs (Global Volume)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs()
// State
const sidebarOpen = ref(false)
@ -151,13 +154,27 @@ const loadLesson = async (lessonId: number) => {
// 2. Fetch Initial Progress (Resume Playback)
if (currentLesson.value.type === 'VIDEO') {
// A. Server Progress
const progressRes = await fetchVideoProgress(lessonId)
let serverProgress = 0
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
serverProgress = progressRes.data.video_progress_seconds
}
// B. Local Progress (Buffer)
const localProgress = getLocalProgress(lessonId)
// C. Hybrid Resume (Max Wins)
const resumeTime = Math.max(serverProgress, localProgress)
if (resumeTime > 0) {
console.log(`Resuming at: ${resumeTime}s (Server: ${serverProgress}, Local: ${localProgress})`)
initialSeekTime.value = resumeTime
maxWatchedTime.value = resumeTime
currentTime.value = resumeTime
} else {
initialSeekTime.value = 0
maxWatchedTime.value = 0
}
}
}
@ -170,21 +187,52 @@ const loadLesson = async (lessonId: number) => {
// Video & Progress State
const initialSeekTime = ref(0)
const maxWatchedTime = ref(0) // Anti-rewind monotonic tracking
const lastSavedTime = ref(-1)
const lastSavedTimestamp = ref(0)
const lastSavedTimestamp = ref(0) // Server throttle timestamp
const lastLocalSaveTimestamp = ref(0) // Local throttle timestamp
// Helper: Get Local Storage Key
const getLocalProgressKey = (lessonId: number) => {
if (!user.value?.id) return null
return `progress:${user.value.id}:${lessonId}`
}
// Helper: Get Local Progress
const getLocalProgress = (lessonId: number): number => {
try {
const key = getLocalProgressKey(lessonId)
if (!key) return 0
const stored = localStorage.getItem(key)
return stored ? parseFloat(stored) : 0
} catch (e) {
return 0
}
}
// Helper: Save to Local Storage
const saveLocalProgress = (lessonId: number, time: number) => {
try {
const key = getLocalProgressKey(lessonId)
if (key) {
localStorage.setItem(key, time.toString())
}
} catch (e) {
// Ignore storage errors
}
}
const onVideoMetadataLoaded = () => {
if (videoRef.value) {
// Bind Media Preferences (Volume/Mute)
applyTo(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
}
}
@ -196,32 +244,32 @@ const togglePlay = () => {
}
// -----------------------------------------------------
// ROBUST PROGRESS SAVING SYSTEM
// ROBUST PROGRESS SAVING SYSTEM (Hybrid: Local + Server)
// -----------------------------------------------------
// Main Save Function
// Main Server 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 maxSec = Math.floor(maxWatchedTime.value) // Use max watched time
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
// 1. Validation: Don't save if progress hasn't increased (Monotonic Check)
if (!force && maxSec <= lastSavedTime.value) return
// 2. Validation: Throttle (Rate Limit) - e.g. every 10 seconds
if (!force && (now - lastSavedTimestamp.value < 10000)) return
// 2. Validation: Server Throttle (15 seconds)
if (!force && (now - lastSavedTimestamp.value < 15000)) return
// Save
lastSavedTime.value = currentSec
lastSavedTime.value = maxSec
lastSavedTimestamp.value = now
console.log(`Saving progress: ${currentSec}/${durationSec} (KeepAlive: ${keepalive})`)
console.log(`Saving Server: ${maxSec}/${durationSec} (KeepAlive: ${keepalive})`)
const res = await saveVideoProgress(currentLesson.value.id, currentSec, durationSec, keepalive)
const res = await saveVideoProgress(currentLesson.value.id, maxSec, durationSec, keepalive)
// Check completion from backend logic
// Check completion
if (res.success && res.data?.is_completed) {
markLessonAsCompletedLocally(currentLesson.value.id)
}
@ -249,56 +297,40 @@ const updateProgress = () => {
duration.value = videoRef.value.duration
videoProgress.value = (currentTime.value / duration.value) * 100
// Logic: Periodic Save (Throttle applied inside)
if (isPlaying.value) {
// Update Monotonic Progress
if (currentTime.value > maxWatchedTime.value) {
maxWatchedTime.value = currentTime.value
}
// Logic: Periodic Save
if (isPlaying.value && currentLesson.value?.id) {
const now = Date.now()
// 1. Local Save Throttle (5 seconds)
if (now - lastLocalSaveTimestamp.value > 5000) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
lastLocalSaveTimestamp.value = now
}
// 2. Server Save Throttle (15 seconds) checked inside function
performSaveProgress(false, false)
}
}
// Volume Controls
const volume = ref(1)
const isMuted = ref(false)
const previousVolume = ref(1)
// Volume Controls Logic replaced by useMediaPrefs
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 handleToggleMute = () => {
setMuted(!isMuted.value)
}
const onVolumeChange = (val: any) => {
// Handle both input event (event.target.value) or direct value if using q-slider
const handleVolumeChange = (val: any) => {
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
}
setVolume(newVol)
}
const videoSrc = computed(() => {
@ -335,18 +367,27 @@ onBeforeUnmount(() => {
}
// Final save attempt when component destroys
if (currentLesson.value?.id) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
}
performSaveProgress(true, true)
})
const handleVisibilityChange = () => {
if (document.hidden) {
console.log('Tab hidden, saving progress...')
if (currentLesson.value?.id) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
}
performSaveProgress(true, true) // Force save with keepalive
}
}
const handlePageHide = () => {
// Triggered on page refresh, close tab, or navigation
if (currentLesson.value?.id) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
}
performSaveProgress(true, true)
}
@ -354,6 +395,9 @@ const handlePageHide = () => {
watch(isPlaying, (playing) => {
if (!playing) {
// Paused: Save immediately
if (currentLesson.value?.id) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
}
performSaveProgress(true, false)
}
})
@ -511,7 +555,7 @@ onBeforeUnmount(() => {
<!-- Volume Control -->
<div class="flex items-center gap-2 group/volume">
<q-btn flat round dense :icon="volumeIcon" @click.stop="toggleMute" color="white" />
<q-btn flat round dense :icon="volumeIcon" @click.stop="handleToggleMute" color="white" />
<div class="w-0 group-hover/volume:w-20 overflow-hidden transition-all duration-300 flex items-center">
<input
type="range"
@ -519,7 +563,7 @@ onBeforeUnmount(() => {
max="1"
step="0.1"
:value="volume"
@input="onVolumeChange"
@input="handleVolumeChange"
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>