From b59eac1388a0603e97fa20d03529dbd75d39d472 Mon Sep 17 00:00:00 2001 From: supalerk-ar66 Date: Thu, 29 Jan 2026 14:02:32 +0700 Subject: [PATCH] feat: Add `useMediaPrefs` composable for persistent media playback controls and introduce the classroom learning page. --- Frontend-Learner/composables/useMediaPrefs.ts | 145 ++++++++++++++++ Frontend-Learner/pages/classroom/learning.vue | 160 +++++++++++------- 2 files changed, 247 insertions(+), 58 deletions(-) create mode 100644 Frontend-Learner/composables/useMediaPrefs.ts diff --git a/Frontend-Learner/composables/useMediaPrefs.ts b/Frontend-Learner/composables/useMediaPrefs.ts new file mode 100644 index 00000000..2bed3024 --- /dev/null +++ b/Frontend-Learner/composables/useMediaPrefs.ts @@ -0,0 +1,145 @@ +export const useMediaPrefs = () => { + // 1. Global State + // ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วทั้ง App (เช่น เปลี่ยนหน้าแล้วเสียงยังเท่าเดิม) + const volume = useState('media_prefs_volume', () => 1) + const muted = useState('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 | 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 + } +} diff --git a/Frontend-Learner/pages/classroom/learning.vue b/Frontend-Learner/pages/classroom/learning.vue index 51e99cdf..ff6c0ab2 100644 --- a/Frontend-Learner/pages/classroom/learning.vue +++ b/Frontend-Learner/pages/classroom/learning.vue @@ -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(() => {
- +
{ 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" />