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
|
|
@ -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