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 { 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue