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

@ -0,0 +1,145 @@
export const useMediaPrefs = () => {
// 1. Global State
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วทั้ง App (เช่น เปลี่ยนหน้าแล้วเสียงยังเท่าเดิม)
const volume = useState<number>('media_prefs_volume', () => 1)
const muted = useState<boolean>('media_prefs_muted', () => false)
const { user } = useAuth()
// 2. Storage Key Helper (User Specific)
const getStorageKey = () => {
const userId = user.value?.id || 'guest'
return `media:prefs:v1:${userId}`
}
// 3. Save Logic (Throttled)
let saveTimeout: ReturnType<typeof setTimeout> | null = null
const save = () => {
if (import.meta.server) return
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
try {
const key = getStorageKey()
const data = {
volume: volume.value,
muted: muted.value,
updatedAt: Date.now()
}
localStorage.setItem(key, JSON.stringify(data))
} catch (e) {
console.error('Failed to save media prefs', e)
}
}, 500) // Throttle 500ms
}
// 4. Load Logic
const load = () => {
if (import.meta.server) return
try {
const key = getStorageKey()
const stored = localStorage.getItem(key)
if (stored) {
const parsed = JSON.parse(stored)
if (typeof parsed.volume === 'number') {
volume.value = Math.max(0, Math.min(1, parsed.volume))
}
if (typeof parsed.muted === 'boolean') {
muted.value = parsed.muted
}
}
} catch (e) {
console.error('Failed to load media prefs', e)
}
}
// 5. Setters (With Logic)
const setVolume = (val: number) => {
const clamped = Math.max(0, Math.min(1, val))
volume.value = clamped
// Auto unmute if volume increased from 0
if (clamped > 0 && muted.value) {
muted.value = false
}
// Auto mute if volume set to 0
if (clamped === 0 && !muted.value) {
muted.value = true
}
save()
}
const setMuted = (val: boolean) => {
muted.value = val
// Logic: Unmuting should restore volume if it was 0
if (!val && volume.value === 0) {
volume.value = 1
}
save()
}
// 6. Apply & Bind to Element (The Magic)
const applyTo = (el: HTMLMediaElement | null | undefined) => {
if (!el) return () => {}
// Initial Apply
el.volume = volume.value
el.muted = muted.value
// A. Watch State -> Update Element
const stopVolWatch = watch(volume, (v) => {
if (Math.abs(el.volume - v) > 0.01) el.volume = v
})
const stopMutedWatch = watch(muted, (m) => {
if (el.muted !== m) el.muted = m
})
// B. Listen Element -> Update State (e.g. Native Controls)
const onVolumeChange = () => {
// Update state only if diff allows (prevent loop)
if (Math.abs(el.volume - volume.value) > 0.01) {
volume.value = el.volume
save()
}
if (el.muted !== muted.value) {
muted.value = el.muted
save()
}
}
el.addEventListener('volumechange', onVolumeChange)
// Cleanup function
return () => {
stopVolWatch()
stopMutedWatch()
el.removeEventListener('volumechange', onVolumeChange)
}
}
// 7. Lifecycle & Sync
if (import.meta.client) {
onMounted(() => {
load()
// Cross-tab sync
window.addEventListener('storage', (e) => {
if (e.key === getStorageKey()) {
load()
}
})
})
}
return {
volume,
muted,
setVolume,
setMuted,
applyTo,
load
}
}

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>