feat: Add useMediaPrefs composable for persistent media playback controls and introduce the classroom learning page.
This commit is contained in:
parent
4c575dc734
commit
b59eac1388
2 changed files with 247 additions and 58 deletions
145
Frontend-Learner/composables/useMediaPrefs.ts
Normal file
145
Frontend-Learner/composables/useMediaPrefs.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,10 @@ useHead({
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { user } = useAuth()
|
||||||
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, markLessonComplete, checkLessonAccess, fetchVideoProgress } = useCourse()
|
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, markLessonComplete, checkLessonAccess, fetchVideoProgress } = useCourse()
|
||||||
|
// Media Prefs (Global Volume)
|
||||||
|
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
|
|
@ -151,13 +154,27 @@ const loadLesson = async (lessonId: number) => {
|
||||||
|
|
||||||
// 2. Fetch Initial Progress (Resume Playback)
|
// 2. Fetch Initial Progress (Resume Playback)
|
||||||
if (currentLesson.value.type === 'VIDEO') {
|
if (currentLesson.value.type === 'VIDEO') {
|
||||||
|
// A. Server Progress
|
||||||
const progressRes = await fetchVideoProgress(lessonId)
|
const progressRes = await fetchVideoProgress(lessonId)
|
||||||
|
let serverProgress = 0
|
||||||
if (progressRes.success && progressRes.data?.video_progress_seconds) {
|
if (progressRes.success && progressRes.data?.video_progress_seconds) {
|
||||||
console.log('Resuming at:', progressRes.data.video_progress_seconds)
|
serverProgress = progressRes.data.video_progress_seconds
|
||||||
initialSeekTime.value = progressRes.data.video_progress_seconds
|
}
|
||||||
currentTime.value = initialSeekTime.value // Update UI immediately
|
|
||||||
|
// 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 {
|
} else {
|
||||||
initialSeekTime.value = 0
|
initialSeekTime.value = 0
|
||||||
|
maxWatchedTime.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,21 +187,52 @@ const loadLesson = async (lessonId: number) => {
|
||||||
|
|
||||||
// Video & Progress State
|
// Video & Progress State
|
||||||
const initialSeekTime = ref(0)
|
const initialSeekTime = ref(0)
|
||||||
|
const maxWatchedTime = ref(0) // Anti-rewind monotonic tracking
|
||||||
const lastSavedTime = ref(-1)
|
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 = () => {
|
const onVideoMetadataLoaded = () => {
|
||||||
if (videoRef.value) {
|
if (videoRef.value) {
|
||||||
|
// Bind Media Preferences (Volume/Mute)
|
||||||
|
applyTo(videoRef.value)
|
||||||
|
|
||||||
// Resume playback if we have a saved position
|
// Resume playback if we have a saved position
|
||||||
if (initialSeekTime.value > 0) {
|
if (initialSeekTime.value > 0) {
|
||||||
// Ensure we don't seek past duration
|
// Ensure we don't seek past duration
|
||||||
const seekTo = Math.min(initialSeekTime.value, videoRef.value.duration || Infinity)
|
const seekTo = Math.min(initialSeekTime.value, videoRef.value.duration || Infinity)
|
||||||
videoRef.value.currentTime = seekTo
|
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) => {
|
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
|
||||||
if (!videoRef.value || !currentLesson.value || currentLesson.value.type !== 'VIDEO') return
|
if (!videoRef.value || !currentLesson.value || currentLesson.value.type !== 'VIDEO') return
|
||||||
|
|
||||||
const now = Date.now()
|
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)
|
const durationSec = Math.floor(videoRef.value.duration || 0)
|
||||||
|
|
||||||
// 1. Validation: Don't save if time hasn't changed (SPAM Protection)
|
// 1. Validation: Don't save if progress hasn't increased (Monotonic Check)
|
||||||
if (!force && currentSec === lastSavedTime.value) return
|
if (!force && maxSec <= lastSavedTime.value) return
|
||||||
|
|
||||||
// 2. Validation: Throttle (Rate Limit) - e.g. every 10 seconds
|
// 2. Validation: Server Throttle (15 seconds)
|
||||||
if (!force && (now - lastSavedTimestamp.value < 10000)) return
|
if (!force && (now - lastSavedTimestamp.value < 15000)) return
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
lastSavedTime.value = currentSec
|
lastSavedTime.value = maxSec
|
||||||
lastSavedTimestamp.value = now
|
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) {
|
if (res.success && res.data?.is_completed) {
|
||||||
markLessonAsCompletedLocally(currentLesson.value.id)
|
markLessonAsCompletedLocally(currentLesson.value.id)
|
||||||
}
|
}
|
||||||
|
|
@ -249,56 +297,40 @@ const updateProgress = () => {
|
||||||
duration.value = videoRef.value.duration
|
duration.value = videoRef.value.duration
|
||||||
videoProgress.value = (currentTime.value / duration.value) * 100
|
videoProgress.value = (currentTime.value / duration.value) * 100
|
||||||
|
|
||||||
// Logic: Periodic Save (Throttle applied inside)
|
// Update Monotonic Progress
|
||||||
if (isPlaying.value) {
|
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)
|
performSaveProgress(false, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume Controls
|
// Volume Controls Logic replaced by useMediaPrefs
|
||||||
const volume = ref(1)
|
|
||||||
const isMuted = ref(false)
|
|
||||||
const previousVolume = ref(1)
|
|
||||||
|
|
||||||
const volumeIcon = computed(() => {
|
const volumeIcon = computed(() => {
|
||||||
if (isMuted.value || volume.value === 0) return 'volume_off'
|
if (isMuted.value || volume.value === 0) return 'volume_off'
|
||||||
if (volume.value < 0.5) return 'volume_down'
|
if (volume.value < 0.5) return 'volume_down'
|
||||||
return 'volume_up'
|
return 'volume_up'
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleMute = () => {
|
const handleToggleMute = () => {
|
||||||
if (!videoRef.value) return
|
setMuted(!isMuted.value)
|
||||||
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) => {
|
const handleVolumeChange = (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)
|
const newVol = typeof val === 'number' ? val : Number(val.target.value)
|
||||||
|
setVolume(newVol)
|
||||||
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(() => {
|
const videoSrc = computed(() => {
|
||||||
|
|
@ -335,18 +367,27 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final save attempt when component destroys
|
// Final save attempt when component destroys
|
||||||
|
if (currentLesson.value?.id) {
|
||||||
|
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
|
||||||
|
}
|
||||||
performSaveProgress(true, true)
|
performSaveProgress(true, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
console.log('Tab hidden, saving progress...')
|
console.log('Tab hidden, saving progress...')
|
||||||
|
if (currentLesson.value?.id) {
|
||||||
|
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
|
||||||
|
}
|
||||||
performSaveProgress(true, true) // Force save with keepalive
|
performSaveProgress(true, true) // Force save with keepalive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageHide = () => {
|
const handlePageHide = () => {
|
||||||
// Triggered on page refresh, close tab, or navigation
|
// Triggered on page refresh, close tab, or navigation
|
||||||
|
if (currentLesson.value?.id) {
|
||||||
|
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
|
||||||
|
}
|
||||||
performSaveProgress(true, true)
|
performSaveProgress(true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,6 +395,9 @@ const handlePageHide = () => {
|
||||||
watch(isPlaying, (playing) => {
|
watch(isPlaying, (playing) => {
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
// Paused: Save immediately
|
// Paused: Save immediately
|
||||||
|
if (currentLesson.value?.id) {
|
||||||
|
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
|
||||||
|
}
|
||||||
performSaveProgress(true, false)
|
performSaveProgress(true, false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -511,7 +555,7 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<!-- Volume Control -->
|
<!-- Volume Control -->
|
||||||
<div class="flex items-center gap-2 group/volume">
|
<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">
|
<div class="w-0 group-hover/volume:w-20 overflow-hidden transition-all duration-300 flex items-center">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|
@ -519,7 +563,7 @@ onBeforeUnmount(() => {
|
||||||
max="1"
|
max="1"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
:value="volume"
|
:value="volume"
|
||||||
@input="onVolumeChange"
|
@input="handleVolumeChange"
|
||||||
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue